Publishing a signed release channel (vendor and release engineers)
This page documents how a signed engine release is published to the update channel. It is the producer side of the trust model the customer-facing pages describe from the other end: the engine PULLS a signed channel from inside the customer’s account and verifies it, and the vendor never pushes code. It is written for a release engineer cutting a release the engine will accept, not for a customer, and it is operator-internal.
The tool described here, engine/tools/publish-channel.mjs, lives in the engine repository (versioned, tested by test/validate-publish-channel.ts, reviewable) but never in the shipped bundle: the deployable artefact is built from src/ alone, so only the verify side ships in the product. The publisher is the vendor’s private process, run on a release engineer’s own machine, and never something a customer runs. For how an account turns the channel on, read update channel activation; for how an applied update is verified, gated and rolled back, read applying an update from the channel.
The channel is live, and a live apply is still a supervised exercise
The owner ceremony is complete and the update channel is live: the vendor serves a signed channel at the singular update.downpipes.io. On the shipped base and demo config the signer is pinned and the channel URL is set, and because a signed channel is being served, the engine reports configured: true and verified: true and the console shows a “Signer verified” posture. The publish-and-apply path has now been exercised against a live Cloudflare account exactly once: on 2026-07-03 an engine promote and an automatic rollback both ran for real, and both executed correctly. One flight is not a production track record, the console component’s deploy path has not run live at all, and the first apply on any given deployment is still supervised first-live. Treat the mechanics below as documented and once-exercised, not battle-tested.
What the publish tool produces
A publish run turns a version into the files the engine knows how to consume: the channel document, its detached signature, and the deployable bundle the channel points at; a release that carries the console adds a fourth, the self-describing console artefact. The signature and the hashes are computed with the engine’s own primitives, imported from the engine source, so the publisher and the verifier can never drift.
| Output | What it is |
|---|---|
| Channel document | A small JSON document naming the channel, the recommended version, an artefacts list, and (schema v2) a components map describing each component of the release. Each entry carries the version, the artefact hash, the artefact URL and optional rich metadata. |
| Detached signature | The hybrid Ed25519 and ML-DSA-87 signature over exactly the channel document bytes, base64url-encoded on a single line. Both halves are required and neither is strippable. |
| Deployable bundle | The engine bundle, built the same way a real deploy builds it, that the safe-apply harness downloads and verifies before deploying. |
| Console artefact (component releases only) | A single self-describing JSON bundle, console-<version>.json, carrying the console’s shell worker, every static asset it serves with a per-asset SHA-256, and its static config. Hash-pinned into the signed channel exactly like the engine bundle. |
The tool builds the bundle by running Wrangler’s dry-run, which bundles exactly what a real deploy would push, locally, with no network and no deploy credential. It then hashes those bundle bytes with the engine’s own SHA-384 and writes the hash into the channel, so the value the engine recomputes at apply time matches the signed hash byte for byte. You can also hand it a bundle you built yourself with a Wrangler dry-run and have it ingest that instead.
This local-build path is the development shape of a publish, not the production one. A release customers will accept is published in ceremony mode from a CI-attested release tree, described below, which replaces --build and --bundle.
# Build the bundle locally, sign a channel that points at it, write all three files.
# Development shape: a production release uses --from-release-dir instead.
node tools/publish-channel.mjs --key-file ./release-signer.key --version 0.2.0 --build \
--sequence 7 \
--url https://update.example.com/engine-0.2.0.mjs \
--out channel.json --out-sig channel.json.sig --out-bundle engine-0.2.0.mjs
| Flag | Meaning |
|---|---|
--key-file |
Path to a file holding the release-signer private (base64url, two 32-byte seeds). Prefer this to --key, which takes the same value on the command line where any process on the machine can read it out of the argument list. |
--key |
The release-signer private inline. Reuse the pinned key so deployed engines keep verifying. Omit both key flags once to generate a fresh keypair. |
--version |
The recommended engine version this channel names. In ceremony mode it must match the version CI built, or nothing is signed. |
--sequence |
The channel’s anti-replay counter, a positive integer that must exceed the last published one. Required: the tool refuses to sign without it unless --allow-unsequenced is explicit. --sequence-state <path> is the JSON file remembering the last published sequence, defaulting to channel-sequence.json beside --key-file, and it only advances after a self-verified emit. |
--build |
Build the deployable bundle via Wrangler’s dry-run. Alternatively --bundle <path> ingests one you built yourself. Neither may be combined with --from-release-dir. |
--url |
The stable HTTPS URL where the bundle bytes will be hosted. Required whenever a bundle is published; the engine downloads from here and verifies against the signed hash. |
--out, --out-sig, --out-bundle |
Where to write the channel document, the signature file and the bundle bytes. |
The tool prints the channel JSON, the signature, the signer public key to pin, and a note on where to publish each file. A channel-only publish (no bundle) is possible for a notify-only release, but the safe-apply harness cannot download or verify an artefact the channel does not fully describe, so an in-console apply needs a bundle with a URL and a hash.
Multi-component releases
Channel schema v2 is additive. A v2 document carries a components map describing each component of the release, the engine as a worker module and the console as a static-assets bundle, while the engine-only artefacts[] list keeps being dual-written indefinitely, because every deployed pre-v2 engine selects its artefact from that list and ignores unknown fields. One publishing rule is absolute: a console artefact is never placed in artefacts[]. An old engine selects from that list by version string alone, so a console entry there could be chosen for the engine script and its hash would verify (it is the console bundle’s true hash); keeping the list engine-only is what makes that mistake impossible to publish.
The recommended version is the release version, and each component carries its own version. A component whose version already matches what a reader runs simply shows as up to date, so a console-only fix repeats the engine’s current version in the engine component and moves only the console’s. “Ship both at once” and “ship one alone” are therefore publishing decisions in one document, not different mechanisms, and every release stays one signed statement under the one pinned key.
The engine verifies the signature over the bytes it fetched, then parses those same bytes. It does not canonicalise or reformat the document before checking the signature. So the published file must be exactly the bytes that were signed.
This has one operational consequence worth stating plainly. Re-serialising or pretty-reformatting the channel document after signing breaks the signature, and the engine will refuse the channel with a verification failure. The tool emits a stable two-space-indented serialisation and signs exactly those bytes; publish that file verbatim, byte for byte.
The metadata is covered by the same one signature
The channel document carries the version, the bundle hash, the bundle URL, and optional rich metadata: a structured changelog, an impact summary, required operator steps, a minimum engine version with a compatibility note, the risk class, and a release timestamp. Every one of those fields sits inside the single detached signature over the whole document. That makes the metadata tamper-evident with no extra trust surface, and it is also why none of it can be edited after signing without re-signing.
The offline-signer discipline
The release-signer private key is offline and never deployed. This is the property that makes pull-not-push safe, and it is worth being precise about why.
The engine pins the release-signer public key in its configuration and verifies every channel against exactly that pinned key. The host that serves the channel is untrusted: a compromised channel host cannot substitute an unsigned document or one signed by a different key, because the engine checks the pinned signature and will not fall back to an unverified document. The trust is in the signature, not the host. Since the signing key never goes near the deployed engine or the channel host, compromising the running platform does not yield the ability to sign a malicious release.
The publish tool reflects that discipline in four ways worth listing.
- It lives outside the shipped bundle (repo-owned under
engine/tools/, never built into the artefact), so the signing key and the signing code are never part of the deployed surface. - It performs no deploy and no upload; in ceremony mode its only reads are the local release tree the engineer already downloaded, and cosign’s verification of the embedded Sigstore material.
- It imports the engine’s own signer derivation, hybrid signer and byte encodings, so the bytes it signs are exactly the bytes the engine verifies.
- It draws OS randomness for both seeds when generating a fresh keypair, and the generated private never leaves the machine: it is written only to a restricted key file or, if you ask, echoed once so you can persist it yourself.
The attested ceremony (dual-root releases)
A production release is published in ceremony mode, which makes the release dual-root: the offline hybrid key stays the one signature customers’ engines enforce, and the CI attestation set makes every release publicly falsifiable. The rule inside that mode is simple: the ceremony refuses to sign bytes CI did not attest.
Ceremony mode is entered by --from-release-dir (and --console-release-dir for a component release), and it is a choice the release engineer makes, not a state the tool infers. Nothing stops a --build publish from signing fresh local bytes, so the discipline that a production release is always published from an attested tree is a process rule, not a guard the tool applies on your behalf. What the tool does guarantee once you are in ceremony mode is that you cannot half-leave it: --from-release-dir refuses to be combined with --build or --bundle, --provenance-out-dir is mandatory, a missing cosign binary is a refusal rather than a skip, and a cross-component guard refuses a channel that mixes one attested component with one built locally beside it.
The flow per release tag:
- CI builds the artefact deterministically on the signed tag (the release workflow verifies the tag signature against the signer roster committed on main), emits one canonical
SHA256SUMS.txtand arelease-facts.jsoncarrying CI’s own identifiers (repository, commit, run id), cosign-signs every file keyless (logged to the public Rekor transparency log) and mints SLSA Build L3 provenance with the artefact digests as subjects. - The release engineer downloads the whole release tree (
gh release download vX.Y.Z -D <dir>) and runs the publisher with--from-release-dir <dir>(and--console-release-dirfor a component release). The tool re-derives every digest locally, checks the SLSA statement’s subjects and builder, and verifies the cosign bundles under the pinned release-workflow identity. Any failure, including a missing cosign binary, is a refusal before anything is signed. - The signed channel then embeds a per-component
provenanceblock (commit, tag, repository, CI run, Rekor log index, and channel-relative attestation paths), so the offline signature transitively vouches for the provenance pointers: stripping or swapping them requires the pinned key. --provenance-out-dir(required in ceremony mode) writes the sidecar set underprovenance/<version>/exactly where the signed paths point, plusrelease-record.jsonas the display mirror the console cross-checks. Publish every sidecar in the same sitting as the channel; a channel whose provenance paths return nothing is a published overclaim.- Every publish carries
sequenceandissuedAtinside the signed body (the tool refuses to sign without them unless the legacy escape hatch--allow-unsequencedis explicit). The first sequenced publish sets the anti-replay baseline for every fielded engine, so choose the first sequence deliberately; the sequence-state file lives beside the signing key and only advances after a self-verified emit.
What this buys, stated precisely: forging an update that customers’ engines accept still requires the offline hybrid key, exactly as before; forging one that also passes public scrutiny now additionally requires either compromising the release workflow identity or breaking the reproducible build. The two roots fail independently.
Risk class and customer-side dual control
A release declares a risk class, and that label drives whether the apply needs a second Owner on the customer side. The classes are routine, migration and breaking.
| Risk class | Customer-side effect |
|---|---|
| routine | The least cautious class. It does not require a second Owner under dual control. |
| migration | Treated as a change that needs care. Under dual control it requires a second Owner to approve the apply. |
| breaking | The most cautious class. It requires a second Owner under dual control and the most careful operator copy. |
The safe default is the load-bearing part. An unlabelled release, or one carrying a value the engine does not recognise, is normalised to migration-class, never to routine. A release that needs a Durable Object migration is also forced to at least migration-class, since a migration is never routine. So the only way a release skips the second-owner approval is to be explicitly labelled routine and to need no migration; an old or garbled channel can never slip through as routine by omission.
Declare the risk class explicitly
Because an absent risk class normalises to migration-gated, a genuinely routine release should be labelled routine on purpose, with --risk-class routine, so it is not unnecessarily gated. A release that changes Durable Object classes should be published with the migration flag set, so the safe-apply harness refuses to auto-apply it and the operator applies it manually.
The channel host
The channel host is a single static endpoint. The engine fetches the channel body from its configured URL and the detached signature from that same URL with a .sig suffix. Because the trust is in the pinned signature and not the host, the channel is served as two static files (the body and its signature) plus the artefact bytes each release points at, the engine bundle and, for a component release, the console artefact, from a public object store behind a custom domain. There is no worker and no billing coupling on the channel host.
The host hostname is the singular update.downpipes.io, per the locked decision and per the shipped UPDATE_CHANNEL_URL in wrangler.toml. Note the singular form: it is update, not the plural. The host is live: the vendor has stood it up and it serves the signed channel. The shipped config pins the signer and sets this URL, so the engine reports configured: true, and because a signed channel is now being served, the channel verifies under the pinned key.
publish-channel.mjs still prints the old plural host in places
tools/publish-channel.mjs’s own usage banner and several of its runtime hints (the --url example text, the --console-url requirement message and the R2 upload hint) have not all been updated to match: most still print the old plural updates.downpipes.io in example URLs, while at least one message correctly uses the singular. Treat those examples as stale and always publish to the singular update.downpipes.io regardless of what the tool’s own text says.
The one-time owner ceremony, in outline
Activating the channel for the first time is one vendor action and a small set of operator actions, all of them documented in the activation runbook.
The vendor signs the first channel offline with the publish tool, persisting the release-signer private so the same pinned key signs every future channel. The operator then pins the printed signer public key as the engine’s update-signer key, points the engine at the channel URL, and publishes the channel body, its .sig and the bundle to the host. From then on the engine pulls and verifies on its own schedule, and the operator reviews and applies updates from the console. Both halves are optional: with the channel unset the engine simply reports it as not configured, and the recovery path never depends on it.
The signer public key the engine pins is the concatenation of the Ed25519 public key and the ML-DSA-87 public key, base64url-encoded. The private the publisher signs with is the two seeds those keys derive from, and it is the part that stays offline.
Where this fits
- Update channel activation is the operator runbook for turning the channel on in an account: pinning the signer key and pointing the engine at the channel.
- Applying an update from the channel covers the consumer side: how a verified update is downloaded, hashed against the signed channel, canary-gated and auto-rolled-back.
- Update channel trust and rollback explains the pinned-signature trust model and the standalone rollback control in full.
- The command reference documents the in-account upgrade runbook the CLI prints, which never holds a standing deploy credential.
- Changelog and versioning explains the engine version line the channel’s recommended version is compared against.
Last updated .