Verified live · 2026-07-20 · europe-west1
A digital worker that costs nothing while idle.
One command puts a Greentic worker on Google Cloud Run — public, on a real
https://….run.app URL, answering in a browser chat window and on Telegram.
When nobody is talking to it, it scales to zero and bills no compute.
Everything below is copy-pasteable verbatim, in your shell, on any machine.
- Deploy to live URL
- 46 s
- Cloud resources created
- 2
- Idle compute bill
- $0
- Commands to type
- 9
What you are actually deploying
Greentic is a deterministic runtime for digital workers. You describe an
environment in one JSON file, and a deployer turns that file into
running infrastructure. This demo uses the gcp-cloudrun deployer, so the
infrastructure is a single Cloud Run service.
The worker itself is a bundle — a signed, content-addressed archive pulled from public GHCR at container boot. This one carries three packs:
| Pack | What it gives you |
|---|---|
webchat-bot | The bot flow — the deterministic graph that decides what to say. |
messaging-webchat-gui | A browser chat SPA served publicly at /v1/web/webchat/default/. No login, CORS on, talks DirectLine. |
messaging-telegram | A Telegram provider. Optional — it only activates if you seed a bot token. |
Nothing about the worker is built on your machine and nothing is pushed to your project. The container image and the bundle are both pulled from public GHCR, pinned by digest. Your Google project ends up holding exactly two objects.
What you need first
- A GCP project with billing enabled (the free tier covers this demo, but Cloud Run requires billing to be on).
gcloud, installed and authenticated.- A Rust toolchain with
cargo-binstall— or any way to get one binary onto yourPATH. - ~5 minutes. Steps 1–4 are once per project; after that the deploy is a single command.
How a Google Cloud Run deployment actually operates
Cloud Run is worth understanding before you deploy to it, because half the surprises in this demo come from the platform rather than from Greentic. Four ideas carry everything.
A service is a name; a revision is the thing that runs
You create a service — a stable name and a stable URL. Every deploy creates a new immutable revision: a container image plus its environment, its CPU and memory, its identity. Traffic is then split across revisions by percentage, which is why blue/green and canary come free — a 50/50 split is one API call, and rolling back is pointing 100% at the previous revision.
Greentic names the service gtc-svc-{deployment_ulid}, so the deployment ID is
legible in the console.
Scaling is request-driven, and zero is a legal number
Cloud Run starts containers when requests arrive and stops them when they stop. With
min_instances = 0 (the default here), an idle service runs no containers at
all and bills no CPU and no memory. You pay for request-time compute,
rounded to 100 ms, and nothing else.
The cost is a cold start: the first request after an idle period waits for the
container to boot, pull its config, and load its bundle. Setting min_instances = 1
removes the cold start and reintroduces a permanent bill — which defeats the point of
being here.
Configuration arrives as a secret, not as environment variables
The container does not get its environment as a pile of env vars. Greentic writes a single
seed — the whole environment document — into Secret Manager as
gtc-{env_id}-environment, and mounts it into the revision. The container reads the
seed at boot, discovers which bundle to run, and pulls it.
That indirection is what makes the deploy small: the bundle reference, the routes, and the provider secrets all ride inside one document, and the only thing the revision needs is permission to read it.
Two identities, and neither one is the other
| Identity | Who it is | What it needs |
|---|---|---|
| Deployer | You — ambient Application Default Credentials, written by gcloud auth application-default login. |
Permission to create services and secrets. On a personal project you are owner, so this is already true. |
| Runtime | The service account the container runs as: gtc-{env_id}-runtime. |
Zero project-level roles. Its one permission — read the seed — is granted on the secret itself, at deploy time. |
That is the security posture in one line: resource-scoped, not project-scoped. Verified after a real run, the runtime service account holds no project IAM bindings at all.
You do not ask Cloud Run for a URL — it assigns one and returns it on the deploy response. There is no second API call to discover it. That matters later: the runtime learns its own public address from the first request that reaches it, which is how the Telegram webhook registers itself with no manual step.
Cloud Run's frontend swallows /healthz and returns Google's own
branded 404 — the request never reaches your container. /health, /livez,
/readyz and /status all arrive normally. Probe /readyz,
never /healthz, or you will spend an hour debugging a healthy worker.
What the one command does
env up prints a six-step plan and then executes it. Each step is idempotent, so
re-running the command is safe — the plan comes back all no-op and you get the same URL.
./state.deployer slot to the Cloud Run handler.secrets slot, and write any from_env secrets into the dev-store.allUsers invoker, warm it, and return the URL.
Only the last step touches Google. Everything before it is local state, which is why
--dry-run works with no credentials at all.
Then, inside the container
The revision boots, reads its seed from the mounted secret, resolves
bundle_source_uri — an OCI reference, pinned by digest — pulls the bundle from
public GHCR, loads its packs, and starts serving. /status reports
bundles_active: 1 once all of that has happened.
How it talks to Google
Two questions worth answering before you trust a deploy tool with a cloud account: what does it actually call, and as whom?
It does not shell out to gcloud
The Cloud Run deployer contains no CLI invocations at all. It links Google's official Rust client libraries and speaks to the API directly:
| Library | What it drives |
|---|---|
google-cloud-run-v2 | Services and Revisions — create, update, poll readiness, shift traffic. |
google-cloud-secretmanager-v1 | Staging the seed as a version-pinned secret. |
google-cloud-iam-v1 | The invoker policy and the secret-accessor grant. |
google-cloud-auth | Resolving credentials, minting and refreshing tokens. |
Clients are built against the regional endpoint
https://<region>-run.googleapis.com, so long-running operations poll the
region that owns them. Secret Manager is global and uses its default endpoint.
gcloud is optional
You need it for exactly two things in this demo: to log in (it writes the
credentials file), and to look at things afterwards. The deploy itself would
run on a machine with no gcloud installed at all.
That is worth calling out because it is not how every provider here works. The AWS path
shells out to the aws CLI, Azure to az, and the Kubernetes path to
kubectl. Cloud Run is the one written as a typed client — no output parsing, no
dependency on a CLI version, and API errors arrive as structured codes rather than exit statuses.
One exception, so it doesn't surprise you: there is an older Terraform path in
the codebase that renders shell snippets containing gcloud for
terraform import. It is guarded by a command -v gcloud check and
env up never goes near it.
Where your credentials come from
There are two branches, and only one of them runs in this demo.
GOOGLE_APPLICATION_CREDENTIALS, then the file
gcloud auth application-default login writes, then the metadata server.
This demo takes the second branch. That is why logging in is the only credential step — nothing is bound, so the deploy runs as you, and on a personal project you are owner. Whichever branch resolves, the credential is injected into all three clients at construction, and the auth library handles token refresh from there.
If an environment declares an identity and that identity cannot be read or parsed, the deploy fails. It does not quietly continue as your ambient login. The reasoning is worth stating plainly: an environment that asks for a narrow, scoped service account must not silently run as your much broader personal credentials because a file was missing.
The deployer can only report your principal when the credential is a service-account key file,
because that is the one form where the email is legible. With ordinary gcloud
login it prints an unknown-principal placeholder — the auth library hands out a token and
nothing else. That is a real limitation, not a bug you need to chase.
What the deployer is allowed to do
The permissions a live deploy exercises are a pinned list in the source — fifteen of them, across
Cloud Run services and revisions, Secret Manager, and one iam.serviceAccounts.actAs
so it can hand the runtime identity to the revision.
A test asserts that list is a subset of what the preflight validates. So adding an API
call without declaring its permission fails CI rather than failing on a customer's first
deploy. The preflight itself asks Google directly — a testIamPermissions call
against the project and against the runtime service account — instead of guessing from role names.
Three ordering decisions inside the deploy
Step 6 of the plan is not a straight line, and the places it deviates are the interesting ones.
Public access is granted last
The invoker policy is applied only after the new revision is proven ready. It has to be: that policy is service-wide, not per-revision. Applying it earlier would change access on the revision currently serving traffic even if this deploy then fails — turning a public service private is an outage, and the reverse exposes production on a failed deploy.
The seed secret is claimed, not checked
The secret name comes partly from a free-text answer, so two environments in one project can resolve to the same name while having different runtime identities. Checking "does it exist?" and then creating it would let two deploys racing each other both see no, and the loser's seed would land in the winner's secret — handing one environment's service account read access over the other's secrets. So the name is claimed in a single operation, with an ownership stamp, and a secret owned by another environment is refused.
Concurrent writes lose, then retry
Service updates carry the version tag they were computed from. If something else changed the service in between, the write is rejected and the deployer re-reads and recomputes rather than overwriting what it never saw. Retries are bounded, so a genuine conflict surfaces as an error instead of spinning.
The commands, start to finish
Pick your shell at the top of the page and every block below switches. Set your project once in step 0 and there is nothing left to hand-edit anywhere after it.
Modern fish takes $(…), &&, || and even
VAR=value cmd prefixes. What differs: fish has no heredocs
(use a quoted multi-line string), and fish rejects bare = assignment
(use set). Everything else on this page is byte-identical in both.
Name your project
Two variables. Every command after this one reads them.
set -x PROJECT your-gcp-project-id
set -x REGION europe-west1
mkdir -p ~/cloudrun-demo; cd ~/cloudrun-demo
export PROJECT=your-gcp-project-id
export REGION=europe-west1
mkdir -p ~/cloudrun-demo; cd ~/cloudrun-demo
Install a deployer that can do Cloud Run once per machine
The Cloud Run deployer is a Cargo feature, deploy-gcp-cloudrun. It is a
default feature — but it only exists on the develop lane, so a stable
gtc has no Cloud Run deployer compiled in at all. On develop, binary crates
publish under a sibling -dev name:
cargo binstall greentic-deployer-dev
--version prints greentic-deployer 1.2.0-dev.0 for every nightly —
the build identity lives only in the crates.io version, not in the binary. So an old
greentic-deployer-dev on your PATH is indistinguishable from a
current one. If anything below fails strangely, re-run the binstall first.
To keep it out of your ~/.cargo/bin, install it somewhere disposable and point
the demo at it:
cargo binstall --root /tmp/gtc-dev greentic-deployer-dev
# → /tmp/gtc-dev/bin/greentic-deployer-dev
Point gcloud at your project and log in once per machine
The deployer takes no --key-file. It runs as the ambient Application Default
Credentials chain — which is a different credential store from
gcloud auth login. You need the ADC one.
gcloud config set project $PROJECT
gcloud auth application-default login
Check it without deploying anything:
gcloud config get-value project
gcloud auth application-default print-access-token >/dev/null && echo "ADC OK"
Enable four APIs once per project
gcloud services enable \
run.googleapis.com \
secretmanager.googleapis.com \
artifactregistry.googleapis.com \
logging.googleapis.com \
--project $PROJECT
run— create the service and its revisions.secretmanager— the seed the container boots from.artifactregistry— needed for the API surface even though this demo creates no repo; the image and bundle both come from public GHCR.logging— sogcloud logging readworks when a boot fails.
Enabling an API costs nothing, and they stay enabled after cleanup.
Create the runtime service account once per project
This is the identity the container runs as — not the identity that deploys.
Naming is gtc-{env_id}-runtime, so environment local gives you
gtc-local-runtime.
gcloud iam service-accounts create gtc-local-runtime \
--display-name "Greentic runtime for env local" \
--project $PROJECT
It needs no project-level role. The one permission it requires — reading the seed — is
bound on the secret itself at deploy time. After a real run,
get-iam-policy filtered to this member returns empty.
Write the one file that describes the environment
env up consumes a greentic.env-manifest.v1 document — not a bag of
answers. Note the absent cluster block: Cloud Run has no cluster, so the
deployer's entire configuration is three keys plus a pinned image.
The two variable slots are written as @@…@@ markers and substituted on the way
out, which keeps the JSON byte-identical in both shells.
echo '{
"schema": "greentic.env-manifest.v1",
"environment": { "id": "local", "name": "cloudrun-byhand" },
"trust_root": "bootstrap",
"packs": [
{ "slot": "deployer",
"kind": "greentic.deployer.gcp-cloudrun@1.0.0",
"pack_ref": "builtin",
"answers": {
"project": "@@PROJECT@@",
"region": "@@REGION@@",
"access_mode": "public",
"runtime_image_digest": "sha256:c122d86143293afec4389dbb57e4a8e9510849a0dd548207acd3892d63582920"
} },
{ "slot": "secrets", "kind": "greentic.secrets.dev-store@1.0.0", "pack_ref": "builtin" }
],
"bundles": [
{ "bundle_id": "cloudrun-byhand",
"bundle_source_uri": "oci://ghcr.io/greenticai/greentic-demo-bundles/webchat-bot:webchat-tg-v1",
"bundle_digest": "sha256:7608e322abf305172e20f7bab0607a36c0b0cc09c1d6869c7e5ba7ebfc094c47",
"route_binding": { "path_prefixes": ["/"] } }
]
}' | sed -e "s|@@PROJECT@@|$PROJECT|" -e "s|@@REGION@@|$REGION|" > cloudrun.env.json
# the check — must print your real project, not the markers
python3 -m json.tool cloudrun.env.json | grep -E '"(project|region)"'
cat > cloudrun.env.json <<EOF
{
"schema": "greentic.env-manifest.v1",
"environment": { "id": "local", "name": "cloudrun-byhand" },
"trust_root": "bootstrap",
"packs": [
{ "slot": "deployer",
"kind": "greentic.deployer.gcp-cloudrun@1.0.0",
"pack_ref": "builtin",
"answers": {
"project": "$PROJECT",
"region": "$REGION",
"access_mode": "public",
"runtime_image_digest": "sha256:c122d86143293afec4389dbb57e4a8e9510849a0dd548207acd3892d63582920"
} },
{ "slot": "secrets", "kind": "greentic.secrets.dev-store@1.0.0", "pack_ref": "builtin" }
],
"bundles": [
{ "bundle_id": "cloudrun-byhand",
"bundle_source_uri": "oci://ghcr.io/greenticai/greentic-demo-bundles/webchat-bot:webchat-tg-v1",
"bundle_digest": "sha256:7608e322abf305172e20f7bab0607a36c0b0cc09c1d6869c7e5ba7ebfc094c47",
"route_binding": { "path_prefixes": ["/"] } }
]
}
EOF
# the check — must print your real project, not \$PROJECT
python3 -m json.tool cloudrun.env.json | grep -E '"(project|region)"'
It should print your real values:
"project": "gen-lang-client-0845272684",
"region": "europe-west1",
Why each key is there
access_mode: "public"— grantsallUsers→roles/run.invoker, so the URL works without a token. Use"authenticated"and callers need an identity token.runtime_image_digest— pins the container image. Load-bearing: leave it out and the deployer uses the moving:developtag, which Cloud Run caches for about an hour and which can resolve to a build too old to serve the webchat SPA or self-register the Telegram webhook.bundle_source_uri— must be an OCI URI, not a local path. Cloud Run pulls the bundle over the network at container boot; a path on your laptop is meaningless to it.bundle_digest— pins the content, so the boot is reproducible even though the tag can move.route_binding.path_prefixes: ["/"]— this bundle serves everything.
Telegram — declare the token in the manifest
Skip this to run webchat-only. To wire up the Telegram provider that ships in the bundle,
add a top-level secrets array — a sibling of packs and
bundles — and export the token. The manifest names the
environment variable; the value never enters the file.
"secrets": [
{ "path": "default/_/messaging-telegram/telegram_bot_token",
"from_env": "TELEGRAM_BOT_TOKEN" }
],
set -x TELEGRAM_BOT_TOKEN 123456:your-token-here
export TELEGRAM_BOT_TOKEN=123456:your-token-here
That is the whole integration. env up resolves the variable at apply time,
writes it to the per-environment dev-store, and stages it into the Cloud Run seed — all
inside the single command in step 6. No separate secrets put, no temp file
holding a token.
- The
pathis<tenant>/<team>/<pack>/<name>._is the default team — a literaldefaultteam is rejected. - If the variable is unset on a mutating run,
env uptreats it as a missing input and refuses. On a TTY it prompts instead, masked.
A bot token is a bearer credential. If one has ever been in a chat window, a log, a
screenshot, or a shell history file, rotate it in @BotFather. Also note that
env destroy cannot clear the Telegram-side webhook — that lives on Telegram's
servers, not in GCP.
The one command
Progress lines go to stderr and the JSON envelope to stdout, so redirecting stdout to a file still lets you watch the plan scroll past.
greentic-deployer-dev op --store-root ./state \
--answers cloudrun.env.json env up --yes > up.json
set -x URL (python3 -c "import json;print(json.load(open('up.json'))['result']['endpoint_url'])")
set -x SVC (python3 -c "import json;print(json.load(open('up.json'))['result']['warmed'][0])")
echo "$URL ($SVC)"
greentic-deployer-dev op --store-root ./state \
--answers cloudrun.env.json env up --yes > up.json
export URL=$(python3 -c "import json;print(json.load(open('up.json'))['result']['endpoint_url'])")
export SVC=$(python3 -c "import json;print(json.load(open('up.json'))['result']['warmed'][0])")
echo "$URL ($SVC)"
--store-root ./state— where the environment lives locally. Omit it and it defaults to~/.greentic/environments; keeping it local makes the demo self-contained and disposable.--answersis a global flag, so it goes beforeenv up, not after.--yesskips the confirmation prompt.
Real output, about 46 seconds:
[1/6] ensure-environment local create…
[2/6] update-host-config local create…
[3/6] bootstrap-trust-root local create…
[4/6] update-pack-binding deployer update…
[5/6] update-pack-binding secrets update…
[6/6] deploy-bundle cloudrun-byhand create…
{"noun":"env","op":"up","result":{
"applied_splits":1,
"endpoint_url":"https://gtc-svc-01kxr5arxfhjay9krstpnrp99t-…-ew.a.run.app",
"environment_id":"local",
"kind":"greentic.deployer.gcp-cloudrun@1.0.0",
"warmed":["gtc-svc-01kxr5arxfhjay9krstpnrp99t"]}}
If you lose the shell, both values are recoverable without redeploying:
set -x SVC (gcloud run services list --project $PROJECT --region $REGION \
--format='value(metadata.name)')
set -x URL (gcloud run services describe $SVC --project $PROJECT --region $REGION \
--format='value(status.url)')
export SVC=$(gcloud run services list --project $PROJECT --region $REGION \
--format='value(metadata.name)')
export URL=$(gcloud run services describe $SVC --project $PROJECT --region $REGION \
--format='value(status.url)')
“Up” and “working” are different claims
curl -s -o /dev/null -w '%{http_code}\n' "$URL/readyz"
curl -s "$URL/status" | python3 -m json.tool
{
"schema": "greentic.status.v1",
"env_id": "local",
"bundles_active": 1,
"deployments_routed": 1,
"revisions_active": 1
}
/readyz is a static route. It returns 200 ok from a runtime
that pulled no bundle and loaded nothing at all. bundles_active is non-zero only
once the seed parsed, the bundle pulled from GHCR, and its packs loaded.
bundles_active: 0 next to a green /readyz is the
signature of a broken bundle reference. The service is up; the worker is not there.
A liveness probe will never tell you.
Who answered — you or Google?
Worth knowing when a path 404s unexpectedly. A response with no server header came
from Google's frontend; one carrying server: Google Frontend genuinely reached
your container.
curl -sD- -o /dev/null "$URL/healthz" | grep -iE '^(HTTP|server)'
# HTTP/2 404 ← no server header: Google answered, not you
curl -sD- -o /dev/null "$URL/nope" | grep -iE '^(HTTP|server)'
# HTTP/2 405
# server: Google Frontend ← reached the container, which 405'd
When it is broken, read the container's own logs
gcloud logging read \
"resource.type=\"cloud_run_revision\" AND resource.labels.service_name=\"$SVC\"" \
--project $PROJECT --limit 50 --freshness 1h
Talking to the worker
In a browser
The chat SPA is a public static route — no login, CORS on — backed by DirectLine underneath. Open it:
echo "$URL/v1/web/webchat/default/"
# headless check: 200 + text/html. A 405 means the image is too old.
curl -s -o /dev/null -w '%{http_code}\n' "$URL/v1/web/webchat/default/"
Under the hood, POST …/token mints a session JWT,
POST …/v3/directline/conversations opens a conversation, and the bot answers with
an Adaptive Card. All of it works from pack presence alone — no messaging
endpoint has to be registered anywhere.
On Telegram — the webhook registers itself
If you seeded a token in step 5½, you do not run setWebhook.
On Cloud Run the runtime derives its own public URL from the first inbound request's
Host header — pinned to this service's own <name>-*.run.app
address, so a forged Host cannot hijack the registration — and calls the
provider's setup_webhook for you.
The curl "$URL/status" you already ran was that first request. So the
webhook is registering as you read this. Confirm it:
# token check + the bot's @username
curl -s "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getMe" | python3 -m json.tool
# poll until the runtime has registered it (a few seconds)
curl -s "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getWebhookInfo" | python3 -m json.tool
# "url": "https://<name>-….run.app/webhook/telegram" ← set by the runtime
Then message the bot. It replies through the deployment.
Self-registration needs the runtime image pinned in step 5. On an older build the webhook URL stays empty — then, and only then, register it by hand.
curl -s "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/setWebhook" \
--data-urlencode "url=$URL/webhook/telegram" -d drop_pending_updates=true
What exists, what it costs, what survives teardown
gcloud run services list --project $PROJECT --region $REGION
gcloud secrets list --project $PROJECT
NAME
gtc-svc-01kxr5arxfhjay9krstpnrp99t ← the Service
NAME
gtc-local-environment ← the seed
Two objects. Leave it fifteen minutes without traffic and the container count drops to zero — visible in the console under Metrics → Container instance count. No compute is billed at zero instances.
Tear it down
greentic-deployer-dev op --store-root ./state env destroy local --confirm
Note the ordering: cloud resources are torn down before local state is removed. If teardown fails, the environment stays intact so you can retry, rather than orphaning cloud resources you can no longer name.
Does it clean up everything? Honestly, no
env destroy removes everything it created per run. It does not remove the
one-time setup, and it was never meant to. Measured immediately after a real destroy:
| Fate | Thing | Cost |
|---|---|---|
| Removed | Cloud Run service gtc-svc-… | — |
| Removed | Secret gtc-local-environment + its IAM binding | — |
| Removed | The allUsers invoker binding | — |
| Removed | Local ./state/local | — |
| Left behind | Service account gtc-local-runtime — zero roles | $0 |
| Left behind | Four enabled APIs | $0 while unused |
| Left behind | Cloud Logging entries | free tier, ~30 days |
| Never existed | An Artifact Registry repo | $0 |
No Artifact Registry repo is ever created, which is why standing storage cost is
zero — the image and the bundle are both pulled from public GHCR. The deployer never creates
one; its ar_repo answer only points at a repo you provisioned yourself.
So the honest claim is not “it costs nothing.” It is: zero Cloud Run compute while idle, plus a short enumerated list of standing charges — in practice the seed secret's active versions, a few cents a month, and nothing at all after a destroy.
To remove even the leftovers
gcloud iam service-accounts delete \
gtc-local-runtime@$PROJECT.iam.gserviceaccount.com --project $PROJECT --quiet
gcloud services disable run.googleapis.com secretmanager.googleapis.com \
--project $PROJECT --force
Or delete the project. The only way to be certain a cloud project costs nothing is for it not to exist.
Four ways this bites you
All four were found by running the demo, not by reading the code. They are the reason the script probes rather than assumes.
A build that cannot deploy Cloud Run plans a perfect deploy anyway
Run this against a stable 1.1.16, which has no Cloud Run deployer compiled in at
all, and it does not say so:
| What you'd check | On a build that cannot deploy Cloud Run |
|---|---|
op env --help | still lists up — it is the generic verb |
op env up --dry-run | green six-step plan, kind → …gcp-cloudrun |
op env apply --yes | exit 0, changed: 6, verify.failures: [] |
op env doctor | unknown_kinds: ["greentic.deployer.gcp-cloudrun@1.0.0"] |
Three of the four happily plan and bind a deployer kind the binary cannot execute.
Only doctor resolves the binding against the handler registry, so it is the only
one that tells the truth. Probe with it, on a throwaway store, before you trust anything:
set S (mktemp -d)
greentic-deployer-dev op --store-root $S --answers cloudrun.env.json env apply --yes >/dev/null
greentic-deployer-dev op --store-root $S env doctor local | python3 -m json.tool | grep -A1 unknown_kinds
rm -rf $S
# "unknown_kinds": [], ← good
# "unknown_kinds": ["greentic.deployer.gcp-cloudrun@1.0.0"] ← no Cloud Run in this build
S=$(mktemp -d)
greentic-deployer-dev op --store-root $S --answers cloudrun.env.json env apply --yes >/dev/null
greentic-deployer-dev op --store-root $S env doctor local | python3 -m json.tool | grep -A1 unknown_kinds
rm -rf $S
# "unknown_kinds": [], ← good
# "unknown_kinds": ["greentic.deployer.gcp-cloudrun@1.0.0"] ← no Cloud Run in this build
The probe's first cut asked only “does unknown_kinds contain
gcp-cloudrun?” — and passed a binary that has no op env up at all:
doctor never ran, the answer was empty, and empty is not a match. It now demands
a parseable report whose bound_slots includes deployer and whose
unknown_kinds excludes cloudrun, and refuses on anything else.
Tag caching hands you yesterday's runtime
Cloud Run caches image tags for roughly an hour. Deploy against a moving :develop
tag and you can get a build old enough that the webchat route 405s or the Telegram webhook
never registers — with no error anywhere to explain it. Pin
runtime_image_digest. To find a newer one:
gh api /orgs/greenticai/packages/container/greentic-start-distroless/versions \
--jq '.[] | select(.metadata.container.tags[]?=="develop") | .name'
The pack matters as much as the image
Webchat GUI packs older than 0.5.10 did not declare the
render_plan / encode / send_payload egress operations, so
the bot's reply was silently rejected by the runner's declared-ops allowlist. The chat loaded
and simply never answered. The bundle pinned here is past that.
gtc op is not the same binary
gtc op … delegates to greentic-operator — a different crate on its own
release cadence. It inherits the deployer's default features, so Cloud Run will ride along
automatically once a stable release carries it; but today's operator does not have it. That is
why every command on this page names greentic-deployer-dev explicitly.