diff --git a/.changeset/release-bundle-contracts.md b/.changeset/release-bundle-contracts.md new file mode 100644 index 00000000..1294bd67 --- /dev/null +++ b/.changeset/release-bundle-contracts.md @@ -0,0 +1,10 @@ +--- +"@cloudhal/core-contracts": patch +--- + +Two new contract roots for signed release bundles (RFC-0046), with golden fixtures and the matching Rust types (`ReleaseManifest`, `ReleaseStatement`): + +- **`release-manifest`** (`cloudhal.release-manifest@1`): the manifest of a release bundle. It lists every built Worker module and static asset by size and SHA-256, with the binding templates, feature levels, migration range and rollback facts a remote deployer needs before it uploads anything. `signing.signature` is `release-statement.sig` for project releases; `manifest.sig` is a legacy form for development keys only. +- **`release-statement`** (`cloudhal.release-statement@1`): the small canonical document a release signs (Ed25519, at most 4096 bytes). It names the version, channel, commit and key id, and pins the SHA-256 and size of `manifest.json` and of the tarball. + +Both roots reference `common.schema.json` only for `SchemaVersion` and `Timestamp`, so a deployer can keep an identical copy under its own `$id`. The package root exports only the release-specific types `ReleaseManifest`, `WorkerArtifact`, `BindingTemplate`, `ReleaseStatement` and `StatementAsset`. The local definitions of the two roots stay local: generic names such as `SiteId` or `Capability` mean something else in the rest of the contracts. A test pins their fingerprints. The release signing key and the steps for verifying a release by hand are in `docs/security/release-signing-keys.md`. Nothing changes for existing contracts. diff --git a/.github/workflows/bundle-build.yml b/.github/workflows/bundle-build.yml new file mode 100644 index 00000000..68906727 --- /dev/null +++ b/.github/workflows/bundle-build.yml @@ -0,0 +1,119 @@ +name: Release bundle build + +# Builds and attests the release bundle of one version (RFC-0046 §4), called by release.yml's +# `bundle` job. It holds NO secret: the job can read the repository and mint an OIDC token for the +# build-provenance attestation, nothing else. Consumers check that attestation with +# gh attestation verify --signer-workflow cloudhal-dev/cloudhal/.github/workflows/bundle-build.yml +# so this file's path is part of the trust chain: renaming it breaks every verifier. +# 1. checks out the release commit without persisted credentials and installs the frozen lockfile +# (no Actions cache: a release build restores nothing another workflow wrote); +# 2. builds the packages and the Studio, creates the standard `cloudhal init` starter and runs +# `deploy --dry-run` in it; +# 3. `scripts/release-bundle.ts build` writes the tarball, manifest.json and the UNSIGNED +# release-statement.json, validated and self-verified (`--security-release` when the caller's +# `security_release` is true); +# 4. attests the three files; the Sigstore bundle becomes cloudhal-release-.sigstore.json; +# 5. uploads the four files as the short-lived artifact release-bundle-unsigned-. +# release.yml's `bundle-sign` job verifies the attestation and signs the statement. + +on: + workflow_call: + inputs: + version: + description: The release version (X.Y.Z or X.Y.Z-pre), equal to packages/cli/package.json. + type: string + required: true + dry_run: + description: A dry run signs with a throwaway key id `dryrun-`. + type: boolean + required: true + security_release: + description: The manifest's and statement's securityRelease (release.yml's plan reads it from the changelogs). + type: boolean + required: true + +permissions: {} + +jobs: + build: + name: Build and attest the bundle + runs-on: ubuntu-latest + timeout-minutes: 45 + permissions: + contents: read + id-token: write # the attestation's Sigstore certificate + attestations: write # store the attestation for this repository + env: + VERSION: ${{ inputs.version }} + DRY_RUN: ${{ inputs.dry_run }} + SECURITY_RELEASE: ${{ inputs.security_release }} + RUN_ID: ${{ github.run_id }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + - name: Check the version + run: | + if ! [[ "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$ ]]; then + echo "not a version: $VERSION" >&2 + exit 1 + fi + cli=$(node -p 'require("./packages/cli/package.json").version') + if [ "$cli" != "$VERSION" ]; then + echo "packages/cli is $cli, not $VERSION" >&2 + exit 1 + fi + # Install scripts stay on: better-sqlite3 (the starter's seeded local store), workerd and + # esbuild need their native builds, which pnpm-workspace.yaml's allowBuilds lists. + - run: pnpm install --frozen-lockfile + # The npm build of the same commit: a package that does not build stops the release here, + # before anything is signed. The bundle itself is built from the workspace sources below. + - run: node scripts/build-packages.ts + - run: pnpm --filter @cloudhal/studio build + - name: Build the standard starter + run: | + node packages/cli/bin/cloudhal.ts init examples/release-std + # The new workspace folder is not in the frozen lockfile: it uses starters/blog's + # installed dependencies, which are the same (init copies that package.json). No pnpm + # command runs after this point: pnpm would add the folder to the lockfile. + node -e ' + const read = (f) => { const p = require(f); delete p.name; delete p.private; return JSON.stringify(p); }; + if (read("./starters/blog/package.json") !== read("./examples/release-std/package.json")) { + console.error("examples/release-std does not depend on what starters/blog does"); + process.exit(1); + }' + ln -s ../../starters/blog/node_modules examples/release-std/node_modules + cd examples/release-std + node ../../packages/cli/bin/cloudhal.ts deploy --dry-run + - name: Build the bundle + run: | + if [ "$DRY_RUN" = "true" ]; then key_id="dryrun-$RUN_ID"; else key_id="cloudhal-release-2026a"; fi + flags=() + if [ "$SECURITY_RELEASE" = "true" ]; then flags+=(--security-release); fi + node scripts/release-bundle.ts build --version "$VERSION" \ + --project examples/release-std --studio apps/studio/dist \ + --migrations-dir packages/storage/migrations \ + --commit "$GITHUB_SHA" --key-id "$key_id" --out "$RUNNER_TEMP/release-bundle" "${flags[@]}" + - id: attest + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: | + ${{ runner.temp }}/release-bundle/cloudhal-release-${{ inputs.version }}.tar.gz + ${{ runner.temp }}/release-bundle/manifest.json + ${{ runner.temp }}/release-bundle/release-statement.json + - name: Keep the attestation bundle + env: + BUNDLE_PATH: ${{ steps.attest.outputs.bundle-path }} + run: cp "$BUNDLE_PATH" "$RUNNER_TEMP/release-bundle/cloudhal-release-$VERSION.sigstore.json" + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: release-bundle-unsigned-${{ inputs.version }} + path: ${{ runner.temp }}/release-bundle/ + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2a572bd0..c15087b6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,16 +1,30 @@ name: Release -# Releases from CI (ADR-0013; the decisions in CONTRIBUTING.md "Releasing"): +# Releases from CI (ADR-0013, RFC-0046; the decisions in CONTRIBUTING.md "Releasing"): # 1. `version`: on every push to main, the Changesets action opens or updates the release PR # `chore(release): x.y.z` from the pending changesets. Merging it is the decision to release. -# 2. `plan`: after that merge, finds that npm lacks the packages' new version. -# 3. `publish`: waits for the `npm-publish` environment's approval (one click, the maintainer's -# 2FA), then scripts/publish-ci.ts builds, smoke-tests the packed tarballs, publishes them with -# npm trusted publishing (OIDC, provenance), and creates one `v` tag and GitHub Release. -# 4. `template`: after publishing, template-sync.yml opens or updates the cloudhal-template PR +# 2. `plan`: after that merge, finds that npm lacks the packages' new version, and whether a +# changeset marks it a security release (`**Security release.**`, CONTRIBUTING.md "Releasing"). +# 3. `bundle` (only with the repository variable RELEASE_BUNDLES = 'true', or in a dry run): the +# reusable bundle-build.yml builds the standard theme's release bundle from this commit and +# attests it (build provenance). It holds no secret. +# 4. `bundle-sign`: waits for the `release-signing` environment's approval, verifies the +# attestation (`gh attestation verify --signer-workflow …/bundle-build.yml`) and the +# statement's digests, then signs release-statement.json with the release key. It checks out +# nothing and installs nothing; the key reaches one step only. +# 5. `publish`: waits for the `npm-publish` environment's approval (one click, the maintainer's +# 2FA), then scripts/publish-ci.ts verifies the statement's signature with the pinned key, +# tags v, creates a DRAFT GitHub Release, attaches the five bundle assets and +# re-checks them, builds, smoke-tests and publishes the packed tarballs with npm trusted +# publishing (OIDC, provenance), re-checks the draft and publishes it. Releases are +# immutable: nothing can be attached after that, so the order matters. +# 6. `template`: after publishing, template-sync.yml opens or updates the cloudhal-template PR # (the Deploy to Cloudflare button's repository; CONTRIBUTING.md "Releasing"). -# 5. `docker`: after publishing, docker.yml builds the self-host image from the npm release and +# 7. `docker`: after publishing, docker.yml builds the self-host image from the npm release and # pushes ghcr.io/cloudhal-dev/cloudhal:, : and :latest (issue #143). +# Actions › Release › Run workflow with `dry_run` runs 2–5 with a throwaway signing key on a +# `v-dryrun.` draft that is deleted at the end, also when the job fails, is cancelled +# or times out: no tag, no npm publish (`npm publish --dry-run`), no release. # npm's trusted-publisher entry for each package names this file and the `npm-publish` # environment: renaming either breaks publishing. @@ -18,6 +32,12 @@ on: push: branches: [main] workflow_dispatch: + inputs: + dry_run: + description: Dry run (throwaway signing key, a draft that is deleted; nothing is published) + type: boolean + required: false + default: false permissions: {} @@ -28,7 +48,7 @@ concurrency: jobs: version: name: Release PR (Changesets) - if: vars.RELEASE_ENABLED == 'true' + if: vars.RELEASE_ENABLED == 'true' && !inputs.dry_run runs-on: ubuntu-latest permissions: contents: write @@ -54,13 +74,15 @@ jobs: plan: name: Anything to publish? - if: vars.RELEASE_ENABLED == 'true' + if: vars.RELEASE_ENABLED == 'true' || inputs.dry_run runs-on: ubuntu-latest permissions: contents: read outputs: publish: ${{ steps.plan.outputs.publish }} version: ${{ steps.plan.outputs.version }} + dry_run: ${{ steps.plan.outputs.dry_run }} + security_release: ${{ steps.plan.outputs.security_release }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -70,11 +92,146 @@ jobs: node-version: 24 - id: plan run: node scripts/publish-ci.ts --plan + env: + RELEASE_DRY_RUN: ${{ inputs.dry_run && 'true' || 'false' }} + + bundle: + name: Bundle ${{ needs.plan.outputs.version }} + needs: plan + if: needs.plan.outputs.publish == 'true' && (vars.RELEASE_BUNDLES == 'true' || needs.plan.outputs.dry_run == 'true') + permissions: + contents: read + id-token: write + attestations: write + uses: ./.github/workflows/bundle-build.yml + with: + version: ${{ needs.plan.outputs.version }} + dry_run: ${{ needs.plan.outputs.dry_run == 'true' }} + security_release: ${{ needs.plan.outputs.security_release == 'true' }} + + bundle-sign: + name: Sign the bundle of ${{ needs.plan.outputs.version }} + needs: [plan, bundle] + if: needs.bundle.result == 'success' + runs-on: ubuntu-latest + timeout-minutes: 15 + # A required reviewer and deployments from main only (CONTRIBUTING.md "Releasing"). Its one + # secret, RELEASE_SIGNING_KEY (PKCS#8 PEM of cloudhal-release-2026a), reaches the sign step only. + environment: release-signing + permissions: + contents: read + attestations: read + actions: read + outputs: + # The raw public key the statement was signed with (public): publish-ci checks a dry run's + # signature with it; outside a dry run it checks against its own pin of the release key. + public_key: ${{ steps.sign.outputs.public_key }} + env: + VERSION: ${{ needs.plan.outputs.version }} + DRY_RUN: ${{ needs.plan.outputs.dry_run }} + SECURITY_RELEASE: ${{ needs.plan.outputs.security_release }} + KEY_ID: ${{ needs.plan.outputs.dry_run == 'true' && format('dryrun-{0}', github.run_id) || 'cloudhal-release-2026a' }} + # No checkout and no package install: the checks and the signer are the inline scripts below. + steps: + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: release-bundle-unsigned-${{ needs.plan.outputs.version }} + path: bundle + - name: Verify the build attestation + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + for f in "bundle/cloudhal-release-$VERSION.tar.gz" bundle/manifest.json bundle/release-statement.json; do + for bundle in "" "bundle/cloudhal-release-$VERSION.sigstore.json"; do + gh attestation verify "$f" ${bundle:+--bundle "$bundle"} --repo cloudhal-dev/cloudhal \ + --signer-workflow cloudhal-dev/cloudhal/.github/workflows/bundle-build.yml \ + --source-ref refs/heads/main --source-digest "$GITHUB_SHA" --deny-self-hosted-runners + done + done + - name: Check the statement + shell: node {0} + run: | + const { createHash } = require("node:crypto"); + const { readFileSync } = require("node:fs"); + const fail = (m) => { console.error(`bundle-sign: ${m}`); process.exit(1); }; + const { VERSION, KEY_ID, GITHUB_SHA, SECURITY_RELEASE } = process.env; + const raw = readFileSync("bundle/release-statement.json"); + if (raw.length > 4096) fail("release-statement.json is above 4096 bytes"); + const s = JSON.parse(raw.toString("utf8")); + const sortDeep = (v) => Array.isArray(v) ? v.map(sortDeep) : v && typeof v === "object" + ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, sortDeep(v[k])])) : v; + if (`${JSON.stringify(sortDeep(s), null, 2)}\n` !== raw.toString("utf8")) fail("the statement is not canonical JSON"); + if (s.schemaVersion !== 1 || s.format !== "cloudhal.release-statement@1") fail("not a release statement"); + if (s.version !== VERSION) fail("the statement is not for the planned version"); + if (s.channel !== (VERSION.includes("-") ? "next" : "stable")) fail("the statement's channel does not match the version"); + if (s.commit !== GITHUB_SHA) fail("the statement is not for this commit"); + if (s.securityRelease !== (SECURITY_RELEASE === "true")) fail("the statement's securityRelease is not the planned one"); + const sg = s.signing ?? {}; + if (sg.keyId !== KEY_ID || sg.algorithm !== "Ed25519" || sg.signature !== "release-statement.sig") fail("the statement does not name the signing key"); + const files = { manifest: "manifest.json", tarball: `cloudhal-release-${VERSION}.tar.gz` }; + for (const [field, name] of Object.entries(files)) { + const want = s[field] ?? {}; + const bytes = readFileSync(`bundle/${name}`); + const sha = createHash("sha256").update(bytes).digest("hex"); + if (want.name !== name || want.bytes !== bytes.length || want.sha256 !== sha) fail(`${name} is not the file the statement pins`); + } + const m = JSON.parse(readFileSync("bundle/manifest.json", "utf8")); + for (const f of ["version", "channel", "securityRelease", "commit", "createdAt"]) if (m[f] !== s[f]) fail(`the manifest's ${f} is not the statement's`); + if (m.signing?.keyId !== KEY_ID || m.signing?.signature !== "release-statement.sig") fail("the manifest does not name the statement"); + console.log(`statement ok: ${s.version} (${s.channel}) at ${s.commit}, key ${KEY_ID}`); + - name: Sign the statement + id: sign + shell: node {0} + env: + RELEASE_SIGNING_KEY: ${{ needs.plan.outputs.dry_run != 'true' && secrets.RELEASE_SIGNING_KEY || '' }} + run: | + const { createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify } = require("node:crypto"); + const { appendFileSync, readFileSync, writeFileSync } = require("node:fs"); + // The active release key (docs/security/release-signing-keys.md; a test keeps them equal). + const PINNED_KEY_ID = "cloudhal-release-2026a"; + const PINNED_PUBLIC_KEY = "7az3pPc4oT0AOfRcdfOZZQkM6XAGHo2jXEdDZ1qEVR8="; + const fail = (m) => { console.error(`bundle-sign: ${m}`); process.exit(1); }; + const dryRun = process.env.DRY_RUN === "true"; + const keyId = dryRun ? `dryrun-${process.env.GITHUB_RUN_ID}` : PINNED_KEY_ID; + if (process.env.KEY_ID !== keyId) fail("KEY_ID is not the key this run signs with"); + let key; + try { + key = dryRun ? generateKeyPairSync("ed25519").privateKey : createPrivateKey(process.env.RELEASE_SIGNING_KEY || ""); + } catch { + fail("RELEASE_SIGNING_KEY is missing or not a PKCS#8 PEM key"); + } finally { + delete process.env.RELEASE_SIGNING_KEY; + } + if (key.asymmetricKeyType !== "ed25519") fail("the signing key is not Ed25519"); + const pub = createPublicKey(key); + const raw = pub.export({ format: "der", type: "spki" }).subarray(12).toString("base64"); + if (!dryRun && raw !== PINNED_PUBLIC_KEY) fail(`the signing key is not ${PINNED_KEY_ID}`); + const statement = readFileSync("bundle/release-statement.json"); + const sig = sign(null, statement, key); + if (sig.length !== 64 || !verify(null, statement, pub, sig)) fail("the signature does not verify"); + writeFileSync("bundle/release-statement.sig", sig); + if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `public_key=${raw}\n`); + console.log(`signed release-statement.json with ${keyId} (public key ${raw})`); + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: release-bundle-${{ needs.plan.outputs.version }} + path: bundle/ + if-no-files-found: error + retention-days: 90 publish: name: Publish ${{ needs.plan.outputs.version }} to npm - needs: plan - if: needs.plan.outputs.publish == 'true' + needs: [plan, bundle-sign] + # Without bundles (RELEASE_BUNDLES off) bundle-sign is skipped and publish runs the same order + # with no assets; with bundles on, or in a dry run, it needs the signed bundle. + if: >- + !cancelled() && needs.plan.result == 'success' && needs.plan.outputs.publish == 'true' && + (needs.bundle-sign.result == 'success' || + (needs.bundle-sign.result == 'skipped' && vars.RELEASE_BUNDLES != 'true' && needs.plan.outputs.dry_run != 'true')) runs-on: ubuntu-latest timeout-minutes: 45 environment: @@ -87,6 +244,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 + persist-credentials: false # publish-ci pushes the tag through `gh auth setup-git` - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: @@ -97,14 +255,44 @@ jobs: - run: npm install -g npm@12 - run: pnpm install --frozen-lockfile - run: node scripts/build-packages.ts --check - - run: node scripts/publish-ci.ts + - if: needs.bundle-sign.result == 'success' + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: release-bundle-${{ needs.plan.outputs.version }} + path: ${{ runner.temp }}/release-bundle + - name: Publish + env: + GH_TOKEN: ${{ github.token }} + HAS_BUNDLE: ${{ needs.bundle-sign.result == 'success' }} + BUNDLE_DIR: ${{ runner.temp }}/release-bundle + DRY_RUN: ${{ needs.plan.outputs.dry_run }} + RELEASE_DRYRUN_PUBLIC_KEY: ${{ needs.plan.outputs.dry_run == 'true' && needs.bundle-sign.outputs.public_key || '' }} + run: | + args=() + if [ "$HAS_BUNDLE" = "true" ]; then args+=(--bundle-dir "$BUNDLE_DIR"); fi + if [ "$DRY_RUN" = "true" ]; then args+=(--dry-run); fi + node scripts/publish-ci.ts "${args[@]}" + # publish-ci deletes a dry run's draft when a step fails; a cancelled or timed-out job never + # gets there. Only drafts with this run's dry-run tag name are deleted (never a release). + - name: Delete the dry run's draft + if: always() && needs.plan.outputs.dry_run == 'true' env: GH_TOKEN: ${{ github.token }} + TAG: v${{ needs.plan.outputs.version }}-dryrun.${{ github.run_number }} + run: | + set -euo pipefail + ids=$(gh api --paginate "repos/$GITHUB_REPOSITORY/releases?per_page=100" \ + --jq '.[] | select(.draft and .tag_name == env.TAG) | .id') + for id in $ids; do + gh api -X DELETE "repos/$GITHUB_REPOSITORY/releases/$id" + echo "deleted the dry run's draft $TAG ($id)" + done + if [ -z "$ids" ]; then echo "no draft $TAG is left"; fi template: name: cloudhal-template PR for ${{ needs.plan.outputs.version }} needs: [plan, publish] - if: needs.publish.result == 'success' + if: needs.publish.result == 'success' && needs.plan.outputs.dry_run != 'true' permissions: contents: read uses: ./.github/workflows/template-sync.yml @@ -116,7 +304,7 @@ jobs: docker: name: Docker image ${{ needs.plan.outputs.version }} needs: [plan, publish] - if: needs.publish.result == 'success' + if: needs.publish.result == 'success' && needs.plan.outputs.dry_run != 'true' permissions: contents: read packages: write diff --git a/AGENTS.md b/AGENTS.md index 665d60d2..68e6fc1a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,7 @@ pnpm design:lint [files…] # Kumo design rules for apps/studio + packag pnpm design:shots -- # headless screenshots at 1440/390 px; fails on sideways scroll / console errors pnpm kumo ls|doc |blocks|add |ai # Kumo CLI: component list, API docs, blocks, usage guide pnpm i18n:export / pnpm i18n:import [--locale xx] # translations as nested ICU JSON in .i18n/ (ADR-0034); only en is hand-edited +node scripts/release-bundle.ts build|verify … # RFC-0046 release bundle: deterministic tarball + manifest + unsigned statement; verify checks a downloaded one ``` ## Layout @@ -40,6 +41,7 @@ pnpm i18n:export / pnpm i18n:import [--locale xx] # translations as nested ICU - `packages/messages` — RFC-0032 / ADR-0032: the ICU MessageFormat 1 subset of Studio's catalogs (`plural`, `select`, `selectordinal`, `number`). `./compile` (build-time only: the Vite plugin `./vite`, the type generator, tests; `bundle-budget.ts` keeps it out of every chunk) and `./format` (Worker-safe runtime, cached `Intl.PluralRules`/`NumberFormat`), which is also Studio's `Intl` layer: `formatNumber`, `formatDate`, `formatRelative`, `formatList` and cached `numberFormat`/`dateTimeFormat`/`relativeTimeFormat`/`displayNames`/`collator` — screens and blocks never build `Intl` formatters themselves (lint `i18n-format`). Catalogs compile to data, never code. - `patches/` — `pnpm patch` files (`patchedDependencies` in `pnpm-workspace.yaml`). `@cloudflare__kumo@2.14.0.patch` is **generated** by `node scripts/kumo-patch.ts`: the hand-written changes in `patches/kumo/*.patch` (`tooltip-timestamp.patch` adds `TimeseriesChart`'s `tooltipTimestampFormat`, DESIGN.md §2v "Kumo locale") plus every physical utility in Kumo's `dist` made logical (ADR-0033 A1). Never edit it; on a Kumo upgrade rename it and the `patchedDependencies` entry, re-run the script, `pnpm install`, re-check the gaps in `docs/design/kumo-upstream-notes.md`. `packages/ui/test/kumo-rtl.test.tsx` fails when the installed Kumo is not patched. `miniflare@4.20260730.0.patch` is **hand-written** against Miniflare's `dist/src/index.js`: it backports Miniflare 5's sync binding-proxy fix (workers-sdk#15552) that the D1 harness needs and closes that proxy pool's idle sockets after 1 s (`keepAliveTimeout`/`keepAliveMaxTimeout` 1000, workers-sdk#16445 A) (#190). On a Miniflare bump the first signal is `pnpm install --frozen-lockfile` failing on the now-unused patch; then follow the upgrade recipe in ADR-0012 (addendum 2026-11-10: drop the patch if the release carries #15552, else re-apply both changes with `pnpm patch`/`pnpm patch-commit` and rename the entry). `packages/storage/test/miniflare-patch.test.ts` fails when the installed Miniflare lacks the fix. - `apps/studio` — SPA; routes in `src/router.tsx`, screens in `src/routes`, typed API client `src/lib/api.ts` (a barrel over `src/lib/api/.ts`), i18n in `src/i18n` (messages are ICU MF1, compiled at build time, with parameter types generated into `src/i18n/generated/params.ts` by `pnpm gen` — never edit it; languages registered in `locales.ts`; add keys to `en.ts` and every `.ts`, or to a lazy namespace `src/i18n/lazy/..ts` (the list and how to add one: `src/i18n/namespaces.ts`; DESIGN.md §1); only the active language of the core catalogs loads, `tr.ts` through `loadLocale`, and each other language's lazy-namespace loaders come with it from the generated `src/i18n/loaders/.ts`; Arabic and Persian format with Western digits through the registry's `intl` tag). +- `scripts/release-bundle.ts` + `scripts/release-bundle/` — RFC-0046's builder and verifier (read the `deploy --dry-run` output, manifest, sorted fixed-header tar, statement, limits, secret scan); `scripts/publish-ci.ts` publishes through the planner `scripts/release-publish-plan.ts` (tag → draft → assets → npm → re-check → publish, resumable, dry run). Workflows: `release.yml` (`bundle` → `bundle-sign` → `publish`) and the reusable `bundle-build.yml`, whose path consumers pin in `gh attestation verify --signer-workflow` (never rename it). Tests in `packages/cli/test/release-*.test.ts`. - `docs/adr/` decisions (with sources) · `docs/rfcs/` proposals · `docs/design/DESIGN.md` the binding admin design system (Kumo). - `.agents/skills/` — skills for you: `contract-change`, `add-capability`, `write-rfc`, `studio-ui`, `translate`, and Cloudflare's official **`kumo-design`** (installed from `cloudflare/kumo`, tracked in `skills-lock.json`; update with `npx skills update`). Any admin-UI change loads `kumo-design` + `studio-ui` and follows `docs/design/DESIGN.md`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 898352cf..e1b95ce2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,21 +45,35 @@ Commit the regenerated files; CI fails on drift. Add a valid and an invalid fixt ## Releasing -All publishable packages (`@cloudhal/*` and `create-cloudhal`) share one version: a changesets "fixed" group. A release is one `vX.Y.Z` tag and one GitHub Release, published from CI by `.github/workflows/release.yml`: +All publishable packages (`@cloudhal/*` and `create-cloudhal`) share one version: a changesets "fixed" group. A release is one `vX.Y.Z` tag and one GitHub Release, published from CI by `.github/workflows/release.yml`. Once bundles are on, the release also carries the signed release bundle of the standard theme (RFC-0046), for anyone who deploys it without building: -1. **Changesets.** Every PR that changes a published package adds one (`pnpm changeset`). In 0.x, use `patch` for fixes and additive features, so `^0.x.y` ranges (the template, existing projects) pick them up; `minor` means a breaking change. +1. **Changesets.** Every PR that changes a published package adds one (`pnpm changeset`). In 0.x, use `patch` for fixes and additive features, so `^0.x.y` ranges (the template, existing projects) pick them up; `minor` means a breaking change. A fix that makes the release a **security release** starts its changeset summary with exactly `**Security release.**`: the `plan` job finds it in the version's changelog entries and the bundle's manifest and statement then say `securityRelease: true` (RFC-0046; remote deployers highlight such a release, none installs it by itself). The release PR's changelog diff shows the marker to whoever merges it. A prerelease (`X.Y.Z-pre`) goes to npm's `next` dist-tag. 2. **Release PR.** On each push to `main`, the Changesets action opens or updates `chore(release): version packages` with the version bump and changelogs. GitHub does not run CI on PRs opened by Actions; the publish job runs the full checks instead. Merging it is the decision to release. -3. **Approval.** The merge starts the `publish` job, which waits for the `npm-publish` environment's approval (the maintainer, with 2FA). Approve it in the run's page on GitHub. -4. **Publish.** `scripts/publish-ci.ts` checks the build, packs every package and installs the tarballs from scratch with npm (`scripts/smoke-pack.ts`), publishes exactly those tarballs with npm trusted publishing (OIDC, with provenance), waits until npm serves all of them, then creates the `vX.Y.Z` tag and the GitHub Release from the changelogs. +3. **Approval.** The merge starts the release jobs. With bundles on (below), `bundle-sign` first waits for the `release-signing` environment's approval, then `publish` waits for the `npm-publish` environment's approval (the maintainer, with 2FA). Approve each in the run's page on GitHub. +4. **Bundle, sign, publish** ([RFC-0046](docs/rfcs/0046-release-bundles.md)). GitHub Releases are immutable here: once a release is published nothing can be attached, so the order is fixed. + - `bundle` (`.github/workflows/bundle-build.yml`, no secrets) builds the Studio and the standard `cloudhal init` starter from the release commit, runs `cloudhal deploy --dry-run`, and writes the release bundle with `node scripts/release-bundle.ts build`: `cloudhal-release-X.Y.Z.tar.gz`, `manifest.json` and the unsigned `release-statement.json`. It then attests the three files (build provenance) and keeps the Sigstore bundle as `cloudhal-release-X.Y.Z.sigstore.json`. + - `bundle-sign` checks out nothing and installs nothing. It verifies the attestation (`gh attestation verify --signer-workflow cloudhal-dev/cloudhal/.github/workflows/bundle-build.yml --source-ref refs/heads/main --source-digest --deny-self-hosted-runners`) and the statement's version, commit, `securityRelease` and digests, then signs the statement with the release key (`release-statement.sig`, raw Ed25519). The key id and public key it accepts are written in the job and must equal [`docs/security/release-signing-keys.md`](docs/security/release-signing-keys.md) (a test checks it). + - `publish` runs `scripts/publish-ci.ts`: before it creates anything it verifies `release-statement.sig` with the release key pinned in `publish-ci.ts` (equal to `bundle-sign`'s, a test checks it) and the statement's digests, so a bundle changed after signing never reaches an immutable release. Then it tags `vX.Y.Z` on the release commit, creates a **draft** release, uploads the five assets and re-reads the draft (exact names and SHA-256 digests), checks the build, packs every package and installs the tarballs from scratch with npm (`scripts/smoke-pack.ts`), publishes exactly those tarballs with npm trusted publishing (OIDC, with provenance), re-checks the draft's assets once more, and only then publishes the release: Latest only for the highest stable version, a prerelease for `X.Y.Z-pre`. A draft asset that changed in between is re-uploaded while the release is still a draft; if that fails, nothing is published. + - Check a release by hand with `node scripts/release-bundle.ts verify --dir --version X.Y.Z --public-key cloudhal-release-2026a=`, `gh release verify-asset` and `gh attestation verify` (the key document lists the commands). 5. **After the release.** Bring the rest along: - **cloudhal-template** (the Deploy to Cloudflare button): the workflow's `template` job (`.github/workflows/template-sync.yml`) opens `chore: Cloudhal X.Y.Z and the X.Y.Z starter theme` from `cloudhal/vX.Y.Z`, with `src/`, `public/`, `astro.config.mjs` and `tsconfig.json` from the published starter, `@cloudhal/*` at `^X.Y.Z` and a lockfile that installs exactly `X.Y.Z`. Merge it once its `cloudhal/template-check` status is green: the same run installs a copy of the synced template with `npm ci` and runs `cloudhal deploy --dry-run` with `WORKERS_CI=1`. To sync again, run Actions › Template sync › Run workflow. Without the `TEMPLATE_SYNC_APP_CLIENT_ID` variable and the `TEMPLATE_SYNC_APP_PRIVATE_KEY` secret (a GitHub App installed on cloudhal-template only, with Contents, Pull requests and Commit statuses write) the job only logs a notice. Then sync by hand from a checkout: clone cloudhal-template next to this repository, run `node scripts/template-sync.ts --template ../cloudhal-template --version X.Y.Z --summary pr-body.md` and then the same command with `--lock` in place of `--summary` (Node 24, no `pnpm install` needed), check a copy of the template (`npm ci`, then `WORKERS_CI=1 npx cloudhal deploy --dry-run`), commit on `cloudhal/vX.Y.Z`, push, open the PR with the title above and `pr-body.md` as its body, and post the `cloudhal/template-check` status on its head commit (`gh api -X POST repos/cloudhal-dev/cloudhal-template/statuses/ -f state=success -f context=cloudhal/template-check -f description='npm ci and cloudhal deploy --dry-run (WORKERS_CI=1) passed, run by hand'`). Dependabot's `@cloudhal/*` PR on the template is no substitute: it moves dependencies only, no theme, and it fails to install whenever the starter's own pins moved with the release (0.6.3 moved astro to 7.4.7 for the `^7.4.0` peer range, so Dependabot's `^0.6.1` PR hit ERESOLVE); close it as superseded by the sync PR. - **cloudhal.com**: Dependabot opens the `@cloudhal/*` PR the next morning (06:00 Europe/Istanbul). A patch release merges itself after its check; a minor bump waits for a person. - **try.cloudhal.com** (`cloudhal-dev/try`): by hand. Bump its exact `@cloudhal/*` pins, then `pnpm install` and `pnpm run deploy`. - **The Docker image** (`ghcr.io/cloudhal-dev/cloudhal`, [self-host guide](docs/guides/self-host.md)): the workflow's `docker` job (`.github/workflows/docker.yml`) builds it from the npm release for linux/amd64 and linux/arm64 with an SBOM and provenance, pushes it under `sha-` only, runs the smoke test on both architectures and Trivy against that digest, and only then tags it: `X.Y.Z` always, `X.Y` when the version is the highest patch of that minor among the `v*` tags, `latest` when it is the highest release of all. The image never carries a version of its own: one release, one version everywhere. To rebuild a version (a base-image fix) run Actions › Docker image › Run workflow with it (0.6.4 or later: older versions fail the smoke test's setup-token check); the same rule decides its tags, so a release finished by hand still gets `latest` and a backfill of an older patch moves nothing. `edge` is built nightly from `main`. -**If something fails:** use **Re-run failed jobs** on the failed run. The script skips packages npm already has and an existing tag or release, so it finishes a half-done release. A new run (Actions › Release › Run workflow) does not: once npm has every package, `plan` skips `publish`, so a missing tag or GitHub Release stays missing. To redo only the template PR, run Actions › Template sync › Run workflow. npm's trusted-publisher entry for each package names `release.yml` and the `npm-publish` environment: renaming either breaks publishing. +**If something fails:** use **Re-run failed jobs** on the failed run. `publish-ci.ts` looks at what already exists and resumes from the first step that is not done: it skips packages npm already has, reuses the tag (it refuses one that points at another commit) and the draft, uploads only missing or wrong assets, and publishes the draft. A new run (Actions › Release › Run workflow) does not: once npm has every package, `plan` skips `publish`, so a release left as a draft stays a draft until a failed run is re-run. Two rules come from immutability: a published release that lacks an asset or carries a different one is refused, and the fix is a new version; and `v0.8.0` and older can never gain a bundle. To redo only the template PR, run Actions › Template sync › Run workflow. npm's trusted-publisher entry for each package names `release.yml` and the `npm-publish` environment: renaming either breaks publishing. Consumers verify the attestation against `bundle-build.yml` by its path: renaming that file breaks them. -**Turning it off:** the repository variable `RELEASE_ENABLED` gates the workflow. Without it, release by hand: `pnpm changeset version` in a PR, merge, then `pnpm release` from an up-to-date `main` and push one `vX.Y.Z` tag. Nothing then opens the cloudhal-template PR: run Actions › Template sync › Run workflow with `X.Y.Z` (it needs the GitHub App, not `RELEASE_ENABLED`), and bring cloudhal.com and try.cloudhal.com along as in step 5. +**Dry run** (required before the first signed release and after any change to `release.yml`, `bundle-build.yml`, `publish-ci.ts` or the builder): Actions › Release › Run workflow with **dry_run** checked. It builds and attests the bundle, signs it in `bundle-sign` with a throwaway key generated in memory (key id `dryrun-`, so the release key is not used), creates the draft `vX.Y.Z-dryrun.` on the commit without pushing a tag, checks the signature with the throwaway key's public half (bundle-sign's job output), uploads and re-checks the assets, runs `npm publish --dry-run` for every package, and deletes the draft. npm asks the registry even in a dry run and refuses a version it already has, which `main` almost always holds, so for those packages the dry run adds `--force`; with `--dry-run` npm never uploads (checked against npm 12.2.2). A version npm lacks keeps npm's own checks. The draft is deleted when a step fails too, and the `publish` job's last step (`if: always()`) deletes any draft with this run's dry-run tag left by a cancelled or timed-out job. If even that did not run (the job was cancelled before it got there), delete the draft by hand: Releases › the `vX.Y.Z-dryrun.` draft › Delete, or `gh release delete vX.Y.Z-dryrun. --yes`; a dry-run draft has no tag and is never published, so nothing else needs undoing. It opens no Changesets PR, no template PR and no Docker image. Both environments still ask for approval. + +**Turning bundles on** (the maintainer, once): +1. Create the environment `release-signing`: a required reviewer, deployments from `main` only. +2. Add the environment secret `RELEASE_SIGNING_KEY`: the PKCS#8 PEM of the active key in `docs/security/release-signing-keys.md`. Then move the local copy of the key to offline storage, as the key document's Custody section says. +3. Run the dry run above and check that it ends with the draft deleted. +4. Set the repository variable `RELEASE_BUNDLES` to `true`. The next release carries a bundle; a `next` prerelease is the safest first one. + +With `RELEASE_BUNDLES` unset, the release runs the same order (tag, draft, npm, publish) with no assets, and both checks require that the draft has none. Turning it off again is the rollback: nothing in an installed site depends on a bundle. + +**Turning it off:** the repository variable `RELEASE_ENABLED` gates the workflow (a dry run runs without it). Without it, release by hand: `pnpm changeset version` in a PR, merge, then `pnpm release` from an up-to-date `main` and push one `vX.Y.Z` tag. Nothing then opens the cloudhal-template PR: run Actions › Template sync › Run workflow with `X.Y.Z` (it needs the GitHub App, not `RELEASE_ENABLED`), and bring cloudhal.com and try.cloudhal.com along as in step 5. ## Where to start diff --git a/crates/cloudhal-schema/src/generated/mod.rs b/crates/cloudhal-schema/src/generated/mod.rs index 2ddc9e2d..fc35e23f 100644 --- a/crates/cloudhal-schema/src/generated/mod.rs +++ b/crates/cloudhal-schema/src/generated/mod.rs @@ -13,6 +13,8 @@ pub mod page; pub mod project; pub mod redirect; pub mod release; +pub mod release_manifest; +pub mod release_statement; pub mod schedule_run; pub mod section; pub mod site; diff --git a/crates/cloudhal-schema/src/generated/release_manifest.rs b/crates/cloudhal-schema/src/generated/release_manifest.rs new file mode 100644 index 00000000..3b98726b --- /dev/null +++ b/crates/cloudhal-schema/src/generated/release_manifest.rs @@ -0,0 +1,2304 @@ +//! GENERATED FILE — DO NOT EDIT. +//! Source: packages/core-contracts/schemas/*.schema.json (run `pnpm gen`) +#![allow(clippy::all, rustdoc::all, dead_code, unused_imports)] +#![allow(clippy::needless_lifetimes)] +#![allow(clippy::match_single_binding)] +#![allow(clippy::clone_on_copy)] + +#[doc = "`BindingName`"] +#[derive(:: serde :: Serialize, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct BindingName(::std::string::String); +impl ::std::ops::Deref for BindingName { + type Target = ::std::string::String; + fn deref(&self) -> &::std::string::String { + &self.0 + } +} +impl ::std::convert::From for ::std::string::String { + fn from(value: BindingName) -> Self { + value.0 + } +} +impl ::std::str::FromStr for BindingName { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + static PATTERN: ::std::sync::LazyLock<::regress::Regex> = + ::std::sync::LazyLock::new(|| { + ::regress::Regex::new("^[A-Z][A-Z0-9_]*(_\\*)?$").unwrap() + }); + if PATTERN.find(value).is_none() { + return Err("doesn't match pattern \"^[A-Z][A-Z0-9_]*(_\\*)?$\"".into()); + } + Ok(Self(value.to_string())) + } +} +impl ::std::convert::TryFrom<&str> for BindingName { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for BindingName { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +impl<'de> ::serde::Deserialize<'de> for BindingName { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + ::std::string::String::deserialize(deserializer)? + .parse() + .map_err(|e: self::error::ConversionError| { + ::custom(e.to_string()) + }) + } +} +#[doc = "`BindingTemplate`"] +#[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct BindingTemplate { + pub bindings: ::std::vec::Vec, + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub cache: ::std::option::Option, + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub cron: ::std::option::Option, + pub secrets: ::std::vec::Vec, + pub vars: ::std::vec::Vec, +} +#[doc = "`BindingTemplateBindingsItem`"] +#[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct BindingTemplateBindingsItem { + #[serde(default, skip_serializing_if = "::std::vec::Vec::is_empty")] + pub aliases: ::std::vec::Vec, + pub name: BindingName, + pub required: bool, + #[serde(rename = "type")] + pub type_: BindingTemplateBindingsItemType, +} +#[doc = "`BindingTemplateBindingsItemType`"] +#[derive( + :: serde :: Deserialize, + :: serde :: Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd, +)] +pub enum BindingTemplateBindingsItemType { + #[serde(rename = "d1")] + D1, + #[serde(rename = "r2_bucket")] + R2Bucket, + #[serde(rename = "assets")] + Assets, + #[serde(rename = "service")] + Service, + #[serde(rename = "durable_object_namespace")] + DurableObjectNamespace, + #[serde(rename = "analytics_engine")] + AnalyticsEngine, + #[serde(rename = "analytics_sql")] + AnalyticsSql, + #[serde(rename = "ratelimit")] + Ratelimit, + #[serde(rename = "ai_search")] + AiSearch, + #[serde(rename = "send_email")] + SendEmail, + #[serde(rename = "ai")] + Ai, + #[serde(rename = "browser")] + Browser, +} +impl ::std::fmt::Display for BindingTemplateBindingsItemType { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::D1 => f.write_str("d1"), + Self::R2Bucket => f.write_str("r2_bucket"), + Self::Assets => f.write_str("assets"), + Self::Service => f.write_str("service"), + Self::DurableObjectNamespace => f.write_str("durable_object_namespace"), + Self::AnalyticsEngine => f.write_str("analytics_engine"), + Self::AnalyticsSql => f.write_str("analytics_sql"), + Self::Ratelimit => f.write_str("ratelimit"), + Self::AiSearch => f.write_str("ai_search"), + Self::SendEmail => f.write_str("send_email"), + Self::Ai => f.write_str("ai"), + Self::Browser => f.write_str("browser"), + } + } +} +impl ::std::str::FromStr for BindingTemplateBindingsItemType { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + match value { + "d1" => Ok(Self::D1), + "r2_bucket" => Ok(Self::R2Bucket), + "assets" => Ok(Self::Assets), + "service" => Ok(Self::Service), + "durable_object_namespace" => Ok(Self::DurableObjectNamespace), + "analytics_engine" => Ok(Self::AnalyticsEngine), + "analytics_sql" => Ok(Self::AnalyticsSql), + "ratelimit" => Ok(Self::Ratelimit), + "ai_search" => Ok(Self::AiSearch), + "send_email" => Ok(Self::SendEmail), + "ai" => Ok(Self::Ai), + "browser" => Ok(Self::Browser), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for BindingTemplateBindingsItemType { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for BindingTemplateBindingsItemType { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +#[doc = "`BindingTemplateCron`"] +#[derive(:: serde :: Serialize, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct BindingTemplateCron(::std::string::String); +impl ::std::ops::Deref for BindingTemplateCron { + type Target = ::std::string::String; + fn deref(&self) -> &::std::string::String { + &self.0 + } +} +impl ::std::convert::From for ::std::string::String { + fn from(value: BindingTemplateCron) -> Self { + value.0 + } +} +impl ::std::str::FromStr for BindingTemplateCron { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + static PATTERN: ::std::sync::LazyLock<::regress::Regex> = + ::std::sync::LazyLock::new(|| ::regress::Regex::new("^[0-9*/,\\- ]+$").unwrap()); + if PATTERN.find(value).is_none() { + return Err("doesn't match pattern \"^[0-9*/,\\- ]+$\"".into()); + } + Ok(Self(value.to_string())) + } +} +impl ::std::convert::TryFrom<&str> for BindingTemplateCron { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for BindingTemplateCron { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +impl<'de> ::serde::Deserialize<'de> for BindingTemplateCron { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + ::std::string::String::deserialize(deserializer)? + .parse() + .map_err(|e: self::error::ConversionError| { + ::custom(e.to_string()) + }) + } +} +#[doc = "`Capability`"] +#[derive( + :: serde :: Deserialize, + :: serde :: Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd, +)] +pub enum Capability { + #[serde(rename = "account")] + Account, + #[serde(rename = "workers")] + Workers, + #[serde(rename = "d1")] + D1, + #[serde(rename = "r2")] + R2, + #[serde(rename = "analytics")] + Analytics, + #[serde(rename = "domains")] + Domains, +} +impl ::std::fmt::Display for Capability { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Account => f.write_str("account"), + Self::Workers => f.write_str("workers"), + Self::D1 => f.write_str("d1"), + Self::R2 => f.write_str("r2"), + Self::Analytics => f.write_str("analytics"), + Self::Domains => f.write_str("domains"), + } + } +} +impl ::std::str::FromStr for Capability { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + match value { + "account" => Ok(Self::Account), + "workers" => Ok(Self::Workers), + "d1" => Ok(Self::D1), + "r2" => Ok(Self::R2), + "analytics" => Ok(Self::Analytics), + "domains" => Ok(Self::Domains), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for Capability { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for Capability { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +#[doc = "`CloudhalVersion`"] +#[derive(:: serde :: Serialize, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct CloudhalVersion(::std::string::String); +impl ::std::ops::Deref for CloudhalVersion { + type Target = ::std::string::String; + fn deref(&self) -> &::std::string::String { + &self.0 + } +} +impl ::std::convert::From for ::std::string::String { + fn from(value: CloudhalVersion) -> Self { + value.0 + } +} +impl ::std::str::FromStr for CloudhalVersion { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + static PATTERN: ::std::sync::LazyLock<::regress::Regex> = + ::std::sync::LazyLock::new(|| { + ::regress::Regex::new( + "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$", + ) + .unwrap() + }); + if PATTERN.find(value).is_none() { + return Err ("doesn't match pattern \"^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$\"" . into ()) ; + } + Ok(Self(value.to_string())) + } +} +impl ::std::convert::TryFrom<&str> for CloudhalVersion { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for CloudhalVersion { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +impl<'de> ::serde::Deserialize<'de> for CloudhalVersion { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + ::std::string::String::deserialize(deserializer)? + .parse() + .map_err(|e: self::error::ConversionError| { + ::custom(e.to_string()) + }) + } +} +#[doc = "`MigrationName`"] +#[derive(:: serde :: Serialize, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct MigrationName(::std::string::String); +impl ::std::ops::Deref for MigrationName { + type Target = ::std::string::String; + fn deref(&self) -> &::std::string::String { + &self.0 + } +} +impl ::std::convert::From for ::std::string::String { + fn from(value: MigrationName) -> Self { + value.0 + } +} +impl ::std::str::FromStr for MigrationName { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + static PATTERN: ::std::sync::LazyLock<::regress::Regex> = + ::std::sync::LazyLock::new(|| ::regress::Regex::new("^[0-9]{4}_[a-z0-9_]+$").unwrap()); + if PATTERN.find(value).is_none() { + return Err("doesn't match pattern \"^[0-9]{4}_[a-z0-9_]+$\"".into()); + } + Ok(Self(value.to_string())) + } +} +impl ::std::convert::TryFrom<&str> for MigrationName { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for MigrationName { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +impl<'de> ::serde::Deserialize<'de> for MigrationName { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + ::std::string::String::deserialize(deserializer)? + .parse() + .map_err(|e: self::error::ConversionError| { + ::custom(e.to_string()) + }) + } +} +#[doc = "cloudhal.release-manifest@1 (RFC-0046): the manifest of one Cloudhal release bundle, published as the release asset manifest.json next to the gzip tarball cloudhal-release-.tar.gz. A project release is signed through release-statement.json, which pins this file's SHA-256 and size (signing.signature = release-statement.sig); the legacy form manifest.sig, a raw 64-byte Ed25519 signature over these exact bytes, is for development keys only. It describes every built Worker module and static asset by size and SHA-256, the binding templates, feature levels, migration range and rollback facts a remote deployer needs to install, update or refuse a version without running a build. Portable on purpose: it references common only for SchemaVersion and Timestamp; every other definition is local."] +#[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ReleaseManifest { + #[serde(rename = "bindingTemplates")] + pub binding_templates: ReleaseManifestBindingTemplates, + pub channel: ReleaseManifestChannel, + pub commit: ReleaseManifestCommit, + pub contracts: ReleaseManifestContracts, + #[serde(rename = "createdAt")] + pub created_at: Timestamp, + #[serde(rename = "durableObjects")] + pub durable_objects: ReleaseManifestDurableObjects, + pub features: ReleaseManifestFeatures, + pub format: ReleaseManifestFormat, + pub levels: ReleaseManifestLevels, + pub migrations: ReleaseManifestMigrations, + #[serde(rename = "optionalCapabilities")] + pub optional_capabilities: ::std::vec::Vec, + #[serde(rename = "optionalScopes")] + pub optional_scopes: ::std::vec::Vec, + #[serde(rename = "planVersion")] + pub plan_version: ::std::num::NonZeroU64, + #[serde(rename = "requiredCapabilities")] + pub required_capabilities: ::std::vec::Vec, + #[serde(rename = "requiredScopes")] + pub required_scopes: ::std::vec::Vec, + pub runtime: ReleaseManifestRuntime, + #[serde(rename = "schemaVersion")] + pub schema_version: SchemaVersion, + #[serde(rename = "securityRelease")] + pub security_release: bool, + pub signing: ReleaseManifestSigning, + pub skills: ::std::vec::Vec, + pub starter: ReleaseManifestStarter, + pub theme: ReleaseManifestTheme, + pub toolchain: + ::std::collections::HashMap<::std::string::String, ReleaseManifestToolchainValue>, + pub version: CloudhalVersion, + pub workers: ReleaseManifestWorkers, +} +#[doc = "`ReleaseManifestBindingTemplates`"] +#[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ReleaseManifestBindingTemplates { + pub admin: BindingTemplate, + pub site: BindingTemplate, +} +#[doc = "`ReleaseManifestChannel`"] +#[derive( + :: serde :: Deserialize, + :: serde :: Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd, +)] +pub enum ReleaseManifestChannel { + #[serde(rename = "stable")] + Stable, + #[serde(rename = "next")] + Next, +} +impl ::std::fmt::Display for ReleaseManifestChannel { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Stable => f.write_str("stable"), + Self::Next => f.write_str("next"), + } + } +} +impl ::std::str::FromStr for ReleaseManifestChannel { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + match value { + "stable" => Ok(Self::Stable), + "next" => Ok(Self::Next), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for ReleaseManifestChannel { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ReleaseManifestChannel { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +#[doc = "`ReleaseManifestCommit`"] +#[derive(:: serde :: Serialize, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct ReleaseManifestCommit(::std::string::String); +impl ::std::ops::Deref for ReleaseManifestCommit { + type Target = ::std::string::String; + fn deref(&self) -> &::std::string::String { + &self.0 + } +} +impl ::std::convert::From for ::std::string::String { + fn from(value: ReleaseManifestCommit) -> Self { + value.0 + } +} +impl ::std::str::FromStr for ReleaseManifestCommit { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + static PATTERN: ::std::sync::LazyLock<::regress::Regex> = + ::std::sync::LazyLock::new(|| ::regress::Regex::new("^[0-9a-f]{40}$").unwrap()); + if PATTERN.find(value).is_none() { + return Err("doesn't match pattern \"^[0-9a-f]{40}$\"".into()); + } + Ok(Self(value.to_string())) + } +} +impl ::std::convert::TryFrom<&str> for ReleaseManifestCommit { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ReleaseManifestCommit { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +impl<'de> ::serde::Deserialize<'de> for ReleaseManifestCommit { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + ::std::string::String::deserialize(deserializer)? + .parse() + .map_err(|e: self::error::ConversionError| { + ::custom(e.to_string()) + }) + } +} +#[doc = "`ReleaseManifestContracts`"] +#[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ReleaseManifestContracts { + #[serde(rename = "schemaVersion")] + pub schema_version: ReleaseManifestContractsSchemaVersion, +} +#[doc = "`ReleaseManifestContractsSchemaVersion`"] +#[derive(:: serde :: Serialize, Clone, Debug, PartialEq)] +#[serde(transparent)] +pub struct ReleaseManifestContractsSchemaVersion(i64); +impl ::std::ops::Deref for ReleaseManifestContractsSchemaVersion { + type Target = i64; + fn deref(&self) -> &i64 { + &self.0 + } +} +impl ::std::convert::From for i64 { + fn from(value: ReleaseManifestContractsSchemaVersion) -> Self { + value.0 + } +} +impl ::std::convert::TryFrom for ReleaseManifestContractsSchemaVersion { + type Error = self::error::ConversionError; + fn try_from(value: i64) -> ::std::result::Result { + if ![1_i64].contains(&value) { + Err("invalid value".into()) + } else { + Ok(Self(value)) + } + } +} +impl<'de> ::serde::Deserialize<'de> for ReleaseManifestContractsSchemaVersion { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + Self::try_from(::deserialize(deserializer)?) + .map_err(|e| ::custom(e.to_string())) + } +} +#[doc = "`ReleaseManifestDurableObjects`"] +#[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ReleaseManifestDurableObjects { + pub bindings: ::std::vec::Vec, + #[serde(rename = "lifecycleChange")] + pub lifecycle_change: bool, +} +#[doc = "`ReleaseManifestDurableObjectsBindingsItem`"] +#[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ReleaseManifestDurableObjectsBindingsItem { + #[serde(rename = "className")] + pub class_name: ReleaseManifestDurableObjectsBindingsItemClassName, + pub migration: ReleaseManifestDurableObjectsBindingsItemMigration, + pub name: BindingName, +} +#[doc = "`ReleaseManifestDurableObjectsBindingsItemClassName`"] +#[derive(:: serde :: Serialize, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct ReleaseManifestDurableObjectsBindingsItemClassName(::std::string::String); +impl ::std::ops::Deref for ReleaseManifestDurableObjectsBindingsItemClassName { + type Target = ::std::string::String; + fn deref(&self) -> &::std::string::String { + &self.0 + } +} +impl ::std::convert::From + for ::std::string::String +{ + fn from(value: ReleaseManifestDurableObjectsBindingsItemClassName) -> Self { + value.0 + } +} +impl ::std::str::FromStr for ReleaseManifestDurableObjectsBindingsItemClassName { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + static PATTERN: ::std::sync::LazyLock<::regress::Regex> = + ::std::sync::LazyLock::new(|| ::regress::Regex::new("^[A-Z][A-Za-z0-9]*$").unwrap()); + if PATTERN.find(value).is_none() { + return Err("doesn't match pattern \"^[A-Z][A-Za-z0-9]*$\"".into()); + } + Ok(Self(value.to_string())) + } +} +impl ::std::convert::TryFrom<&str> for ReleaseManifestDurableObjectsBindingsItemClassName { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> + for ReleaseManifestDurableObjectsBindingsItemClassName +{ + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +impl<'de> ::serde::Deserialize<'de> for ReleaseManifestDurableObjectsBindingsItemClassName { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + ::std::string::String::deserialize(deserializer)? + .parse() + .map_err(|e: self::error::ConversionError| { + ::custom(e.to_string()) + }) + } +} +#[doc = "`ReleaseManifestDurableObjectsBindingsItemMigration`"] +#[derive(:: serde :: Serialize, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct ReleaseManifestDurableObjectsBindingsItemMigration(::std::string::String); +impl ::std::ops::Deref for ReleaseManifestDurableObjectsBindingsItemMigration { + type Target = ::std::string::String; + fn deref(&self) -> &::std::string::String { + &self.0 + } +} +impl ::std::convert::From + for ::std::string::String +{ + fn from(value: ReleaseManifestDurableObjectsBindingsItemMigration) -> Self { + value.0 + } +} +impl ::std::str::FromStr for ReleaseManifestDurableObjectsBindingsItemMigration { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + static PATTERN: ::std::sync::LazyLock<::regress::Regex> = + ::std::sync::LazyLock::new(|| ::regress::Regex::new("^v[1-9][0-9]*$").unwrap()); + if PATTERN.find(value).is_none() { + return Err("doesn't match pattern \"^v[1-9][0-9]*$\"".into()); + } + Ok(Self(value.to_string())) + } +} +impl ::std::convert::TryFrom<&str> for ReleaseManifestDurableObjectsBindingsItemMigration { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> + for ReleaseManifestDurableObjectsBindingsItemMigration +{ + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +impl<'de> ::serde::Deserialize<'de> for ReleaseManifestDurableObjectsBindingsItemMigration { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + ::std::string::String::deserialize(deserializer)? + .parse() + .map_err(|e: self::error::ConversionError| { + ::custom(e.to_string()) + }) + } +} +#[doc = "`ReleaseManifestFeatures`"] +#[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug, PartialEq)] +pub struct ReleaseManifestFeatures { + #[serde(rename = "adminSitesAcceptsSiteId")] + pub admin_sites_accepts_site_id: bool, + #[serde(rename = "healthReportsVersion")] + pub health_reports_version: bool, + #[serde(rename = "mediaTransformVar")] + pub media_transform_var: bool, + #[serde(flatten)] + pub extra: ::std::collections::HashMap<::std::string::String, bool>, +} +#[doc = "`ReleaseManifestFormat`"] +#[derive( + :: serde :: Deserialize, + :: serde :: Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd, +)] +pub enum ReleaseManifestFormat { + #[serde(rename = "cloudhal.release-manifest@1")] + CloudhalReleaseManifest1, +} +impl ::std::fmt::Display for ReleaseManifestFormat { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::CloudhalReleaseManifest1 => f.write_str("cloudhal.release-manifest@1"), + } + } +} +impl ::std::str::FromStr for ReleaseManifestFormat { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + match value { + "cloudhal.release-manifest@1" => Ok(Self::CloudhalReleaseManifest1), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for ReleaseManifestFormat { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ReleaseManifestFormat { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +#[doc = "`ReleaseManifestLevels`"] +#[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ReleaseManifestLevels { + pub gates: u64, + pub schedules: u64, +} +#[doc = "`ReleaseManifestMigrations`"] +#[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ReleaseManifestMigrations { + #[serde(rename = "compatibleWithPrevious")] + pub compatible_with_previous: bool, + pub count: ::std::num::NonZeroU64, + pub first: MigrationName, + pub last: MigrationName, + pub names: ::std::vec::Vec, +} +#[doc = "`ReleaseManifestRuntime`"] +#[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ReleaseManifestRuntime { + #[serde(rename = "compatibilityDate")] + pub compatibility_date: ReleaseManifestRuntimeCompatibilityDate, + #[serde(rename = "compatibilityFlags")] + pub compatibility_flags: ::std::vec::Vec, +} +#[doc = "`ReleaseManifestRuntimeCompatibilityDate`"] +#[derive(:: serde :: Serialize, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct ReleaseManifestRuntimeCompatibilityDate(::std::string::String); +impl ::std::ops::Deref for ReleaseManifestRuntimeCompatibilityDate { + type Target = ::std::string::String; + fn deref(&self) -> &::std::string::String { + &self.0 + } +} +impl ::std::convert::From for ::std::string::String { + fn from(value: ReleaseManifestRuntimeCompatibilityDate) -> Self { + value.0 + } +} +impl ::std::str::FromStr for ReleaseManifestRuntimeCompatibilityDate { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + static PATTERN: ::std::sync::LazyLock<::regress::Regex> = + ::std::sync::LazyLock::new(|| { + ::regress::Regex::new("^[0-9]{4}-[0-9]{2}-[0-9]{2}$").unwrap() + }); + if PATTERN.find(value).is_none() { + return Err("doesn't match pattern \"^[0-9]{4}-[0-9]{2}-[0-9]{2}$\"".into()); + } + Ok(Self(value.to_string())) + } +} +impl ::std::convert::TryFrom<&str> for ReleaseManifestRuntimeCompatibilityDate { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ReleaseManifestRuntimeCompatibilityDate { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +impl<'de> ::serde::Deserialize<'de> for ReleaseManifestRuntimeCompatibilityDate { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + ::std::string::String::deserialize(deserializer)? + .parse() + .map_err(|e: self::error::ConversionError| { + ::custom(e.to_string()) + }) + } +} +#[doc = "`ReleaseManifestRuntimeCompatibilityFlagsItem`"] +#[derive(:: serde :: Serialize, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct ReleaseManifestRuntimeCompatibilityFlagsItem(::std::string::String); +impl ::std::ops::Deref for ReleaseManifestRuntimeCompatibilityFlagsItem { + type Target = ::std::string::String; + fn deref(&self) -> &::std::string::String { + &self.0 + } +} +impl ::std::convert::From for ::std::string::String { + fn from(value: ReleaseManifestRuntimeCompatibilityFlagsItem) -> Self { + value.0 + } +} +impl ::std::str::FromStr for ReleaseManifestRuntimeCompatibilityFlagsItem { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + static PATTERN: ::std::sync::LazyLock<::regress::Regex> = + ::std::sync::LazyLock::new(|| ::regress::Regex::new("^[a-z0-9_]+$").unwrap()); + if PATTERN.find(value).is_none() { + return Err("doesn't match pattern \"^[a-z0-9_]+$\"".into()); + } + Ok(Self(value.to_string())) + } +} +impl ::std::convert::TryFrom<&str> for ReleaseManifestRuntimeCompatibilityFlagsItem { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> + for ReleaseManifestRuntimeCompatibilityFlagsItem +{ + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +impl<'de> ::serde::Deserialize<'de> for ReleaseManifestRuntimeCompatibilityFlagsItem { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + ::std::string::String::deserialize(deserializer)? + .parse() + .map_err(|e: self::error::ConversionError| { + ::custom(e.to_string()) + }) + } +} +#[doc = "`ReleaseManifestSigning`"] +#[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ReleaseManifestSigning { + pub algorithm: ReleaseManifestSigningAlgorithm, + #[serde(rename = "keyId")] + pub key_id: ReleaseManifestSigningKeyId, + pub signature: ReleaseManifestSigningSignature, +} +#[doc = "`ReleaseManifestSigningAlgorithm`"] +#[derive( + :: serde :: Deserialize, + :: serde :: Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd, +)] +pub enum ReleaseManifestSigningAlgorithm { + Ed25519, +} +impl ::std::fmt::Display for ReleaseManifestSigningAlgorithm { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Ed25519 => f.write_str("Ed25519"), + } + } +} +impl ::std::str::FromStr for ReleaseManifestSigningAlgorithm { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + match value { + "Ed25519" => Ok(Self::Ed25519), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for ReleaseManifestSigningAlgorithm { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ReleaseManifestSigningAlgorithm { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +#[doc = "`ReleaseManifestSigningKeyId`"] +#[derive(:: serde :: Serialize, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct ReleaseManifestSigningKeyId(::std::string::String); +impl ::std::ops::Deref for ReleaseManifestSigningKeyId { + type Target = ::std::string::String; + fn deref(&self) -> &::std::string::String { + &self.0 + } +} +impl ::std::convert::From for ::std::string::String { + fn from(value: ReleaseManifestSigningKeyId) -> Self { + value.0 + } +} +impl ::std::str::FromStr for ReleaseManifestSigningKeyId { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + static PATTERN: ::std::sync::LazyLock<::regress::Regex> = + ::std::sync::LazyLock::new(|| { + ::regress::Regex::new("^[a-z0-9][a-z0-9-]{2,63}$").unwrap() + }); + if PATTERN.find(value).is_none() { + return Err("doesn't match pattern \"^[a-z0-9][a-z0-9-]{2,63}$\"".into()); + } + Ok(Self(value.to_string())) + } +} +impl ::std::convert::TryFrom<&str> for ReleaseManifestSigningKeyId { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ReleaseManifestSigningKeyId { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +impl<'de> ::serde::Deserialize<'de> for ReleaseManifestSigningKeyId { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + ::std::string::String::deserialize(deserializer)? + .parse() + .map_err(|e: self::error::ConversionError| { + ::custom(e.to_string()) + }) + } +} +#[doc = "`ReleaseManifestSigningSignature`"] +#[derive( + :: serde :: Deserialize, + :: serde :: Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd, +)] +pub enum ReleaseManifestSigningSignature { + #[serde(rename = "manifest.sig")] + ManifestSig, + #[serde(rename = "release-statement.sig")] + ReleaseStatementSig, +} +impl ::std::fmt::Display for ReleaseManifestSigningSignature { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::ManifestSig => f.write_str("manifest.sig"), + Self::ReleaseStatementSig => f.write_str("release-statement.sig"), + } + } +} +impl ::std::str::FromStr for ReleaseManifestSigningSignature { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + match value { + "manifest.sig" => Ok(Self::ManifestSig), + "release-statement.sig" => Ok(Self::ReleaseStatementSig), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for ReleaseManifestSigningSignature { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ReleaseManifestSigningSignature { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +#[doc = "`ReleaseManifestSkillsItem`"] +#[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ReleaseManifestSkillsItem { + pub name: ReleaseManifestSkillsItemName, + pub sha256: Sha256Hex, +} +#[doc = "`ReleaseManifestSkillsItemName`"] +#[derive(:: serde :: Serialize, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct ReleaseManifestSkillsItemName(::std::string::String); +impl ::std::ops::Deref for ReleaseManifestSkillsItemName { + type Target = ::std::string::String; + fn deref(&self) -> &::std::string::String { + &self.0 + } +} +impl ::std::convert::From for ::std::string::String { + fn from(value: ReleaseManifestSkillsItemName) -> Self { + value.0 + } +} +impl ::std::str::FromStr for ReleaseManifestSkillsItemName { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + static PATTERN: ::std::sync::LazyLock<::regress::Regex> = + ::std::sync::LazyLock::new(|| ::regress::Regex::new("^[a-z][a-z0-9-]{0,63}$").unwrap()); + if PATTERN.find(value).is_none() { + return Err("doesn't match pattern \"^[a-z][a-z0-9-]{0,63}$\"".into()); + } + Ok(Self(value.to_string())) + } +} +impl ::std::convert::TryFrom<&str> for ReleaseManifestSkillsItemName { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ReleaseManifestSkillsItemName { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +impl<'de> ::serde::Deserialize<'de> for ReleaseManifestSkillsItemName { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + ::std::string::String::deserialize(deserializer)? + .parse() + .map_err(|e: self::error::ConversionError| { + ::custom(e.to_string()) + }) + } +} +#[doc = "`ReleaseManifestStarter`"] +#[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug, PartialEq)] +#[serde(tag = "kind", deny_unknown_fields)] +pub enum ReleaseManifestStarter { + #[serde(rename = "admin-api")] + AdminApi, + #[serde(rename = "bundle")] + Bundle { + bytes: ::std::num::NonZeroU64, + path: ReleaseManifestStarterPath, + sha256: Sha256Hex, + #[serde(rename = "siteId")] + site_id: SiteId, + }, +} +#[doc = "`ReleaseManifestStarterPath`"] +#[derive(:: serde :: Serialize, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct ReleaseManifestStarterPath(::std::string::String); +impl ::std::ops::Deref for ReleaseManifestStarterPath { + type Target = ::std::string::String; + fn deref(&self) -> &::std::string::String { + &self.0 + } +} +impl ::std::convert::From for ::std::string::String { + fn from(value: ReleaseManifestStarterPath) -> Self { + value.0 + } +} +impl ::std::str::FromStr for ReleaseManifestStarterPath { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + static PATTERN: ::std::sync::LazyLock<::regress::Regex> = + ::std::sync::LazyLock::new(|| { + ::regress::Regex::new("^starter/[A-Za-z0-9._-]+$").unwrap() + }); + if PATTERN.find(value).is_none() { + return Err("doesn't match pattern \"^starter/[A-Za-z0-9._-]+$\"".into()); + } + Ok(Self(value.to_string())) + } +} +impl ::std::convert::TryFrom<&str> for ReleaseManifestStarterPath { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ReleaseManifestStarterPath { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +impl<'de> ::serde::Deserialize<'de> for ReleaseManifestStarterPath { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + ::std::string::String::deserialize(deserializer)? + .parse() + .map_err(|e: self::error::ConversionError| { + ::custom(e.to_string()) + }) + } +} +#[doc = "`ReleaseManifestTheme`"] +#[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ReleaseManifestTheme { + #[serde(rename = "blockTypes")] + pub block_types: ::std::vec::Vec<::std::string::String>, + #[serde(rename = "designSha256")] + pub design_sha256: Sha256Hex, + pub name: ReleaseManifestThemeName, + #[serde(rename = "settingsSha256")] + pub settings_sha256: Sha256Hex, + pub version: ReleaseManifestThemeVersion, +} +#[doc = "`ReleaseManifestThemeName`"] +#[derive(:: serde :: Serialize, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct ReleaseManifestThemeName(::std::string::String); +impl ::std::ops::Deref for ReleaseManifestThemeName { + type Target = ::std::string::String; + fn deref(&self) -> &::std::string::String { + &self.0 + } +} +impl ::std::convert::From for ::std::string::String { + fn from(value: ReleaseManifestThemeName) -> Self { + value.0 + } +} +impl ::std::str::FromStr for ReleaseManifestThemeName { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + static PATTERN: ::std::sync::LazyLock<::regress::Regex> = + ::std::sync::LazyLock::new(|| ::regress::Regex::new("^[a-z][a-z0-9-]{0,63}$").unwrap()); + if PATTERN.find(value).is_none() { + return Err("doesn't match pattern \"^[a-z][a-z0-9-]{0,63}$\"".into()); + } + Ok(Self(value.to_string())) + } +} +impl ::std::convert::TryFrom<&str> for ReleaseManifestThemeName { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ReleaseManifestThemeName { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +impl<'de> ::serde::Deserialize<'de> for ReleaseManifestThemeName { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + ::std::string::String::deserialize(deserializer)? + .parse() + .map_err(|e: self::error::ConversionError| { + ::custom(e.to_string()) + }) + } +} +#[doc = "`ReleaseManifestThemeVersion`"] +#[derive(:: serde :: Serialize, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct ReleaseManifestThemeVersion(::std::string::String); +impl ::std::ops::Deref for ReleaseManifestThemeVersion { + type Target = ::std::string::String; + fn deref(&self) -> &::std::string::String { + &self.0 + } +} +impl ::std::convert::From for ::std::string::String { + fn from(value: ReleaseManifestThemeVersion) -> Self { + value.0 + } +} +impl ::std::str::FromStr for ReleaseManifestThemeVersion { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + if value.chars().count() > 64usize { + return Err("longer than 64 characters".into()); + } + Ok(Self(value.to_string())) + } +} +impl ::std::convert::TryFrom<&str> for ReleaseManifestThemeVersion { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ReleaseManifestThemeVersion { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +impl<'de> ::serde::Deserialize<'de> for ReleaseManifestThemeVersion { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + ::std::string::String::deserialize(deserializer)? + .parse() + .map_err(|e: self::error::ConversionError| { + ::custom(e.to_string()) + }) + } +} +#[doc = "`ReleaseManifestToolchainValue`"] +#[derive(:: serde :: Serialize, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct ReleaseManifestToolchainValue(::std::string::String); +impl ::std::ops::Deref for ReleaseManifestToolchainValue { + type Target = ::std::string::String; + fn deref(&self) -> &::std::string::String { + &self.0 + } +} +impl ::std::convert::From for ::std::string::String { + fn from(value: ReleaseManifestToolchainValue) -> Self { + value.0 + } +} +impl ::std::str::FromStr for ReleaseManifestToolchainValue { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + if value.chars().count() > 64usize { + return Err("longer than 64 characters".into()); + } + Ok(Self(value.to_string())) + } +} +impl ::std::convert::TryFrom<&str> for ReleaseManifestToolchainValue { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ReleaseManifestToolchainValue { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +impl<'de> ::serde::Deserialize<'de> for ReleaseManifestToolchainValue { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + ::std::string::String::deserialize(deserializer)? + .parse() + .map_err(|e: self::error::ConversionError| { + ::custom(e.to_string()) + }) + } +} +#[doc = "`ReleaseManifestWorkers`"] +#[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ReleaseManifestWorkers { + pub admin: WorkerArtifact, + pub site: WorkerArtifact, +} +#[doc = "Version of this contract. Readers reject unknown versions; migrations move documents forward one version at a time."] +#[derive(:: serde :: Serialize, Clone, Debug, PartialEq)] +#[serde(transparent)] +pub struct SchemaVersion(i64); +impl ::std::ops::Deref for SchemaVersion { + type Target = i64; + fn deref(&self) -> &i64 { + &self.0 + } +} +impl ::std::convert::From for i64 { + fn from(value: SchemaVersion) -> Self { + value.0 + } +} +impl ::std::convert::TryFrom for SchemaVersion { + type Error = self::error::ConversionError; + fn try_from(value: i64) -> ::std::result::Result { + if ![1_i64].contains(&value) { + Err("invalid value".into()) + } else { + Ok(Self(value)) + } + } +} +impl<'de> ::serde::Deserialize<'de> for SchemaVersion { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + Self::try_from(::deserialize(deserializer)?) + .map_err(|e| ::custom(e.to_string())) + } +} +#[doc = "`ScopeId`"] +#[derive(:: serde :: Serialize, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct ScopeId(::std::string::String); +impl ::std::ops::Deref for ScopeId { + type Target = ::std::string::String; + fn deref(&self) -> &::std::string::String { + &self.0 + } +} +impl ::std::convert::From for ::std::string::String { + fn from(value: ScopeId) -> Self { + value.0 + } +} +impl ::std::str::FromStr for ScopeId { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + static PATTERN: ::std::sync::LazyLock<::regress::Regex> = + ::std::sync::LazyLock::new(|| { + ::regress::Regex::new("^[a-z0-9-]+(\\.[a-z0-9-]+)+$").unwrap() + }); + if PATTERN.find(value).is_none() { + return Err("doesn't match pattern \"^[a-z0-9-]+(\\.[a-z0-9-]+)+$\"".into()); + } + Ok(Self(value.to_string())) + } +} +impl ::std::convert::TryFrom<&str> for ScopeId { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ScopeId { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +impl<'de> ::serde::Deserialize<'de> for ScopeId { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + ::std::string::String::deserialize(deserializer)? + .parse() + .map_err(|e: self::error::ConversionError| { + ::custom(e.to_string()) + }) + } +} +#[doc = "`SecretName`"] +#[derive( + :: serde :: Deserialize, + :: serde :: Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd, +)] +pub enum SecretName { + #[serde(rename = "BETTER_AUTH_SECRET")] + BetterAuthSecret, + #[serde(rename = "MCP_STATE_KEY")] + McpStateKey, + #[serde(rename = "CLOUDHAL_PREVIEW_SECRET")] + CloudhalPreviewSecret, + #[serde(rename = "CLOUDHAL_PURGE_TOKEN")] + CloudhalPurgeToken, + #[serde(rename = "CLOUDHAL_DEPLOY_TOKEN")] + CloudhalDeployToken, + #[serde(rename = "CLOUDHAL_SETUP_TOKEN")] + CloudhalSetupToken, + #[serde(rename = "CLOUDHAL_COMMENTS_KEY")] + CloudhalCommentsKey, +} +impl ::std::fmt::Display for SecretName { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::BetterAuthSecret => f.write_str("BETTER_AUTH_SECRET"), + Self::McpStateKey => f.write_str("MCP_STATE_KEY"), + Self::CloudhalPreviewSecret => f.write_str("CLOUDHAL_PREVIEW_SECRET"), + Self::CloudhalPurgeToken => f.write_str("CLOUDHAL_PURGE_TOKEN"), + Self::CloudhalDeployToken => f.write_str("CLOUDHAL_DEPLOY_TOKEN"), + Self::CloudhalSetupToken => f.write_str("CLOUDHAL_SETUP_TOKEN"), + Self::CloudhalCommentsKey => f.write_str("CLOUDHAL_COMMENTS_KEY"), + } + } +} +impl ::std::str::FromStr for SecretName { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + match value { + "BETTER_AUTH_SECRET" => Ok(Self::BetterAuthSecret), + "MCP_STATE_KEY" => Ok(Self::McpStateKey), + "CLOUDHAL_PREVIEW_SECRET" => Ok(Self::CloudhalPreviewSecret), + "CLOUDHAL_PURGE_TOKEN" => Ok(Self::CloudhalPurgeToken), + "CLOUDHAL_DEPLOY_TOKEN" => Ok(Self::CloudhalDeployToken), + "CLOUDHAL_SETUP_TOKEN" => Ok(Self::CloudhalSetupToken), + "CLOUDHAL_COMMENTS_KEY" => Ok(Self::CloudhalCommentsKey), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for SecretName { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for SecretName { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +#[doc = "`Sha256Hex`"] +#[derive(:: serde :: Serialize, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct Sha256Hex(::std::string::String); +impl ::std::ops::Deref for Sha256Hex { + type Target = ::std::string::String; + fn deref(&self) -> &::std::string::String { + &self.0 + } +} +impl ::std::convert::From for ::std::string::String { + fn from(value: Sha256Hex) -> Self { + value.0 + } +} +impl ::std::str::FromStr for Sha256Hex { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + static PATTERN: ::std::sync::LazyLock<::regress::Regex> = + ::std::sync::LazyLock::new(|| ::regress::Regex::new("^[0-9a-f]{64}$").unwrap()); + if PATTERN.find(value).is_none() { + return Err("doesn't match pattern \"^[0-9a-f]{64}$\"".into()); + } + Ok(Self(value.to_string())) + } +} +impl ::std::convert::TryFrom<&str> for Sha256Hex { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for Sha256Hex { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +impl<'de> ::serde::Deserialize<'de> for Sha256Hex { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + ::std::string::String::deserialize(deserializer)? + .parse() + .map_err(|e: self::error::ConversionError| { + ::custom(e.to_string()) + }) + } +} +#[doc = "`SiteId`"] +#[derive(:: serde :: Serialize, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct SiteId(::std::string::String); +impl ::std::ops::Deref for SiteId { + type Target = ::std::string::String; + fn deref(&self) -> &::std::string::String { + &self.0 + } +} +impl ::std::convert::From for ::std::string::String { + fn from(value: SiteId) -> Self { + value.0 + } +} +impl ::std::str::FromStr for SiteId { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + static PATTERN: ::std::sync::LazyLock<::regress::Regex> = + ::std::sync::LazyLock::new(|| { + ::regress::Regex::new("^site_[A-Za-z0-9]{6,32}$").unwrap() + }); + if PATTERN.find(value).is_none() { + return Err("doesn't match pattern \"^site_[A-Za-z0-9]{6,32}$\"".into()); + } + Ok(Self(value.to_string())) + } +} +impl ::std::convert::TryFrom<&str> for SiteId { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for SiteId { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +impl<'de> ::serde::Deserialize<'de> for SiteId { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + ::std::string::String::deserialize(deserializer)? + .parse() + .map_err(|e: self::error::ConversionError| { + ::custom(e.to_string()) + }) + } +} +#[doc = "RFC 3339 / ISO 8601 instant in UTC."] +#[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug, PartialEq)] +#[serde(transparent)] +pub struct Timestamp(pub ::chrono::DateTime<::chrono::offset::Utc>); +impl ::std::ops::Deref for Timestamp { + type Target = ::chrono::DateTime<::chrono::offset::Utc>; + fn deref(&self) -> &::chrono::DateTime<::chrono::offset::Utc> { + &self.0 + } +} +impl ::std::convert::From for ::chrono::DateTime<::chrono::offset::Utc> { + fn from(value: Timestamp) -> Self { + value.0 + } +} +impl ::std::convert::From<::chrono::DateTime<::chrono::offset::Utc>> for Timestamp { + fn from(value: ::chrono::DateTime<::chrono::offset::Utc>) -> Self { + Self(value) + } +} +impl ::std::fmt::Display for Timestamp { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + self.0.fmt(f) + } +} +impl ::std::str::FromStr for Timestamp { + type Err = <::chrono::DateTime<::chrono::offset::Utc> as ::std::str::FromStr>::Err; + fn from_str(value: &str) -> ::std::result::Result { + Ok(Self(value.parse()?)) + } +} +impl ::std::convert::TryFrom<&str> for Timestamp { + type Error = <::chrono::DateTime<::chrono::offset::Utc> as ::std::str::FromStr>::Err; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom for Timestamp { + type Error = <::chrono::DateTime<::chrono::offset::Utc> as ::std::str::FromStr>::Err; + fn try_from(value: String) -> ::std::result::Result { + value.parse() + } +} +#[doc = "`WorkerArtifact`"] +#[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct WorkerArtifact { + pub assets: WorkerArtifactAssets, + #[serde( + rename = "bindingTemplate", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub binding_template: ::std::option::Option, + #[serde(rename = "mainModule")] + pub main_module: WorkerArtifactMainModule, + pub modules: ::std::vec::Vec, +} +#[doc = "`WorkerArtifactAssets`"] +#[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct WorkerArtifactAssets { + pub bytes: u64, + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub config: ::std::option::Option, + pub count: i64, + pub files: ::std::vec::Vec, + pub headers: WorkerArtifactAssetsHeaders, +} +#[doc = "`WorkerArtifactAssetsConfig`"] +#[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug, Default, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct WorkerArtifactAssetsConfig { + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub not_found_handling: ::std::option::Option, + #[serde(default, skip_serializing_if = "::std::vec::Vec::is_empty")] + pub run_worker_first: ::std::vec::Vec, +} +#[doc = "`WorkerArtifactAssetsConfigNotFoundHandling`"] +#[derive( + :: serde :: Deserialize, + :: serde :: Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd, +)] +pub enum WorkerArtifactAssetsConfigNotFoundHandling { + #[serde(rename = "none")] + None, + #[serde(rename = "404-page")] + X404Page, + #[serde(rename = "single-page-application")] + SinglePageApplication, +} +impl ::std::fmt::Display for WorkerArtifactAssetsConfigNotFoundHandling { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::None => f.write_str("none"), + Self::X404Page => f.write_str("404-page"), + Self::SinglePageApplication => f.write_str("single-page-application"), + } + } +} +impl ::std::str::FromStr for WorkerArtifactAssetsConfigNotFoundHandling { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + match value { + "none" => Ok(Self::None), + "404-page" => Ok(Self::X404Page), + "single-page-application" => Ok(Self::SinglePageApplication), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for WorkerArtifactAssetsConfigNotFoundHandling { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for WorkerArtifactAssetsConfigNotFoundHandling { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +#[doc = "`WorkerArtifactAssetsConfigRunWorkerFirstItem`"] +#[derive(:: serde :: Serialize, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct WorkerArtifactAssetsConfigRunWorkerFirstItem(::std::string::String); +impl ::std::ops::Deref for WorkerArtifactAssetsConfigRunWorkerFirstItem { + type Target = ::std::string::String; + fn deref(&self) -> &::std::string::String { + &self.0 + } +} +impl ::std::convert::From for ::std::string::String { + fn from(value: WorkerArtifactAssetsConfigRunWorkerFirstItem) -> Self { + value.0 + } +} +impl ::std::str::FromStr for WorkerArtifactAssetsConfigRunWorkerFirstItem { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + static PATTERN: ::std::sync::LazyLock<::regress::Regex> = + ::std::sync::LazyLock::new(|| ::regress::Regex::new("^/").unwrap()); + if PATTERN.find(value).is_none() { + return Err("doesn't match pattern \"^/\"".into()); + } + Ok(Self(value.to_string())) + } +} +impl ::std::convert::TryFrom<&str> for WorkerArtifactAssetsConfigRunWorkerFirstItem { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> + for WorkerArtifactAssetsConfigRunWorkerFirstItem +{ + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +impl<'de> ::serde::Deserialize<'de> for WorkerArtifactAssetsConfigRunWorkerFirstItem { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + ::std::string::String::deserialize(deserializer)? + .parse() + .map_err(|e: self::error::ConversionError| { + ::custom(e.to_string()) + }) + } +} +#[doc = "`WorkerArtifactAssetsFilesItem`"] +#[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct WorkerArtifactAssetsFilesItem { + #[serde(rename = "assetHash")] + pub asset_hash: WorkerArtifactAssetsFilesItemAssetHash, + pub bytes: i64, + #[serde(rename = "contentType")] + pub content_type: WorkerArtifactAssetsFilesItemContentType, + pub path: WorkerArtifactAssetsFilesItemPath, + pub sha256: Sha256Hex, +} +#[doc = "`WorkerArtifactAssetsFilesItemAssetHash`"] +#[derive(:: serde :: Serialize, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct WorkerArtifactAssetsFilesItemAssetHash(::std::string::String); +impl ::std::ops::Deref for WorkerArtifactAssetsFilesItemAssetHash { + type Target = ::std::string::String; + fn deref(&self) -> &::std::string::String { + &self.0 + } +} +impl ::std::convert::From for ::std::string::String { + fn from(value: WorkerArtifactAssetsFilesItemAssetHash) -> Self { + value.0 + } +} +impl ::std::str::FromStr for WorkerArtifactAssetsFilesItemAssetHash { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + static PATTERN: ::std::sync::LazyLock<::regress::Regex> = + ::std::sync::LazyLock::new(|| ::regress::Regex::new("^[0-9a-f]{32}$").unwrap()); + if PATTERN.find(value).is_none() { + return Err("doesn't match pattern \"^[0-9a-f]{32}$\"".into()); + } + Ok(Self(value.to_string())) + } +} +impl ::std::convert::TryFrom<&str> for WorkerArtifactAssetsFilesItemAssetHash { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for WorkerArtifactAssetsFilesItemAssetHash { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +impl<'de> ::serde::Deserialize<'de> for WorkerArtifactAssetsFilesItemAssetHash { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + ::std::string::String::deserialize(deserializer)? + .parse() + .map_err(|e: self::error::ConversionError| { + ::custom(e.to_string()) + }) + } +} +#[doc = "`WorkerArtifactAssetsFilesItemContentType`"] +#[derive(:: serde :: Serialize, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct WorkerArtifactAssetsFilesItemContentType(::std::string::String); +impl ::std::ops::Deref for WorkerArtifactAssetsFilesItemContentType { + type Target = ::std::string::String; + fn deref(&self) -> &::std::string::String { + &self.0 + } +} +impl ::std::convert::From for ::std::string::String { + fn from(value: WorkerArtifactAssetsFilesItemContentType) -> Self { + value.0 + } +} +impl ::std::str::FromStr for WorkerArtifactAssetsFilesItemContentType { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + if value.chars().count() > 128usize { + return Err("longer than 128 characters".into()); + } + Ok(Self(value.to_string())) + } +} +impl ::std::convert::TryFrom<&str> for WorkerArtifactAssetsFilesItemContentType { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for WorkerArtifactAssetsFilesItemContentType { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +impl<'de> ::serde::Deserialize<'de> for WorkerArtifactAssetsFilesItemContentType { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + ::std::string::String::deserialize(deserializer)? + .parse() + .map_err(|e: self::error::ConversionError| { + ::custom(e.to_string()) + }) + } +} +#[doc = "`WorkerArtifactAssetsFilesItemPath`"] +#[derive(:: serde :: Serialize, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct WorkerArtifactAssetsFilesItemPath(::std::string::String); +impl ::std::ops::Deref for WorkerArtifactAssetsFilesItemPath { + type Target = ::std::string::String; + fn deref(&self) -> &::std::string::String { + &self.0 + } +} +impl ::std::convert::From for ::std::string::String { + fn from(value: WorkerArtifactAssetsFilesItemPath) -> Self { + value.0 + } +} +impl ::std::str::FromStr for WorkerArtifactAssetsFilesItemPath { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + if value.chars().count() > 1024usize { + return Err("longer than 1024 characters".into()); + } + static PATTERN: ::std::sync::LazyLock<::regress::Regex> = + ::std::sync::LazyLock::new(|| ::regress::Regex::new("^/[^\\s]*$").unwrap()); + if PATTERN.find(value).is_none() { + return Err("doesn't match pattern \"^/[^\\s]*$\"".into()); + } + Ok(Self(value.to_string())) + } +} +impl ::std::convert::TryFrom<&str> for WorkerArtifactAssetsFilesItemPath { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for WorkerArtifactAssetsFilesItemPath { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +impl<'de> ::serde::Deserialize<'de> for WorkerArtifactAssetsFilesItemPath { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + ::std::string::String::deserialize(deserializer)? + .parse() + .map_err(|e: self::error::ConversionError| { + ::custom(e.to_string()) + }) + } +} +#[doc = "`WorkerArtifactAssetsHeaders`"] +#[derive(:: serde :: Serialize, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct WorkerArtifactAssetsHeaders(::std::string::String); +impl ::std::ops::Deref for WorkerArtifactAssetsHeaders { + type Target = ::std::string::String; + fn deref(&self) -> &::std::string::String { + &self.0 + } +} +impl ::std::convert::From for ::std::string::String { + fn from(value: WorkerArtifactAssetsHeaders) -> Self { + value.0 + } +} +impl ::std::str::FromStr for WorkerArtifactAssetsHeaders { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + if value.chars().count() > 100000usize { + return Err("longer than 100000 characters".into()); + } + Ok(Self(value.to_string())) + } +} +impl ::std::convert::TryFrom<&str> for WorkerArtifactAssetsHeaders { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for WorkerArtifactAssetsHeaders { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +impl<'de> ::serde::Deserialize<'de> for WorkerArtifactAssetsHeaders { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + ::std::string::String::deserialize(deserializer)? + .parse() + .map_err(|e: self::error::ConversionError| { + ::custom(e.to_string()) + }) + } +} +#[doc = "`WorkerArtifactBindingTemplate`"] +#[derive( + :: serde :: Deserialize, + :: serde :: Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd, +)] +pub enum WorkerArtifactBindingTemplate { + #[serde(rename = "site")] + Site, + #[serde(rename = "admin")] + Admin, +} +impl ::std::fmt::Display for WorkerArtifactBindingTemplate { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Site => f.write_str("site"), + Self::Admin => f.write_str("admin"), + } + } +} +impl ::std::str::FromStr for WorkerArtifactBindingTemplate { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + match value { + "site" => Ok(Self::Site), + "admin" => Ok(Self::Admin), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for WorkerArtifactBindingTemplate { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for WorkerArtifactBindingTemplate { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +#[doc = "`WorkerArtifactMainModule`"] +#[derive(:: serde :: Serialize, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct WorkerArtifactMainModule(::std::string::String); +impl ::std::ops::Deref for WorkerArtifactMainModule { + type Target = ::std::string::String; + fn deref(&self) -> &::std::string::String { + &self.0 + } +} +impl ::std::convert::From for ::std::string::String { + fn from(value: WorkerArtifactMainModule) -> Self { + value.0 + } +} +impl ::std::str::FromStr for WorkerArtifactMainModule { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + static PATTERN: ::std::sync::LazyLock<::regress::Regex> = + ::std::sync::LazyLock::new(|| ::regress::Regex::new("^[A-Za-z0-9._/-]+$").unwrap()); + if PATTERN.find(value).is_none() { + return Err("doesn't match pattern \"^[A-Za-z0-9._/-]+$\"".into()); + } + Ok(Self(value.to_string())) + } +} +impl ::std::convert::TryFrom<&str> for WorkerArtifactMainModule { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for WorkerArtifactMainModule { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +impl<'de> ::serde::Deserialize<'de> for WorkerArtifactMainModule { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + ::std::string::String::deserialize(deserializer)? + .parse() + .map_err(|e: self::error::ConversionError| { + ::custom(e.to_string()) + }) + } +} +#[doc = "`WorkerArtifactModulesItem`"] +#[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct WorkerArtifactModulesItem { + pub bytes: ::std::num::NonZeroU64, + pub path: WorkerArtifactModulesItemPath, + pub sha256: Sha256Hex, + #[serde(rename = "type")] + pub type_: WorkerArtifactModulesItemType, +} +#[doc = "`WorkerArtifactModulesItemPath`"] +#[derive(:: serde :: Serialize, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct WorkerArtifactModulesItemPath(::std::string::String); +impl ::std::ops::Deref for WorkerArtifactModulesItemPath { + type Target = ::std::string::String; + fn deref(&self) -> &::std::string::String { + &self.0 + } +} +impl ::std::convert::From for ::std::string::String { + fn from(value: WorkerArtifactModulesItemPath) -> Self { + value.0 + } +} +impl ::std::str::FromStr for WorkerArtifactModulesItemPath { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + static PATTERN: ::std::sync::LazyLock<::regress::Regex> = + ::std::sync::LazyLock::new(|| { + ::regress::Regex::new("^[A-Za-z0-9._-]+(/[A-Za-z0-9._-]+)*$").unwrap() + }); + if PATTERN.find(value).is_none() { + return Err("doesn't match pattern \"^[A-Za-z0-9._-]+(/[A-Za-z0-9._-]+)*$\"".into()); + } + Ok(Self(value.to_string())) + } +} +impl ::std::convert::TryFrom<&str> for WorkerArtifactModulesItemPath { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for WorkerArtifactModulesItemPath { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +impl<'de> ::serde::Deserialize<'de> for WorkerArtifactModulesItemPath { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + ::std::string::String::deserialize(deserializer)? + .parse() + .map_err(|e: self::error::ConversionError| { + ::custom(e.to_string()) + }) + } +} +#[doc = "`WorkerArtifactModulesItemType`"] +#[derive( + :: serde :: Deserialize, + :: serde :: Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd, +)] +pub enum WorkerArtifactModulesItemType { + #[serde(rename = "esm")] + Esm, + #[serde(rename = "cjs")] + Cjs, + #[serde(rename = "wasm")] + Wasm, + #[serde(rename = "text")] + Text, + #[serde(rename = "data")] + Data, +} +impl ::std::fmt::Display for WorkerArtifactModulesItemType { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Esm => f.write_str("esm"), + Self::Cjs => f.write_str("cjs"), + Self::Wasm => f.write_str("wasm"), + Self::Text => f.write_str("text"), + Self::Data => f.write_str("data"), + } + } +} +impl ::std::str::FromStr for WorkerArtifactModulesItemType { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + match value { + "esm" => Ok(Self::Esm), + "cjs" => Ok(Self::Cjs), + "wasm" => Ok(Self::Wasm), + "text" => Ok(Self::Text), + "data" => Ok(Self::Data), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for WorkerArtifactModulesItemType { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for WorkerArtifactModulesItemType { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +#[doc = " Error types."] +pub mod error { + #[doc = r" Error from a `TryFrom` or `FromStr` implementation."] + pub struct ConversionError(::std::borrow::Cow<'static, str>); + impl ::std::error::Error for ConversionError {} + impl ::std::fmt::Display for ConversionError { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> Result<(), ::std::fmt::Error> { + ::std::fmt::Display::fmt(&self.0, f) + } + } + impl ::std::fmt::Debug for ConversionError { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> Result<(), ::std::fmt::Error> { + ::std::fmt::Debug::fmt(&self.0, f) + } + } + impl From<&'static str> for ConversionError { + fn from(value: &'static str) -> Self { + Self(value.into()) + } + } + impl From for ConversionError { + fn from(value: String) -> Self { + Self(value.into()) + } + } +} diff --git a/crates/cloudhal-schema/src/generated/release_statement.rs b/crates/cloudhal-schema/src/generated/release_statement.rs new file mode 100644 index 00000000..1d5072c1 --- /dev/null +++ b/crates/cloudhal-schema/src/generated/release_statement.rs @@ -0,0 +1,605 @@ +//! GENERATED FILE — DO NOT EDIT. +//! Source: packages/core-contracts/schemas/*.schema.json (run `pnpm gen`) +#![allow(clippy::all, rustdoc::all, dead_code, unused_imports)] +#![allow(clippy::needless_lifetimes)] +#![allow(clippy::match_single_binding)] +#![allow(clippy::clone_on_copy)] + +#[doc = "`CloudhalVersion`"] +#[derive(:: serde :: Serialize, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct CloudhalVersion(::std::string::String); +impl ::std::ops::Deref for CloudhalVersion { + type Target = ::std::string::String; + fn deref(&self) -> &::std::string::String { + &self.0 + } +} +impl ::std::convert::From for ::std::string::String { + fn from(value: CloudhalVersion) -> Self { + value.0 + } +} +impl ::std::str::FromStr for CloudhalVersion { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + static PATTERN: ::std::sync::LazyLock<::regress::Regex> = + ::std::sync::LazyLock::new(|| { + ::regress::Regex::new( + "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$", + ) + .unwrap() + }); + if PATTERN.find(value).is_none() { + return Err ("doesn't match pattern \"^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$\"" . into ()) ; + } + Ok(Self(value.to_string())) + } +} +impl ::std::convert::TryFrom<&str> for CloudhalVersion { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for CloudhalVersion { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +impl<'de> ::serde::Deserialize<'de> for CloudhalVersion { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + ::std::string::String::deserialize(deserializer)? + .parse() + .map_err(|e: self::error::ConversionError| { + ::custom(e.to_string()) + }) + } +} +#[doc = "cloudhal.release-statement@1 (RFC-0046): the small canonical document a Cloudhal release signs, published as the release asset release-statement.json next to release-statement.sig (a raw 64-byte Ed25519 signature over the exact bytes, at most 4096 of them). It names the version, channel, commit and key id and pins the SHA-256 and size of manifest.json and of the tarball, so a remote deployer verifies one small file and then checks every other asset by digest. Canonical bytes: keys sorted recursively, 2-space indent, a trailing newline; a consumer verifies the bytes it downloaded and never re-serialises them. Portable on purpose: it references common only for SchemaVersion and Timestamp."] +#[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ReleaseStatement { + pub channel: ReleaseStatementChannel, + pub commit: ReleaseStatementCommit, + #[serde(rename = "createdAt")] + pub created_at: Timestamp, + pub format: ReleaseStatementFormat, + pub manifest: StatementAsset, + #[serde(rename = "schemaVersion")] + pub schema_version: SchemaVersion, + #[serde(rename = "securityRelease")] + pub security_release: bool, + pub signing: ReleaseStatementSigning, + pub tarball: StatementAsset, + pub version: CloudhalVersion, +} +#[doc = "`ReleaseStatementChannel`"] +#[derive( + :: serde :: Deserialize, + :: serde :: Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd, +)] +pub enum ReleaseStatementChannel { + #[serde(rename = "stable")] + Stable, + #[serde(rename = "next")] + Next, +} +impl ::std::fmt::Display for ReleaseStatementChannel { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Stable => f.write_str("stable"), + Self::Next => f.write_str("next"), + } + } +} +impl ::std::str::FromStr for ReleaseStatementChannel { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + match value { + "stable" => Ok(Self::Stable), + "next" => Ok(Self::Next), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for ReleaseStatementChannel { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ReleaseStatementChannel { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +#[doc = "`ReleaseStatementCommit`"] +#[derive(:: serde :: Serialize, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct ReleaseStatementCommit(::std::string::String); +impl ::std::ops::Deref for ReleaseStatementCommit { + type Target = ::std::string::String; + fn deref(&self) -> &::std::string::String { + &self.0 + } +} +impl ::std::convert::From for ::std::string::String { + fn from(value: ReleaseStatementCommit) -> Self { + value.0 + } +} +impl ::std::str::FromStr for ReleaseStatementCommit { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + static PATTERN: ::std::sync::LazyLock<::regress::Regex> = + ::std::sync::LazyLock::new(|| ::regress::Regex::new("^[0-9a-f]{40}$").unwrap()); + if PATTERN.find(value).is_none() { + return Err("doesn't match pattern \"^[0-9a-f]{40}$\"".into()); + } + Ok(Self(value.to_string())) + } +} +impl ::std::convert::TryFrom<&str> for ReleaseStatementCommit { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ReleaseStatementCommit { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +impl<'de> ::serde::Deserialize<'de> for ReleaseStatementCommit { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + ::std::string::String::deserialize(deserializer)? + .parse() + .map_err(|e: self::error::ConversionError| { + ::custom(e.to_string()) + }) + } +} +#[doc = "`ReleaseStatementFormat`"] +#[derive( + :: serde :: Deserialize, + :: serde :: Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd, +)] +pub enum ReleaseStatementFormat { + #[serde(rename = "cloudhal.release-statement@1")] + CloudhalReleaseStatement1, +} +impl ::std::fmt::Display for ReleaseStatementFormat { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::CloudhalReleaseStatement1 => f.write_str("cloudhal.release-statement@1"), + } + } +} +impl ::std::str::FromStr for ReleaseStatementFormat { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + match value { + "cloudhal.release-statement@1" => Ok(Self::CloudhalReleaseStatement1), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for ReleaseStatementFormat { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ReleaseStatementFormat { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +#[doc = "`ReleaseStatementSigning`"] +#[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ReleaseStatementSigning { + pub algorithm: ReleaseStatementSigningAlgorithm, + #[serde(rename = "keyId")] + pub key_id: ReleaseStatementSigningKeyId, + pub signature: ReleaseStatementSigningSignature, +} +#[doc = "`ReleaseStatementSigningAlgorithm`"] +#[derive( + :: serde :: Deserialize, + :: serde :: Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd, +)] +pub enum ReleaseStatementSigningAlgorithm { + Ed25519, +} +impl ::std::fmt::Display for ReleaseStatementSigningAlgorithm { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Ed25519 => f.write_str("Ed25519"), + } + } +} +impl ::std::str::FromStr for ReleaseStatementSigningAlgorithm { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + match value { + "Ed25519" => Ok(Self::Ed25519), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for ReleaseStatementSigningAlgorithm { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ReleaseStatementSigningAlgorithm { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +#[doc = "`ReleaseStatementSigningKeyId`"] +#[derive(:: serde :: Serialize, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct ReleaseStatementSigningKeyId(::std::string::String); +impl ::std::ops::Deref for ReleaseStatementSigningKeyId { + type Target = ::std::string::String; + fn deref(&self) -> &::std::string::String { + &self.0 + } +} +impl ::std::convert::From for ::std::string::String { + fn from(value: ReleaseStatementSigningKeyId) -> Self { + value.0 + } +} +impl ::std::str::FromStr for ReleaseStatementSigningKeyId { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + static PATTERN: ::std::sync::LazyLock<::regress::Regex> = + ::std::sync::LazyLock::new(|| { + ::regress::Regex::new("^[a-z0-9][a-z0-9-]{2,63}$").unwrap() + }); + if PATTERN.find(value).is_none() { + return Err("doesn't match pattern \"^[a-z0-9][a-z0-9-]{2,63}$\"".into()); + } + Ok(Self(value.to_string())) + } +} +impl ::std::convert::TryFrom<&str> for ReleaseStatementSigningKeyId { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ReleaseStatementSigningKeyId { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +impl<'de> ::serde::Deserialize<'de> for ReleaseStatementSigningKeyId { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + ::std::string::String::deserialize(deserializer)? + .parse() + .map_err(|e: self::error::ConversionError| { + ::custom(e.to_string()) + }) + } +} +#[doc = "`ReleaseStatementSigningSignature`"] +#[derive( + :: serde :: Deserialize, + :: serde :: Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd, +)] +pub enum ReleaseStatementSigningSignature { + #[serde(rename = "release-statement.sig")] + ReleaseStatementSig, +} +impl ::std::fmt::Display for ReleaseStatementSigningSignature { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::ReleaseStatementSig => f.write_str("release-statement.sig"), + } + } +} +impl ::std::str::FromStr for ReleaseStatementSigningSignature { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + match value { + "release-statement.sig" => Ok(Self::ReleaseStatementSig), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for ReleaseStatementSigningSignature { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ReleaseStatementSigningSignature { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +#[doc = "Version of this contract. Readers reject unknown versions; migrations move documents forward one version at a time."] +#[derive(:: serde :: Serialize, Clone, Debug, PartialEq)] +#[serde(transparent)] +pub struct SchemaVersion(i64); +impl ::std::ops::Deref for SchemaVersion { + type Target = i64; + fn deref(&self) -> &i64 { + &self.0 + } +} +impl ::std::convert::From for i64 { + fn from(value: SchemaVersion) -> Self { + value.0 + } +} +impl ::std::convert::TryFrom for SchemaVersion { + type Error = self::error::ConversionError; + fn try_from(value: i64) -> ::std::result::Result { + if ![1_i64].contains(&value) { + Err("invalid value".into()) + } else { + Ok(Self(value)) + } + } +} +impl<'de> ::serde::Deserialize<'de> for SchemaVersion { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + Self::try_from(::deserialize(deserializer)?) + .map_err(|e| ::custom(e.to_string())) + } +} +#[doc = "`Sha256Hex`"] +#[derive(:: serde :: Serialize, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct Sha256Hex(::std::string::String); +impl ::std::ops::Deref for Sha256Hex { + type Target = ::std::string::String; + fn deref(&self) -> &::std::string::String { + &self.0 + } +} +impl ::std::convert::From for ::std::string::String { + fn from(value: Sha256Hex) -> Self { + value.0 + } +} +impl ::std::str::FromStr for Sha256Hex { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + static PATTERN: ::std::sync::LazyLock<::regress::Regex> = + ::std::sync::LazyLock::new(|| ::regress::Regex::new("^[0-9a-f]{64}$").unwrap()); + if PATTERN.find(value).is_none() { + return Err("doesn't match pattern \"^[0-9a-f]{64}$\"".into()); + } + Ok(Self(value.to_string())) + } +} +impl ::std::convert::TryFrom<&str> for Sha256Hex { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for Sha256Hex { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +impl<'de> ::serde::Deserialize<'de> for Sha256Hex { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + ::std::string::String::deserialize(deserializer)? + .parse() + .map_err(|e: self::error::ConversionError| { + ::custom(e.to_string()) + }) + } +} +#[doc = "`StatementAsset`"] +#[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct StatementAsset { + pub bytes: ::std::num::NonZeroU64, + pub name: StatementAssetName, + pub sha256: Sha256Hex, +} +#[doc = "`StatementAssetName`"] +#[derive(:: serde :: Serialize, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct StatementAssetName(::std::string::String); +impl ::std::ops::Deref for StatementAssetName { + type Target = ::std::string::String; + fn deref(&self) -> &::std::string::String { + &self.0 + } +} +impl ::std::convert::From for ::std::string::String { + fn from(value: StatementAssetName) -> Self { + value.0 + } +} +impl ::std::str::FromStr for StatementAssetName { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + static PATTERN: ::std::sync::LazyLock<::regress::Regex> = + ::std::sync::LazyLock::new(|| { + ::regress::Regex::new("^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$").unwrap() + }); + if PATTERN.find(value).is_none() { + return Err("doesn't match pattern \"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$\"".into()); + } + Ok(Self(value.to_string())) + } +} +impl ::std::convert::TryFrom<&str> for StatementAssetName { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for StatementAssetName { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +impl<'de> ::serde::Deserialize<'de> for StatementAssetName { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + ::std::string::String::deserialize(deserializer)? + .parse() + .map_err(|e: self::error::ConversionError| { + ::custom(e.to_string()) + }) + } +} +#[doc = "RFC 3339 / ISO 8601 instant in UTC."] +#[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug, PartialEq)] +#[serde(transparent)] +pub struct Timestamp(pub ::chrono::DateTime<::chrono::offset::Utc>); +impl ::std::ops::Deref for Timestamp { + type Target = ::chrono::DateTime<::chrono::offset::Utc>; + fn deref(&self) -> &::chrono::DateTime<::chrono::offset::Utc> { + &self.0 + } +} +impl ::std::convert::From for ::chrono::DateTime<::chrono::offset::Utc> { + fn from(value: Timestamp) -> Self { + value.0 + } +} +impl ::std::convert::From<::chrono::DateTime<::chrono::offset::Utc>> for Timestamp { + fn from(value: ::chrono::DateTime<::chrono::offset::Utc>) -> Self { + Self(value) + } +} +impl ::std::fmt::Display for Timestamp { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + self.0.fmt(f) + } +} +impl ::std::str::FromStr for Timestamp { + type Err = <::chrono::DateTime<::chrono::offset::Utc> as ::std::str::FromStr>::Err; + fn from_str(value: &str) -> ::std::result::Result { + Ok(Self(value.parse()?)) + } +} +impl ::std::convert::TryFrom<&str> for Timestamp { + type Error = <::chrono::DateTime<::chrono::offset::Utc> as ::std::str::FromStr>::Err; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom for Timestamp { + type Error = <::chrono::DateTime<::chrono::offset::Utc> as ::std::str::FromStr>::Err; + fn try_from(value: String) -> ::std::result::Result { + value.parse() + } +} +#[doc = " Error types."] +pub mod error { + #[doc = r" Error from a `TryFrom` or `FromStr` implementation."] + pub struct ConversionError(::std::borrow::Cow<'static, str>); + impl ::std::error::Error for ConversionError {} + impl ::std::fmt::Display for ConversionError { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> Result<(), ::std::fmt::Error> { + ::std::fmt::Display::fmt(&self.0, f) + } + } + impl ::std::fmt::Debug for ConversionError { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> Result<(), ::std::fmt::Error> { + ::std::fmt::Debug::fmt(&self.0, f) + } + } + impl From<&'static str> for ConversionError { + fn from(value: &'static str) -> Self { + Self(value.into()) + } + } + impl From for ConversionError { + fn from(value: String) -> Self { + Self(value.into()) + } + } +} diff --git a/crates/cloudhal-schema/src/lib.rs b/crates/cloudhal-schema/src/lib.rs index b0c65743..30ef60b0 100644 --- a/crates/cloudhal-schema/src/lib.rs +++ b/crates/cloudhal-schema/src/lib.rs @@ -18,6 +18,8 @@ pub use generated::page::Page; pub use generated::project::Project; pub use generated::redirect::Redirect; pub use generated::release::Release; +pub use generated::release_manifest::ReleaseManifest; +pub use generated::release_statement::ReleaseStatement; pub use generated::schedule_run::ScheduleRun; pub use generated::section::Section; pub use generated::site::Site; diff --git a/crates/cloudhal-schema/tests/fixtures.rs b/crates/cloudhal-schema/tests/fixtures.rs index 2a340da9..10d23436 100644 --- a/crates/cloudhal-schema/tests/fixtures.rs +++ b/crates/cloudhal-schema/tests/fixtures.rs @@ -157,3 +157,11 @@ fn automation() { fn schedule_run() { check::("schedule-run"); } +#[test] +fn release_manifest() { + check::("release-manifest"); +} +#[test] +fn release_statement() { + check::("release-statement"); +} diff --git a/docs/rfcs/0046-release-bundles.md b/docs/rfcs/0046-release-bundles.md new file mode 100644 index 00000000..71266634 --- /dev/null +++ b/docs/rfcs/0046-release-bundles.md @@ -0,0 +1,238 @@ +# RFC-0046: Signed release bundles + +- Status: Proposed +- Author: Halil (Madraka), drafted with Claude · Date: 2026-11-12 +- Related: + - ADR-0001 / ADR-0008 (the contract pipeline, precompiled validators), ADR-0012 (the site and admin Workers), ADR-0013 (npm packaging: one version for the fixed group), ADR-0037 (`cloudhal.dev/schemas/v1` permanent URLs). + - RFC-0008 (`schedulesLevel`, the downgrade guard), RFC-0034 (several site Workers), RFC-0042 / RFC-0043 (the `gates` and `status` feature levels). + - Code read: `.github/workflows/release.yml` (job graph; npm's trusted-publisher entry names this file and the `npm-publish` environment), `scripts/publish-ci.ts` (today: npm publish, then `gh release create --verify-tag --latest`), `packages/cli/src/archive/` (RFC-0039's deterministic tar and fixed-header gzip), `packages/cli/src/commands/deploy/`, `packages/core-contracts/schemas/common.schema.json`. + - Sources (checked 2026-11-11): + - GitHub, [immutable releases](https://docs.github.com/en/code-security/concepts/supply-chain-security/immutable-releases): once published, a release's assets cannot be added, changed or deleted, and the tag of a deleted immutable release cannot be reused. This repository has immutable releases on; `v0.8.0` (the latest when this RFC was written) is immutable with no assets. + - GitHub, [artifact attestations with reusable workflows](https://docs.github.com/en/actions/security-for-github-actions/using-artifact-attestations/using-artifact-attestations-and-reusable-workflows-to-achieve-slsa-v1-build-level-3): the build steps move into a reusable workflow, which must also produce the attestation; `gh attestation verify --signer-workflow` checks it. + - AWS KMS [`Sign`](https://docs.aws.amazon.com/kms/latest/APIReference/API_Sign.html): a `RAW` message is at most 4096 bytes (pure Ed25519 signs the message itself). + - WebCrypto Ed25519 `verify`; Workers `DecompressionStream` (gzip and deflate, no zstd); Workers static assets (20,000 files and 25 MiB per file per Worker). + - The WordPress update-signing history (`wp-admin/includes/file.php`): a single trusted key that lapsed in 2021 with soft-fail verification as the default, the failure mode this design avoids. + +## Summary + +Every Cloudhal version `vX.Y.Z` gains a **release bundle**: the built site and admin Worker modules and static assets of the standard theme in one deterministic tarball, a `manifest.json` (`cloudhal.release-manifest@1`) describing every part by size and SHA-256 together with the facts a deployer needs before uploading anything, and a small canonical `release-statement.json` (`cloudhal.release-statement@1`) that pins the manifest and the tarball and is the only file the project key signs. The bundle is built and attested in one reusable workflow that holds no secret, signed in a separate job only after that attestation verifies, and attached to a **draft** GitHub Release before npm publish, so the release is complete and immutable from the moment anyone can see it. Anyone (a CLI user, a CI job, a hosting provider, a remote deployer) can install or update the standard theme by downloading, verifying and uploading known-good bytes, with no toolchain and no trust in anyone but the project. + +## Motivation + +- **Deploy without a toolchain.** `cloudhal deploy` needs Node, wrangler and an Astro build. A prebuilt, verifiable bundle lets any remote deployer install or update the standard theme by uploading known-good artifacts, and gives every one of them the same bytes. +- **One checkable answer to "what exactly is X.Y.Z".** Per-file hashes under a project signature make a release a fact a user or a reviewer can check, instead of N different customer-side builds of the same version. +- **Safe updates, downgrades and rollbacks.** The downgrade guard (RFC-0008) and the maintenance guard need the target version's feature levels and migration range, and a code rollback is safe only when the target's migrations are compatible with the previous code and no Durable Object class changed. Today those facts come from the running code; the manifest states them before anything is uploaded. +- **Why now.** Releases are immutable in this repository, and today's `publish-ci.ts` publishes the release at once. An asset cannot be added afterwards, so the order has to change before the first bundle ships; `v0.8.0` and older can never gain one, and the first bundled release is the next version. + +## Proposal + +### 1. Assets of a release + +A release carries exactly these assets, all attached before it is published: + +| Asset | Content | +|---|---| +| `cloudhal-release-.tar.gz` | `site/modules/…`, `site/assets/…`, `admin/modules/…`, `admin/assets/…`, optional `starter/`; gzip, because Workers decompress gzip with `DecompressionStream` and have no zstd decompressor | +| `manifest.json` | `cloudhal.release-manifest@1`, canonical JSON | +| `release-statement.json` | `cloudhal.release-statement@1`, canonical JSON, at most 4096 bytes | +| `release-statement.sig` | the raw 64-byte Ed25519 signature over the exact bytes of `release-statement.json` | +| `cloudhal-release-.sigstore.json` | the Sigstore bundle of the build-provenance attestation over the first three files | + +The manifest's `signing` is `{ "keyId": "", "algorithm": "Ed25519", "signature": "release-statement.sig" }`. The enum also admits `"manifest.sig"`, a detached signature over the manifest bytes: that legacy form exists for development keys a consumer operates itself, and a consumer must never accept it for a project key. Project releases never carry `manifest.sig`. + +Canonical JSON, for both documents: object keys sorted recursively by UTF-16 code unit, arrays in order, 2-space indent, a trailing `\n`, UTF-8. A consumer verifies the bytes it downloaded and never re-serialises them before verifying. + +### 2. The statement + +New root `packages/core-contracts/schemas/release-statement.schema.json` (`$id` `https://cloudhal.dev/schemas/v1/release-statement.schema.json`): + +```jsonc +{ + "schemaVersion": 1, + "format": "cloudhal.release-statement@1", + "version": "0.8.0", // $defs/CloudhalVersion, the manifest's pattern + "channel": "stable", // "stable" | "next" + "securityRelease": false, + "commit": "<40 hex>", // the release commit, which the tag names + "createdAt": "2026-11-12T08:00:00Z", // common Timestamp + "signing": { "keyId": "cloudhal-release-2026a", "algorithm": "Ed25519", "signature": "release-statement.sig" }, + "manifest": { "name": "manifest.json", "sha256": "<64 hex>", "bytes": 48213 }, + "tarball": { "name": "cloudhal-release-0.8.0.tar.gz", "sha256": "<64 hex>", "bytes": 7340032 } +} +``` + +Every field is required and `additionalProperties` is `false` throughout. `keyId` matches `^[a-z0-9][a-z0-9-]{2,63}$`, `name` matches `^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`, `bytes` is 1 to 104,857,600. The root references `common.schema.json` only for `SchemaVersion` and `Timestamp` and copies `CloudhalVersion` and `Sha256Hex` byte for byte from the manifest, so it stays portable: another repository can hold an identical copy under its own `$id`. + +Invariants the schema cannot express, enforced by the producer and by every consumer: + +1. `manifest.name` is `manifest.json` and `tarball.name` is `cloudhal-release-.tar.gz`. +2. `manifest.bytes` is at most 1,900,000. +3. `channel` is `next` exactly when `version` has a prerelease suffix (§6). +4. The statement's `signing.keyId` equals the manifest's `signing.keyId`. +5. The statement's `version`, `channel`, `securityRelease`, `commit` and `createdAt` equal the manifest's. +6. The serialised statement is at most 4096 bytes. That is the AWS KMS `RAW` limit for pure Ed25519, so moving the key into a KMS later changes no format. +7. **The statement is for the release the consumer asked for.** `version` equals the version the consumer requested, which is the version of the tag `v` it downloaded the assets from. `channel` equals the channel the consumer asked for; a consumer that asked for no channel uses the channel that invariant 3 derives from the requested version. Only the consumer can check this invariant, because nothing inside a release knows what was requested. Without it, a validly signed older release served under a newer version's name or URL would pass every other check. For example, a vulnerable `0.7.9` bundle placed where `0.8.0` is expected is a rollback attack. + +Verification by a consumer, in order, with no soft-fail anywhere: + +1. Read `release-statement.json` (refuse above 4096 bytes) and `release-statement.sig` (exactly 64 bytes). +2. Verify Ed25519 over the exact statement bytes with the consumer's pinned public key for `signing.keyId`. An unknown, revoked or expired key is a refusal. +3. Bind the statement to the request (invariant 7). Refuse unless `version` equals the requested version and `channel` equals the requested channel. +4. Validate the statement with the precompiled `release-statement` validator and check invariants 1 to 3 and 6. +5. Read `manifest.json`; its size and SHA-256 must equal `statement.manifest`. Validate it with the precompiled `release-manifest` validator, check invariants 4 and 5, and refuse when `planVersion` exceeds what the deployer supports. +6. Stream the tarball. Its size and SHA-256 must equal `statement.tarball`, and every entry must be listed in the manifest with its size and SHA-256. Accept the release only when every part verifies. + +Two further checks use standard tools: + +- `gh release verify-asset` shows that GitHub served these bytes for this immutable release. It is not a trust factor. +- `gh attestation verify --signer-workflow cloudhal-dev/cloudhal/.github/workflows/bundle-build.yml` shows that the bytes came from this repository's build workflow on this commit. It is factor 2 of §9. + +A consumer whose tooling can verify the attestation should do so, and should refuse when the attestation fails. A consumer that cannot verify it has only one factor and relies on the release key alone (§9). A Worker-based deployer that cannot run `gh` or a Sigstore verifier is one example. + +### 3. The manifest + +Root `release-manifest.schema.json` (`$id` `https://cloudhal.dev/schemas/v1/release-manifest.schema.json`), unchanged in shape from the draft that preceded this RFC except `signing.signature`: + +```diff + "signature": { + "type": "string", +- "enum": ["manifest.sig"] ++ "enum": ["manifest.sig", "release-statement.sig"] + } +``` + +It holds `version`, `channel`, `securityRelease`, `commit`, `createdAt`, `contracts`, `levels` (`schedules`, `gates`, …), `migrations` (`first`, `last`, `count`, `names`, `compatibleWithPrevious`), `durableObjects` (`bindings`, `lifecycleChange`), `runtime` (compatibility date and flags), `toolchain`, `theme`, `skills`, `workers.site` and `workers.admin` (`mainModule`, `modules[]` with `path`/`type`/`bytes`/`sha256`, `assets` with `count`/`bytes`/`files[]` with a precomputed `assetHash`, inline `headers`), `bindingTemplates`, `requiredScopes`/`optionalScopes`, `requiredCapabilities`/`optionalCapabilities`, `starter`, `features` (a declared map of booleans: the named flags are required and unknown flags are allowed, so an older deployer ignores a new flag), `planVersion` and `signing`. + +Provenance fields: + +- `commit` is the release commit, which is also the commit the tag names. +- `createdAt` is that commit's committer time, in whole seconds (`Z`). It is also every tar entry's mtime, so two builds of one commit write the same metadata. +- `toolchain` is `{ node, producer: "cloudhal-release-bundle", cli, astro, wrangler, starter: "blog" }`, each value at most 64 characters. +- `compatibleWithPrevious` defaults to `true` in the producer until a release process field says otherwise (open question 3). + +### 4. Build and attest in one reusable workflow + +`.github/workflows/bundle-build.yml` is a reusable workflow (`workflow_call`, inputs `version`, `dry_run` and `security_release`). It holds **no secret**; its job has `contents: read`, `id-token: write` and `attestations: write` and nothing else. It: + +1. checks out the release commit without persisted credentials; +2. runs `pnpm install --frozen-lockfile`, builds the packages and the Studio; +3. creates the standard starter with `cloudhal init` and runs `cloudhal deploy --dry-run` in it, which produces the same build output a user's deploy would; +4. runs `scripts/release-bundle.ts build`, which writes the tarball, `manifest.json` and the **unsigned** `release-statement.json`, validates both documents, self-verifies the output and refuses a part that contains a value from the project's generated deploy secrets; +5. runs `actions/attest-build-provenance` over the three files and saves the Sigstore bundle as `cloudhal-release-.sigstore.json`; +6. uploads the four files as a short-lived workflow artifact. + +This is described accurately as **L3-style isolation of the signing key, with build provenance produced by the building workflow**. The project does not claim a SLSA build level for it until the build platform's own third-party requirements are assessed. + +### 5. Sign only after verifying + +A `bundle-sign` job runs in the protected environment `release-signing` (a required reviewer, deployments from `main` only), with `contents: read`, `attestations: read` and `actions: read`. It checks out nothing and runs no `pnpm`, `npm` or `npx` and no repository build code; the checks and the signer are short inline scripts in the workflow file. It: + +1. downloads the unsigned artifact; +2. runs `gh attestation verify --repo cloudhal-dev/cloudhal --signer-workflow cloudhal-dev/cloudhal/.github/workflows/bundle-build.yml --source-ref refs/heads/main --source-digest $GITHUB_SHA --deny-self-hosted-runners` for each of the three files; +3. checks that the statement's `version` equals the planned version, its `commit` equals `$GITHUB_SHA`, its key id equals the active key, and its `manifest`/`tarball` digests and sizes equal the downloaded files; +4. signs. The private key (PKCS#8 PEM) is exposed only to this one step's environment. The step reads it once into a `KeyObject` and removes it from the environment. It then checks that the derived public key equals the pinned public key. The active key id and its raw public key are written inline in the `bundle-sign` job, because the job checks out nothing and cannot read a document. A repository test keeps them equal to `docs/security/release-signing-keys.md`. The step then signs, verifies its own signature, and writes `release-statement.sig`. Errors are fixed strings; the key is never echoed and the shell never traces; +5. uploads the five files as the signed artifact. + +Phase 1 custody is that environment secret (`RELEASE_SIGNING_KEY`), readable only by this job. Phase 2 moves the key into a KMS reached through GitHub OIDC; the statement already fits its message limit (§2, invariant 6), so nothing a consumer reads changes. + +### 6. Publication on immutable releases + +The `publish` job keeps its name, its workflow file and its `npm-publish` environment (npm trusted publishing names them). Before any tag, draft or upload exists, `scripts/publish-ci.ts` verifies `release-statement.sig` over the exact statement bytes with the pinned project key (in a dry run, with the throwaway key's public half that `bundle-sign` outputs), and checks that the statement is for the planned version, its channel and the checked-out commit and pins the local manifest and tarball. It then runs, and resumes from the first incomplete step: + +1. **Tag** `v` on the statement's commit. An existing tag must already point at it, or the run refuses. +2. **Draft**: `gh release create v --draft --verify-tag [--prerelease]`. A draft is never marked Latest. +3. **Attach and re-check**: upload the five assets while the release is a draft, then re-read the draft and require exactly the expected names, each with a digest equal to the local SHA-256. +4. **npm publish** of the fixed group, as today (trusted publishing, provenance). +5. **Re-check before publishing**: immediately before publishing the release, re-read the draft and repeat step 3's check of exact names and digests. Anyone with write access to the repository can replace a draft asset while npm publish runs. Publishing swapped bytes would make the version permanently bundle-broken: consumers refuse them, and immutability then forbids a repair. On a mismatch the run refuses to publish, re-uploads the expected assets while the release is still a draft, and checks again. +6. **Publish the release**: `--draft=false`, with `--latest` only for the highest stable version, otherwise `--prerelease --latest=false`. + +A published release never gains or changes an asset. If a published release lacks an asset, the run refuses and the fix is a new version. A draft with a wrong digest is re-uploaded at step 3 or 5, since it is still a draft. With bundles switched off (repository variable `RELEASE_BUNDLES` not `true`) the same order runs with no assets, and steps 3 and 5 require that the draft has none. + +**Release facts the plan decides.** A changeset whose summary starts with exactly `**Security release.**` makes the version a security release: the `plan` job reads the marker from the version's changelog entries and passes it to `bundle-build.yml` (`security_release`), the manifest and the statement then carry `securityRelease: true`, and `bundle-sign` refuses a statement whose `securityRelease` is not the planned value. A consumer may highlight such a release; none installs it by itself. A prerelease publishes to npm's `next` dist-tag (npm 12 refuses a prerelease without `--tag`). + +**Channel rule:** `vX.Y.Z-
` ⇔ `channel: "next"` ⇔ a GitHub prerelease, never Latest; `vX.Y.Z` ⇔ `stable`.
+
+**Mandatory dry run** before the first signed release and after any change to the workflows: a `workflow_dispatch` with `dry_run: true` runs `bundle` and `bundle-sign` with a throwaway key generated in memory (key id `dryrun-`), creates a draft on `v-dryrun.` with `--target ` (a draft creates no tag, so no tag is pushed), uploads and re-checks the assets, runs `npm publish --dry-run` (with `--force` for a version npm already has: npm 12 refuses `--dry-run` over a published version without it, and never uploads in a dry run), and deletes the draft unpublished.
+
+### 7. Normative limits
+
+| Item | Limit |
+|---|---|
+| `manifest.json` | ≤ 1,900,000 bytes |
+| `release-statement.json` | ≤ 4096 bytes |
+| tarball | ≤ 100 MiB (104,857,600 bytes) |
+| one part (module, asset or starter file) | ≤ 64 MiB; a static asset ≤ 25 MiB |
+| static assets per Worker | ≤ 20,000 |
+| tar entries | ≤ 30,000 |
+
+Tar format: ustar with PAX `path` records for long names; regular files only (no links, devices or directories); mode `0644`, uid and gid `0`, empty user and group names, mtime = `createdAt`; entries sorted by path; no entry the manifest does not list. Gzip with a fixed header (mtime `0`, OS byte `255`, no file name). A consumer refuses anything outside these limits before it uploads a byte.
+
+### 8. Invariants
+
+1. A published release and its bundle are immutable; a fix is a new version.
+2. Every byte a deployer uploads is covered by the signed statement, through the manifest's per-part hashes.
+3. The project key signs only `release-statement.json`, only in `bundle-sign`, only after the attestation and the digests verify.
+4. Verification hard-fails for every consumer. No consumer may offer a soft-fail switch or install an unverified bundle. Verification includes binding the statement to the request (§2, invariant 7): a validly signed statement for a version or channel other than the one requested is a refusal.
+5. `compatibleWithPrevious: false` or `durableObjects.lifecycleChange: true` means "do not roll code back past this release".
+6. Bundles are never installed automatically by the project's tools; `securityRelease` is a highlight, not an auto-update.
+7. Every version ships to everyone at the same moment: there is no early-access channel and no audience-restricted asset.
+
+### 9. Keys and trust (hooks designed here, built later)
+
+- **Two factors for a consumer that verifies both.** Factor 1 is the Ed25519 statement. Factor 2 is the `bundle-build.yml` attestation, verified by the consumer's own tooling.
+  - For a consumer that verifies both, the two factors together defeat theft of the release key alone.
+  - A consumer that verifies only the Ed25519 statement relies on the release key alone. A deployer running inside a Worker, without `gh` or a Sigstore verifier, is one example. A stolen key is enough against such a consumer until the key is revoked; §2 asks every consumer whose tooling can verify the attestation to do so.
+  - Neither factor defeats a malicious commit on `main` or a compromised build dependency, because both factors then cover bad bytes. Required reviewers on `main`, the frozen lockfile, and the consumer's own soak, backup and rollback guards cover those cases.
+- **Rollback and freeze.** Binding the statement to the request (§2, invariant 7) refuses an older signed release served under a newer name. It cannot tell a consumer that a newer version exists, or that the version it holds is withdrawn. The expiring yank list below covers that case once it is built. Until then, a consumer learns about versions from the release feed, which it uses for discovery only and never as a trust factor.
+- **Key list** `keys.json` (designed, not built): entries `{ keyId, publicKey, role: "release" | "yank", notBefore, notAfter, status: "active" | "retiring" | "revoked" }`, signed by an offline root key held by two people (a hardware key plus a sealed backup), about one year of validity, published at a stable URL and as a release asset. Two release keys overlap at every rotation. The sign job will refuse when the active key's `notAfter` is under 90 days away. A consumer may use `keys.json` only to reduce trust (act on `revoked` or an expired `notAfter`); adding a key to a consumer stays a reviewed change on the consumer's side.
+- **Yank list** `yanked.json` (designed, not built): signed by the dedicated `yank` key from `keys.json`, not the release key, so a stolen release key cannot un-yank; it carries `expires`, and a stale list alerts and never blocks verification of other versions. Installed copies keep running.
+- Until `keys.json` exists, the pinned keys are the ones in `docs/security/release-signing-keys.md`, and a rotation is announced there before its first use.
+
+### 10. Later phases (in scope, not in this change)
+
+- `cloudhal deploy --release `: the CLI installs the standard theme from a verified bundle instead of building it. Until then the CLI's bytes differ from the bundle's; what is shared is the manifest contract and its guards.
+- Runtime configuration of the standard theme: values baked into its build today (theme settings, block types, installation names, site ids) move to bindings and `CLOUDHAL_CONFIG` vars, so one bundle serves every installation of the standard theme. Custom themes keep building with `cloudhal deploy`. Until then the bundle is the standard `cloudhal init` starter.
+- A zero-dependency WebCrypto reference verifier package, KMS custody, an SBOM, and an independent rebuild check once two builds of one tag are byte-identical.
+
+## Alternatives considered
+
+| Option | Pros | Cons | Why not |
+|---|---|---|---|
+| Users always build (status quo) | No new artifacts | Full toolchain on every deploy; N builds of one version are N different artifacts with nothing to verify | Prebuilt bundles for the standard theme |
+| A host builds and signs its own bundles | The host controls timing | Every host becomes its own trust root and a second long-lived key that can push code; a second full dependency install outside the project; users must trust a vendor, not the project | The project builds and signs once, for everyone |
+| Customer-side builds (deploy button, Workers Builds) | No project infrastructure | No update path for existing copies; nothing comparable to verify | Kept as the do-it-yourself channel, not as an update path |
+| Keyless Sigstore only | No long-lived key | Verification needs the Sigstore trust root and ECDSA tooling, heavy for a Worker; ties every consumer to one transparency service | Ed25519 statement as the portable root; the attestation as the second factor |
+| `manifest.sig` over the manifest only | Simpler; one signed file | The signed file can approach 1.9 MB, above a KMS `RAW` message, so moving the key later changes the format | The small statement now, with no dual-format period for project keys |
+| Sign in the publish job | One job fewer | That job runs `pnpm install` with scripts and holds npm's OIDC token | A separate `bundle-sign` job with no install |
+| Publish the release, then attach assets | Today's order | Impossible with immutable releases | Draft, attach, npm, publish |
+| zstd tarballs | Smaller | No Worker-side decompressor | gzip |
+
+## Compatibility & migration
+
+- **Contracts:** two new `core-contracts` roots, `release-manifest` and `release-statement`, both `schemaVersion: 1`, with golden fixtures and the Rust round trip (`ReleaseManifest`, `ReleaseStatement`). No existing contract changes; the content `release` root (RFC-0008) is unrelated.
+- **npm users:** nothing changes. Packages are published as today, after the bundle is attached.
+- **Releases:** `v0.8.0` and older have no bundle and never will (immutable); a consumer treats them as unbundled. The first bundled version is the next one.
+- **Rollout:** (1) the contracts and this RFC; (2) the builder, `bundle-build.yml`, `bundle-sign`, the reordered `publish-ci.ts` and the dry run, merged with `RELEASE_BUNDLES` off; (3) the maintainer creates `release-signing` and its secret, runs the dry run and turns `RELEASE_BUNDLES` on; (4) the first bundle ships, preferably on a `next` prerelease.
+- **Rollback:** set `RELEASE_BUNDLES` off. Releases go on without assets in the same order; nothing in an installation depends on a bundle.
+
+## Security & policy impact
+
+- No new scopes and no runtime change in any Worker.
+- **New secret:** the release key, in the `release-signing` environment only (required reviewer, `main` only), exposed to one step of one job that runs no repository code. The npm job never sees it; the build job holds no secret at all.
+- **Residual risks:**
+  - a compromised `release-signing` environment together with a compromised build (both factors);
+  - a compromised `release-signing` environment alone, against a consumer that verifies only the Ed25519 statement;
+  - a malicious commit on `main`;
+  - a compromised build dependency.
+
+  §9 lists what covers them. Consumers pin key ids and can withdraw trust in one at once.
+- Bundles contain no secrets; per-installation secrets are generated at deploy time, and the builder refuses a part that contains a generated deploy secret.
+- The public key, its status and the hand-verification steps are in `docs/security/release-signing-keys.md`.
+
+## Open questions
+
+1. Is the Astro and wrangler output byte-stable across two CI runs of one tag? The independent rebuild check waits for the answer.
+2. Where does `keys.json` live: a `cloudhal.dev/.well-known/` URL, release assets only, or both? **[default: both]**
+3. What is the source of truth for `compatibleWithPrevious` (a changeset field, a migration annotation)? **[default: `true` unless a release sets it]**
+4. Should every `next` prerelease ship a bundle? **[default: yes]**
+5. Is the inline `_headers` text in the manifest acceptable, or should it be a signed part? **[default: inline]**
diff --git a/docs/security/release-signing-keys.md b/docs/security/release-signing-keys.md
new file mode 100644
index 00000000..0fb0306b
--- /dev/null
+++ b/docs/security/release-signing-keys.md
@@ -0,0 +1,115 @@
+# Release signing keys
+
+The public keys that sign Cloudhal release bundles ([RFC-0046](../rfcs/0046-release-bundles.md)). A key signs exactly one kind of file: a release's `release-statement.json`. The signature is the release asset `release-statement.sig`, a raw 64-byte Ed25519 signature over the exact bytes of the statement. The statement pins the SHA-256 and size of `manifest.json` and of `cloudhal-release-.tar.gz`, and the manifest pins every part inside the tarball.
+
+A consumer pins the keys below by key id, and must refuse a release whose `signing.keyId` it does not pin, whose key it treats as revoked, or whose signature does not verify. There is no soft-fail mode.
+
+## Keys
+
+| Key id | Algorithm | Public key (raw 32 bytes, base64) | Status | Since |
+|---|---|---|---|---|
+| `cloudhal-release-2026a` | Ed25519 | `7az3pPc4oT0AOfRcdfOZZQkM6XAGHo2jXEdDZ1qEVR8=` | active | 2026-11-12 |
+
+Other forms of `cloudhal-release-2026a`, for cross-checking:
+
+- Raw public key, hex: `edacf7a4f738a13d0039f45c75f39965090ce970061e8da35c4743675a84551f`
+- SHA-256 of the raw public key, hex: `da578ea6508924721c9343d29a94b598e4e3df9af0f5f37c6bcd0df6c9327dae`
+- SPKI PEM:
+
+  ```
+  -----BEGIN PUBLIC KEY-----
+  MCowBQYDK2VwAyEA7az3pPc4oT0AOfRcdfOZZQkM6XAGHo2jXEdDZ1qEVR8=
+  -----END PUBLIC KEY-----
+  ```
+
+Statuses:
+
+- `active` signs new releases.
+- `retiring` verifies existing releases and signs nothing new.
+- `revoked` verifies nothing: a consumer refuses every release the key signed.
+
+Releases are immutable and can never gain an asset, so a release signed by a revoked key can never be re-signed. The remedy is a new version signed by an active key. Later, the signed `keys.json` and yank list of RFC-0046 §9 will record that decision. Releases up to and including `v0.8.0` carry no bundle and no signature.
+
+## Custody
+
+- In GitHub, the private key exists only as the secret `RELEASE_SIGNING_KEY` (PKCS#8 PEM) of the GitHub environment `release-signing` in `cloudhal-dev/cloudhal`. The environment requires a reviewer and allows deployments from `main` only.
+- Only one step of the `bundle-sign` job in `.github/workflows/release.yml` receives the secret. That job checks out no code and runs no package install. It signs only after two checks pass: `gh attestation verify` has checked the files against `bundle-build.yml`, and the statement's digests match the files. Before signing, it checks that the key it was given derives the public key above. The key id and public key are written inline in that job, and a repository test keeps them equal to this file.
+- No other job, workflow or repository receives the key.
+- Outside GitHub, the maintainer holds the key.
+  - The copies are the PKCS#8 file the key was generated into, kept readable by its owner only, and the maintainer's backups.
+  - No copy is ever in a repository, a chat, an issue or a cloud service.
+  - Once the environment secret is set, and before the first signed release, the maintainer moves the generated file to offline storage. From then on, the environment secret and the offline backups are the only copies.
+- Phase 2 (designed in RFC-0046, not built) moves the key into a KMS reached through GitHub OIDC, after which no person can export it.
+
+## Rotation
+
+1. Generate the new key pair offline. Name it `cloudhal-release-` (for example `cloudhal-release-2027a`).
+2. Add a row for it to the table above with status `active` in a reviewed pull request, at least 30 days before its first use, so consumers can pin it in time. Until the signed `keys.json` of RFC-0046 §9 exists, this file is the announcement.
+3. Replace the `RELEASE_SIGNING_KEY` secret of `release-signing`, update the inline key id and public key in the `bundle-sign` job (the test that keeps them equal to this file fails until both agree), and run the release workflow's dry run (`workflow_dispatch`, `dry_run: true`).
+4. Set the old key's status to `retiring` in this file. Keep it pinned by consumers for as long as releases it signed are installed.
+5. A compromised key goes straight to `revoked` here, with an advisory under [SECURITY.md](../../SECURITY.md), and the next release is signed with the new key.
+
+## Verifying a release by hand
+
+Download the release's assets into an empty directory, for example with `gh release download v --repo cloudhal-dev/cloudhal --dir `.
+
+### 1. The Ed25519 statement (required)
+
+With Node 24, from inside that directory (`node verify.mjs `):
+
+```js
+import { createHash, createPublicKey, verify } from "node:crypto";
+import { readFileSync, statSync } from "node:fs";
+
+const version = process.argv[2];
+const PINNED = { "cloudhal-release-2026a": "7az3pPc4oT0AOfRcdfOZZQkM6XAGHo2jXEdDZ1qEVR8=" };
+
+const statementBytes = readFileSync("release-statement.json");
+const sig = readFileSync("release-statement.sig");
+if (statementBytes.length > 4096 || sig.length !== 64) throw new Error("wrong statement or signature size");
+const statement = JSON.parse(statementBytes.toString("utf8"));
+const raw = Buffer.from(PINNED[statement.signing.keyId] ?? "", "base64");
+if (raw.length !== 32) throw new Error("key id not pinned");
+// SPKI DER of an Ed25519 public key = this 12-byte prefix + the raw 32 bytes.
+const key = createPublicKey({
+  key: Buffer.concat([Buffer.from("302a300506032b6570032100", "hex"), raw]),
+  format: "der",
+  type: "spki",
+});
+if (!verify(null, statementBytes, key, sig)) throw new Error("signature does not verify");
+// Bind the statement to what you asked for (RFC-0046 §2, invariant 7): a validly signed older
+// release served under this version's name must be refused.
+if (statement.version !== version) throw new Error("statement is for another version");
+if (statement.channel !== (version.includes("-") ? "next" : "stable")) throw new Error("wrong channel");
+
+const sha256 = (file) => createHash("sha256").update(readFileSync(file)).digest("hex");
+for (const part of [statement.manifest, statement.tarball]) {
+  if (sha256(part.name) !== part.sha256 || statSync(part.name).size !== part.bytes)
+    throw new Error(`${part.name} does not match the statement`);
+}
+console.log(`ok: ${version} signed by ${statement.signing.keyId}, commit ${statement.commit}`);
+```
+
+Verify the exact downloaded bytes. Never parse and re-serialise the statement before verifying it. Then check that `manifest.json` names the same `version`, `channel`, `securityRelease`, `commit`, `createdAt` and `signing.keyId` as the statement, and that every entry of the tarball is listed in the manifest with its size and SHA-256 (RFC-0046 §2, steps 5 and 6).
+
+### 2. GitHub's release record (recommended)
+
+```sh
+for f in cloudhal-release-.tar.gz manifest.json release-statement.json release-statement.sig; do
+  gh release verify-asset v "$f" --repo cloudhal-dev/cloudhal
+done
+```
+
+This shows that GitHub served these bytes as assets of the immutable release `v`. It is not a trust factor on its own.
+
+### 3. The build-provenance attestation (recommended; the second factor)
+
+```sh
+for f in cloudhal-release-.tar.gz manifest.json release-statement.json; do
+  gh attestation verify "$f" --repo cloudhal-dev/cloudhal \
+    --signer-workflow cloudhal-dev/cloudhal/.github/workflows/bundle-build.yml \
+    --source-ref refs/heads/main --deny-self-hosted-runners
+done
+```
+
+This shows that the files were built by this repository's `bundle-build.yml` on GitHub-hosted runners. Offline, pass `--bundle cloudhal-release-.sigstore.json` (the attestation bundle attached to the release). Add `--source-digest ` with the statement's `commit` to pin the exact commit.
diff --git a/packages/cli/test/fixtures/release-bundle/fixture.ts b/packages/cli/test/fixtures/release-bundle/fixture.ts
new file mode 100644
index 00000000..27a6ce2b
--- /dev/null
+++ b/packages/cli/test/fixtures/release-bundle/fixture.ts
@@ -0,0 +1,79 @@
+/**
+ * A small `cloudhal deploy --dry-run` build output for the release-bundle tests, written into a
+ * temp directory per test (nothing here is committed output). The deploy secrets are generated per
+ * call, never fixed values (AGENTS.md rule 9).
+ */
+import { randomUUID } from "node:crypto";
+import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import type { ReleaseFacts } from "../../../../../scripts/release-bundle/manifest.ts";
+
+export interface FixtureProject {
+  readonly root: string;
+  readonly project: string;
+  readonly studio: string;
+  readonly migrations: string;
+  readonly secrets: Record;
+}
+
+function put(file: string, content: string | Uint8Array): void {
+  mkdirSync(path.dirname(file), { recursive: true });
+  writeFileSync(file, content);
+}
+
+/** Writes the build layout `read-build.ts` reads; `extra` adds or overrides files by path. */
+export function fixtureProject(
+  extra: Readonly> = {},
+): FixtureProject {
+  const root = mkdtempSync(path.join(os.tmpdir(), "release-bundle-"));
+  const project = path.join(root, "project");
+  const studio = path.join(root, "studio");
+  const migrations = path.join(root, "migrations");
+  const secrets = {
+    BETTER_AUTH_SECRET: `${randomUUID()}${randomUUID()}`.replaceAll("-", ""),
+    CLOUDHAL_PREVIEW_SECRET: `${randomUUID()}${randomUUID()}`.replaceAll("-", ""),
+  };
+  const files: Record = {
+    "project/.cloudhal/deploy/secrets.json": `${JSON.stringify(secrets, null, 2)}\n`,
+    "project/.cloudhal/deploy/out/site/entry.mjs": "export default { fetch() {} };\n",
+    "project/.cloudhal/deploy/out/site/chunks/page.mjs": "export const page = 1;\n",
+    "project/.cloudhal/deploy/out/site/entry.mjs.map": "{}",
+    "project/.cloudhal/deploy/out/site/README.md": "wrangler output\n",
+    "project/.cloudhal/deploy/out/admin/worker.js": "export default { fetch() {} };\n",
+    "project/.cloudhal/deploy/out/admin/worker.js.map": "{}",
+    "project/dist/client/favicon.svg": "",
+    "project/dist/client/_headers": "/*\n  X-Frame-Options: DENY\n",
+    "project/dist/client/.assetsignore": "notes.txt\nprivate/**\n",
+    "project/dist/client/notes.txt": "ignored\n",
+    "project/dist/client/private/a.html": "ignored",
+    [`project/dist/client/_astro/${"deep/".repeat(22)}style.css`]: "body{}",
+    "project/dist/server/wrangler.json": JSON.stringify({
+      compatibility_date: "2026-10-01",
+      compatibility_flags: ["nodejs_compat", "nodejs_compat"],
+    }),
+    "studio/index.html": "Studio",
+    "studio/assets/app.js": "console.log(1);\n",
+    "studio/assets/app.js.map": "{}",
+    "migrations/0001_init.ts": "export default 1;\n",
+    "migrations/0002_auth.sql": "select 1;\n",
+    "migrations/index.ts": "export {};\n",
+    ...extra,
+  };
+  for (const [rel, content] of Object.entries(files)) put(path.join(root, rel), content);
+  return { root, project, studio, migrations, secrets };
+}
+
+export const COMMIT = "0123456789abcdef0123456789abcdef01234567";
+
+export function facts(over: Partial = {}): ReleaseFacts {
+  return {
+    version: "0.8.0",
+    commit: COMMIT,
+    createdAt: "2026-11-12T08:00:00Z",
+    keyId: "cloudhal-release-2026a",
+    migrations: ["0001_init", "0002_auth"],
+    toolchain: { node: "24.0.0", cli: "0.8.0", astro: "7.4.7", wrangler: "4.158.0" },
+    ...over,
+  };
+}
diff --git a/packages/cli/test/fixtures/release-bundle/workflow-text.ts b/packages/cli/test/fixtures/release-bundle/workflow-text.ts
new file mode 100644
index 00000000..f3e8b8b6
--- /dev/null
+++ b/packages/cli/test/fixtures/release-bundle/workflow-text.ts
@@ -0,0 +1,72 @@
+/**
+ * The release workflows as text (no YAML dependency): the files are read as lines, jobs and
+ * steps are found by indentation. Used by release-workflows.test.ts and release-bundle-sign.test.ts.
+ */
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../../..");
+const read = (f: string) => readFileSync(path.join(ROOT, ".github/workflows", f), "utf8");
+export const RELEASE = read("release.yml");
+export const BUILD = read("bundle-build.yml");
+
+/** A GitHub Actions expression `${{ inner }}` (written so, not as a JS template). */
+export const gha = (inner: string) => ["$", "{{ ", inner, " }}"].join("");
+export const OPEN = ["$", "{{"].join("");
+
+export const indentOf = (l: string) => l.length - l.trimStart().length;
+export const code = (text: string) =>
+  text
+    .split("\n")
+    .filter((l) => !l.trimStart().startsWith("#"))
+    .join("\n");
+
+/** The lines of a block that starts at `header` (exact line) and ends at the next less-indented line. */
+export function block(text: string, header: string): string {
+  const start = text.split("\n").indexOf(header);
+  if (start === -1) throw new Error(`no line ${JSON.stringify(header)}`);
+  return blockAt(text.split("\n"), start);
+}
+export function blockAt(lines: readonly string[], start: number): string {
+  const ind = indentOf(lines[start] as string);
+  let end = start + 1;
+  while (end < lines.length && (lines[end]?.trim() === "" || indentOf(lines[end] as string) > ind))
+    end++;
+  return lines.slice(start, end).join("\n");
+}
+export const job = (text: string, name: string) => block(text, `  ${name}:`);
+/** The step whose `- name:` is `name` (up to the next step). */
+export function step(jobText: string, name: string): string {
+  const lines = jobText.split("\n");
+  const start = lines.findIndex((l) => l.trim() === `- name: ${name}`);
+  if (start === -1) throw new Error(`no step ${name}`);
+  const ind = indentOf(lines[start] as string);
+  let end = start + 1;
+  while (end < lines.length && !(indentOf(lines[end] as string) <= ind && lines[end]?.trim()))
+    end++;
+  return lines.slice(start, end).join("\n");
+}
+/** The body of every `run: |` block (and one-line `run:`) in `text`. */
+export function runs(text: string): string[] {
+  const lines = text.split("\n");
+  const out: string[] = [];
+  for (const [i, l] of lines.entries()) {
+    const m = /^(\s*)(?:- )?run: ?(.*)$/.exec(l);
+    if (!m) continue;
+    if (m[2] !== "|" && m[2] !== ">-") {
+      out.push(m[2] as string);
+      continue;
+    }
+    const ind = (m[1] as string).length;
+    const body: string[] = [];
+    for (let j = i + 1; j < lines.length; j++) {
+      const x = lines[j] as string;
+      if (x.trim() && indentOf(x) <= ind + (l.trimStart().startsWith("- ") ? 2 : 0)) break;
+      body.push(x);
+    }
+    const strip = Math.min(...body.filter((x) => x.trim()).map(indentOf));
+    out.push(body.map((x) => x.slice(strip)).join("\n"));
+  }
+  return out;
+}
diff --git a/packages/cli/test/release-bundle-publish.test.ts b/packages/cli/test/release-bundle-publish.test.ts
new file mode 100644
index 00000000..94eef4cb
--- /dev/null
+++ b/packages/cli/test/release-bundle-publish.test.ts
@@ -0,0 +1,104 @@
+/** `scripts/publish-ci.ts` reads and checks the signed bundle it uploads (RFC-0046 §6 step 3). */
+import { createHash, generateKeyPairSync, type KeyObject, sign } from "node:crypto";
+import { readFileSync, rmSync, writeFileSync } from "node:fs";
+import path from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+import { loadBundle, publicKeysFor, RELEASE_KEY } from "../../../scripts/publish-ci.ts";
+import { buildReleaseBundle, writeReleaseBundle } from "../../../scripts/release-bundle/build.ts";
+import { readBuildOutput, readDeploySecrets } from "../../../scripts/release-bundle/read-build.ts";
+import { releaseAssetNames } from "../../../scripts/release-bundle/statement.ts";
+import { COMMIT, facts, fixtureProject } from "./fixtures/release-bundle/fixture.ts";
+
+const NAMES = releaseAssetNames("0.8.0");
+const KEY_ID = "dryrun-42";
+
+/** A throwaway Ed25519 key per run, and its raw public key in base64. */
+function throwawayKey(): { privateKey: KeyObject; raw: string } {
+  const { privateKey, publicKey } = generateKeyPairSync("ed25519");
+  const spki = publicKey.export({ format: "der", type: "spki" });
+  const raw = btoa(String.fromCharCode(...spki.subarray(12)));
+  return { privateKey, raw };
+}
+
+describe("publish-ci loadBundle", () => {
+  const roots: string[] = [];
+  afterEach(() => {
+    for (const r of roots.splice(0)) rmSync(r, { recursive: true, force: true });
+  });
+
+  /** A bundle signed as bundle-sign signs it, with `key`. */
+  async function signedDir(key: KeyObject, keyId = KEY_ID): Promise {
+    const fx = fixtureProject();
+    roots.push(fx.root);
+    const out = readBuildOutput({ project: fx.project, studio: fx.studio });
+    const secrets = readDeploySecrets(path.join(fx.project, ".cloudhal/deploy/secrets.json"));
+    const b = await buildReleaseBundle(out, facts({ keyId }), secrets);
+    const dir = path.join(fx.root, "signed");
+    writeReleaseBundle(b, dir);
+    const statement = readFileSync(path.join(dir, "release-statement.json"));
+    writeFileSync(path.join(dir, "release-statement.sig"), sign(null, statement, key));
+    writeFileSync(path.join(dir, "cloudhal-release-0.8.0.sigstore.json"), "{}\n");
+    return dir;
+  }
+
+  it("reads the five assets in upload order with their digests, and the statement", async () => {
+    const k = throwawayKey();
+    const dir = await signedDir(k.privateKey);
+    const b = loadBundle(dir, "0.8.0", { [KEY_ID]: k.raw });
+    expect(b.assets.map((a) => a.name)).toEqual(NAMES);
+    expect(b.statement).toMatchObject({ version: "0.8.0", channel: "stable", commit: COMMIT });
+    const manifest = readFileSync(path.join(dir, "manifest.json"));
+    expect(b.assets[1]).toMatchObject({
+      sha256: createHash("sha256").update(manifest).digest("hex"),
+      bytes: manifest.length,
+    });
+  });
+
+  it("refuses a signature by another key, over other bytes, or for an unpinned key id", async () => {
+    const k = throwawayKey();
+    const dir = await signedDir(k.privateKey);
+    expect(() => loadBundle(dir, "0.8.0", { [KEY_ID]: throwawayKey().raw })).toThrow(
+      /release-statement\.sig does not verify with dryrun-42/,
+    );
+    expect(() => loadBundle(dir, "0.8.0", { "dryrun-43": k.raw })).toThrow(
+      /no pinned public key for dryrun-42/,
+    );
+    // The release key pin never accepts a dry-run statement.
+    expect(() => loadBundle(dir, "0.8.0", RELEASE_KEY)).toThrow(/no pinned public key/);
+    const file = path.join(dir, "release-statement.json");
+    writeFileSync(file, readFileSync(file, "utf8").replace('"stable"', '"stable" '));
+    expect(() => loadBundle(dir, "0.8.0", { [KEY_ID]: k.raw })).toThrow(/does not verify/);
+  });
+
+  it("refuses a manifest the statement does not pin, a short signature and a missing file", async () => {
+    const k = throwawayKey();
+    const dir = await signedDir(k.privateKey);
+    const keys = { [KEY_ID]: k.raw };
+    const sig = readFileSync(path.join(dir, "release-statement.sig"));
+    writeFileSync(path.join(dir, "release-statement.sig"), sig.subarray(0, 63));
+    expect(() => loadBundle(dir, "0.8.0", keys)).toThrow(/not a 64-byte signature/);
+    writeFileSync(path.join(dir, "release-statement.sig"), sig);
+    writeFileSync(path.join(dir, "manifest.json"), "{}\n");
+    expect(() => loadBundle(dir, "0.8.0", keys)).toThrow(
+      /manifest\.json is not the file the statement signs/,
+    );
+    expect(() => loadBundle(dir, "0.8.1", keys)).toThrow(/ENOENT/);
+  });
+});
+
+describe("publish-ci publicKeysFor", () => {
+  it("pins the release key outside a dry run, whatever the environment says", () => {
+    expect(publicKeysFor({ dryRun: false, runId: "42", dryRunPublicKey: "x" })).toEqual({
+      "cloudhal-release-2026a": "7az3pPc4oT0AOfRcdfOZZQkM6XAGHo2jXEdDZ1qEVR8=",
+    });
+  });
+
+  it("a dry run accepts only its own dryrun- key and needs it", () => {
+    const k = throwawayKey();
+    expect(publicKeysFor({ dryRun: true, runId: "42", dryRunPublicKey: k.raw })).toEqual({
+      "dryrun-42": k.raw,
+    });
+    expect(() => publicKeysFor({ dryRun: true, runId: "42" })).toThrow(/RELEASE_DRYRUN_PUBLIC_KEY/);
+    expect(() => publicKeysFor({ dryRun: true, dryRunPublicKey: k.raw })).toThrow(/GITHUB_RUN_ID/);
+  });
+});
diff --git a/packages/cli/test/release-bundle-sign.test.ts b/packages/cli/test/release-bundle-sign.test.ts
new file mode 100644
index 00000000..fbc83a1f
--- /dev/null
+++ b/packages/cli/test/release-bundle-sign.test.ts
@@ -0,0 +1,145 @@
+/**
+ * bundle-sign's inline scripts (release.yml, RFC-0046 §5), run as written over a bundle this test
+ * builds, with throwaway keys only.
+ */
+import { execFileSync } from "node:child_process";
+import { createPublicKey, generateKeyPairSync, verify } from "node:crypto";
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+import { buildReleaseBundle, writeReleaseBundle } from "../../../scripts/release-bundle/build.ts";
+import { readBuildOutput, readDeploySecrets } from "../../../scripts/release-bundle/read-build.ts";
+import { COMMIT, facts, fixtureProject } from "./fixtures/release-bundle/fixture.ts";
+import { job, RELEASE, runs, step } from "./fixtures/release-bundle/workflow-text.ts";
+
+describe("bundle-sign's inline scripts, run as written", () => {
+  const roots: string[] = [];
+  afterEach(() => {
+    for (const r of roots.splice(0)) rmSync(r, { recursive: true, force: true });
+  });
+  const script = (name: string) => {
+    const s = step(job(RELEASE, "bundle-sign"), name);
+    expect(s).toContain("shell: node {0}");
+    return runs(s)[0] as string;
+  };
+
+  async function workspace(keyId: string): Promise {
+    const fx = fixtureProject();
+    roots.push(fx.root);
+    const out = readBuildOutput({ project: fx.project, studio: fx.studio });
+    const b = await buildReleaseBundle(
+      out,
+      facts({ keyId }),
+      readDeploySecrets(path.join(fx.project, ".cloudhal/deploy/secrets.json")),
+    );
+    const ws = mkdtempSync(path.join(os.tmpdir(), "bundle-sign-"));
+    roots.push(ws);
+    mkdirSync(path.join(ws, "bundle"));
+    writeReleaseBundle(b, path.join(ws, "bundle"));
+    for (const name of ["Check the statement", "Sign the statement"])
+      writeFileSync(path.join(ws, `${name.split(" ")[0]}.cjs`), script(name));
+    return ws;
+  }
+  const exec = (ws: string, file: string, env: Record) => {
+    try {
+      return {
+        ok: true,
+        out: execFileSync(process.execPath, [file], {
+          cwd: ws,
+          env: { PATH: process.env.PATH ?? "", ...env },
+          encoding: "utf8",
+          stdio: "pipe",
+        }),
+      };
+    } catch (e) {
+      return { ok: false, out: String((e as { stderr?: string }).stderr) };
+    }
+  };
+
+  it("a dry run checks the statement and signs it with a throwaway key: a raw 64-byte signature", async () => {
+    const ws = await workspace("dryrun-42");
+    const env = {
+      VERSION: "0.8.0",
+      KEY_ID: "dryrun-42",
+      GITHUB_SHA: COMMIT,
+      DRY_RUN: "true",
+      GITHUB_RUN_ID: "42",
+      SECURITY_RELEASE: "false",
+    };
+    expect(exec(ws, "Check.cjs", env)).toMatchObject({ ok: true });
+    const output = path.join(ws, "github-output");
+    writeFileSync(output, "");
+    const r = exec(ws, "Sign.cjs", { ...env, GITHUB_OUTPUT: output });
+    expect(r.ok).toBe(true);
+    const sig = readFileSync(path.join(ws, "bundle/release-statement.sig"));
+    expect(sig.length).toBe(64);
+    const raw = /public key ([A-Za-z0-9+/=]+)\)/.exec(r.out)?.[1] as string;
+    // The public half reaches publish-ci (job output); nothing else is written there.
+    expect(readFileSync(output, "utf8")).toBe(`public_key=${raw}\n`);
+    const key = createPublicKey({
+      key: Buffer.concat([
+        Buffer.from("302a300506032b6570032100", "hex"),
+        Buffer.from(raw, "base64"),
+      ]),
+      format: "der",
+      type: "spki",
+    });
+    expect(
+      verify(null, readFileSync(path.join(ws, "bundle/release-statement.json")), key, sig),
+    ).toBe(true);
+  });
+
+  it("refuses a statement for another commit or version, and a tampered tarball", async () => {
+    const ws = await workspace("dryrun-42");
+    const env = {
+      VERSION: "0.8.0",
+      KEY_ID: "dryrun-42",
+      GITHUB_SHA: COMMIT,
+      DRY_RUN: "true",
+      GITHUB_RUN_ID: "42",
+      SECURITY_RELEASE: "false",
+    };
+    expect(exec(ws, "Check.cjs", { ...env, GITHUB_SHA: "f".repeat(40) }).out).toContain(
+      "not for this commit",
+    );
+    expect(exec(ws, "Check.cjs", { ...env, VERSION: "0.8.1" }).out).toContain(
+      "not for the planned version",
+    );
+    expect(exec(ws, "Check.cjs", { ...env, KEY_ID: "dryrun-43" }).out).toContain(
+      "does not name the signing key",
+    );
+    expect(exec(ws, "Check.cjs", { ...env, SECURITY_RELEASE: "true" }).out).toContain(
+      "securityRelease is not the planned one",
+    );
+    writeFileSync(path.join(ws, "bundle/cloudhal-release-0.8.0.tar.gz"), "tampered");
+    expect(exec(ws, "Check.cjs", env).out).toContain("is not the file the statement pins");
+  });
+
+  it("outside a dry run, refuses any key but the pinned one and never prints the key", async () => {
+    const ws = await workspace("cloudhal-release-2026a");
+    const pem = generateKeyPairSync("ed25519")
+      .privateKey.export({ format: "pem", type: "pkcs8" })
+      .toString();
+    const env = {
+      VERSION: "0.8.0",
+      KEY_ID: "cloudhal-release-2026a",
+      GITHUB_SHA: COMMIT,
+      DRY_RUN: "false",
+      SECURITY_RELEASE: "false",
+    };
+    const wrong = exec(ws, "Sign.cjs", { ...env, RELEASE_SIGNING_KEY: pem });
+    expect(wrong).toMatchObject({ ok: false });
+    expect(wrong.out).toContain("the signing key is not cloudhal-release-2026a");
+    expect(wrong.out).not.toContain("PRIVATE KEY");
+    const missing = exec(ws, "Sign.cjs", env);
+    expect(missing.out).toContain("RELEASE_SIGNING_KEY is missing or not a PKCS#8 PEM key");
+    const garbage = exec(ws, "Sign.cjs", {
+      ...env,
+      RELEASE_SIGNING_KEY: "-----BEGIN PRIVATE KEY-----\nnot-a-key\n-----END PRIVATE KEY-----",
+    });
+    expect(garbage.out).toBe(
+      "bundle-sign: RELEASE_SIGNING_KEY is missing or not a PKCS#8 PEM key\n",
+    );
+  });
+});
diff --git a/packages/cli/test/release-bundle-verify.test.ts b/packages/cli/test/release-bundle-verify.test.ts
new file mode 100644
index 00000000..cfb89531
--- /dev/null
+++ b/packages/cli/test/release-bundle-verify.test.ts
@@ -0,0 +1,192 @@
+import { generateKeyPairSync, sign } from "node:crypto";
+import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+import {
+  buildReleaseBundle,
+  type ReleaseBundle,
+  writeReleaseBundle,
+} from "../../../scripts/release-bundle/build.ts";
+import { canonicalJson } from "../../../scripts/release-bundle/manifest.ts";
+import {
+  readBuildOutput,
+  readDeploySecrets,
+  type SourceFile,
+} from "../../../scripts/release-bundle/read-build.ts";
+import { statementOf } from "../../../scripts/release-bundle/statement.ts";
+import { gzipBytes, tarArchive } from "../../../scripts/release-bundle/tar.ts";
+import { checkGzipHeader, gunzipBytes, readTar } from "../../../scripts/release-bundle/tar-read.ts";
+import { verifyBundle } from "../../../scripts/release-bundle/verify.ts";
+import { facts, fixtureProject } from "./fixtures/release-bundle/fixture.ts";
+
+const roots: string[] = [];
+afterEach(() => {
+  for (const r of roots.splice(0)) rmSync(r, { recursive: true, force: true });
+});
+
+const MTIME = Date.parse("2026-11-12T08:00:00Z") / 1000;
+const enc = new TextEncoder();
+
+async function bundleDir(version = "0.8.0"): Promise<{ dir: string; b: ReleaseBundle }> {
+  const fx = fixtureProject();
+  roots.push(fx.root);
+  const out = readBuildOutput({ project: fx.project, studio: fx.studio });
+  const secrets = readDeploySecrets(path.join(fx.project, ".cloudhal/deploy/secrets.json"));
+  const b = await buildReleaseBundle(out, facts({ version }), secrets);
+  const dir = mkdtempSync(path.join(os.tmpdir(), "release-bundle-out-"));
+  roots.push(dir);
+  writeReleaseBundle(b, dir);
+  return { dir, b };
+}
+
+/** A throwaway key: raw public half (base64) and a signer. */
+function throwaway() {
+  const { privateKey, publicKey } = generateKeyPairSync("ed25519");
+  const raw = (publicKey.export({ format: "jwk" }) as { x: string }).x;
+  return {
+    publicKey: Buffer.from(raw, "base64url").toString("base64"),
+    sign: (b: Uint8Array) => sign(null, b, privateKey),
+  };
+}
+
+/** Replaces the tarball with one of `entries` and re-pins it in the statement. */
+async function retar(dir: string, b: ReleaseBundle, tar: Uint8Array): Promise {
+  const gz = await gzipBytes(tar);
+  const { bytes } = statementOf(b.statement, b.manifestBytes, gz);
+  writeFileSync(path.join(dir, "cloudhal-release-0.8.0.tar.gz"), gz);
+  writeFileSync(path.join(dir, "release-statement.json"), bytes);
+}
+
+const entriesOf = async (b: ReleaseBundle): Promise =>
+  readTar(await gunzipBytes(b.tarball), MTIME);
+
+describe("verifyBundle", () => {
+  it("accepts the builder's own output, unsigned and signed with a pinned key", async () => {
+    const { dir, b } = await bundleDir();
+    expect((await verifyBundle(dir, { version: "0.8.0" })).parts).toBe(b.parts.length);
+    const k = throwaway();
+    writeFileSync(path.join(dir, "release-statement.sig"), k.sign(b.statementBytes));
+    const r = await verifyBundle(dir, {
+      version: "0.8.0",
+      signed: true,
+      publicKeys: { "cloudhal-release-2026a": k.publicKey },
+    });
+    expect(r.statement.signing.keyId).toBe("cloudhal-release-2026a");
+  });
+
+  it("refuses a signature by another key, a missing signature and an unknown key id", async () => {
+    const { dir, b } = await bundleDir();
+    const signed = (keys: Record) =>
+      verifyBundle(dir, { version: "0.8.0", signed: true, publicKeys: keys });
+    await expect(signed({ "cloudhal-release-2026a": throwaway().publicKey })).rejects.toThrow(
+      /release-statement\.sig is missing/,
+    );
+    writeFileSync(path.join(dir, "release-statement.sig"), throwaway().sign(b.statementBytes));
+    await expect(signed({ "cloudhal-release-2026a": throwaway().publicKey })).rejects.toThrow(
+      /does not verify/,
+    );
+    await expect(signed({ other: throwaway().publicKey })).rejects.toThrow(
+      /no pinned public key for cloudhal-release-2026a/,
+    );
+  });
+
+  it("binds the statement to the requested version and channel (invariant 7)", async () => {
+    const { dir } = await bundleDir();
+    await expect(verifyBundle(dir, { version: "0.8.1" })).rejects.toThrow(
+      /for 0\.8\.0, not 0\.8\.1/,
+    );
+    await expect(verifyBundle(dir, { version: "0.8.0", channel: "next" })).rejects.toThrow(
+      /channel stable, not next/,
+    );
+  });
+
+  it("refuses a statement that is not its canonical serialisation", async () => {
+    const { dir, b } = await bundleDir();
+    writeFileSync(path.join(dir, "release-statement.json"), JSON.stringify(b.statement));
+    await expect(verifyBundle(dir, { version: "0.8.0" })).rejects.toThrow(/not canonical JSON/);
+  });
+
+  it("refuses a manifest or tarball the statement does not pin", async () => {
+    const { dir, b } = await bundleDir();
+    const m = JSON.parse(new TextDecoder().decode(b.manifestBytes));
+    writeFileSync(path.join(dir, "manifest.json"), canonicalJson({ ...m, securityRelease: true }));
+    await expect(verifyBundle(dir, { version: "0.8.0" })).rejects.toThrow(
+      /manifest\.json does not match the statement/,
+    );
+    writeReleaseBundle(b, dir);
+    const gz = new Uint8Array(b.tarball);
+    gz[gz.length - 20] = (gz[gz.length - 20] as number) ^ 1;
+    writeFileSync(path.join(dir, "cloudhal-release-0.8.0.tar.gz"), gz);
+    await expect(verifyBundle(dir, { version: "0.8.0" })).rejects.toThrow(
+      /tar\.gz does not match the statement/,
+    );
+  });
+
+  it("refuses an unlisted entry, a missing part and a changed part, even when re-pinned", async () => {
+    const { dir, b } = await bundleDir();
+    const entries = await entriesOf(b);
+    const extra = [...entries, { path: "site/modules/zz-extra.mjs", bytes: enc.encode("x") }];
+    await retar(dir, b, tarArchive(extra, MTIME));
+    await expect(verifyBundle(dir, { version: "0.8.0" })).rejects.toThrow(
+      /zz-extra\.mjs is in the tarball but not in the manifest/,
+    );
+    await retar(dir, b, tarArchive(entries.slice(1), MTIME));
+    await expect(verifyBundle(dir, { version: "0.8.0" })).rejects.toThrow(
+      /is in the manifest but not in the tarball/,
+    );
+    const changed = entries.map((e, i) => (i === 0 ? { ...e, bytes: enc.encode("changed") } : e));
+    await retar(dir, b, tarArchive(changed, MTIME));
+    await expect(verifyBundle(dir, { version: "0.8.0" })).rejects.toThrow(
+      /does not match the manifest's size and SHA-256/,
+    );
+    await retar(dir, b, tarArchive([...entries].reverse(), MTIME));
+    await expect(verifyBundle(dir, { version: "0.8.0" })).rejects.toThrow(/not sorted/);
+    await retar(dir, b, tarArchive(entries, MTIME + 1));
+    await expect(verifyBundle(dir, { version: "0.8.0" })).rejects.toThrow(/mtime is not createdAt/);
+  });
+});
+
+describe("tar and gzip format checks", () => {
+  const file = { path: "site/modules/a.mjs", bytes: enc.encode("a") };
+
+  it("round-trips long PAX paths", () => {
+    const long = { path: `site/assets/${"x".repeat(150)}.css`, bytes: enc.encode("b") };
+    expect(readTar(tarArchive([file, long], MTIME), MTIME).map((e) => e.path)).toEqual([
+      file.path,
+      long.path,
+    ]);
+  });
+
+  it("refuses links, other modes, owners and a broken checksum", () => {
+    const tar = tarArchive([file], MTIME);
+    const patched = (at: number, value: string, reseal = true) => {
+      const t = new Uint8Array(tar);
+      t.set(enc.encode(value), at);
+      if (reseal) {
+        t.fill(0x20, 148, 156);
+        let sum = 0;
+        for (let i = 0; i < 512; i++) sum += t[i] as number;
+        t.set(enc.encode(`${sum.toString(8).padStart(6, "0")}\0 `), 148);
+      }
+      return t;
+    };
+    expect(() => readTar(patched(156, "2"), MTIME)).toThrow(/type 2: not a file/);
+    expect(() => readTar(patched(100, "0000755"), MTIME)).toThrow(/mode is not 0644/);
+    expect(() => readTar(patched(108, "0000001"), MTIME)).toThrow(/uid or gid/);
+    expect(() => readTar(patched(265, "root"), MTIME)).toThrow(/owner name/);
+    expect(() => readTar(patched(0, "z", false), MTIME)).toThrow(/bad checksum/);
+    expect(() => readTar(tar.subarray(0, 1024), MTIME)).toThrow(/truncated|zero blocks/);
+  });
+
+  it("refuses a gzip header that is not the fixed one", async () => {
+    const gz = await gzipBytes(tarArchive([file], MTIME));
+    expect(() => checkGzipHeader(gz)).not.toThrow();
+    const os3 = new Uint8Array(gz);
+    os3[9] = 3;
+    expect(() => checkGzipHeader(os3)).toThrow(/OS byte/);
+    const mtime = new Uint8Array(gz);
+    mtime[4] = 1;
+    expect(() => checkGzipHeader(mtime)).toThrow(/fixed one/);
+  });
+});
diff --git a/packages/cli/test/release-bundle.test.ts b/packages/cli/test/release-bundle.test.ts
new file mode 100644
index 00000000..6392518a
--- /dev/null
+++ b/packages/cli/test/release-bundle.test.ts
@@ -0,0 +1,257 @@
+import { createHash } from "node:crypto";
+import { readFileSync, rmSync, writeFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { isValid } from "@cloudhal/core-contracts";
+import { afterEach, describe, expect, it } from "vitest";
+import { buildReleaseBundle, scanForSecrets } from "../../../scripts/release-bundle/build.ts";
+import {
+  assetHash,
+  bundleEntries,
+  canonicalJson,
+  checkLimits,
+  LIMITS,
+} from "../../../scripts/release-bundle/manifest.ts";
+import {
+  ignoreMatcher,
+  migrationNames,
+  readBuildOutput,
+  readDeploySecrets,
+} from "../../../scripts/release-bundle/read-build.ts";
+import * as standard from "../../../scripts/release-bundle/standard.ts";
+import { gzipBytes, tarArchive } from "../../../scripts/release-bundle/tar.ts";
+import { committedAt, toolchainOf } from "../../../scripts/release-bundle.ts";
+import * as cfg from "../src/cloudflare/config.ts";
+import { SECRET_KEYS, SITE_SECRET_KEYS } from "../src/commands/deploy/state.ts";
+import {
+  COMMIT,
+  type FixtureProject,
+  facts,
+  fixtureProject,
+} from "./fixtures/release-bundle/fixture.ts";
+
+const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
+const enc = new TextEncoder();
+const sha256 = (b: Uint8Array) => createHash("sha256").update(b).digest("hex");
+
+let fx: FixtureProject | undefined;
+afterEach(() => {
+  if (fx) rmSync(fx.root, { recursive: true, force: true });
+  fx = undefined;
+});
+
+async function build(extra?: Record, over?: Parameters[0]) {
+  fx = fixtureProject(extra);
+  const out = readBuildOutput({ project: fx.project, studio: fx.studio });
+  return buildReleaseBundle(
+    out,
+    facts(over),
+    readDeploySecrets(path.join(fx.project, ".cloudhal/deploy/secrets.json")),
+  );
+}
+
+describe("golden vectors (equal to the dev-bundle.ts of the remote deployer that first consumed bundles)", () => {
+  it("assetHash = sha256(base64(bytes) + extension)[0:32]", () => {
+    expect(assetHash("/favicon.svg", enc.encode(""))).toBe(
+      "631b5e37230fd031efdc7d00e06edf26",
+    );
+    expect(assetHash("/LICENSE", enc.encode("MIT\n"))).toBe("7626dd24f1b1407333cb4d7381c3bd8a");
+    expect(assetHash("/img/Photo.JPG", new Uint8Array([0, 1, 2, 255]))).toBe(
+      "ea5d106a73084f9d717f0e41b71f7dec",
+    );
+  });
+
+  it("the tar bytes (ustar, PAX path for a long name, fixed mtime/uid/gid/mode)", () => {
+    const tar = tarArchive(
+      [
+        { path: "admin/modules/worker.js", bytes: enc.encode("export default {};\n") },
+        { path: `site/assets/${"d/".repeat(50)}long-name.txt`, bytes: enc.encode("long\n") },
+        { path: "site/assets/empty.txt", bytes: new Uint8Array(0) },
+        { path: "site/modules/entry.mjs", bytes: enc.encode("x".repeat(700)) },
+      ],
+      1762857600,
+    );
+    expect(tar.length).toBe(6144);
+    expect(sha256(tar)).toBe("566424a5bebaf5173867eb5b95ffa4b8dcf329b90efecb250293ce1e9989937e");
+  });
+
+  it("gzip carries the fixed header: no flags, mtime 0, OS 255", async () => {
+    const gz = await gzipBytes(enc.encode("hello"));
+    expect([...gz.subarray(0, 10)]).toEqual([0x1f, 0x8b, 8, 0, 0, 0, 0, 0, 0, 255]);
+  });
+});
+
+describe("release bundle build", () => {
+  it("is byte-identical across two builds of the same input", async () => {
+    const a = await build();
+    const first = { ...a.files };
+    rmSync(fx?.root ?? "", { recursive: true, force: true });
+    const b = await build();
+    for (const name of Object.keys(first))
+      expect(sha256(b.files[name] as Uint8Array)).toBe(sha256(first[name] as Uint8Array));
+    expect(Object.keys(b.files).sort()).toEqual(
+      ["cloudhal-release-0.8.0.tar.gz", "manifest.json", "release-statement.json"].sort(),
+    );
+  });
+
+  it("writes canonical documents that validate as both core-contracts roots", async () => {
+    const b = await build();
+    const manifest = JSON.parse(new TextDecoder().decode(b.manifestBytes));
+    const statement = JSON.parse(new TextDecoder().decode(b.statementBytes));
+    expect(isValid("release-manifest", manifest)).toBe(true);
+    expect(isValid("release-statement", statement)).toBe(true);
+    expect(new TextDecoder().decode(b.statementBytes)).toBe(canonicalJson(statement));
+    expect(b.statementBytes.length).toBeLessThanOrEqual(LIMITS.statementBytes);
+    expect(statement.manifest).toEqual({
+      name: "manifest.json",
+      sha256: sha256(b.manifestBytes),
+      bytes: b.manifestBytes.length,
+    });
+    expect(statement.tarball).toEqual({
+      name: "cloudhal-release-0.8.0.tar.gz",
+      sha256: sha256(b.tarball),
+      bytes: b.tarball.length,
+    });
+    expect(statement.signing).toEqual({
+      keyId: "cloudhal-release-2026a",
+      algorithm: "Ed25519",
+      signature: "release-statement.sig",
+    });
+    expect(manifest.signing).toEqual(statement.signing);
+    for (const k of ["version", "channel", "securityRelease", "commit", "createdAt"])
+      expect(statement[k]).toEqual(manifest[k]);
+    expect(manifest.toolchain).toMatchObject({
+      producer: "cloudhal-release-bundle",
+      starter: "blog",
+      cli: "0.8.0",
+    });
+    expect(manifest.migrations).toMatchObject({ first: "0001_init", last: "0002_auth", count: 2 });
+    expect(manifest.runtime).toEqual({
+      compatibilityDate: "2026-10-01",
+      compatibilityFlags: ["nodejs_compat"],
+    });
+  });
+
+  it("derives the channel from the version and keeps the tar sorted with mtime = createdAt", async () => {
+    const b = await build(undefined, { version: "0.8.0-rc.1" });
+    expect(b.statement.channel).toBe("next");
+    expect(b.parts).toEqual([...b.parts].sort());
+    expect(b.parts).toContain("admin/assets/index.html");
+    expect(b.parts).toContain("site/modules/chunks/page.mjs");
+    // source maps, READMEs, _headers, .assetsignore and ignored assets are never parts
+    expect(
+      b.parts.filter((p) => /\.map$|README|_headers|assetsignore|notes\.txt|private\//.test(p)),
+    ).toEqual([]);
+    const manifest = JSON.parse(new TextDecoder().decode(b.manifestBytes));
+    expect(manifest.workers.site.assets.headers).toBe("/*\n  X-Frame-Options: DENY\n");
+  });
+
+  it("refuses a dev- key id, a short commit and a createdAt with milliseconds", async () => {
+    await expect(build(undefined, { keyId: "dev-staging-2026a" })).rejects.toThrow(/key-id/);
+    await expect(build(undefined, { commit: "abc" })).rejects.toThrow(/commit/);
+    await expect(build(undefined, { createdAt: "2026-11-12T08:00:00.000Z" })).rejects.toThrow(
+      /whole-second/,
+    );
+  });
+
+  it("refuses _redirects in the site's assets", async () => {
+    await expect(build({ "project/dist/client/_redirects": "/a /b 301\n" })).rejects.toThrow(
+      /_redirects/,
+    );
+  });
+
+  it("refuses a part that contains a deploy secret, naming the part and never the value", async () => {
+    fx = fixtureProject();
+    const value = fx.secrets.CLOUDHAL_PREVIEW_SECRET as string;
+    writeFileSync(
+      path.join(fx.project, ".cloudhal/deploy/out/site/chunks/leak.mjs"),
+      `const k = "${value}";\n`,
+    );
+    const out = readBuildOutput({ project: fx.project, studio: fx.studio });
+    const secrets = readDeploySecrets(path.join(fx.project, ".cloudhal/deploy/secrets.json"));
+    const err = await buildReleaseBundle(out, facts(), secrets).catch((e: Error) => e);
+    expect(err).toBeInstanceOf(Error);
+    expect((err as Error).message).toMatch(
+      /^site\/modules\/chunks\/leak\.mjs contains generated deploy secret #2/,
+    );
+    expect((err as Error).message).not.toContain(value);
+  });
+
+  it("scans the manifest too, which carries the _headers text inline", () => {
+    const secret = "s".repeat(40);
+    expect(() => scanForSecrets([], enc.encode(`{"headers":"${secret}"}`), [secret])).toThrow(
+      /^manifest\.json contains/,
+    );
+  });
+
+  it("enforces the normative limits by part name", () => {
+    fx = fixtureProject();
+    const out = readBuildOutput({ project: fx.project, studio: fx.studio });
+    const big = { path: "/big.bin", bytes: new Uint8Array(LIMITS.assetBytes + 1) };
+    const withBig = { ...out, site: { ...out.site, assets: [...out.site.assets, big] } };
+    expect(() => checkLimits(withBig, bundleEntries(withBig))).toThrow(
+      /^site\/assets\/big\.bin is above 26214400/,
+    );
+    const many = Array.from({ length: LIMITS.assetsPerWorker + 1 }, (_, i) => ({
+      path: `/a${i}`,
+      bytes: new Uint8Array(0),
+    }));
+    const tooMany = { ...out, admin: { ...out.admin, assets: many } };
+    expect(() => checkLimits(tooMany, bundleEntries(tooMany))).toThrow(/admin: 20001 assets/);
+    const dup = {
+      ...out,
+      site: { ...out.site, modules: [...out.site.modules, out.site.modules[0] as never] },
+    };
+    expect(() => checkLimits(dup, bundleEntries(dup))).toThrow(/appears twice/);
+  });
+});
+
+describe("build inputs", () => {
+  it("reads migration names from .ts, .js and .sql files only", () => {
+    fx = fixtureProject({ "migrations/0003_more.js": "", "migrations/notes.md": "" });
+    expect(migrationNames(fx.migrations)).toEqual(["0001_init", "0002_auth", "0003_more"]);
+  });
+
+  it(".assetsignore: basename rules, ** across segments, comments", () => {
+    const m = ignoreMatcher("# c\nnotes.txt\n/private/**\n*.map\n");
+    expect(m("a/b/notes.txt")).toBe(true);
+    expect(m("private/x/y.html")).toBe(true);
+    expect(m("x.js.map")).toBe(true);
+    expect(m("public/private/x.html")).toBe(false);
+    expect(m("notes.txt.bak")).toBe(false);
+  });
+
+  it("createdAt is the commit's committer time in whole seconds, UTC", () => {
+    expect(committedAt("HEAD")).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/);
+    expect(() => committedAt(COMMIT)).toThrow();
+  });
+
+  it("toolchain records node and the CLI version, and 'unknown' for a missing package", () => {
+    const t = toolchainOf("/nonexistent");
+    expect(t.node).toBe(process.versions.node);
+    expect(t.cli).toBe(
+      JSON.parse(readFileSync(path.join(ROOT, "packages/cli/package.json"), "utf8")).version,
+    );
+    expect(t.astro).toBe("unknown");
+  });
+});
+
+describe("standard manifest facts follow the CLI", () => {
+  it("compatibility date, Durable Objects, admin routes and cron", () => {
+    expect(standard.DEFAULT_RUNTIME.compatibilityDate).toBe(cfg.COMPATIBILITY_DATE);
+    expect(standard.DURABLE_OBJECTS.map((d) => [d.className, d.migration])).toEqual([
+      [cfg.SCHEDULER_CLOCK_CLASS, cfg.SCHEDULER_CLOCK_MIGRATION],
+      [cfg.BUILD_ROOMS_CLASS, cfg.BUILD_ROOMS_MIGRATION],
+    ]);
+    expect(standard.ADMIN_CRON).toBe(cfg.MINUTE_CRON);
+    const config = readFileSync(path.join(ROOT, "packages/cli/src/cloudflare/config.ts"), "utf8");
+    expect(config).toContain(
+      `run_worker_first: ${JSON.stringify(standard.ADMIN_CONFIG.run_worker_first).replaceAll(",", ", ")}`,
+    );
+  });
+
+  it("the admin and site secrets", () => {
+    expect([...standard.ADMIN_SECRETS]).toEqual([...SECRET_KEYS]);
+    expect([...standard.SITE_SECRETS].sort()).toEqual([...SITE_SECRET_KEYS].sort());
+  });
+});
diff --git a/packages/cli/test/release-plan.test.ts b/packages/cli/test/release-plan.test.ts
index 7c0a203f..08280058 100644
--- a/packages/cli/test/release-plan.test.ts
+++ b/packages/cli/test/release-plan.test.ts
@@ -2,6 +2,7 @@ import path from "node:path";
 import { fileURLToPath } from "node:url";
 import { describe, expect, it } from "vitest";
 import {
+  isSecurityRelease,
   type Pkg,
   readPackages,
   releaseNotes,
@@ -76,4 +77,30 @@ describe("release plan", () => {
       "- Dependency updates only.",
     );
   });
+
+  it("a changeset that starts with **Security release.** marks only its own version", () => {
+    const cli = [
+      "# @cloudhal/cli",
+      "",
+      "## 0.8.1",
+      "",
+      "### Patch Changes",
+      "",
+      "- 1a2b3c4: **Security release.** The setup token is required on the Node path too.",
+      "- Updated dependencies [1a2b3c4]",
+      "",
+      "## 0.8.0",
+      "",
+      "- 5d6e7f8: A feature.",
+      "",
+    ].join("\n");
+    const api = "# @cloudhal/api\n\n## 0.8.1\n\n- Updated dependencies [1a2b3c4]\n";
+    expect(isSecurityRelease([api, cli], "0.8.1")).toBe(true);
+    expect(isSecurityRelease([api, cli], "0.8.0")).toBe(false);
+    // Prose that mentions security, or the marker inside an entry, is not the flag.
+    const prose =
+      "# x\n\n## 0.8.2\n\n- abcdef1: **Security (self-hosting).** Fixed.\n- See **Security release.** notes.\n";
+    expect(isSecurityRelease([prose], "0.8.2")).toBe(false);
+    expect(isSecurityRelease([], "0.8.1")).toBe(false);
+  });
 });
diff --git a/packages/cli/test/release-publish-plan.test.ts b/packages/cli/test/release-publish-plan.test.ts
new file mode 100644
index 00000000..35dd87c1
--- /dev/null
+++ b/packages/cli/test/release-publish-plan.test.ts
@@ -0,0 +1,400 @@
+import { describe, expect, it } from "vitest";
+import {
+  assetProblems,
+  isHighestStable,
+  type LocalAsset,
+  type NpmPublish,
+  npmFlags,
+  type PublishInput,
+  type PublishPort,
+  PublishRefusal,
+  planPublish,
+  type ReleaseView,
+  type RemoteAsset,
+  runPublish,
+  type Step,
+} from "../../../scripts/release-publish-plan.ts";
+
+const HEAD = "a".repeat(40);
+const hex = (c: string) => c.repeat(64);
+const NAMES = [
+  "cloudhal-release-0.8.0.tar.gz",
+  "manifest.json",
+  "release-statement.json",
+  "release-statement.sig",
+  "cloudhal-release-0.8.0.sigstore.json",
+];
+const ASSETS: LocalAsset[] = NAMES.map((name, i) => ({
+  name,
+  sha256: hex(String(i + 1)),
+  bytes: 100 + i,
+  file: `/b/${name}`,
+}));
+const remote = (a: LocalAsset): RemoteAsset => ({
+  name: a.name,
+  digest: `sha256:${a.sha256}`,
+  size: a.bytes,
+});
+const draft = (assets: RemoteAsset[] = ASSETS.map(remote)): ReleaseView => ({
+  draft: true,
+  prerelease: false,
+  assets,
+});
+const published = (assets: RemoteAsset[] = ASSETS.map(remote)): ReleaseView => ({
+  ...draft(assets),
+  draft: false,
+});
+
+function input(over: Partial = {}): PublishInput {
+  return {
+    version: "0.8.0",
+    head: HEAD,
+    dryRun: false,
+    bundle: { statement: { version: "0.8.0", channel: "stable", commit: HEAD }, assets: ASSETS },
+    tagCommit: null,
+    releases: [],
+    packages: ["@cloudhal/core-contracts", "@cloudhal/cli"],
+    npmMissing: ["@cloudhal/core-contracts", "@cloudhal/cli"],
+    tags: ["v0.7.2", "v0.7.3"],
+    ...over,
+  };
+}
+const BASE = ["--access", "public", "--ignore-scripts"];
+const DRY_FORCE = [...BASE, "--dry-run", "--force"];
+const kinds = (steps: Step[]) =>
+  steps.map((s) => (s.kind === "check-assets" ? `check-before-${s.before}` : s.kind));
+
+describe("planPublish: the order on immutable releases", () => {
+  it("a fresh release: tag → draft → attach → check → npm → check → publish", () => {
+    const steps = planPublish(input());
+    expect(kinds(steps)).toEqual([
+      "push-tag",
+      "create-draft",
+      "sync-assets",
+      "check-before-npm",
+      "npm-publish",
+      "check-before-publish",
+      "publish-release",
+    ]);
+    expect(steps[0]).toEqual({ kind: "push-tag", tag: "v0.8.0", commit: HEAD });
+    expect(steps.at(-1)).toEqual({
+      kind: "publish-release",
+      tag: "v0.8.0",
+      latest: true,
+      prerelease: false,
+    });
+  });
+
+  it("refuses a bundle from another commit, version or channel, and a tag on another commit", () => {
+    const st = (s: Partial<{ version: string; channel: string; commit: string }>) =>
+      input({
+        bundle: {
+          statement: { version: "0.8.0", channel: "stable", commit: HEAD, ...s },
+          assets: ASSETS,
+        },
+      });
+    expect(() => planPublish(st({ commit: "b".repeat(40) }))).toThrow(
+      /built from b+, but HEAD is a+/,
+    );
+    expect(() => planPublish(st({ version: "0.7.9" }))).toThrow(/for 0\.7\.9, not 0\.8\.0/);
+    expect(() => planPublish(st({ channel: "next" }))).toThrow(/channel next/);
+    expect(() => planPublish(input({ tagCommit: "c".repeat(40) }))).toThrow(PublishRefusal);
+  });
+
+  it("Latest only for the highest stable version; a prerelease is marked and never Latest", () => {
+    expect(isHighestStable("0.8.0", ["v0.7.3", "v0.8.0-rc.1"])).toBe(true);
+    expect(isHighestStable("0.7.4", ["v0.8.0"])).toBe(false);
+    expect(isHighestStable("0.10.0", ["v0.9.9"])).toBe(true);
+    expect(isHighestStable("0.8.0-rc.1", [])).toBe(false);
+    const pre = planPublish(
+      input({
+        version: "0.8.0-rc.1",
+        bundle: {
+          statement: { version: "0.8.0-rc.1", channel: "next", commit: HEAD },
+          assets: ASSETS,
+        },
+      }),
+    );
+    expect(pre.find((s) => s.kind === "create-draft")).toMatchObject({ prerelease: true });
+    expect(pre.at(-1)).toMatchObject({
+      kind: "publish-release",
+      tag: "v0.8.0-rc.1",
+      latest: false,
+      prerelease: true,
+    });
+    expect(planPublish(input({ tags: ["v0.9.0"] })).at(-1)).toMatchObject({ latest: false });
+  });
+
+  it("resume: the tag and the draft exist, npm is done → check, check, publish", () => {
+    const steps = planPublish(input({ tagCommit: HEAD, releases: [draft()], npmMissing: [] }));
+    expect(kinds(steps)).toEqual([
+      "sync-assets",
+      "check-before-npm",
+      "check-before-publish",
+      "publish-release",
+    ]);
+  });
+
+  it("resume: published with every asset and npm done → nothing; npm missing → npm only", () => {
+    expect(
+      planPublish(input({ tagCommit: HEAD, releases: [published()], npmMissing: [] })),
+    ).toEqual([]);
+    expect(
+      kinds(
+        planPublish(
+          input({ tagCommit: HEAD, releases: [published()], npmMissing: ["@cloudhal/cli"] }),
+        ),
+      ),
+    ).toEqual(["npm-publish"]);
+  });
+
+  it("refuses a published release with missing or different assets (immutable)", () => {
+    const missing = published(ASSETS.slice(1).map(remote));
+    expect(() => planPublish(input({ tagCommit: HEAD, releases: [missing] }))).toThrow(
+      /published and immutable.*\(missing cloudhal-release-0\.8\.0\.tar\.gz\); release a new version/,
+    );
+    const wrong = published(
+      ASSETS.map((a, i) => ({
+        ...remote(a),
+        digest: i === 1 ? `sha256:${hex("f")}` : remote(a).digest,
+      })),
+    );
+    expect(() => planPublish(input({ tagCommit: HEAD, releases: [wrong] }))).toThrow(
+      /wrong digest manifest\.json/,
+    );
+  });
+
+  it("refuses two releases on one tag (duplicate drafts)", () => {
+    expect(() => planPublish(input({ releases: [draft(), draft()] }))).toThrow(
+      /2 releases use the tag v0\.8\.0/,
+    );
+  });
+
+  it("without a bundle: the same order with no assets, and a published release with none is complete", () => {
+    expect(kinds(planPublish(input({ bundle: null })))).toEqual([
+      "push-tag",
+      "create-draft",
+      "sync-assets",
+      "check-before-npm",
+      "npm-publish",
+      "check-before-publish",
+      "publish-release",
+    ]);
+    expect(
+      planPublish(
+        input({ bundle: null, tagCommit: HEAD, releases: [published([])], npmMissing: [] }),
+      ),
+    ).toEqual([]);
+  });
+
+  it("a dry run pushes no tag, publishes nothing, runs every package with --dry-run and deletes its draft", () => {
+    const steps = planPublish(input({ dryRun: true, runNumber: "17", npmMissing: [] }));
+    expect(kinds(steps)).toEqual([
+      "create-draft",
+      "sync-assets",
+      "check-before-npm",
+      "npm-publish",
+      "check-before-publish",
+      "delete-draft",
+    ]);
+    expect(steps[0]).toEqual({
+      kind: "create-draft",
+      tag: "v0.8.0-dryrun.17",
+      prerelease: false,
+      target: HEAD,
+    });
+    expect(steps.find((s) => s.kind === "npm-publish")).toEqual({
+      kind: "npm-publish",
+      packages: [
+        { name: "@cloudhal/core-contracts", flags: DRY_FORCE },
+        { name: "@cloudhal/cli", flags: DRY_FORCE },
+      ],
+      dryRun: true,
+    });
+    expect(() => planPublish(input({ dryRun: true }))).toThrow(/GITHUB_RUN_NUMBER/);
+    expect(() =>
+      planPublish(input({ dryRun: true, runNumber: "17", releases: [published()] })),
+    ).toThrow(/a dry run never is/);
+  });
+
+  it("npm publish: a dry run forces exactly the versions npm has, a real run never forces", () => {
+    const npmOf = (i: PublishInput) => planPublish(i).find((s) => s.kind === "npm-publish");
+    const dry = npmOf(input({ dryRun: true, runNumber: "5", npmMissing: ["@cloudhal/cli"] }));
+    expect(dry).toHaveProperty("packages", [
+      { name: "@cloudhal/core-contracts", flags: DRY_FORCE },
+      { name: "@cloudhal/cli", flags: [...BASE, "--dry-run"] }, // npm lacks it: its checks run
+    ]);
+    expect(npmOf(input({ npmMissing: ["@cloudhal/cli"] }))).toHaveProperty("packages", [
+      { name: "@cloudhal/cli", flags: [...BASE, "--provenance"] },
+    ]);
+    expect(() => npmFlags({ prerelease: false, dryRun: false, onNpm: true })).toThrow(/already/);
+    // npm 12 refuses a prerelease without --tag: it goes to `next`.
+    const pre = (dryRun: boolean) => npmFlags({ prerelease: true, dryRun, onNpm: dryRun });
+    expect(pre(false)).toEqual([...BASE, "--tag", "next", "--provenance"]);
+    expect(pre(true)).toEqual([...BASE, "--tag", "next", "--dry-run", "--force"]);
+  });
+
+  it("asset problems: missing, unexpected, mismatched size and null digest", () => {
+    const view = draft([
+      remote(ASSETS[0] as LocalAsset),
+      { ...remote(ASSETS[1] as LocalAsset), size: 1 },
+      { ...remote(ASSETS[2] as LocalAsset), digest: null },
+      { name: "stray.txt", digest: null, size: 1 },
+    ]);
+    expect(assetProblems(ASSETS, view)).toEqual({
+      missing: ["release-statement.sig", "cloudhal-release-0.8.0.sigstore.json"],
+      unexpected: ["stray.txt"],
+      mismatched: ["manifest.json", "release-statement.json"],
+    });
+  });
+});
+
+/** A GitHub that holds one release per tag and records every call. */
+function fakeGithub(start: ReleaseView[] = [], hooks: { onNpm?: (gh: FakeGithub) => void } = {}) {
+  const gh = {
+    releases: new Map(start.map((r) => ["v0.8.0", r])),
+    calls: [] as string[],
+    npm: [] as NpmPublish[],
+  };
+  const put = (tag: string, r: ReleaseView) => gh.releases.set(tag, r);
+  const get = (tag: string) => gh.releases.get(tag) as ReleaseView;
+  const port: PublishPort = {
+    pushTag: (tag) => gh.calls.push(`tag ${tag}`),
+    releases: (tag) => (gh.releases.has(tag) ? [get(tag)] : []),
+    createDraft(tag, o) {
+      gh.calls.push(`draft ${tag}${o.target ? " target" : ""}`);
+      put(tag, { draft: true, prerelease: o.prerelease, assets: [] });
+    },
+    upload(tag, assets) {
+      if (!get(tag).draft) throw new Error("immutable");
+      gh.calls.push(`upload ${assets.map((a) => a.name).join(",")}`);
+      const names = new Set(assets.map((a) => a.name));
+      put(tag, {
+        ...get(tag),
+        assets: [...get(tag).assets.filter((a) => !names.has(a.name)), ...assets.map(remote)],
+      });
+    },
+    deleteAsset(tag, name) {
+      gh.calls.push(`delete-asset ${name}`);
+      put(tag, { ...get(tag), assets: get(tag).assets.filter((a) => a.name !== name) });
+    },
+    async npmPublish(packages: readonly NpmPublish[], dryRun) {
+      gh.calls.push(`npm ${packages.length}${dryRun ? " --dry-run" : ""}`);
+      gh.npm.push(...packages);
+      hooks.onNpm?.(gh);
+    },
+    publishRelease(tag, o) {
+      gh.calls.push(`publish ${tag}${o.latest ? " latest" : ""}`);
+      put(tag, { ...get(tag), draft: false });
+    },
+    deleteRelease(tag) {
+      gh.calls.push(`delete ${tag}`);
+      gh.releases.delete(tag);
+    },
+    log: () => {},
+  };
+  return { gh, port };
+}
+type FakeGithub = ReturnType["gh"];
+
+describe("runPublish", () => {
+  it("publishes a release whose draft holds exactly the five assets", async () => {
+    const { gh, port } = fakeGithub();
+    await runPublish(port, input());
+    expect(gh.calls).toEqual([
+      "tag v0.8.0",
+      "draft v0.8.0",
+      `upload ${NAMES.join(",")}`,
+      "npm 2",
+      "publish v0.8.0 latest",
+    ]);
+    expect(gh.releases.get("v0.8.0")).toEqual(published());
+  });
+
+  it("re-uploads a draft asset swapped during npm publish, then re-checks before publishing", async () => {
+    const { gh, port } = fakeGithub([], {
+      onNpm: (g) => {
+        const r = g.releases.get("v0.8.0") as ReleaseView;
+        const swapped = r.assets.map((a) =>
+          a.name === "manifest.json" ? { ...a, digest: `sha256:${hex("e")}` } : a,
+        );
+        g.releases.set("v0.8.0", {
+          ...r,
+          assets: [...swapped, { name: "evil.sh", digest: null, size: 9 }],
+        });
+      },
+    });
+    await runPublish(port, input());
+    expect(gh.calls.slice(3)).toEqual([
+      "npm 2",
+      "delete-asset evil.sh",
+      "upload manifest.json",
+      "publish v0.8.0 latest",
+    ]);
+    const r = gh.releases.get("v0.8.0") as ReleaseView;
+    expect(r.draft).toBe(false);
+    expect(assetProblems(ASSETS, r)).toEqual({ missing: [], unexpected: [], mismatched: [] });
+  });
+
+  it("refuses to publish when the draft is still wrong after the re-upload", async () => {
+    const { gh, port } = fakeGithub();
+    const upload = port.upload;
+    let n = 0;
+    port.upload = (tag, assets) => {
+      upload(tag, assets);
+      if (++n > 1) {
+        const r = gh.releases.get(tag) as ReleaseView;
+        gh.releases.set(tag, { ...r, assets: r.assets.slice(1) });
+      }
+    };
+    const onNpm = port.npmPublish;
+    port.npmPublish = async (p, d) => {
+      await onNpm(p, d);
+      const r = gh.releases.get("v0.8.0") as ReleaseView;
+      gh.releases.set("v0.8.0", { ...r, assets: r.assets.slice(1) });
+    };
+    await expect(runPublish(port, input())).rejects.toThrow(/still wrong/);
+    expect(gh.calls.some((c) => c.startsWith("publish"))).toBe(false);
+  });
+
+  it("resumes a half-done run from its draft: uploads only what is missing", async () => {
+    const { gh, port } = fakeGithub([draft(ASSETS.slice(0, 2).map(remote))]);
+    await runPublish(
+      port,
+      input({ tagCommit: HEAD, npmMissing: [], releases: port.releases("v0.8.0") }),
+    );
+    expect(gh.calls).toEqual([`upload ${NAMES.slice(2).join(",")}`, "publish v0.8.0 latest"]);
+  });
+
+  it("without a bundle, removes an asset someone attached to the draft", async () => {
+    const { gh, port } = fakeGithub([draft([{ name: "x.zip", digest: null, size: 1 }])]);
+    await runPublish(
+      port,
+      input({ bundle: null, tagCommit: HEAD, npmMissing: [], releases: port.releases("v0.8.0") }),
+    );
+    expect(gh.calls).toEqual(["delete-asset x.zip", "publish v0.8.0 latest"]);
+  });
+
+  it("a dry run deletes its draft at the end, and also when a step fails", async () => {
+    const ok = fakeGithub();
+    await runPublish(ok.port, input({ dryRun: true, runNumber: "3", npmMissing: [] }));
+    expect(ok.gh.calls).toEqual([
+      "draft v0.8.0-dryrun.3 target",
+      `upload ${NAMES.join(",")}`,
+      "npm 2 --dry-run",
+      "delete v0.8.0-dryrun.3",
+    ]);
+    expect(ok.gh.releases.size).toBe(0);
+    // Every version here is on npm: no npm publish reaches npm without --force and --dry-run.
+    expect(ok.gh.npm.map((p) => p.flags)).toEqual([DRY_FORCE, DRY_FORCE]);
+    const bad = fakeGithub();
+    bad.port.npmPublish = async () => {
+      throw new Error("smoke test failed");
+    };
+    await expect(runPublish(bad.port, input({ dryRun: true, runNumber: "4" }))).rejects.toThrow(
+      /smoke test failed/,
+    );
+    expect(bad.gh.calls.at(-1)).toBe("delete v0.8.0-dryrun.4");
+    expect(bad.gh.calls.some((c) => c.startsWith("tag") || c.startsWith("publish"))).toBe(false);
+  });
+});
diff --git a/packages/cli/test/release-workflows.test.ts b/packages/cli/test/release-workflows.test.ts
new file mode 100644
index 00000000..98e3bae0
--- /dev/null
+++ b/packages/cli/test/release-workflows.test.ts
@@ -0,0 +1,238 @@
+/**
+ * Text checks of the release workflows (RFC-0046 §4–§6), with no YAML dependency (see
+ * fixtures/release-bundle/workflow-text.ts). release-bundle-sign.test.ts runs bundle-sign's inline
+ * scripts.
+ */
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { describe, expect, it } from "vitest";
+import { RELEASE_KEY } from "../../../scripts/publish-ci.ts";
+import {
+  BUILD,
+  block,
+  blockAt,
+  code,
+  gha,
+  job,
+  OPEN,
+  RELEASE,
+  ROOT,
+  runs,
+  step,
+} from "./fixtures/release-bundle/workflow-text.ts";
+
+const KEYS_DOC = readFileSync(path.join(ROOT, "docs/security/release-signing-keys.md"), "utf8");
+
+const permissions = (jobText: string) =>
+  block(jobText, jobText.split("\n").find((l) => l.trim() === "permissions:") as string)
+    .split("\n")
+    .slice(1)
+    .map((l) => l.trim().replace(/ #.*$/, ""))
+    .filter(Boolean)
+    .sort();
+
+describe("release workflows: pins, permissions, secrets", () => {
+  it("pins every action to a 40-hex commit with its version", () => {
+    for (const text of [RELEASE, BUILD])
+      for (const l of text.split("\n").filter((x) => /^\s*(- )?uses: /.test(x))) {
+        const ref = l.split("uses: ")[1] as string;
+        if (ref.startsWith("./.github/workflows/")) continue;
+        expect(ref).toMatch(/^[\w.-]+\/[\w./-]+@[0-9a-f]{40} # v\d+(\.\d+)*$/);
+      }
+  });
+
+  it("grants nothing at the top and each job only what it needs", () => {
+    expect(RELEASE).toMatch(/^permissions: \{\}$/m);
+    expect(BUILD).toMatch(/^permissions: \{\}$/m);
+    expect(permissions(job(BUILD, "build"))).toEqual([
+      "attestations: write",
+      "contents: read",
+      "id-token: write",
+    ]);
+    expect(permissions(job(RELEASE, "bundle"))).toEqual([
+      "attestations: write",
+      "contents: read",
+      "id-token: write",
+    ]);
+    expect(permissions(job(RELEASE, "bundle-sign"))).toEqual([
+      "actions: read",
+      "attestations: read",
+      "contents: read",
+    ]);
+    expect(permissions(job(RELEASE, "publish"))).toEqual(["contents: write", "id-token: write"]);
+  });
+
+  it("the build workflow holds no secret; the signing key reaches the sign step only", () => {
+    expect(code(BUILD)).not.toContain("secrets.");
+    const sign = step(job(RELEASE, "bundle-sign"), "Sign the statement");
+    expect(sign).toContain(
+      `RELEASE_SIGNING_KEY: ${gha("needs.plan.outputs.dry_run != 'true' && secrets.RELEASE_SIGNING_KEY || ''")}`,
+    );
+    const outside = code(RELEASE).replace(sign, "");
+    expect(outside).not.toContain("RELEASE_SIGNING_KEY");
+    expect(code(RELEASE).match(/secrets\.RELEASE_SIGNING_KEY/g)).toHaveLength(1);
+  });
+
+  it("never expands an expression inside a shell or script body", () => {
+    expect(runs(RELEASE).length).toBeGreaterThan(8);
+    expect(runs(BUILD).some((b) => b.includes("release-bundle.ts build"))).toBe(true);
+    for (const text of [RELEASE, BUILD])
+      for (const body of runs(text)) expect(body).not.toContain(OPEN);
+  });
+
+  it("checks out without persisted credentials", () => {
+    for (const text of [RELEASE, BUILD]) {
+      const lines = text.split("\n");
+      const checkouts = lines.flatMap((l, i) => (l.includes("uses: actions/checkout@") ? [i] : []));
+      expect(checkouts.length).toBeGreaterThan(0);
+      for (const i of checkouts) expect(blockAt(lines, i)).toContain("persist-credentials: false");
+    }
+  });
+});
+
+describe("release.yml jobs", () => {
+  it("keeps the workflow name and the npm-publish environment npm trusted publishing names", () => {
+    expect(RELEASE.startsWith("name: Release\n")).toBe(true);
+    expect(job(RELEASE, "publish")).toContain("environment:\n      name: npm-publish");
+  });
+
+  it("orders bundle → bundle-sign → publish → template and docker", () => {
+    expect(job(RELEASE, "bundle")).toContain("uses: ./.github/workflows/bundle-build.yml");
+    expect(job(RELEASE, "bundle")).toContain(
+      "vars.RELEASE_BUNDLES == 'true' || needs.plan.outputs.dry_run == 'true'",
+    );
+    expect(job(RELEASE, "bundle-sign")).toContain("needs: [plan, bundle]");
+    expect(job(RELEASE, "publish")).toContain("needs: [plan, bundle-sign]");
+    expect(job(RELEASE, "publish")).toContain("vars.RELEASE_BUNDLES != 'true'");
+    // A cancelled run never starts publish (always() would, in the gap after plan).
+    expect(job(RELEASE, "publish")).toContain("!cancelled() && needs.plan.result == 'success'");
+    for (const j of ["template", "docker"]) {
+      expect(job(RELEASE, j)).toContain("needs: [plan, publish]");
+      expect(job(RELEASE, j)).toContain(
+        "needs.publish.result == 'success' && needs.plan.outputs.dry_run != 'true'",
+      );
+    }
+  });
+
+  it("bundle-sign: protected environment, no checkout or package manager, attestation verified first", () => {
+    const j = job(RELEASE, "bundle-sign");
+    expect(j).toContain("environment: release-signing");
+    expect(code(j)).not.toMatch(/\b(pnpm|npm|npx)\b|actions\/checkout/);
+    const verifyStep = step(j, "Verify the build attestation");
+    for (const flag of [
+      "--repo cloudhal-dev/cloudhal",
+      "--signer-workflow cloudhal-dev/cloudhal/.github/workflows/bundle-build.yml",
+      "--source-ref refs/heads/main",
+      '--source-digest "$GITHUB_SHA"',
+      "--deny-self-hosted-runners",
+    ])
+      expect(verifyStep).toContain(flag);
+    for (const f of [
+      '"bundle/cloudhal-release-$VERSION.tar.gz"',
+      "bundle/manifest.json",
+      "bundle/release-statement.json",
+    ])
+      expect(verifyStep).toContain(f);
+    expect(j.indexOf("Verify the build attestation")).toBeLessThan(j.indexOf("Sign the statement"));
+    expect(j.indexOf("Check the statement")).toBeLessThan(j.indexOf("Sign the statement"));
+  });
+
+  it("bundle-sign pins the key the security doc lists as active", () => {
+    const row =
+      /\| `(cloudhal-release-\d{4}[a-z])` \| Ed25519 \| `([A-Za-z0-9+/]{43}=)` \| active \|/.exec(
+        KEYS_DOC,
+      );
+    expect(row).not.toBeNull();
+    const sign = step(job(RELEASE, "bundle-sign"), "Sign the statement");
+    expect(sign).toContain(`const PINNED_KEY_ID = "${row?.[1]}";`);
+    expect(sign).toContain(`const PINNED_PUBLIC_KEY = "${row?.[2]}";`);
+    expect(row?.[1]).toBe("cloudhal-release-2026a");
+    expect(row?.[2]).toBe("7az3pPc4oT0AOfRcdfOZZQkM6XAGHo2jXEdDZ1qEVR8=");
+  });
+
+  it("a dry run: no Changesets PR, the plan forced on, publish-ci --dry-run, no template or image", () => {
+    expect(RELEASE).toContain("      dry_run:\n        description:");
+    expect(job(RELEASE, "version")).toContain(
+      "if: vars.RELEASE_ENABLED == 'true' && !inputs.dry_run",
+    );
+    expect(job(RELEASE, "plan")).toContain(
+      `RELEASE_DRY_RUN: ${gha("inputs.dry_run && 'true' || 'false'")}`,
+    );
+    const publish = step(job(RELEASE, "publish"), "Publish");
+    expect(publish).toContain('if [ "$DRY_RUN" = "true" ]; then args+=(--dry-run); fi');
+    expect(publish).toContain("node scripts/publish-ci.ts");
+    const publishCi = readFileSync(path.join(ROOT, "scripts/publish-ci.ts"), "utf8");
+    // The flags come from the planner's npmFlags (release-publish-plan.test.ts), nothing else.
+    expect(publishCi).toContain('sh("npm", ["publish", path.join(tarballs, tgz), ...flags]);');
+    expect(publishCi.match(/"--(dry-run|provenance)"/g)).toBeNull();
+  });
+
+  it("a dry run's draft is deleted by an always() step, even when the job is cancelled", () => {
+    const j = job(RELEASE, "publish");
+    const clean = step(j, "Delete the dry run's draft");
+    expect(j.indexOf("- name: Publish")).toBeLessThan(j.indexOf("Delete the dry run's draft"));
+    expect(clean).toContain("if: always() && needs.plan.outputs.dry_run == 'true'");
+    expect(clean).toContain(
+      `TAG: v${gha("needs.plan.outputs.version")}-dryrun.${gha("github.run_number")}`,
+    );
+    // Drafts with exactly this run's tag name only: never a published release, never another run's.
+    expect(clean).toContain("select(.draft and .tag_name == env.TAG) | .id");
+    expect(runs(clean)[0]).toMatch(/gh api -X DELETE "repos\/\$GITHUB_REPOSITORY\/releases\/\$id"/);
+  });
+
+  it("publish-ci checks the signature with release.yml's pin, or the dry run's own key", () => {
+    const sign = step(job(RELEASE, "bundle-sign"), "Sign the statement");
+    const id = /const PINNED_KEY_ID = "([^"]+)";/.exec(sign)?.[1] as string;
+    const pub = /const PINNED_PUBLIC_KEY = "([^"]+)";/.exec(sign)?.[1] as string;
+    expect(RELEASE_KEY).toEqual({ [id]: pub });
+    expect(sign).toContain("id: sign");
+    expect(job(RELEASE, "bundle-sign")).toContain(
+      `public_key: ${gha("steps.sign.outputs.public_key")}`,
+    );
+    expect(step(job(RELEASE, "publish"), "Publish")).toContain(
+      `RELEASE_DRYRUN_PUBLIC_KEY: ${gha("needs.plan.outputs.dry_run == 'true' && needs.bundle-sign.outputs.public_key || ''")}`,
+    );
+  });
+
+  it("a security release: the plan's flag reaches the build and bundle-sign checks it", () => {
+    expect(job(RELEASE, "plan")).toContain(
+      `security_release: ${gha("steps.plan.outputs.security_release")}`,
+    );
+    expect(job(RELEASE, "bundle")).toContain(
+      `security_release: ${gha("needs.plan.outputs.security_release == 'true'")}`,
+    );
+    expect(job(RELEASE, "bundle-sign")).toContain(
+      `SECURITY_RELEASE: ${gha("needs.plan.outputs.security_release")}`,
+    );
+  });
+});
+
+describe("bundle-build.yml", () => {
+  it("is reusable with version and dry_run, builds the starter, attests three files, uploads four", () => {
+    expect(BUILD).toMatch(
+      /on:\n {2}workflow_call:\n {4}inputs:\n {6}version:[\s\S]*\n {6}dry_run:[\s\S]*\n {6}security_release:/,
+    );
+    const b = job(BUILD, "build");
+    expect(b).toContain(`ref: ${gha("github.sha")}`);
+    expect(b).toContain("pnpm install --frozen-lockfile");
+    expect(b).not.toMatch(/cache: pnpm/);
+    expect(b).toContain("node packages/cli/bin/cloudhal.ts init examples/release-std");
+    expect(b).toContain("node ../../packages/cli/bin/cloudhal.ts deploy --dry-run");
+    expect(b).toContain("node scripts/release-bundle.ts build");
+    expect(b).toContain('key_id="dryrun-$RUN_ID"; else key_id="cloudhal-release-2026a"');
+    expect(b).toContain(`SECURITY_RELEASE: ${gha("inputs.security_release")}`);
+    expect(b).toContain(
+      'if [ "$SECURITY_RELEASE" = "true" ]; then flags+=(--security-release); fi',
+    );
+    expect(b).toContain(['--out "$RUNNER_TEMP/release-bundle" "$', '{flags[@]}"'].join(""));
+    const attest = b.slice(b.indexOf("uses: actions/attest-build-provenance@"));
+    for (const f of [
+      `cloudhal-release-${gha("inputs.version")}.tar.gz`,
+      "release-bundle/manifest.json",
+      "release-bundle/release-statement.json",
+    ])
+      expect(attest).toContain(f);
+    expect(b).toContain("cloudhal-release-$VERSION.sigstore.json");
+    expect(b).toContain(`name: release-bundle-unsigned-${gha("inputs.version")}`);
+  });
+});
diff --git a/packages/core-contracts/fixtures/invalid/release-manifest/asset-too-large.json b/packages/core-contracts/fixtures/invalid/release-manifest/asset-too-large.json
new file mode 100644
index 00000000..f3a1fda1
--- /dev/null
+++ b/packages/core-contracts/fixtures/invalid/release-manifest/asset-too-large.json
@@ -0,0 +1,189 @@
+{
+  "reason": "25 MiB per asset",
+  "data": {
+    "schemaVersion": 1,
+    "format": "cloudhal.release-manifest@1",
+    "version": "0.6.3",
+    "channel": "stable",
+    "securityRelease": false,
+    "commit": "e433958d370ef64c6c21063c150bb8cd085404eb",
+    "createdAt": "2026-11-08T10:00:00.000Z",
+    "contracts": {
+      "schemaVersion": 1
+    },
+    "levels": {
+      "schedules": 2,
+      "gates": 1
+    },
+    "migrations": {
+      "first": "0001_init",
+      "last": "0042_comments",
+      "count": 42,
+      "names": ["0001_init", "0042_comments"],
+      "compatibleWithPrevious": true
+    },
+    "durableObjects": {
+      "bindings": [
+        {
+          "name": "SCHEDULER",
+          "className": "Scheduler",
+          "migration": "v1"
+        }
+      ],
+      "lifecycleChange": false
+    },
+    "runtime": {
+      "compatibilityDate": "2026-10-01",
+      "compatibilityFlags": ["nodejs_compat"]
+    },
+    "toolchain": {
+      "wrangler": "4.158.0",
+      "node": "24.15.0"
+    },
+    "theme": {
+      "name": "default",
+      "version": "0.6.3",
+      "blockTypes": ["hero", "richText"],
+      "settingsSha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
+      "designSha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
+    },
+    "skills": [
+      {
+        "name": "cloudhal-site",
+        "sha256": "1111111111111111111111111111111111111111111111111111111111111111"
+      }
+    ],
+    "workers": {
+      "site": {
+        "mainModule": "index.js",
+        "modules": [
+          {
+            "path": "index.js",
+            "type": "esm",
+            "bytes": 1024,
+            "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
+          }
+        ],
+        "assets": {
+          "count": 1,
+          "bytes": 512,
+          "files": [
+            {
+              "path": "/big.bin",
+              "bytes": 26214401,
+              "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+              "assetHash": "dddddddddddddddddddddddddddddddd",
+              "contentType": "application/octet-stream"
+            }
+          ],
+          "headers": "/*\n  x-content-type-options: nosniff\n",
+          "config": {
+            "not_found_handling": "404-page",
+            "run_worker_first": ["/api/*"]
+          }
+        },
+        "bindingTemplate": "site"
+      },
+      "admin": {
+        "mainModule": "index.js",
+        "modules": [
+          {
+            "path": "index.js",
+            "type": "esm",
+            "bytes": 2048,
+            "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
+          }
+        ],
+        "assets": {
+          "count": 1,
+          "bytes": 512,
+          "files": [
+            {
+              "path": "/index.html",
+              "bytes": 512,
+              "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+              "assetHash": "dddddddddddddddddddddddddddddddd",
+              "contentType": "text/html"
+            }
+          ],
+          "headers": "/*\n  x-content-type-options: nosniff\n",
+          "config": {
+            "not_found_handling": "404-page",
+            "run_worker_first": ["/api/*"]
+          }
+        },
+        "bindingTemplate": "admin"
+      }
+    },
+    "bindingTemplates": {
+      "site": {
+        "bindings": [
+          {
+            "name": "DB",
+            "type": "d1",
+            "required": true
+          },
+          {
+            "name": "BLOBS",
+            "type": "r2_bucket",
+            "required": true,
+            "aliases": ["MEDIA"]
+          }
+        ],
+        "vars": ["CLOUDHAL_SCHEDULER"],
+        "secrets": ["CLOUDHAL_PREVIEW_SECRET", "CLOUDHAL_PURGE_TOKEN", "CLOUDHAL_COMMENTS_KEY"],
+        "cache": true
+      },
+      "admin": {
+        "bindings": [
+          {
+            "name": "DB",
+            "type": "d1",
+            "required": true
+          },
+          {
+            "name": "BLOBS",
+            "type": "r2_bucket",
+            "required": true,
+            "aliases": ["MEDIA"]
+          }
+        ],
+        "vars": ["CLOUDHAL_SCHEDULER"],
+        "secrets": [
+          "BETTER_AUTH_SECRET",
+          "MCP_STATE_KEY",
+          "CLOUDHAL_PREVIEW_SECRET",
+          "CLOUDHAL_PURGE_TOKEN",
+          "CLOUDHAL_DEPLOY_TOKEN",
+          "CLOUDHAL_SETUP_TOKEN",
+          "CLOUDHAL_COMMENTS_KEY"
+        ],
+        "cache": true
+      }
+    },
+    "requiredScopes": [
+      "account-settings.read",
+      "workers-scripts.edit",
+      "d1.write",
+      "workers-r2.write"
+    ],
+    "optionalScopes": ["account-analytics.read"],
+    "requiredCapabilities": ["account", "workers", "d1", "r2"],
+    "optionalCapabilities": ["analytics", "domains"],
+    "starter": {
+      "kind": "admin-api"
+    },
+    "features": {
+      "adminSitesAcceptsSiteId": true,
+      "healthReportsVersion": true,
+      "mediaTransformVar": false,
+      "futureFlag": true
+    },
+    "planVersion": 1,
+    "signing": {
+      "keyId": "cloudhal-release-2026",
+      "algorithm": "Ed25519",
+      "signature": "manifest.sig"
+    }
+  }
+}
diff --git a/packages/core-contracts/fixtures/invalid/release-manifest/cli-site-id-too-short.json b/packages/core-contracts/fixtures/invalid/release-manifest/cli-site-id-too-short.json
new file mode 100644
index 00000000..616afda9
--- /dev/null
+++ b/packages/core-contracts/fixtures/invalid/release-manifest/cli-site-id-too-short.json
@@ -0,0 +1,193 @@
+{
+  "reason": "SiteId follows the CLI pattern site_[A-Za-z0-9]{6,32}",
+  "data": {
+    "schemaVersion": 1,
+    "format": "cloudhal.release-manifest@1",
+    "version": "0.6.3",
+    "channel": "stable",
+    "securityRelease": false,
+    "commit": "e433958d370ef64c6c21063c150bb8cd085404eb",
+    "createdAt": "2026-11-08T10:00:00.000Z",
+    "contracts": {
+      "schemaVersion": 1
+    },
+    "levels": {
+      "schedules": 2,
+      "gates": 1
+    },
+    "migrations": {
+      "first": "0001_init",
+      "last": "0042_comments",
+      "count": 42,
+      "names": ["0001_init", "0042_comments"],
+      "compatibleWithPrevious": true
+    },
+    "durableObjects": {
+      "bindings": [
+        {
+          "name": "SCHEDULER",
+          "className": "Scheduler",
+          "migration": "v1"
+        }
+      ],
+      "lifecycleChange": false
+    },
+    "runtime": {
+      "compatibilityDate": "2026-10-01",
+      "compatibilityFlags": ["nodejs_compat"]
+    },
+    "toolchain": {
+      "wrangler": "4.158.0",
+      "node": "24.15.0"
+    },
+    "theme": {
+      "name": "default",
+      "version": "0.6.3",
+      "blockTypes": ["hero", "richText"],
+      "settingsSha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
+      "designSha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
+    },
+    "skills": [
+      {
+        "name": "cloudhal-site",
+        "sha256": "1111111111111111111111111111111111111111111111111111111111111111"
+      }
+    ],
+    "workers": {
+      "site": {
+        "mainModule": "index.js",
+        "modules": [
+          {
+            "path": "index.js",
+            "type": "esm",
+            "bytes": 1024,
+            "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
+          }
+        ],
+        "assets": {
+          "count": 1,
+          "bytes": 512,
+          "files": [
+            {
+              "path": "/index.html",
+              "bytes": 512,
+              "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+              "assetHash": "dddddddddddddddddddddddddddddddd",
+              "contentType": "text/html"
+            }
+          ],
+          "headers": "/*\n  x-content-type-options: nosniff\n",
+          "config": {
+            "not_found_handling": "404-page",
+            "run_worker_first": ["/api/*"]
+          }
+        },
+        "bindingTemplate": "site"
+      },
+      "admin": {
+        "mainModule": "index.js",
+        "modules": [
+          {
+            "path": "index.js",
+            "type": "esm",
+            "bytes": 2048,
+            "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
+          }
+        ],
+        "assets": {
+          "count": 1,
+          "bytes": 512,
+          "files": [
+            {
+              "path": "/index.html",
+              "bytes": 512,
+              "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+              "assetHash": "dddddddddddddddddddddddddddddddd",
+              "contentType": "text/html"
+            }
+          ],
+          "headers": "/*\n  x-content-type-options: nosniff\n",
+          "config": {
+            "not_found_handling": "404-page",
+            "run_worker_first": ["/api/*"]
+          }
+        },
+        "bindingTemplate": "admin"
+      }
+    },
+    "bindingTemplates": {
+      "site": {
+        "bindings": [
+          {
+            "name": "DB",
+            "type": "d1",
+            "required": true
+          },
+          {
+            "name": "BLOBS",
+            "type": "r2_bucket",
+            "required": true,
+            "aliases": ["MEDIA"]
+          }
+        ],
+        "vars": ["CLOUDHAL_SCHEDULER"],
+        "secrets": ["CLOUDHAL_PREVIEW_SECRET", "CLOUDHAL_PURGE_TOKEN", "CLOUDHAL_COMMENTS_KEY"],
+        "cache": true
+      },
+      "admin": {
+        "bindings": [
+          {
+            "name": "DB",
+            "type": "d1",
+            "required": true
+          },
+          {
+            "name": "BLOBS",
+            "type": "r2_bucket",
+            "required": true,
+            "aliases": ["MEDIA"]
+          }
+        ],
+        "vars": ["CLOUDHAL_SCHEDULER"],
+        "secrets": [
+          "BETTER_AUTH_SECRET",
+          "MCP_STATE_KEY",
+          "CLOUDHAL_PREVIEW_SECRET",
+          "CLOUDHAL_PURGE_TOKEN",
+          "CLOUDHAL_DEPLOY_TOKEN",
+          "CLOUDHAL_SETUP_TOKEN",
+          "CLOUDHAL_COMMENTS_KEY"
+        ],
+        "cache": true
+      }
+    },
+    "requiredScopes": [
+      "account-settings.read",
+      "workers-scripts.edit",
+      "d1.write",
+      "workers-r2.write"
+    ],
+    "optionalScopes": ["account-analytics.read"],
+    "requiredCapabilities": ["account", "workers", "d1", "r2"],
+    "optionalCapabilities": ["analytics", "domains"],
+    "starter": {
+      "kind": "bundle",
+      "path": "starter/site.json",
+      "bytes": 1,
+      "sha256": "2222222222222222222222222222222222222222222222222222222222222222",
+      "siteId": "site_abc"
+    },
+    "features": {
+      "adminSitesAcceptsSiteId": true,
+      "healthReportsVersion": true,
+      "mediaTransformVar": false,
+      "futureFlag": true
+    },
+    "planVersion": 1,
+    "signing": {
+      "keyId": "cloudhal-release-2026",
+      "algorithm": "Ed25519",
+      "signature": "manifest.sig"
+    }
+  }
+}
diff --git a/packages/core-contracts/fixtures/invalid/release-manifest/missing-feature-flag.json b/packages/core-contracts/fixtures/invalid/release-manifest/missing-feature-flag.json
new file mode 100644
index 00000000..8fb427e6
--- /dev/null
+++ b/packages/core-contracts/fixtures/invalid/release-manifest/missing-feature-flag.json
@@ -0,0 +1,187 @@
+{
+  "reason": "the three named feature flags are required",
+  "data": {
+    "schemaVersion": 1,
+    "format": "cloudhal.release-manifest@1",
+    "version": "0.6.3",
+    "channel": "stable",
+    "securityRelease": false,
+    "commit": "e433958d370ef64c6c21063c150bb8cd085404eb",
+    "createdAt": "2026-11-08T10:00:00.000Z",
+    "contracts": {
+      "schemaVersion": 1
+    },
+    "levels": {
+      "schedules": 2,
+      "gates": 1
+    },
+    "migrations": {
+      "first": "0001_init",
+      "last": "0042_comments",
+      "count": 42,
+      "names": ["0001_init", "0042_comments"],
+      "compatibleWithPrevious": true
+    },
+    "durableObjects": {
+      "bindings": [
+        {
+          "name": "SCHEDULER",
+          "className": "Scheduler",
+          "migration": "v1"
+        }
+      ],
+      "lifecycleChange": false
+    },
+    "runtime": {
+      "compatibilityDate": "2026-10-01",
+      "compatibilityFlags": ["nodejs_compat"]
+    },
+    "toolchain": {
+      "wrangler": "4.158.0",
+      "node": "24.15.0"
+    },
+    "theme": {
+      "name": "default",
+      "version": "0.6.3",
+      "blockTypes": ["hero", "richText"],
+      "settingsSha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
+      "designSha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
+    },
+    "skills": [
+      {
+        "name": "cloudhal-site",
+        "sha256": "1111111111111111111111111111111111111111111111111111111111111111"
+      }
+    ],
+    "workers": {
+      "site": {
+        "mainModule": "index.js",
+        "modules": [
+          {
+            "path": "index.js",
+            "type": "esm",
+            "bytes": 1024,
+            "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
+          }
+        ],
+        "assets": {
+          "count": 1,
+          "bytes": 512,
+          "files": [
+            {
+              "path": "/index.html",
+              "bytes": 512,
+              "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+              "assetHash": "dddddddddddddddddddddddddddddddd",
+              "contentType": "text/html"
+            }
+          ],
+          "headers": "/*\n  x-content-type-options: nosniff\n",
+          "config": {
+            "not_found_handling": "404-page",
+            "run_worker_first": ["/api/*"]
+          }
+        },
+        "bindingTemplate": "site"
+      },
+      "admin": {
+        "mainModule": "index.js",
+        "modules": [
+          {
+            "path": "index.js",
+            "type": "esm",
+            "bytes": 2048,
+            "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
+          }
+        ],
+        "assets": {
+          "count": 1,
+          "bytes": 512,
+          "files": [
+            {
+              "path": "/index.html",
+              "bytes": 512,
+              "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+              "assetHash": "dddddddddddddddddddddddddddddddd",
+              "contentType": "text/html"
+            }
+          ],
+          "headers": "/*\n  x-content-type-options: nosniff\n",
+          "config": {
+            "not_found_handling": "404-page",
+            "run_worker_first": ["/api/*"]
+          }
+        },
+        "bindingTemplate": "admin"
+      }
+    },
+    "bindingTemplates": {
+      "site": {
+        "bindings": [
+          {
+            "name": "DB",
+            "type": "d1",
+            "required": true
+          },
+          {
+            "name": "BLOBS",
+            "type": "r2_bucket",
+            "required": true,
+            "aliases": ["MEDIA"]
+          }
+        ],
+        "vars": ["CLOUDHAL_SCHEDULER"],
+        "secrets": ["CLOUDHAL_PREVIEW_SECRET", "CLOUDHAL_PURGE_TOKEN", "CLOUDHAL_COMMENTS_KEY"],
+        "cache": true
+      },
+      "admin": {
+        "bindings": [
+          {
+            "name": "DB",
+            "type": "d1",
+            "required": true
+          },
+          {
+            "name": "BLOBS",
+            "type": "r2_bucket",
+            "required": true,
+            "aliases": ["MEDIA"]
+          }
+        ],
+        "vars": ["CLOUDHAL_SCHEDULER"],
+        "secrets": [
+          "BETTER_AUTH_SECRET",
+          "MCP_STATE_KEY",
+          "CLOUDHAL_PREVIEW_SECRET",
+          "CLOUDHAL_PURGE_TOKEN",
+          "CLOUDHAL_DEPLOY_TOKEN",
+          "CLOUDHAL_SETUP_TOKEN",
+          "CLOUDHAL_COMMENTS_KEY"
+        ],
+        "cache": true
+      }
+    },
+    "requiredScopes": [
+      "account-settings.read",
+      "workers-scripts.edit",
+      "d1.write",
+      "workers-r2.write"
+    ],
+    "optionalScopes": ["account-analytics.read"],
+    "requiredCapabilities": ["account", "workers", "d1", "r2"],
+    "optionalCapabilities": ["analytics", "domains"],
+    "starter": {
+      "kind": "admin-api"
+    },
+    "features": {
+      "adminSitesAcceptsSiteId": true,
+      "healthReportsVersion": true
+    },
+    "planVersion": 1,
+    "signing": {
+      "keyId": "cloudhal-release-2026",
+      "algorithm": "Ed25519",
+      "signature": "manifest.sig"
+    }
+  }
+}
diff --git a/packages/core-contracts/fixtures/invalid/release-manifest/schema-version-2.json b/packages/core-contracts/fixtures/invalid/release-manifest/schema-version-2.json
new file mode 100644
index 00000000..d0b22d2c
--- /dev/null
+++ b/packages/core-contracts/fixtures/invalid/release-manifest/schema-version-2.json
@@ -0,0 +1,189 @@
+{
+  "reason": "schemaVersion is enum [1]",
+  "data": {
+    "schemaVersion": 2,
+    "format": "cloudhal.release-manifest@1",
+    "version": "0.6.3",
+    "channel": "stable",
+    "securityRelease": false,
+    "commit": "e433958d370ef64c6c21063c150bb8cd085404eb",
+    "createdAt": "2026-11-08T10:00:00.000Z",
+    "contracts": {
+      "schemaVersion": 1
+    },
+    "levels": {
+      "schedules": 2,
+      "gates": 1
+    },
+    "migrations": {
+      "first": "0001_init",
+      "last": "0042_comments",
+      "count": 42,
+      "names": ["0001_init", "0042_comments"],
+      "compatibleWithPrevious": true
+    },
+    "durableObjects": {
+      "bindings": [
+        {
+          "name": "SCHEDULER",
+          "className": "Scheduler",
+          "migration": "v1"
+        }
+      ],
+      "lifecycleChange": false
+    },
+    "runtime": {
+      "compatibilityDate": "2026-10-01",
+      "compatibilityFlags": ["nodejs_compat"]
+    },
+    "toolchain": {
+      "wrangler": "4.158.0",
+      "node": "24.15.0"
+    },
+    "theme": {
+      "name": "default",
+      "version": "0.6.3",
+      "blockTypes": ["hero", "richText"],
+      "settingsSha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
+      "designSha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
+    },
+    "skills": [
+      {
+        "name": "cloudhal-site",
+        "sha256": "1111111111111111111111111111111111111111111111111111111111111111"
+      }
+    ],
+    "workers": {
+      "site": {
+        "mainModule": "index.js",
+        "modules": [
+          {
+            "path": "index.js",
+            "type": "esm",
+            "bytes": 1024,
+            "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
+          }
+        ],
+        "assets": {
+          "count": 1,
+          "bytes": 512,
+          "files": [
+            {
+              "path": "/index.html",
+              "bytes": 512,
+              "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+              "assetHash": "dddddddddddddddddddddddddddddddd",
+              "contentType": "text/html"
+            }
+          ],
+          "headers": "/*\n  x-content-type-options: nosniff\n",
+          "config": {
+            "not_found_handling": "404-page",
+            "run_worker_first": ["/api/*"]
+          }
+        },
+        "bindingTemplate": "site"
+      },
+      "admin": {
+        "mainModule": "index.js",
+        "modules": [
+          {
+            "path": "index.js",
+            "type": "esm",
+            "bytes": 2048,
+            "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
+          }
+        ],
+        "assets": {
+          "count": 1,
+          "bytes": 512,
+          "files": [
+            {
+              "path": "/index.html",
+              "bytes": 512,
+              "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+              "assetHash": "dddddddddddddddddddddddddddddddd",
+              "contentType": "text/html"
+            }
+          ],
+          "headers": "/*\n  x-content-type-options: nosniff\n",
+          "config": {
+            "not_found_handling": "404-page",
+            "run_worker_first": ["/api/*"]
+          }
+        },
+        "bindingTemplate": "admin"
+      }
+    },
+    "bindingTemplates": {
+      "site": {
+        "bindings": [
+          {
+            "name": "DB",
+            "type": "d1",
+            "required": true
+          },
+          {
+            "name": "BLOBS",
+            "type": "r2_bucket",
+            "required": true,
+            "aliases": ["MEDIA"]
+          }
+        ],
+        "vars": ["CLOUDHAL_SCHEDULER"],
+        "secrets": ["CLOUDHAL_PREVIEW_SECRET", "CLOUDHAL_PURGE_TOKEN", "CLOUDHAL_COMMENTS_KEY"],
+        "cache": true
+      },
+      "admin": {
+        "bindings": [
+          {
+            "name": "DB",
+            "type": "d1",
+            "required": true
+          },
+          {
+            "name": "BLOBS",
+            "type": "r2_bucket",
+            "required": true,
+            "aliases": ["MEDIA"]
+          }
+        ],
+        "vars": ["CLOUDHAL_SCHEDULER"],
+        "secrets": [
+          "BETTER_AUTH_SECRET",
+          "MCP_STATE_KEY",
+          "CLOUDHAL_PREVIEW_SECRET",
+          "CLOUDHAL_PURGE_TOKEN",
+          "CLOUDHAL_DEPLOY_TOKEN",
+          "CLOUDHAL_SETUP_TOKEN",
+          "CLOUDHAL_COMMENTS_KEY"
+        ],
+        "cache": true
+      }
+    },
+    "requiredScopes": [
+      "account-settings.read",
+      "workers-scripts.edit",
+      "d1.write",
+      "workers-r2.write"
+    ],
+    "optionalScopes": ["account-analytics.read"],
+    "requiredCapabilities": ["account", "workers", "d1", "r2"],
+    "optionalCapabilities": ["analytics", "domains"],
+    "starter": {
+      "kind": "admin-api"
+    },
+    "features": {
+      "adminSitesAcceptsSiteId": true,
+      "healthReportsVersion": true,
+      "mediaTransformVar": false,
+      "futureFlag": true
+    },
+    "planVersion": 1,
+    "signing": {
+      "keyId": "cloudhal-release-2026",
+      "algorithm": "Ed25519",
+      "signature": "manifest.sig"
+    }
+  }
+}
diff --git a/packages/core-contracts/fixtures/invalid/release-manifest/signature-name-unknown.json b/packages/core-contracts/fixtures/invalid/release-manifest/signature-name-unknown.json
new file mode 100644
index 00000000..a50ae7dd
--- /dev/null
+++ b/packages/core-contracts/fixtures/invalid/release-manifest/signature-name-unknown.json
@@ -0,0 +1,189 @@
+{
+  "reason": "signing.signature is manifest.sig or release-statement.sig",
+  "data": {
+    "schemaVersion": 1,
+    "format": "cloudhal.release-manifest@1",
+    "version": "0.6.3",
+    "channel": "stable",
+    "securityRelease": false,
+    "commit": "e433958d370ef64c6c21063c150bb8cd085404eb",
+    "createdAt": "2026-11-08T10:00:00.000Z",
+    "contracts": {
+      "schemaVersion": 1
+    },
+    "levels": {
+      "schedules": 2,
+      "gates": 1
+    },
+    "migrations": {
+      "first": "0001_init",
+      "last": "0042_comments",
+      "count": 42,
+      "names": ["0001_init", "0042_comments"],
+      "compatibleWithPrevious": true
+    },
+    "durableObjects": {
+      "bindings": [
+        {
+          "name": "SCHEDULER",
+          "className": "Scheduler",
+          "migration": "v1"
+        }
+      ],
+      "lifecycleChange": false
+    },
+    "runtime": {
+      "compatibilityDate": "2026-10-01",
+      "compatibilityFlags": ["nodejs_compat"]
+    },
+    "toolchain": {
+      "wrangler": "4.158.0",
+      "node": "24.15.0"
+    },
+    "theme": {
+      "name": "default",
+      "version": "0.6.3",
+      "blockTypes": ["hero", "richText"],
+      "settingsSha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
+      "designSha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
+    },
+    "skills": [
+      {
+        "name": "cloudhal-site",
+        "sha256": "1111111111111111111111111111111111111111111111111111111111111111"
+      }
+    ],
+    "workers": {
+      "site": {
+        "mainModule": "index.js",
+        "modules": [
+          {
+            "path": "index.js",
+            "type": "esm",
+            "bytes": 1024,
+            "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
+          }
+        ],
+        "assets": {
+          "count": 1,
+          "bytes": 512,
+          "files": [
+            {
+              "path": "/index.html",
+              "bytes": 512,
+              "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+              "assetHash": "dddddddddddddddddddddddddddddddd",
+              "contentType": "text/html"
+            }
+          ],
+          "headers": "/*\n  x-content-type-options: nosniff\n",
+          "config": {
+            "not_found_handling": "404-page",
+            "run_worker_first": ["/api/*"]
+          }
+        },
+        "bindingTemplate": "site"
+      },
+      "admin": {
+        "mainModule": "index.js",
+        "modules": [
+          {
+            "path": "index.js",
+            "type": "esm",
+            "bytes": 2048,
+            "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
+          }
+        ],
+        "assets": {
+          "count": 1,
+          "bytes": 512,
+          "files": [
+            {
+              "path": "/index.html",
+              "bytes": 512,
+              "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+              "assetHash": "dddddddddddddddddddddddddddddddd",
+              "contentType": "text/html"
+            }
+          ],
+          "headers": "/*\n  x-content-type-options: nosniff\n",
+          "config": {
+            "not_found_handling": "404-page",
+            "run_worker_first": ["/api/*"]
+          }
+        },
+        "bindingTemplate": "admin"
+      }
+    },
+    "bindingTemplates": {
+      "site": {
+        "bindings": [
+          {
+            "name": "DB",
+            "type": "d1",
+            "required": true
+          },
+          {
+            "name": "BLOBS",
+            "type": "r2_bucket",
+            "required": true,
+            "aliases": ["MEDIA"]
+          }
+        ],
+        "vars": ["CLOUDHAL_SCHEDULER"],
+        "secrets": ["CLOUDHAL_PREVIEW_SECRET", "CLOUDHAL_PURGE_TOKEN", "CLOUDHAL_COMMENTS_KEY"],
+        "cache": true
+      },
+      "admin": {
+        "bindings": [
+          {
+            "name": "DB",
+            "type": "d1",
+            "required": true
+          },
+          {
+            "name": "BLOBS",
+            "type": "r2_bucket",
+            "required": true,
+            "aliases": ["MEDIA"]
+          }
+        ],
+        "vars": ["CLOUDHAL_SCHEDULER"],
+        "secrets": [
+          "BETTER_AUTH_SECRET",
+          "MCP_STATE_KEY",
+          "CLOUDHAL_PREVIEW_SECRET",
+          "CLOUDHAL_PURGE_TOKEN",
+          "CLOUDHAL_DEPLOY_TOKEN",
+          "CLOUDHAL_SETUP_TOKEN",
+          "CLOUDHAL_COMMENTS_KEY"
+        ],
+        "cache": true
+      }
+    },
+    "requiredScopes": [
+      "account-settings.read",
+      "workers-scripts.edit",
+      "d1.write",
+      "workers-r2.write"
+    ],
+    "optionalScopes": ["account-analytics.read"],
+    "requiredCapabilities": ["account", "workers", "d1", "r2"],
+    "optionalCapabilities": ["analytics", "domains"],
+    "starter": {
+      "kind": "admin-api"
+    },
+    "features": {
+      "adminSitesAcceptsSiteId": true,
+      "healthReportsVersion": true,
+      "mediaTransformVar": false,
+      "futureFlag": true
+    },
+    "planVersion": 1,
+    "signing": {
+      "keyId": "cloudhal-release-2026",
+      "algorithm": "Ed25519",
+      "signature": "bundle.sig"
+    }
+  }
+}
diff --git a/packages/core-contracts/fixtures/invalid/release-manifest/unknown-capability.json b/packages/core-contracts/fixtures/invalid/release-manifest/unknown-capability.json
new file mode 100644
index 00000000..1b9f184e
--- /dev/null
+++ b/packages/core-contracts/fixtures/invalid/release-manifest/unknown-capability.json
@@ -0,0 +1,189 @@
+{
+  "reason": "capabilities are a closed enum",
+  "data": {
+    "schemaVersion": 1,
+    "format": "cloudhal.release-manifest@1",
+    "version": "0.6.3",
+    "channel": "stable",
+    "securityRelease": false,
+    "commit": "e433958d370ef64c6c21063c150bb8cd085404eb",
+    "createdAt": "2026-11-08T10:00:00.000Z",
+    "contracts": {
+      "schemaVersion": 1
+    },
+    "levels": {
+      "schedules": 2,
+      "gates": 1
+    },
+    "migrations": {
+      "first": "0001_init",
+      "last": "0042_comments",
+      "count": 42,
+      "names": ["0001_init", "0042_comments"],
+      "compatibleWithPrevious": true
+    },
+    "durableObjects": {
+      "bindings": [
+        {
+          "name": "SCHEDULER",
+          "className": "Scheduler",
+          "migration": "v1"
+        }
+      ],
+      "lifecycleChange": false
+    },
+    "runtime": {
+      "compatibilityDate": "2026-10-01",
+      "compatibilityFlags": ["nodejs_compat"]
+    },
+    "toolchain": {
+      "wrangler": "4.158.0",
+      "node": "24.15.0"
+    },
+    "theme": {
+      "name": "default",
+      "version": "0.6.3",
+      "blockTypes": ["hero", "richText"],
+      "settingsSha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
+      "designSha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
+    },
+    "skills": [
+      {
+        "name": "cloudhal-site",
+        "sha256": "1111111111111111111111111111111111111111111111111111111111111111"
+      }
+    ],
+    "workers": {
+      "site": {
+        "mainModule": "index.js",
+        "modules": [
+          {
+            "path": "index.js",
+            "type": "esm",
+            "bytes": 1024,
+            "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
+          }
+        ],
+        "assets": {
+          "count": 1,
+          "bytes": 512,
+          "files": [
+            {
+              "path": "/index.html",
+              "bytes": 512,
+              "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+              "assetHash": "dddddddddddddddddddddddddddddddd",
+              "contentType": "text/html"
+            }
+          ],
+          "headers": "/*\n  x-content-type-options: nosniff\n",
+          "config": {
+            "not_found_handling": "404-page",
+            "run_worker_first": ["/api/*"]
+          }
+        },
+        "bindingTemplate": "site"
+      },
+      "admin": {
+        "mainModule": "index.js",
+        "modules": [
+          {
+            "path": "index.js",
+            "type": "esm",
+            "bytes": 2048,
+            "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
+          }
+        ],
+        "assets": {
+          "count": 1,
+          "bytes": 512,
+          "files": [
+            {
+              "path": "/index.html",
+              "bytes": 512,
+              "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+              "assetHash": "dddddddddddddddddddddddddddddddd",
+              "contentType": "text/html"
+            }
+          ],
+          "headers": "/*\n  x-content-type-options: nosniff\n",
+          "config": {
+            "not_found_handling": "404-page",
+            "run_worker_first": ["/api/*"]
+          }
+        },
+        "bindingTemplate": "admin"
+      }
+    },
+    "bindingTemplates": {
+      "site": {
+        "bindings": [
+          {
+            "name": "DB",
+            "type": "d1",
+            "required": true
+          },
+          {
+            "name": "BLOBS",
+            "type": "r2_bucket",
+            "required": true,
+            "aliases": ["MEDIA"]
+          }
+        ],
+        "vars": ["CLOUDHAL_SCHEDULER"],
+        "secrets": ["CLOUDHAL_PREVIEW_SECRET", "CLOUDHAL_PURGE_TOKEN", "CLOUDHAL_COMMENTS_KEY"],
+        "cache": true
+      },
+      "admin": {
+        "bindings": [
+          {
+            "name": "DB",
+            "type": "d1",
+            "required": true
+          },
+          {
+            "name": "BLOBS",
+            "type": "r2_bucket",
+            "required": true,
+            "aliases": ["MEDIA"]
+          }
+        ],
+        "vars": ["CLOUDHAL_SCHEDULER"],
+        "secrets": [
+          "BETTER_AUTH_SECRET",
+          "MCP_STATE_KEY",
+          "CLOUDHAL_PREVIEW_SECRET",
+          "CLOUDHAL_PURGE_TOKEN",
+          "CLOUDHAL_DEPLOY_TOKEN",
+          "CLOUDHAL_SETUP_TOKEN",
+          "CLOUDHAL_COMMENTS_KEY"
+        ],
+        "cache": true
+      }
+    },
+    "requiredScopes": [
+      "account-settings.read",
+      "workers-scripts.edit",
+      "d1.write",
+      "workers-r2.write"
+    ],
+    "optionalScopes": ["account-analytics.read"],
+    "requiredCapabilities": ["account", "workers", "d1", "r2"],
+    "optionalCapabilities": ["analytics", "kv"],
+    "starter": {
+      "kind": "admin-api"
+    },
+    "features": {
+      "adminSitesAcceptsSiteId": true,
+      "healthReportsVersion": true,
+      "mediaTransformVar": false,
+      "futureFlag": true
+    },
+    "planVersion": 1,
+    "signing": {
+      "keyId": "cloudhal-release-2026",
+      "algorithm": "Ed25519",
+      "signature": "manifest.sig"
+    }
+  }
+}
diff --git a/packages/core-contracts/fixtures/invalid/release-manifest/unknown-property.json b/packages/core-contracts/fixtures/invalid/release-manifest/unknown-property.json
new file mode 100644
index 00000000..72c4ea41
--- /dev/null
+++ b/packages/core-contracts/fixtures/invalid/release-manifest/unknown-property.json
@@ -0,0 +1,190 @@
+{
+  "reason": "objects are closed (additionalProperties: false)",
+  "data": {
+    "schemaVersion": 1,
+    "format": "cloudhal.release-manifest@1",
+    "version": "0.6.3",
+    "channel": "stable",
+    "securityRelease": false,
+    "commit": "e433958d370ef64c6c21063c150bb8cd085404eb",
+    "createdAt": "2026-11-08T10:00:00.000Z",
+    "contracts": {
+      "schemaVersion": 1
+    },
+    "levels": {
+      "schedules": 2,
+      "gates": 1
+    },
+    "migrations": {
+      "first": "0001_init",
+      "last": "0042_comments",
+      "count": 42,
+      "names": ["0001_init", "0042_comments"],
+      "compatibleWithPrevious": true
+    },
+    "durableObjects": {
+      "bindings": [
+        {
+          "name": "SCHEDULER",
+          "className": "Scheduler",
+          "migration": "v1"
+        }
+      ],
+      "lifecycleChange": false
+    },
+    "runtime": {
+      "compatibilityDate": "2026-10-01",
+      "compatibilityFlags": ["nodejs_compat"]
+    },
+    "toolchain": {
+      "wrangler": "4.158.0",
+      "node": "24.15.0"
+    },
+    "theme": {
+      "name": "default",
+      "version": "0.6.3",
+      "blockTypes": ["hero", "richText"],
+      "settingsSha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
+      "designSha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
+    },
+    "skills": [
+      {
+        "name": "cloudhal-site",
+        "sha256": "1111111111111111111111111111111111111111111111111111111111111111"
+      }
+    ],
+    "workers": {
+      "site": {
+        "mainModule": "index.js",
+        "modules": [
+          {
+            "path": "index.js",
+            "type": "esm",
+            "bytes": 1024,
+            "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
+          }
+        ],
+        "assets": {
+          "count": 1,
+          "bytes": 512,
+          "files": [
+            {
+              "path": "/index.html",
+              "bytes": 512,
+              "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+              "assetHash": "dddddddddddddddddddddddddddddddd",
+              "contentType": "text/html"
+            }
+          ],
+          "headers": "/*\n  x-content-type-options: nosniff\n",
+          "config": {
+            "not_found_handling": "404-page",
+            "run_worker_first": ["/api/*"]
+          }
+        },
+        "bindingTemplate": "site"
+      },
+      "admin": {
+        "mainModule": "index.js",
+        "modules": [
+          {
+            "path": "index.js",
+            "type": "esm",
+            "bytes": 2048,
+            "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
+          }
+        ],
+        "assets": {
+          "count": 1,
+          "bytes": 512,
+          "files": [
+            {
+              "path": "/index.html",
+              "bytes": 512,
+              "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+              "assetHash": "dddddddddddddddddddddddddddddddd",
+              "contentType": "text/html"
+            }
+          ],
+          "headers": "/*\n  x-content-type-options: nosniff\n",
+          "config": {
+            "not_found_handling": "404-page",
+            "run_worker_first": ["/api/*"]
+          }
+        },
+        "bindingTemplate": "admin"
+      }
+    },
+    "bindingTemplates": {
+      "site": {
+        "bindings": [
+          {
+            "name": "DB",
+            "type": "d1",
+            "required": true
+          },
+          {
+            "name": "BLOBS",
+            "type": "r2_bucket",
+            "required": true,
+            "aliases": ["MEDIA"]
+          }
+        ],
+        "vars": ["CLOUDHAL_SCHEDULER"],
+        "secrets": ["CLOUDHAL_PREVIEW_SECRET", "CLOUDHAL_PURGE_TOKEN", "CLOUDHAL_COMMENTS_KEY"],
+        "cache": true
+      },
+      "admin": {
+        "bindings": [
+          {
+            "name": "DB",
+            "type": "d1",
+            "required": true
+          },
+          {
+            "name": "BLOBS",
+            "type": "r2_bucket",
+            "required": true,
+            "aliases": ["MEDIA"]
+          }
+        ],
+        "vars": ["CLOUDHAL_SCHEDULER"],
+        "secrets": [
+          "BETTER_AUTH_SECRET",
+          "MCP_STATE_KEY",
+          "CLOUDHAL_PREVIEW_SECRET",
+          "CLOUDHAL_PURGE_TOKEN",
+          "CLOUDHAL_DEPLOY_TOKEN",
+          "CLOUDHAL_SETUP_TOKEN",
+          "CLOUDHAL_COMMENTS_KEY"
+        ],
+        "cache": true
+      }
+    },
+    "requiredScopes": [
+      "account-settings.read",
+      "workers-scripts.edit",
+      "d1.write",
+      "workers-r2.write"
+    ],
+    "optionalScopes": ["account-analytics.read"],
+    "requiredCapabilities": ["account", "workers", "d1", "r2"],
+    "optionalCapabilities": ["analytics", "domains"],
+    "starter": {
+      "kind": "admin-api"
+    },
+    "features": {
+      "adminSitesAcceptsSiteId": true,
+      "healthReportsVersion": true,
+      "mediaTransformVar": false,
+      "futureFlag": true
+    },
+    "planVersion": 1,
+    "signing": {
+      "keyId": "cloudhal-release-2026",
+      "algorithm": "Ed25519",
+      "signature": "manifest.sig"
+    },
+    "unexpected": true
+  }
+}
diff --git a/packages/core-contracts/fixtures/invalid/release-statement/bad-format.json b/packages/core-contracts/fixtures/invalid/release-statement/bad-format.json
new file mode 100644
index 00000000..1120a113
--- /dev/null
+++ b/packages/core-contracts/fixtures/invalid/release-statement/bad-format.json
@@ -0,0 +1,27 @@
+{
+  "reason": "format is cloudhal.release-statement@1",
+  "data": {
+    "schemaVersion": 1,
+    "format": "cloudhal.release-manifest@1",
+    "version": "0.8.0",
+    "channel": "stable",
+    "securityRelease": false,
+    "commit": "0123456789abcdef0123456789abcdef01234567",
+    "createdAt": "2026-11-12T08:00:00Z",
+    "signing": {
+      "keyId": "cloudhal-release-2026a",
+      "algorithm": "Ed25519",
+      "signature": "release-statement.sig"
+    },
+    "manifest": {
+      "name": "manifest.json",
+      "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+      "bytes": 48213
+    },
+    "tarball": {
+      "name": "cloudhal-release-0.8.0.tar.gz",
+      "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+      "bytes": 7340032
+    }
+  }
+}
diff --git a/packages/core-contracts/fixtures/invalid/release-statement/bytes-zero.json b/packages/core-contracts/fixtures/invalid/release-statement/bytes-zero.json
new file mode 100644
index 00000000..22074274
--- /dev/null
+++ b/packages/core-contracts/fixtures/invalid/release-statement/bytes-zero.json
@@ -0,0 +1,27 @@
+{
+  "reason": "an asset has at least one byte",
+  "data": {
+    "schemaVersion": 1,
+    "format": "cloudhal.release-statement@1",
+    "version": "0.8.0",
+    "channel": "stable",
+    "securityRelease": false,
+    "commit": "0123456789abcdef0123456789abcdef01234567",
+    "createdAt": "2026-11-12T08:00:00Z",
+    "signing": {
+      "keyId": "cloudhal-release-2026a",
+      "algorithm": "Ed25519",
+      "signature": "release-statement.sig"
+    },
+    "manifest": {
+      "name": "manifest.json",
+      "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+      "bytes": 0
+    },
+    "tarball": {
+      "name": "cloudhal-release-0.8.0.tar.gz",
+      "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+      "bytes": 7340032
+    }
+  }
+}
diff --git a/packages/core-contracts/fixtures/invalid/release-statement/extra-field.json b/packages/core-contracts/fixtures/invalid/release-statement/extra-field.json
new file mode 100644
index 00000000..e78e0115
--- /dev/null
+++ b/packages/core-contracts/fixtures/invalid/release-statement/extra-field.json
@@ -0,0 +1,28 @@
+{
+  "reason": "objects are closed (additionalProperties: false)",
+  "data": {
+    "schemaVersion": 1,
+    "format": "cloudhal.release-statement@1",
+    "version": "0.8.0",
+    "channel": "stable",
+    "securityRelease": false,
+    "commit": "0123456789abcdef0123456789abcdef01234567",
+    "createdAt": "2026-11-12T08:00:00Z",
+    "signing": {
+      "keyId": "cloudhal-release-2026a",
+      "algorithm": "Ed25519",
+      "signature": "release-statement.sig"
+    },
+    "manifest": {
+      "name": "manifest.json",
+      "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+      "bytes": 48213
+    },
+    "tarball": {
+      "name": "cloudhal-release-0.8.0.tar.gz",
+      "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+      "bytes": 7340032
+    },
+    "notes": "extra"
+  }
+}
diff --git a/packages/core-contracts/fixtures/invalid/release-statement/key-id-bad.json b/packages/core-contracts/fixtures/invalid/release-statement/key-id-bad.json
new file mode 100644
index 00000000..3469dcc3
--- /dev/null
+++ b/packages/core-contracts/fixtures/invalid/release-statement/key-id-bad.json
@@ -0,0 +1,27 @@
+{
+  "reason": "keyId is lower-case [a-z0-9-], 3 to 64 characters",
+  "data": {
+    "schemaVersion": 1,
+    "format": "cloudhal.release-statement@1",
+    "version": "0.8.0",
+    "channel": "stable",
+    "securityRelease": false,
+    "commit": "0123456789abcdef0123456789abcdef01234567",
+    "createdAt": "2026-11-12T08:00:00Z",
+    "signing": {
+      "keyId": "Cloudhal_Release",
+      "algorithm": "Ed25519",
+      "signature": "release-statement.sig"
+    },
+    "manifest": {
+      "name": "manifest.json",
+      "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+      "bytes": 48213
+    },
+    "tarball": {
+      "name": "cloudhal-release-0.8.0.tar.gz",
+      "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+      "bytes": 7340032
+    }
+  }
+}
diff --git a/packages/core-contracts/fixtures/invalid/release-statement/missing-tarball.json b/packages/core-contracts/fixtures/invalid/release-statement/missing-tarball.json
new file mode 100644
index 00000000..38e40bf5
--- /dev/null
+++ b/packages/core-contracts/fixtures/invalid/release-statement/missing-tarball.json
@@ -0,0 +1,22 @@
+{
+  "reason": "the tarball digest is required",
+  "data": {
+    "schemaVersion": 1,
+    "format": "cloudhal.release-statement@1",
+    "version": "0.8.0",
+    "channel": "stable",
+    "securityRelease": false,
+    "commit": "0123456789abcdef0123456789abcdef01234567",
+    "createdAt": "2026-11-12T08:00:00Z",
+    "signing": {
+      "keyId": "cloudhal-release-2026a",
+      "algorithm": "Ed25519",
+      "signature": "release-statement.sig"
+    },
+    "manifest": {
+      "name": "manifest.json",
+      "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+      "bytes": 48213
+    }
+  }
+}
diff --git a/packages/core-contracts/fixtures/invalid/release-statement/schema-version-2.json b/packages/core-contracts/fixtures/invalid/release-statement/schema-version-2.json
new file mode 100644
index 00000000..52d14b4e
--- /dev/null
+++ b/packages/core-contracts/fixtures/invalid/release-statement/schema-version-2.json
@@ -0,0 +1,27 @@
+{
+  "reason": "schemaVersion is 1",
+  "data": {
+    "schemaVersion": 2,
+    "format": "cloudhal.release-statement@1",
+    "version": "0.8.0",
+    "channel": "stable",
+    "securityRelease": false,
+    "commit": "0123456789abcdef0123456789abcdef01234567",
+    "createdAt": "2026-11-12T08:00:00Z",
+    "signing": {
+      "keyId": "cloudhal-release-2026a",
+      "algorithm": "Ed25519",
+      "signature": "release-statement.sig"
+    },
+    "manifest": {
+      "name": "manifest.json",
+      "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+      "bytes": 48213
+    },
+    "tarball": {
+      "name": "cloudhal-release-0.8.0.tar.gz",
+      "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+      "bytes": 7340032
+    }
+  }
+}
diff --git a/packages/core-contracts/fixtures/invalid/release-statement/sha-short.json b/packages/core-contracts/fixtures/invalid/release-statement/sha-short.json
new file mode 100644
index 00000000..c3768f86
--- /dev/null
+++ b/packages/core-contracts/fixtures/invalid/release-statement/sha-short.json
@@ -0,0 +1,27 @@
+{
+  "reason": "sha256 is 64 lower-case hex",
+  "data": {
+    "schemaVersion": 1,
+    "format": "cloudhal.release-statement@1",
+    "version": "0.8.0",
+    "channel": "stable",
+    "securityRelease": false,
+    "commit": "0123456789abcdef0123456789abcdef01234567",
+    "createdAt": "2026-11-12T08:00:00Z",
+    "signing": {
+      "keyId": "cloudhal-release-2026a",
+      "algorithm": "Ed25519",
+      "signature": "release-statement.sig"
+    },
+    "manifest": {
+      "name": "manifest.json",
+      "sha256": "abcd",
+      "bytes": 48213
+    },
+    "tarball": {
+      "name": "cloudhal-release-0.8.0.tar.gz",
+      "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+      "bytes": 7340032
+    }
+  }
+}
diff --git a/packages/core-contracts/fixtures/invalid/release-statement/sig-name-wrong.json b/packages/core-contracts/fixtures/invalid/release-statement/sig-name-wrong.json
new file mode 100644
index 00000000..ae4b40d0
--- /dev/null
+++ b/packages/core-contracts/fixtures/invalid/release-statement/sig-name-wrong.json
@@ -0,0 +1,27 @@
+{
+  "reason": "a statement is signed only as release-statement.sig",
+  "data": {
+    "schemaVersion": 1,
+    "format": "cloudhal.release-statement@1",
+    "version": "0.8.0",
+    "channel": "stable",
+    "securityRelease": false,
+    "commit": "0123456789abcdef0123456789abcdef01234567",
+    "createdAt": "2026-11-12T08:00:00Z",
+    "signing": {
+      "keyId": "cloudhal-release-2026a",
+      "algorithm": "Ed25519",
+      "signature": "manifest.sig"
+    },
+    "manifest": {
+      "name": "manifest.json",
+      "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+      "bytes": 48213
+    },
+    "tarball": {
+      "name": "cloudhal-release-0.8.0.tar.gz",
+      "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+      "bytes": 7340032
+    }
+  }
+}
diff --git a/packages/core-contracts/fixtures/invalid/release-statement/tarball-too-large.json b/packages/core-contracts/fixtures/invalid/release-statement/tarball-too-large.json
new file mode 100644
index 00000000..b8dc165b
--- /dev/null
+++ b/packages/core-contracts/fixtures/invalid/release-statement/tarball-too-large.json
@@ -0,0 +1,27 @@
+{
+  "reason": "a tarball is at most 100 MiB",
+  "data": {
+    "schemaVersion": 1,
+    "format": "cloudhal.release-statement@1",
+    "version": "0.8.0",
+    "channel": "stable",
+    "securityRelease": false,
+    "commit": "0123456789abcdef0123456789abcdef01234567",
+    "createdAt": "2026-11-12T08:00:00Z",
+    "signing": {
+      "keyId": "cloudhal-release-2026a",
+      "algorithm": "Ed25519",
+      "signature": "release-statement.sig"
+    },
+    "manifest": {
+      "name": "manifest.json",
+      "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+      "bytes": 48213
+    },
+    "tarball": {
+      "name": "cloudhal-release-0.8.0.tar.gz",
+      "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+      "bytes": 104857601
+    }
+  }
+}
diff --git a/packages/core-contracts/fixtures/valid/release-manifest/cloudhal-0.6.3.json b/packages/core-contracts/fixtures/valid/release-manifest/cloudhal-0.6.3.json
new file mode 100644
index 00000000..ab338a99
--- /dev/null
+++ b/packages/core-contracts/fixtures/valid/release-manifest/cloudhal-0.6.3.json
@@ -0,0 +1,186 @@
+{
+  "schemaVersion": 1,
+  "format": "cloudhal.release-manifest@1",
+  "version": "0.6.3",
+  "channel": "stable",
+  "securityRelease": false,
+  "commit": "e433958d370ef64c6c21063c150bb8cd085404eb",
+  "createdAt": "2026-11-08T10:00:00Z",
+  "contracts": {
+    "schemaVersion": 1
+  },
+  "levels": {
+    "schedules": 2,
+    "gates": 1
+  },
+  "migrations": {
+    "first": "0001_init",
+    "last": "0042_comments",
+    "count": 42,
+    "names": ["0001_init", "0042_comments"],
+    "compatibleWithPrevious": true
+  },
+  "durableObjects": {
+    "bindings": [
+      {
+        "name": "SCHEDULER",
+        "className": "Scheduler",
+        "migration": "v1"
+      }
+    ],
+    "lifecycleChange": false
+  },
+  "runtime": {
+    "compatibilityDate": "2026-10-01",
+    "compatibilityFlags": ["nodejs_compat"]
+  },
+  "toolchain": {
+    "wrangler": "4.158.0",
+    "node": "24.15.0"
+  },
+  "theme": {
+    "name": "default",
+    "version": "0.6.3",
+    "blockTypes": ["hero", "richText"],
+    "settingsSha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
+    "designSha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
+  },
+  "skills": [
+    {
+      "name": "cloudhal-site",
+      "sha256": "1111111111111111111111111111111111111111111111111111111111111111"
+    }
+  ],
+  "workers": {
+    "site": {
+      "mainModule": "index.js",
+      "modules": [
+        {
+          "path": "index.js",
+          "type": "esm",
+          "bytes": 1024,
+          "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
+        }
+      ],
+      "assets": {
+        "count": 1,
+        "bytes": 512,
+        "files": [
+          {
+            "path": "/index.html",
+            "bytes": 512,
+            "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+            "assetHash": "dddddddddddddddddddddddddddddddd",
+            "contentType": "text/html"
+          }
+        ],
+        "headers": "/*\n  x-content-type-options: nosniff\n",
+        "config": {
+          "not_found_handling": "404-page",
+          "run_worker_first": ["/api/*"]
+        }
+      },
+      "bindingTemplate": "site"
+    },
+    "admin": {
+      "mainModule": "index.js",
+      "modules": [
+        {
+          "path": "index.js",
+          "type": "esm",
+          "bytes": 2048,
+          "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
+        }
+      ],
+      "assets": {
+        "count": 1,
+        "bytes": 512,
+        "files": [
+          {
+            "path": "/index.html",
+            "bytes": 512,
+            "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+            "assetHash": "dddddddddddddddddddddddddddddddd",
+            "contentType": "text/html"
+          }
+        ],
+        "headers": "/*\n  x-content-type-options: nosniff\n",
+        "config": {
+          "not_found_handling": "404-page",
+          "run_worker_first": ["/api/*"]
+        }
+      },
+      "bindingTemplate": "admin"
+    }
+  },
+  "bindingTemplates": {
+    "site": {
+      "bindings": [
+        {
+          "name": "DB",
+          "type": "d1",
+          "required": true
+        },
+        {
+          "name": "BLOBS",
+          "type": "r2_bucket",
+          "required": true,
+          "aliases": ["MEDIA"]
+        }
+      ],
+      "vars": ["CLOUDHAL_SCHEDULER"],
+      "secrets": ["CLOUDHAL_PREVIEW_SECRET", "CLOUDHAL_PURGE_TOKEN", "CLOUDHAL_COMMENTS_KEY"],
+      "cache": true
+    },
+    "admin": {
+      "bindings": [
+        {
+          "name": "DB",
+          "type": "d1",
+          "required": true
+        },
+        {
+          "name": "BLOBS",
+          "type": "r2_bucket",
+          "required": true,
+          "aliases": ["MEDIA"]
+        }
+      ],
+      "vars": ["CLOUDHAL_SCHEDULER"],
+      "secrets": [
+        "BETTER_AUTH_SECRET",
+        "MCP_STATE_KEY",
+        "CLOUDHAL_PREVIEW_SECRET",
+        "CLOUDHAL_PURGE_TOKEN",
+        "CLOUDHAL_DEPLOY_TOKEN",
+        "CLOUDHAL_SETUP_TOKEN",
+        "CLOUDHAL_COMMENTS_KEY"
+      ],
+      "cache": true
+    }
+  },
+  "requiredScopes": [
+    "account-settings.read",
+    "workers-scripts.edit",
+    "d1.write",
+    "workers-r2.write"
+  ],
+  "optionalScopes": ["account-analytics.read"],
+  "requiredCapabilities": ["account", "workers", "d1", "r2"],
+  "optionalCapabilities": ["analytics", "domains"],
+  "starter": {
+    "kind": "admin-api"
+  },
+  "features": {
+    "adminSitesAcceptsSiteId": true,
+    "healthReportsVersion": true,
+    "mediaTransformVar": false,
+    "futureFlag": true
+  },
+  "planVersion": 1,
+  "signing": {
+    "keyId": "cloudhal-release-2026",
+    "algorithm": "Ed25519",
+    "signature": "manifest.sig"
+  }
+}
diff --git a/packages/core-contracts/fixtures/valid/release-manifest/starter-bundle.json b/packages/core-contracts/fixtures/valid/release-manifest/starter-bundle.json
new file mode 100644
index 00000000..0e1613a6
--- /dev/null
+++ b/packages/core-contracts/fixtures/valid/release-manifest/starter-bundle.json
@@ -0,0 +1,190 @@
+{
+  "schemaVersion": 1,
+  "format": "cloudhal.release-manifest@1",
+  "version": "0.6.3",
+  "channel": "stable",
+  "securityRelease": false,
+  "commit": "e433958d370ef64c6c21063c150bb8cd085404eb",
+  "createdAt": "2026-11-08T10:00:00Z",
+  "contracts": {
+    "schemaVersion": 1
+  },
+  "levels": {
+    "schedules": 2,
+    "gates": 1
+  },
+  "migrations": {
+    "first": "0001_init",
+    "last": "0042_comments",
+    "count": 42,
+    "names": ["0001_init", "0042_comments"],
+    "compatibleWithPrevious": true
+  },
+  "durableObjects": {
+    "bindings": [
+      {
+        "name": "SCHEDULER",
+        "className": "Scheduler",
+        "migration": "v1"
+      }
+    ],
+    "lifecycleChange": false
+  },
+  "runtime": {
+    "compatibilityDate": "2026-10-01",
+    "compatibilityFlags": ["nodejs_compat"]
+  },
+  "toolchain": {
+    "wrangler": "4.158.0",
+    "node": "24.15.0"
+  },
+  "theme": {
+    "name": "default",
+    "version": "0.6.3",
+    "blockTypes": ["hero", "richText"],
+    "settingsSha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
+    "designSha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
+  },
+  "skills": [
+    {
+      "name": "cloudhal-site",
+      "sha256": "1111111111111111111111111111111111111111111111111111111111111111"
+    }
+  ],
+  "workers": {
+    "site": {
+      "mainModule": "index.js",
+      "modules": [
+        {
+          "path": "index.js",
+          "type": "esm",
+          "bytes": 1024,
+          "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
+        }
+      ],
+      "assets": {
+        "count": 1,
+        "bytes": 512,
+        "files": [
+          {
+            "path": "/index.html",
+            "bytes": 512,
+            "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+            "assetHash": "dddddddddddddddddddddddddddddddd",
+            "contentType": "text/html"
+          }
+        ],
+        "headers": "/*\n  x-content-type-options: nosniff\n",
+        "config": {
+          "not_found_handling": "404-page",
+          "run_worker_first": ["/api/*"]
+        }
+      },
+      "bindingTemplate": "site"
+    },
+    "admin": {
+      "mainModule": "index.js",
+      "modules": [
+        {
+          "path": "index.js",
+          "type": "esm",
+          "bytes": 2048,
+          "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
+        }
+      ],
+      "assets": {
+        "count": 1,
+        "bytes": 512,
+        "files": [
+          {
+            "path": "/index.html",
+            "bytes": 512,
+            "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+            "assetHash": "dddddddddddddddddddddddddddddddd",
+            "contentType": "text/html"
+          }
+        ],
+        "headers": "/*\n  x-content-type-options: nosniff\n",
+        "config": {
+          "not_found_handling": "404-page",
+          "run_worker_first": ["/api/*"]
+        }
+      },
+      "bindingTemplate": "admin"
+    }
+  },
+  "bindingTemplates": {
+    "site": {
+      "bindings": [
+        {
+          "name": "DB",
+          "type": "d1",
+          "required": true
+        },
+        {
+          "name": "BLOBS",
+          "type": "r2_bucket",
+          "required": true,
+          "aliases": ["MEDIA"]
+        }
+      ],
+      "vars": ["CLOUDHAL_SCHEDULER"],
+      "secrets": ["CLOUDHAL_PREVIEW_SECRET", "CLOUDHAL_PURGE_TOKEN", "CLOUDHAL_COMMENTS_KEY"],
+      "cache": true
+    },
+    "admin": {
+      "bindings": [
+        {
+          "name": "DB",
+          "type": "d1",
+          "required": true
+        },
+        {
+          "name": "BLOBS",
+          "type": "r2_bucket",
+          "required": true,
+          "aliases": ["MEDIA"]
+        }
+      ],
+      "vars": ["CLOUDHAL_SCHEDULER"],
+      "secrets": [
+        "BETTER_AUTH_SECRET",
+        "MCP_STATE_KEY",
+        "CLOUDHAL_PREVIEW_SECRET",
+        "CLOUDHAL_PURGE_TOKEN",
+        "CLOUDHAL_DEPLOY_TOKEN",
+        "CLOUDHAL_SETUP_TOKEN",
+        "CLOUDHAL_COMMENTS_KEY"
+      ],
+      "cache": true
+    }
+  },
+  "requiredScopes": [
+    "account-settings.read",
+    "workers-scripts.edit",
+    "d1.write",
+    "workers-r2.write"
+  ],
+  "optionalScopes": ["account-analytics.read"],
+  "requiredCapabilities": ["account", "workers", "d1", "r2"],
+  "optionalCapabilities": ["analytics", "domains"],
+  "starter": {
+    "kind": "bundle",
+    "path": "starter/site.json",
+    "bytes": 4096,
+    "sha256": "2222222222222222222222222222222222222222222222222222222222222222",
+    "siteId": "site_01JC0FXR3Z8K2M4N6P8Q"
+  },
+  "features": {
+    "adminSitesAcceptsSiteId": true,
+    "healthReportsVersion": true,
+    "mediaTransformVar": false,
+    "futureFlag": true
+  },
+  "planVersion": 1,
+  "signing": {
+    "keyId": "cloudhal-release-2026",
+    "algorithm": "Ed25519",
+    "signature": "manifest.sig"
+  }
+}
diff --git a/packages/core-contracts/fixtures/valid/release-manifest/statement-signed.json b/packages/core-contracts/fixtures/valid/release-manifest/statement-signed.json
new file mode 100644
index 00000000..74f8ef67
--- /dev/null
+++ b/packages/core-contracts/fixtures/valid/release-manifest/statement-signed.json
@@ -0,0 +1,186 @@
+{
+  "schemaVersion": 1,
+  "format": "cloudhal.release-manifest@1",
+  "version": "0.6.3",
+  "channel": "stable",
+  "securityRelease": false,
+  "commit": "e433958d370ef64c6c21063c150bb8cd085404eb",
+  "createdAt": "2026-11-08T10:00:00Z",
+  "contracts": {
+    "schemaVersion": 1
+  },
+  "levels": {
+    "schedules": 2,
+    "gates": 1
+  },
+  "migrations": {
+    "first": "0001_init",
+    "last": "0042_comments",
+    "count": 42,
+    "names": ["0001_init", "0042_comments"],
+    "compatibleWithPrevious": true
+  },
+  "durableObjects": {
+    "bindings": [
+      {
+        "name": "SCHEDULER",
+        "className": "Scheduler",
+        "migration": "v1"
+      }
+    ],
+    "lifecycleChange": false
+  },
+  "runtime": {
+    "compatibilityDate": "2026-10-01",
+    "compatibilityFlags": ["nodejs_compat"]
+  },
+  "toolchain": {
+    "wrangler": "4.158.0",
+    "node": "24.15.0"
+  },
+  "theme": {
+    "name": "default",
+    "version": "0.6.3",
+    "blockTypes": ["hero", "richText"],
+    "settingsSha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
+    "designSha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
+  },
+  "skills": [
+    {
+      "name": "cloudhal-site",
+      "sha256": "1111111111111111111111111111111111111111111111111111111111111111"
+    }
+  ],
+  "workers": {
+    "site": {
+      "mainModule": "index.js",
+      "modules": [
+        {
+          "path": "index.js",
+          "type": "esm",
+          "bytes": 1024,
+          "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
+        }
+      ],
+      "assets": {
+        "count": 1,
+        "bytes": 512,
+        "files": [
+          {
+            "path": "/index.html",
+            "bytes": 512,
+            "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+            "assetHash": "dddddddddddddddddddddddddddddddd",
+            "contentType": "text/html"
+          }
+        ],
+        "headers": "/*\n  x-content-type-options: nosniff\n",
+        "config": {
+          "not_found_handling": "404-page",
+          "run_worker_first": ["/api/*"]
+        }
+      },
+      "bindingTemplate": "site"
+    },
+    "admin": {
+      "mainModule": "index.js",
+      "modules": [
+        {
+          "path": "index.js",
+          "type": "esm",
+          "bytes": 2048,
+          "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
+        }
+      ],
+      "assets": {
+        "count": 1,
+        "bytes": 512,
+        "files": [
+          {
+            "path": "/index.html",
+            "bytes": 512,
+            "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+            "assetHash": "dddddddddddddddddddddddddddddddd",
+            "contentType": "text/html"
+          }
+        ],
+        "headers": "/*\n  x-content-type-options: nosniff\n",
+        "config": {
+          "not_found_handling": "404-page",
+          "run_worker_first": ["/api/*"]
+        }
+      },
+      "bindingTemplate": "admin"
+    }
+  },
+  "bindingTemplates": {
+    "site": {
+      "bindings": [
+        {
+          "name": "DB",
+          "type": "d1",
+          "required": true
+        },
+        {
+          "name": "BLOBS",
+          "type": "r2_bucket",
+          "required": true,
+          "aliases": ["MEDIA"]
+        }
+      ],
+      "vars": ["CLOUDHAL_SCHEDULER"],
+      "secrets": ["CLOUDHAL_PREVIEW_SECRET", "CLOUDHAL_PURGE_TOKEN", "CLOUDHAL_COMMENTS_KEY"],
+      "cache": true
+    },
+    "admin": {
+      "bindings": [
+        {
+          "name": "DB",
+          "type": "d1",
+          "required": true
+        },
+        {
+          "name": "BLOBS",
+          "type": "r2_bucket",
+          "required": true,
+          "aliases": ["MEDIA"]
+        }
+      ],
+      "vars": ["CLOUDHAL_SCHEDULER"],
+      "secrets": [
+        "BETTER_AUTH_SECRET",
+        "MCP_STATE_KEY",
+        "CLOUDHAL_PREVIEW_SECRET",
+        "CLOUDHAL_PURGE_TOKEN",
+        "CLOUDHAL_DEPLOY_TOKEN",
+        "CLOUDHAL_SETUP_TOKEN",
+        "CLOUDHAL_COMMENTS_KEY"
+      ],
+      "cache": true
+    }
+  },
+  "requiredScopes": [
+    "account-settings.read",
+    "workers-scripts.edit",
+    "d1.write",
+    "workers-r2.write"
+  ],
+  "optionalScopes": ["account-analytics.read"],
+  "requiredCapabilities": ["account", "workers", "d1", "r2"],
+  "optionalCapabilities": ["analytics", "domains"],
+  "starter": {
+    "kind": "admin-api"
+  },
+  "features": {
+    "adminSitesAcceptsSiteId": true,
+    "healthReportsVersion": true,
+    "mediaTransformVar": false,
+    "futureFlag": true
+  },
+  "planVersion": 1,
+  "signing": {
+    "keyId": "cloudhal-release-2026a",
+    "algorithm": "Ed25519",
+    "signature": "release-statement.sig"
+  }
+}
diff --git a/packages/core-contracts/fixtures/valid/release-statement/next-prerelease.json b/packages/core-contracts/fixtures/valid/release-statement/next-prerelease.json
new file mode 100644
index 00000000..977e2039
--- /dev/null
+++ b/packages/core-contracts/fixtures/valid/release-statement/next-prerelease.json
@@ -0,0 +1,24 @@
+{
+  "schemaVersion": 1,
+  "format": "cloudhal.release-statement@1",
+  "version": "0.9.0-next.1",
+  "channel": "next",
+  "securityRelease": false,
+  "commit": "0123456789abcdef0123456789abcdef01234567",
+  "createdAt": "2026-11-12T08:00:00Z",
+  "signing": {
+    "keyId": "cloudhal-release-2026a",
+    "algorithm": "Ed25519",
+    "signature": "release-statement.sig"
+  },
+  "manifest": {
+    "name": "manifest.json",
+    "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+    "bytes": 48213
+  },
+  "tarball": {
+    "name": "cloudhal-release-0.9.0-next.1.tar.gz",
+    "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+    "bytes": 7340032
+  }
+}
diff --git a/packages/core-contracts/fixtures/valid/release-statement/security.json b/packages/core-contracts/fixtures/valid/release-statement/security.json
new file mode 100644
index 00000000..c6ba61f7
--- /dev/null
+++ b/packages/core-contracts/fixtures/valid/release-statement/security.json
@@ -0,0 +1,24 @@
+{
+  "schemaVersion": 1,
+  "format": "cloudhal.release-statement@1",
+  "version": "0.8.1",
+  "channel": "stable",
+  "securityRelease": true,
+  "commit": "0123456789abcdef0123456789abcdef01234567",
+  "createdAt": "2026-11-12T08:00:00Z",
+  "signing": {
+    "keyId": "cloudhal-release-2026a",
+    "algorithm": "Ed25519",
+    "signature": "release-statement.sig"
+  },
+  "manifest": {
+    "name": "manifest.json",
+    "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+    "bytes": 48213
+  },
+  "tarball": {
+    "name": "cloudhal-release-0.8.1.tar.gz",
+    "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+    "bytes": 7340032
+  }
+}
diff --git a/packages/core-contracts/fixtures/valid/release-statement/stable.json b/packages/core-contracts/fixtures/valid/release-statement/stable.json
new file mode 100644
index 00000000..b195ad75
--- /dev/null
+++ b/packages/core-contracts/fixtures/valid/release-statement/stable.json
@@ -0,0 +1,24 @@
+{
+  "schemaVersion": 1,
+  "format": "cloudhal.release-statement@1",
+  "version": "0.8.0",
+  "channel": "stable",
+  "securityRelease": false,
+  "commit": "0123456789abcdef0123456789abcdef01234567",
+  "createdAt": "2026-11-12T08:00:00Z",
+  "signing": {
+    "keyId": "cloudhal-release-2026a",
+    "algorithm": "Ed25519",
+    "signature": "release-statement.sig"
+  },
+  "manifest": {
+    "name": "manifest.json",
+    "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+    "bytes": 48213
+  },
+  "tarball": {
+    "name": "cloudhal-release-0.8.0.tar.gz",
+    "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+    "bytes": 7340032
+  }
+}
diff --git a/packages/core-contracts/schemas/release-manifest.schema.json b/packages/core-contracts/schemas/release-manifest.schema.json
new file mode 100644
index 00000000..3298f19e
--- /dev/null
+++ b/packages/core-contracts/schemas/release-manifest.schema.json
@@ -0,0 +1,571 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$id": "https://cloudhal.dev/schemas/v1/release-manifest.schema.json",
+  "title": "ReleaseManifest",
+  "description": "cloudhal.release-manifest@1 (RFC-0046): the manifest of one Cloudhal release bundle, published as the release asset manifest.json next to the gzip tarball cloudhal-release-.tar.gz. A project release is signed through release-statement.json, which pins this file's SHA-256 and size (signing.signature = release-statement.sig); the legacy form manifest.sig, a raw 64-byte Ed25519 signature over these exact bytes, is for development keys only. It describes every built Worker module and static asset by size and SHA-256, the binding templates, feature levels, migration range and rollback facts a remote deployer needs to install, update or refuse a version without running a build. Portable on purpose: it references common only for SchemaVersion and Timestamp; every other definition is local.",
+  "type": "object",
+  "additionalProperties": false,
+  "required": [
+    "schemaVersion",
+    "format",
+    "version",
+    "channel",
+    "securityRelease",
+    "commit",
+    "createdAt",
+    "contracts",
+    "levels",
+    "migrations",
+    "durableObjects",
+    "runtime",
+    "toolchain",
+    "theme",
+    "skills",
+    "workers",
+    "bindingTemplates",
+    "requiredScopes",
+    "optionalScopes",
+    "requiredCapabilities",
+    "optionalCapabilities",
+    "starter",
+    "features",
+    "planVersion",
+    "signing"
+  ],
+  "properties": {
+    "schemaVersion": {
+      "$ref": "common.schema.json#/$defs/SchemaVersion"
+    },
+    "format": {
+      "type": "string",
+      "enum": ["cloudhal.release-manifest@1"]
+    },
+    "version": {
+      "$ref": "#/$defs/CloudhalVersion"
+    },
+    "channel": {
+      "type": "string",
+      "enum": ["stable", "next"]
+    },
+    "securityRelease": {
+      "type": "boolean"
+    },
+    "commit": {
+      "type": "string",
+      "pattern": "^[0-9a-f]{40}$"
+    },
+    "createdAt": {
+      "$ref": "common.schema.json#/$defs/Timestamp"
+    },
+    "contracts": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": ["schemaVersion"],
+      "properties": {
+        "schemaVersion": {
+          "type": "integer",
+          "enum": [1]
+        }
+      }
+    },
+    "levels": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": ["schedules", "gates"],
+      "properties": {
+        "schedules": {
+          "type": "integer",
+          "minimum": 0
+        },
+        "gates": {
+          "type": "integer",
+          "minimum": 0
+        }
+      }
+    },
+    "migrations": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": ["first", "last", "count", "names", "compatibleWithPrevious"],
+      "properties": {
+        "first": {
+          "$ref": "#/$defs/MigrationName"
+        },
+        "last": {
+          "$ref": "#/$defs/MigrationName"
+        },
+        "count": {
+          "type": "integer",
+          "minimum": 1
+        },
+        "names": {
+          "type": "array",
+          "minItems": 1,
+          "uniqueItems": true,
+          "items": {
+            "$ref": "#/$defs/MigrationName"
+          }
+        },
+        "compatibleWithPrevious": {
+          "type": "boolean"
+        }
+      }
+    },
+    "durableObjects": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": ["bindings", "lifecycleChange"],
+      "properties": {
+        "bindings": {
+          "type": "array",
+          "items": {
+            "type": "object",
+            "additionalProperties": false,
+            "required": ["name", "className", "migration"],
+            "properties": {
+              "name": {
+                "$ref": "#/$defs/BindingName"
+              },
+              "className": {
+                "type": "string",
+                "pattern": "^[A-Z][A-Za-z0-9]*$"
+              },
+              "migration": {
+                "type": "string",
+                "pattern": "^v[1-9][0-9]*$"
+              }
+            }
+          }
+        },
+        "lifecycleChange": {
+          "type": "boolean"
+        }
+      }
+    },
+    "runtime": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": ["compatibilityDate", "compatibilityFlags"],
+      "properties": {
+        "compatibilityDate": {
+          "type": "string",
+          "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+        },
+        "compatibilityFlags": {
+          "type": "array",
+          "uniqueItems": true,
+          "items": {
+            "type": "string",
+            "pattern": "^[a-z0-9_]+$"
+          }
+        }
+      }
+    },
+    "toolchain": {
+      "type": "object",
+      "additionalProperties": {
+        "type": "string",
+        "maxLength": 64
+      }
+    },
+    "theme": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": ["name", "version", "blockTypes", "settingsSha256", "designSha256"],
+      "properties": {
+        "name": {
+          "type": "string",
+          "pattern": "^[a-z][a-z0-9-]{0,63}$"
+        },
+        "version": {
+          "type": "string",
+          "maxLength": 64
+        },
+        "blockTypes": {
+          "type": "array",
+          "uniqueItems": true,
+          "items": {
+            "type": "string"
+          }
+        },
+        "settingsSha256": {
+          "$ref": "#/$defs/Sha256Hex"
+        },
+        "designSha256": {
+          "$ref": "#/$defs/Sha256Hex"
+        }
+      }
+    },
+    "skills": {
+      "type": "array",
+      "items": {
+        "type": "object",
+        "additionalProperties": false,
+        "required": ["name", "sha256"],
+        "properties": {
+          "name": {
+            "type": "string",
+            "pattern": "^[a-z][a-z0-9-]{0,63}$"
+          },
+          "sha256": {
+            "$ref": "#/$defs/Sha256Hex"
+          }
+        }
+      }
+    },
+    "workers": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": ["site", "admin"],
+      "properties": {
+        "site": {
+          "$ref": "#/$defs/WorkerArtifact"
+        },
+        "admin": {
+          "$ref": "#/$defs/WorkerArtifact"
+        }
+      }
+    },
+    "bindingTemplates": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": ["site", "admin"],
+      "properties": {
+        "site": {
+          "$ref": "#/$defs/BindingTemplate"
+        },
+        "admin": {
+          "$ref": "#/$defs/BindingTemplate"
+        }
+      }
+    },
+    "requiredScopes": {
+      "type": "array",
+      "uniqueItems": true,
+      "items": {
+        "$ref": "#/$defs/ScopeId"
+      }
+    },
+    "optionalScopes": {
+      "type": "array",
+      "uniqueItems": true,
+      "items": {
+        "$ref": "#/$defs/ScopeId"
+      }
+    },
+    "requiredCapabilities": {
+      "type": "array",
+      "uniqueItems": true,
+      "items": {
+        "$ref": "#/$defs/Capability"
+      }
+    },
+    "optionalCapabilities": {
+      "type": "array",
+      "uniqueItems": true,
+      "items": {
+        "$ref": "#/$defs/Capability"
+      }
+    },
+    "starter": {
+      "oneOf": [
+        {
+          "type": "object",
+          "additionalProperties": false,
+          "required": ["kind"],
+          "properties": {
+            "kind": {
+              "type": "string",
+              "enum": ["admin-api"]
+            }
+          }
+        },
+        {
+          "type": "object",
+          "additionalProperties": false,
+          "required": ["kind", "path", "bytes", "sha256", "siteId"],
+          "properties": {
+            "kind": {
+              "type": "string",
+              "enum": ["bundle"]
+            },
+            "path": {
+              "type": "string",
+              "pattern": "^starter/[A-Za-z0-9._-]+$"
+            },
+            "bytes": {
+              "type": "integer",
+              "minimum": 1
+            },
+            "sha256": {
+              "$ref": "#/$defs/Sha256Hex"
+            },
+            "siteId": {
+              "$ref": "#/$defs/SiteId"
+            }
+          }
+        }
+      ]
+    },
+    "features": {
+      "type": "object",
+      "additionalProperties": {
+        "type": "boolean"
+      },
+      "required": ["adminSitesAcceptsSiteId", "healthReportsVersion", "mediaTransformVar"],
+      "properties": {
+        "adminSitesAcceptsSiteId": {
+          "type": "boolean"
+        },
+        "healthReportsVersion": {
+          "type": "boolean"
+        },
+        "mediaTransformVar": {
+          "type": "boolean"
+        }
+      }
+    },
+    "planVersion": {
+      "type": "integer",
+      "minimum": 1
+    },
+    "signing": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": ["keyId", "algorithm", "signature"],
+      "properties": {
+        "keyId": {
+          "type": "string",
+          "pattern": "^[a-z0-9][a-z0-9-]{2,63}$"
+        },
+        "algorithm": {
+          "type": "string",
+          "enum": ["Ed25519"]
+        },
+        "signature": {
+          "type": "string",
+          "enum": ["manifest.sig", "release-statement.sig"]
+        }
+      }
+    }
+  },
+  "$defs": {
+    "MigrationName": {
+      "type": "string",
+      "pattern": "^[0-9]{4}_[a-z0-9_]+$"
+    },
+    "BindingName": {
+      "type": "string",
+      "pattern": "^[A-Z][A-Z0-9_]*(_\\*)?$"
+    },
+    "ScopeId": {
+      "type": "string",
+      "pattern": "^[a-z0-9-]+(\\.[a-z0-9-]+)+$"
+    },
+    "Capability": {
+      "type": "string",
+      "enum": ["account", "workers", "d1", "r2", "analytics", "domains"]
+    },
+    "CloudhalVersion": {
+      "type": "string",
+      "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$"
+    },
+    "Sha256Hex": {
+      "type": "string",
+      "pattern": "^[0-9a-f]{64}$"
+    },
+    "SiteId": {
+      "type": "string",
+      "pattern": "^site_[A-Za-z0-9]{6,32}$"
+    },
+    "SecretName": {
+      "type": "string",
+      "enum": [
+        "BETTER_AUTH_SECRET",
+        "MCP_STATE_KEY",
+        "CLOUDHAL_PREVIEW_SECRET",
+        "CLOUDHAL_PURGE_TOKEN",
+        "CLOUDHAL_DEPLOY_TOKEN",
+        "CLOUDHAL_SETUP_TOKEN",
+        "CLOUDHAL_COMMENTS_KEY"
+      ]
+    },
+    "WorkerArtifact": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": ["mainModule", "modules", "assets"],
+      "properties": {
+        "mainModule": {
+          "type": "string",
+          "pattern": "^[A-Za-z0-9._/-]+$"
+        },
+        "modules": {
+          "type": "array",
+          "minItems": 1,
+          "items": {
+            "type": "object",
+            "additionalProperties": false,
+            "required": ["path", "type", "bytes", "sha256"],
+            "properties": {
+              "path": {
+                "type": "string",
+                "pattern": "^[A-Za-z0-9._-]+(/[A-Za-z0-9._-]+)*$"
+              },
+              "type": {
+                "type": "string",
+                "enum": ["esm", "cjs", "wasm", "text", "data"]
+              },
+              "bytes": {
+                "type": "integer",
+                "minimum": 1
+              },
+              "sha256": {
+                "$ref": "#/$defs/Sha256Hex"
+              }
+            }
+          }
+        },
+        "assets": {
+          "type": "object",
+          "additionalProperties": false,
+          "required": ["count", "bytes", "files", "headers"],
+          "properties": {
+            "count": {
+              "type": "integer",
+              "minimum": 0,
+              "maximum": 20000
+            },
+            "bytes": {
+              "type": "integer",
+              "minimum": 0
+            },
+            "files": {
+              "type": "array",
+              "maxItems": 20000,
+              "items": {
+                "type": "object",
+                "additionalProperties": false,
+                "required": ["path", "bytes", "sha256", "assetHash", "contentType"],
+                "properties": {
+                  "path": {
+                    "type": "string",
+                    "pattern": "^/[^\\s]*$",
+                    "maxLength": 1024
+                  },
+                  "bytes": {
+                    "type": "integer",
+                    "minimum": 0,
+                    "maximum": 26214400
+                  },
+                  "sha256": {
+                    "$ref": "#/$defs/Sha256Hex"
+                  },
+                  "assetHash": {
+                    "type": "string",
+                    "pattern": "^[0-9a-f]{32}$"
+                  },
+                  "contentType": {
+                    "type": "string",
+                    "maxLength": 128
+                  }
+                }
+              }
+            },
+            "headers": {
+              "type": "string",
+              "maxLength": 100000
+            },
+            "config": {
+              "type": "object",
+              "additionalProperties": false,
+              "properties": {
+                "not_found_handling": {
+                  "type": "string",
+                  "enum": ["none", "404-page", "single-page-application"]
+                },
+                "run_worker_first": {
+                  "type": "array",
+                  "items": {
+                    "type": "string",
+                    "pattern": "^/"
+                  }
+                }
+              }
+            }
+          }
+        },
+        "bindingTemplate": {
+          "type": "string",
+          "enum": ["site", "admin"]
+        }
+      }
+    },
+    "BindingTemplate": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": ["bindings", "vars", "secrets"],
+      "properties": {
+        "bindings": {
+          "type": "array",
+          "items": {
+            "type": "object",
+            "additionalProperties": false,
+            "required": ["name", "type", "required"],
+            "properties": {
+              "name": {
+                "$ref": "#/$defs/BindingName"
+              },
+              "type": {
+                "type": "string",
+                "enum": [
+                  "d1",
+                  "r2_bucket",
+                  "assets",
+                  "service",
+                  "durable_object_namespace",
+                  "analytics_engine",
+                  "analytics_sql",
+                  "ratelimit",
+                  "ai_search",
+                  "send_email",
+                  "ai",
+                  "browser"
+                ]
+              },
+              "required": {
+                "type": "boolean"
+              },
+              "aliases": {
+                "type": "array",
+                "items": {
+                  "$ref": "#/$defs/BindingName"
+                }
+              }
+            }
+          }
+        },
+        "vars": {
+          "type": "array",
+          "uniqueItems": true,
+          "items": {
+            "$ref": "#/$defs/BindingName"
+          }
+        },
+        "secrets": {
+          "type": "array",
+          "uniqueItems": true,
+          "items": {
+            "$ref": "#/$defs/SecretName"
+          }
+        },
+        "cache": {
+          "type": "boolean"
+        },
+        "cron": {
+          "type": "string",
+          "pattern": "^[0-9*/,\\- ]+$"
+        }
+      }
+    }
+  }
+}
diff --git a/packages/core-contracts/schemas/release-statement.schema.json b/packages/core-contracts/schemas/release-statement.schema.json
new file mode 100644
index 00000000..804beba6
--- /dev/null
+++ b/packages/core-contracts/schemas/release-statement.schema.json
@@ -0,0 +1,100 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$id": "https://cloudhal.dev/schemas/v1/release-statement.schema.json",
+  "title": "ReleaseStatement",
+  "description": "cloudhal.release-statement@1 (RFC-0046): the small canonical document a Cloudhal release signs, published as the release asset release-statement.json next to release-statement.sig (a raw 64-byte Ed25519 signature over the exact bytes, at most 4096 of them). It names the version, channel, commit and key id and pins the SHA-256 and size of manifest.json and of the tarball, so a remote deployer verifies one small file and then checks every other asset by digest. Canonical bytes: keys sorted recursively, 2-space indent, a trailing newline; a consumer verifies the bytes it downloaded and never re-serialises them. Portable on purpose: it references common only for SchemaVersion and Timestamp.",
+  "type": "object",
+  "additionalProperties": false,
+  "required": [
+    "schemaVersion",
+    "format",
+    "version",
+    "channel",
+    "securityRelease",
+    "commit",
+    "createdAt",
+    "signing",
+    "manifest",
+    "tarball"
+  ],
+  "properties": {
+    "schemaVersion": {
+      "$ref": "common.schema.json#/$defs/SchemaVersion"
+    },
+    "format": {
+      "type": "string",
+      "enum": ["cloudhal.release-statement@1"]
+    },
+    "version": {
+      "$ref": "#/$defs/CloudhalVersion"
+    },
+    "channel": {
+      "type": "string",
+      "enum": ["stable", "next"]
+    },
+    "securityRelease": {
+      "type": "boolean"
+    },
+    "commit": {
+      "type": "string",
+      "pattern": "^[0-9a-f]{40}$"
+    },
+    "createdAt": {
+      "$ref": "common.schema.json#/$defs/Timestamp"
+    },
+    "signing": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": ["keyId", "algorithm", "signature"],
+      "properties": {
+        "keyId": {
+          "type": "string",
+          "pattern": "^[a-z0-9][a-z0-9-]{2,63}$"
+        },
+        "algorithm": {
+          "type": "string",
+          "enum": ["Ed25519"]
+        },
+        "signature": {
+          "type": "string",
+          "enum": ["release-statement.sig"]
+        }
+      }
+    },
+    "manifest": {
+      "$ref": "#/$defs/StatementAsset"
+    },
+    "tarball": {
+      "$ref": "#/$defs/StatementAsset"
+    }
+  },
+  "$defs": {
+    "CloudhalVersion": {
+      "type": "string",
+      "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$"
+    },
+    "Sha256Hex": {
+      "type": "string",
+      "pattern": "^[0-9a-f]{64}$"
+    },
+    "StatementAsset": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": ["name", "sha256", "bytes"],
+      "properties": {
+        "name": {
+          "type": "string",
+          "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"
+        },
+        "sha256": {
+          "$ref": "#/$defs/Sha256Hex"
+        },
+        "bytes": {
+          "type": "integer",
+          "minimum": 1,
+          "maximum": 104857600
+        }
+      }
+    }
+  }
+}
diff --git a/packages/core-contracts/scripts/gen-ts.ts b/packages/core-contracts/scripts/gen-ts.ts
index 67777c43..79d20d32 100644
--- a/packages/core-contracts/scripts/gen-ts.ts
+++ b/packages/core-contracts/scripts/gen-ts.ts
@@ -7,7 +7,7 @@ import path from "node:path";
 import { fileURLToPath } from "node:url";
 import { compile } from "json-schema-to-typescript";
 import { writeBundled } from "./bundle.ts";
-import { ROOT_SCHEMAS } from "./schema-list.ts";
+import { INDEX_EXPORTS, ROOT_SCHEMAS } from "./schema-list.ts";
 
 const here = path.dirname(fileURLToPath(import.meta.url));
 const OUT_DIR = path.resolve(here, "../src/generated");
@@ -24,7 +24,8 @@ const bundled = await writeBundled();
 await mkdir(OUT_DIR, { recursive: true });
 
 // Each generated module re-declares the shared $defs it uses (Id, Timestamp, ...). The index
-// exports every name exactly once, from the first module (in ROOT_SCHEMAS order) declaring it.
+// exports every name exactly once, from the first module (in ROOT_SCHEMAS order) declaring it;
+// a root listed in INDEX_EXPORTS contributes only the names listed there.
 const exportsByModule = new Map();
 const claimed = new Set();
 for (const name of ROOT_SCHEMAS) {
@@ -41,8 +42,15 @@ for (const name of ROOT_SCHEMAS) {
   });
   await writeFile(path.join(OUT_DIR, `${name}.ts`), ts);
   const names: string[] = [];
-  for (const m of ts.matchAll(/^export (?:interface|type) (\w+)/gm)) {
-    const id = m[1] as string;
+  const declared = [...ts.matchAll(/^export (?:interface|type) (\w+)/gm)].map(
+    (m) => m[1] as string,
+  );
+  const only = INDEX_EXPORTS[name];
+  for (const id of only ?? []) {
+    if (!declared.includes(id)) throw new Error(`INDEX_EXPORTS: ${name} declares no ${id}`);
+  }
+  for (const id of declared) {
+    if (only && !only.includes(id)) continue;
     if (!claimed.has(id)) {
       claimed.add(id);
       names.push(id);
diff --git a/packages/core-contracts/scripts/schema-list.ts b/packages/core-contracts/scripts/schema-list.ts
index ce4520ee..0ba0d01e 100644
--- a/packages/core-contracts/scripts/schema-list.ts
+++ b/packages/core-contracts/scripts/schema-list.ts
@@ -23,8 +23,22 @@ export const ROOT_SCHEMAS = [
   "automation",
   "schedule-run",
   "glossary",
+  "release-manifest",
+  "release-statement",
 ] as const;
 
 export type RootSchemaName = (typeof ROOT_SCHEMAS)[number];
 
+/**
+ * Roots whose module the package index re-exports only by these names. The release roots
+ * (RFC-0046) keep their local `$defs` local: those definitions are portable copies (`SiteId`,
+ * `Capability`, `Sha256Hex`, …) whose generic names would claim the package's public type
+ * namespace with meanings that hold only inside a release manifest. Every listed name must be
+ * declared by the generated module (gen-ts refuses otherwise).
+ */
+export const INDEX_EXPORTS: Partial> = {
+  "release-manifest": ["ReleaseManifest", "WorkerArtifact", "BindingTemplate"],
+  "release-statement": ["ReleaseStatement", "StatementAsset"],
+};
+
 export const COMMON_SCHEMA = "common";
diff --git a/packages/core-contracts/src/generated/index.ts b/packages/core-contracts/src/generated/index.ts
index b8e8a2a4..04ec9b18 100644
--- a/packages/core-contracts/src/generated/index.ts
+++ b/packages/core-contracts/src/generated/index.ts
@@ -24,3 +24,5 @@ export type { ReleaseId, ReleaseItem, ReleaseNodeKind, ReleaseWhen, LocalDateTim
 export type { AutomationId, AutomationJob, AutomationRule, AutomationState, Automation, BuiltinAutomationJob, AgentTaskAutomationJob, AutomationBudget, AutomationRecurrence, AutomationOwner } from "./automation.ts";
 export type { ScheduleRunId, ScheduleRunSubject, ScheduleRunState, ScheduleRun, ReleaseRunSubject, AutomationRunSubject, ScheduleRunWindow, ScheduleRunUsage, ScheduleRunTaint, ScheduleRunError } from "./schedule-run.ts";
 export type { Glossary, GlossaryEntry } from "./glossary.ts";
+export type { ReleaseManifest, WorkerArtifact, BindingTemplate } from "./release-manifest.ts";
+export type { ReleaseStatement, StatementAsset } from "./release-statement.ts";
diff --git a/packages/core-contracts/src/generated/release-manifest.ts b/packages/core-contracts/src/generated/release-manifest.ts
new file mode 100644
index 00000000..e0e49fb5
--- /dev/null
+++ b/packages/core-contracts/src/generated/release-manifest.ts
@@ -0,0 +1,231 @@
+/* eslint-disable */
+/* biome-ignore-all lint: generated */
+/**
+ * GENERATED FILE — DO NOT EDIT.
+ * Source: packages/core-contracts/schemas/*.schema.json  (run `pnpm gen`)
+ */
+
+/**
+ * Version of this contract. Readers reject unknown versions; migrations move documents forward one version at a time.
+ *
+ * This interface was referenced by `ReleaseManifest`'s JSON-Schema
+ * via the `definition` "SchemaVersion".
+ */
+export type SchemaVersion = 1;
+/**
+ * This interface was referenced by `ReleaseManifest`'s JSON-Schema
+ * via the `definition` "CloudhalVersion".
+ */
+export type CloudhalVersion = string;
+/**
+ * RFC 3339 / ISO 8601 instant in UTC.
+ *
+ * This interface was referenced by `ReleaseManifest`'s JSON-Schema
+ * via the `definition` "Timestamp".
+ */
+export type Timestamp = string;
+/**
+ * This interface was referenced by `ReleaseManifest`'s JSON-Schema
+ * via the `definition` "MigrationName".
+ */
+export type MigrationName = string;
+/**
+ * This interface was referenced by `ReleaseManifest`'s JSON-Schema
+ * via the `definition` "BindingName".
+ */
+export type BindingName = string;
+/**
+ * This interface was referenced by `ReleaseManifest`'s JSON-Schema
+ * via the `definition` "Sha256Hex".
+ */
+export type Sha256Hex = string;
+/**
+ * This interface was referenced by `ReleaseManifest`'s JSON-Schema
+ * via the `definition` "SecretName".
+ */
+export type SecretName =
+  | "BETTER_AUTH_SECRET"
+  | "MCP_STATE_KEY"
+  | "CLOUDHAL_PREVIEW_SECRET"
+  | "CLOUDHAL_PURGE_TOKEN"
+  | "CLOUDHAL_DEPLOY_TOKEN"
+  | "CLOUDHAL_SETUP_TOKEN"
+  | "CLOUDHAL_COMMENTS_KEY";
+/**
+ * This interface was referenced by `ReleaseManifest`'s JSON-Schema
+ * via the `definition` "ScopeId".
+ */
+export type ScopeId = string;
+/**
+ * This interface was referenced by `ReleaseManifest`'s JSON-Schema
+ * via the `definition` "Capability".
+ */
+export type Capability = "account" | "workers" | "d1" | "r2" | "analytics" | "domains";
+/**
+ * This interface was referenced by `ReleaseManifest`'s JSON-Schema
+ * via the `definition` "SiteId".
+ */
+export type SiteId = string;
+
+/**
+ * cloudhal.release-manifest@1 (RFC-0046): the manifest of one Cloudhal release bundle, published as the release asset manifest.json next to the gzip tarball cloudhal-release-.tar.gz. A project release is signed through release-statement.json, which pins this file's SHA-256 and size (signing.signature = release-statement.sig); the legacy form manifest.sig, a raw 64-byte Ed25519 signature over these exact bytes, is for development keys only. It describes every built Worker module and static asset by size and SHA-256, the binding templates, feature levels, migration range and rollback facts a remote deployer needs to install, update or refuse a version without running a build. Portable on purpose: it references common only for SchemaVersion and Timestamp; every other definition is local.
+ */
+export interface ReleaseManifest {
+  schemaVersion: SchemaVersion;
+  format: "cloudhal.release-manifest@1";
+  version: CloudhalVersion;
+  channel: "stable" | "next";
+  securityRelease: boolean;
+  commit: string;
+  createdAt: Timestamp;
+  contracts: {
+    schemaVersion: 1;
+  };
+  levels: {
+    schedules: number;
+    gates: number;
+  };
+  migrations: {
+    first: MigrationName;
+    last: MigrationName;
+    count: number;
+    /**
+     * @minItems 1
+     */
+    names: [MigrationName, ...MigrationName[]];
+    compatibleWithPrevious: boolean;
+  };
+  durableObjects: {
+    bindings: {
+      name: BindingName;
+      className: string;
+      migration: string;
+    }[];
+    lifecycleChange: boolean;
+  };
+  runtime: {
+    compatibilityDate: string;
+    compatibilityFlags: string[];
+  };
+  toolchain: {
+    [k: string]: string | undefined;
+  };
+  theme: {
+    name: string;
+    version: string;
+    blockTypes: string[];
+    settingsSha256: Sha256Hex;
+    designSha256: Sha256Hex;
+  };
+  skills: {
+    name: string;
+    sha256: Sha256Hex;
+  }[];
+  workers: {
+    site: WorkerArtifact;
+    admin: WorkerArtifact;
+  };
+  bindingTemplates: {
+    site: BindingTemplate;
+    admin: BindingTemplate;
+  };
+  requiredScopes: ScopeId[];
+  optionalScopes: ScopeId[];
+  requiredCapabilities: Capability[];
+  optionalCapabilities: Capability[];
+  starter:
+    | {
+        kind: "admin-api";
+      }
+    | {
+        kind: "bundle";
+        path: string;
+        bytes: number;
+        sha256: Sha256Hex;
+        siteId: SiteId;
+      };
+  features: {
+    adminSitesAcceptsSiteId: boolean;
+    healthReportsVersion: boolean;
+    mediaTransformVar: boolean;
+    [k: string]: boolean | undefined;
+  };
+  planVersion: number;
+  signing: {
+    keyId: string;
+    algorithm: "Ed25519";
+    signature: "manifest.sig" | "release-statement.sig";
+  };
+}
+/**
+ * This interface was referenced by `ReleaseManifest`'s JSON-Schema
+ * via the `definition` "WorkerArtifact".
+ */
+export interface WorkerArtifact {
+  mainModule: string;
+  /**
+   * @minItems 1
+   */
+  modules: [
+    {
+      path: string;
+      type: "esm" | "cjs" | "wasm" | "text" | "data";
+      bytes: number;
+      sha256: Sha256Hex;
+    },
+    ...{
+      path: string;
+      type: "esm" | "cjs" | "wasm" | "text" | "data";
+      bytes: number;
+      sha256: Sha256Hex;
+    }[],
+  ];
+  assets: {
+    count: number;
+    bytes: number;
+    /**
+     * @maxItems 20000
+     */
+    files: {
+      path: string;
+      bytes: number;
+      sha256: Sha256Hex;
+      assetHash: string;
+      contentType: string;
+    }[];
+    headers: string;
+    config?: {
+      not_found_handling?: "none" | "404-page" | "single-page-application";
+      run_worker_first?: string[];
+    };
+  };
+  bindingTemplate?: "site" | "admin";
+}
+/**
+ * This interface was referenced by `ReleaseManifest`'s JSON-Schema
+ * via the `definition` "BindingTemplate".
+ */
+export interface BindingTemplate {
+  bindings: {
+    name: BindingName;
+    type:
+      | "d1"
+      | "r2_bucket"
+      | "assets"
+      | "service"
+      | "durable_object_namespace"
+      | "analytics_engine"
+      | "analytics_sql"
+      | "ratelimit"
+      | "ai_search"
+      | "send_email"
+      | "ai"
+      | "browser";
+    required: boolean;
+    aliases?: BindingName[];
+  }[];
+  vars: BindingName[];
+  secrets: SecretName[];
+  cache?: boolean;
+  cron?: string;
+}
diff --git a/packages/core-contracts/src/generated/release-statement.ts b/packages/core-contracts/src/generated/release-statement.ts
new file mode 100644
index 00000000..928da72b
--- /dev/null
+++ b/packages/core-contracts/src/generated/release-statement.ts
@@ -0,0 +1,60 @@
+/* eslint-disable */
+/* biome-ignore-all lint: generated */
+/**
+ * GENERATED FILE — DO NOT EDIT.
+ * Source: packages/core-contracts/schemas/*.schema.json  (run `pnpm gen`)
+ */
+
+/**
+ * Version of this contract. Readers reject unknown versions; migrations move documents forward one version at a time.
+ *
+ * This interface was referenced by `ReleaseStatement`'s JSON-Schema
+ * via the `definition` "SchemaVersion".
+ */
+export type SchemaVersion = 1;
+/**
+ * This interface was referenced by `ReleaseStatement`'s JSON-Schema
+ * via the `definition` "CloudhalVersion".
+ */
+export type CloudhalVersion = string;
+/**
+ * RFC 3339 / ISO 8601 instant in UTC.
+ *
+ * This interface was referenced by `ReleaseStatement`'s JSON-Schema
+ * via the `definition` "Timestamp".
+ */
+export type Timestamp = string;
+/**
+ * This interface was referenced by `ReleaseStatement`'s JSON-Schema
+ * via the `definition` "Sha256Hex".
+ */
+export type Sha256Hex = string;
+
+/**
+ * cloudhal.release-statement@1 (RFC-0046): the small canonical document a Cloudhal release signs, published as the release asset release-statement.json next to release-statement.sig (a raw 64-byte Ed25519 signature over the exact bytes, at most 4096 of them). It names the version, channel, commit and key id and pins the SHA-256 and size of manifest.json and of the tarball, so a remote deployer verifies one small file and then checks every other asset by digest. Canonical bytes: keys sorted recursively, 2-space indent, a trailing newline; a consumer verifies the bytes it downloaded and never re-serialises them. Portable on purpose: it references common only for SchemaVersion and Timestamp.
+ */
+export interface ReleaseStatement {
+  schemaVersion: SchemaVersion;
+  format: "cloudhal.release-statement@1";
+  version: CloudhalVersion;
+  channel: "stable" | "next";
+  securityRelease: boolean;
+  commit: string;
+  createdAt: Timestamp;
+  signing: {
+    keyId: string;
+    algorithm: "Ed25519";
+    signature: "release-statement.sig";
+  };
+  manifest: StatementAsset;
+  tarball: StatementAsset;
+}
+/**
+ * This interface was referenced by `ReleaseStatement`'s JSON-Schema
+ * via the `definition` "StatementAsset".
+ */
+export interface StatementAsset {
+  name: string;
+  sha256: Sha256Hex;
+  bytes: number;
+}
diff --git a/packages/core-contracts/src/generated/schemas.ts b/packages/core-contracts/src/generated/schemas.ts
index 36527b44..24a6d882 100644
--- a/packages/core-contracts/src/generated/schemas.ts
+++ b/packages/core-contracts/src/generated/schemas.ts
@@ -8636,6 +8636,836 @@ export const GlossarySchema = {
   }
 } as const;
 
+export const ReleaseManifestSchema = {
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$id": "https://cloudhal.dev/schemas/v1/release-manifest.schema.json",
+  "title": "ReleaseManifest",
+  "description": "cloudhal.release-manifest@1 (RFC-0046): the manifest of one Cloudhal release bundle, published as the release asset manifest.json next to the gzip tarball cloudhal-release-.tar.gz. A project release is signed through release-statement.json, which pins this file's SHA-256 and size (signing.signature = release-statement.sig); the legacy form manifest.sig, a raw 64-byte Ed25519 signature over these exact bytes, is for development keys only. It describes every built Worker module and static asset by size and SHA-256, the binding templates, feature levels, migration range and rollback facts a remote deployer needs to install, update or refuse a version without running a build. Portable on purpose: it references common only for SchemaVersion and Timestamp; every other definition is local.",
+  "type": "object",
+  "additionalProperties": false,
+  "required": [
+    "schemaVersion",
+    "format",
+    "version",
+    "channel",
+    "securityRelease",
+    "commit",
+    "createdAt",
+    "contracts",
+    "levels",
+    "migrations",
+    "durableObjects",
+    "runtime",
+    "toolchain",
+    "theme",
+    "skills",
+    "workers",
+    "bindingTemplates",
+    "requiredScopes",
+    "optionalScopes",
+    "requiredCapabilities",
+    "optionalCapabilities",
+    "starter",
+    "features",
+    "planVersion",
+    "signing"
+  ],
+  "properties": {
+    "schemaVersion": {
+      "$ref": "#/$defs/SchemaVersion"
+    },
+    "format": {
+      "type": "string",
+      "enum": [
+        "cloudhal.release-manifest@1"
+      ]
+    },
+    "version": {
+      "$ref": "#/$defs/CloudhalVersion"
+    },
+    "channel": {
+      "type": "string",
+      "enum": [
+        "stable",
+        "next"
+      ]
+    },
+    "securityRelease": {
+      "type": "boolean"
+    },
+    "commit": {
+      "type": "string",
+      "pattern": "^[0-9a-f]{40}$"
+    },
+    "createdAt": {
+      "$ref": "#/$defs/Timestamp"
+    },
+    "contracts": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": [
+        "schemaVersion"
+      ],
+      "properties": {
+        "schemaVersion": {
+          "type": "integer",
+          "enum": [
+            1
+          ]
+        }
+      }
+    },
+    "levels": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": [
+        "schedules",
+        "gates"
+      ],
+      "properties": {
+        "schedules": {
+          "type": "integer",
+          "minimum": 0
+        },
+        "gates": {
+          "type": "integer",
+          "minimum": 0
+        }
+      }
+    },
+    "migrations": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": [
+        "first",
+        "last",
+        "count",
+        "names",
+        "compatibleWithPrevious"
+      ],
+      "properties": {
+        "first": {
+          "$ref": "#/$defs/MigrationName"
+        },
+        "last": {
+          "$ref": "#/$defs/MigrationName"
+        },
+        "count": {
+          "type": "integer",
+          "minimum": 1
+        },
+        "names": {
+          "type": "array",
+          "minItems": 1,
+          "uniqueItems": true,
+          "items": {
+            "$ref": "#/$defs/MigrationName"
+          }
+        },
+        "compatibleWithPrevious": {
+          "type": "boolean"
+        }
+      }
+    },
+    "durableObjects": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": [
+        "bindings",
+        "lifecycleChange"
+      ],
+      "properties": {
+        "bindings": {
+          "type": "array",
+          "items": {
+            "type": "object",
+            "additionalProperties": false,
+            "required": [
+              "name",
+              "className",
+              "migration"
+            ],
+            "properties": {
+              "name": {
+                "$ref": "#/$defs/BindingName"
+              },
+              "className": {
+                "type": "string",
+                "pattern": "^[A-Z][A-Za-z0-9]*$"
+              },
+              "migration": {
+                "type": "string",
+                "pattern": "^v[1-9][0-9]*$"
+              }
+            }
+          }
+        },
+        "lifecycleChange": {
+          "type": "boolean"
+        }
+      }
+    },
+    "runtime": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": [
+        "compatibilityDate",
+        "compatibilityFlags"
+      ],
+      "properties": {
+        "compatibilityDate": {
+          "type": "string",
+          "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+        },
+        "compatibilityFlags": {
+          "type": "array",
+          "uniqueItems": true,
+          "items": {
+            "type": "string",
+            "pattern": "^[a-z0-9_]+$"
+          }
+        }
+      }
+    },
+    "toolchain": {
+      "type": "object",
+      "additionalProperties": {
+        "type": "string",
+        "maxLength": 64
+      }
+    },
+    "theme": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": [
+        "name",
+        "version",
+        "blockTypes",
+        "settingsSha256",
+        "designSha256"
+      ],
+      "properties": {
+        "name": {
+          "type": "string",
+          "pattern": "^[a-z][a-z0-9-]{0,63}$"
+        },
+        "version": {
+          "type": "string",
+          "maxLength": 64
+        },
+        "blockTypes": {
+          "type": "array",
+          "uniqueItems": true,
+          "items": {
+            "type": "string"
+          }
+        },
+        "settingsSha256": {
+          "$ref": "#/$defs/Sha256Hex"
+        },
+        "designSha256": {
+          "$ref": "#/$defs/Sha256Hex"
+        }
+      }
+    },
+    "skills": {
+      "type": "array",
+      "items": {
+        "type": "object",
+        "additionalProperties": false,
+        "required": [
+          "name",
+          "sha256"
+        ],
+        "properties": {
+          "name": {
+            "type": "string",
+            "pattern": "^[a-z][a-z0-9-]{0,63}$"
+          },
+          "sha256": {
+            "$ref": "#/$defs/Sha256Hex"
+          }
+        }
+      }
+    },
+    "workers": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": [
+        "site",
+        "admin"
+      ],
+      "properties": {
+        "site": {
+          "$ref": "#/$defs/WorkerArtifact"
+        },
+        "admin": {
+          "$ref": "#/$defs/WorkerArtifact"
+        }
+      }
+    },
+    "bindingTemplates": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": [
+        "site",
+        "admin"
+      ],
+      "properties": {
+        "site": {
+          "$ref": "#/$defs/BindingTemplate"
+        },
+        "admin": {
+          "$ref": "#/$defs/BindingTemplate"
+        }
+      }
+    },
+    "requiredScopes": {
+      "type": "array",
+      "uniqueItems": true,
+      "items": {
+        "$ref": "#/$defs/ScopeId"
+      }
+    },
+    "optionalScopes": {
+      "type": "array",
+      "uniqueItems": true,
+      "items": {
+        "$ref": "#/$defs/ScopeId"
+      }
+    },
+    "requiredCapabilities": {
+      "type": "array",
+      "uniqueItems": true,
+      "items": {
+        "$ref": "#/$defs/Capability"
+      }
+    },
+    "optionalCapabilities": {
+      "type": "array",
+      "uniqueItems": true,
+      "items": {
+        "$ref": "#/$defs/Capability"
+      }
+    },
+    "starter": {
+      "oneOf": [
+        {
+          "type": "object",
+          "additionalProperties": false,
+          "required": [
+            "kind"
+          ],
+          "properties": {
+            "kind": {
+              "type": "string",
+              "enum": [
+                "admin-api"
+              ]
+            }
+          }
+        },
+        {
+          "type": "object",
+          "additionalProperties": false,
+          "required": [
+            "kind",
+            "path",
+            "bytes",
+            "sha256",
+            "siteId"
+          ],
+          "properties": {
+            "kind": {
+              "type": "string",
+              "enum": [
+                "bundle"
+              ]
+            },
+            "path": {
+              "type": "string",
+              "pattern": "^starter/[A-Za-z0-9._-]+$"
+            },
+            "bytes": {
+              "type": "integer",
+              "minimum": 1
+            },
+            "sha256": {
+              "$ref": "#/$defs/Sha256Hex"
+            },
+            "siteId": {
+              "$ref": "#/$defs/SiteId"
+            }
+          }
+        }
+      ]
+    },
+    "features": {
+      "type": "object",
+      "additionalProperties": {
+        "type": "boolean"
+      },
+      "required": [
+        "adminSitesAcceptsSiteId",
+        "healthReportsVersion",
+        "mediaTransformVar"
+      ],
+      "properties": {
+        "adminSitesAcceptsSiteId": {
+          "type": "boolean"
+        },
+        "healthReportsVersion": {
+          "type": "boolean"
+        },
+        "mediaTransformVar": {
+          "type": "boolean"
+        }
+      }
+    },
+    "planVersion": {
+      "type": "integer",
+      "minimum": 1
+    },
+    "signing": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": [
+        "keyId",
+        "algorithm",
+        "signature"
+      ],
+      "properties": {
+        "keyId": {
+          "type": "string",
+          "pattern": "^[a-z0-9][a-z0-9-]{2,63}$"
+        },
+        "algorithm": {
+          "type": "string",
+          "enum": [
+            "Ed25519"
+          ]
+        },
+        "signature": {
+          "type": "string",
+          "enum": [
+            "manifest.sig",
+            "release-statement.sig"
+          ]
+        }
+      }
+    }
+  },
+  "$defs": {
+    "BindingName": {
+      "type": "string",
+      "pattern": "^[A-Z][A-Z0-9_]*(_\\*)?$"
+    },
+    "BindingTemplate": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": [
+        "bindings",
+        "vars",
+        "secrets"
+      ],
+      "properties": {
+        "bindings": {
+          "type": "array",
+          "items": {
+            "type": "object",
+            "additionalProperties": false,
+            "required": [
+              "name",
+              "type",
+              "required"
+            ],
+            "properties": {
+              "name": {
+                "$ref": "#/$defs/BindingName"
+              },
+              "type": {
+                "type": "string",
+                "enum": [
+                  "d1",
+                  "r2_bucket",
+                  "assets",
+                  "service",
+                  "durable_object_namespace",
+                  "analytics_engine",
+                  "analytics_sql",
+                  "ratelimit",
+                  "ai_search",
+                  "send_email",
+                  "ai",
+                  "browser"
+                ]
+              },
+              "required": {
+                "type": "boolean"
+              },
+              "aliases": {
+                "type": "array",
+                "items": {
+                  "$ref": "#/$defs/BindingName"
+                }
+              }
+            }
+          }
+        },
+        "vars": {
+          "type": "array",
+          "uniqueItems": true,
+          "items": {
+            "$ref": "#/$defs/BindingName"
+          }
+        },
+        "secrets": {
+          "type": "array",
+          "uniqueItems": true,
+          "items": {
+            "$ref": "#/$defs/SecretName"
+          }
+        },
+        "cache": {
+          "type": "boolean"
+        },
+        "cron": {
+          "type": "string",
+          "pattern": "^[0-9*/,\\- ]+$"
+        }
+      }
+    },
+    "Capability": {
+      "type": "string",
+      "enum": [
+        "account",
+        "workers",
+        "d1",
+        "r2",
+        "analytics",
+        "domains"
+      ]
+    },
+    "CloudhalVersion": {
+      "type": "string",
+      "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$"
+    },
+    "MigrationName": {
+      "type": "string",
+      "pattern": "^[0-9]{4}_[a-z0-9_]+$"
+    },
+    "SchemaVersion": {
+      "type": "integer",
+      "description": "Version of this contract. Readers reject unknown versions; migrations move documents forward one version at a time.",
+      "enum": [
+        1
+      ]
+    },
+    "ScopeId": {
+      "type": "string",
+      "pattern": "^[a-z0-9-]+(\\.[a-z0-9-]+)+$"
+    },
+    "SecretName": {
+      "type": "string",
+      "enum": [
+        "BETTER_AUTH_SECRET",
+        "MCP_STATE_KEY",
+        "CLOUDHAL_PREVIEW_SECRET",
+        "CLOUDHAL_PURGE_TOKEN",
+        "CLOUDHAL_DEPLOY_TOKEN",
+        "CLOUDHAL_SETUP_TOKEN",
+        "CLOUDHAL_COMMENTS_KEY"
+      ]
+    },
+    "Sha256Hex": {
+      "type": "string",
+      "pattern": "^[0-9a-f]{64}$"
+    },
+    "SiteId": {
+      "type": "string",
+      "pattern": "^site_[A-Za-z0-9]{6,32}$"
+    },
+    "Timestamp": {
+      "type": "string",
+      "description": "RFC 3339 / ISO 8601 instant in UTC.",
+      "format": "date-time"
+    },
+    "WorkerArtifact": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": [
+        "mainModule",
+        "modules",
+        "assets"
+      ],
+      "properties": {
+        "mainModule": {
+          "type": "string",
+          "pattern": "^[A-Za-z0-9._/-]+$"
+        },
+        "modules": {
+          "type": "array",
+          "minItems": 1,
+          "items": {
+            "type": "object",
+            "additionalProperties": false,
+            "required": [
+              "path",
+              "type",
+              "bytes",
+              "sha256"
+            ],
+            "properties": {
+              "path": {
+                "type": "string",
+                "pattern": "^[A-Za-z0-9._-]+(/[A-Za-z0-9._-]+)*$"
+              },
+              "type": {
+                "type": "string",
+                "enum": [
+                  "esm",
+                  "cjs",
+                  "wasm",
+                  "text",
+                  "data"
+                ]
+              },
+              "bytes": {
+                "type": "integer",
+                "minimum": 1
+              },
+              "sha256": {
+                "$ref": "#/$defs/Sha256Hex"
+              }
+            }
+          }
+        },
+        "assets": {
+          "type": "object",
+          "additionalProperties": false,
+          "required": [
+            "count",
+            "bytes",
+            "files",
+            "headers"
+          ],
+          "properties": {
+            "count": {
+              "type": "integer",
+              "minimum": 0,
+              "maximum": 20000
+            },
+            "bytes": {
+              "type": "integer",
+              "minimum": 0
+            },
+            "files": {
+              "type": "array",
+              "maxItems": 20000,
+              "items": {
+                "type": "object",
+                "additionalProperties": false,
+                "required": [
+                  "path",
+                  "bytes",
+                  "sha256",
+                  "assetHash",
+                  "contentType"
+                ],
+                "properties": {
+                  "path": {
+                    "type": "string",
+                    "pattern": "^/[^\\s]*$",
+                    "maxLength": 1024
+                  },
+                  "bytes": {
+                    "type": "integer",
+                    "minimum": 0,
+                    "maximum": 26214400
+                  },
+                  "sha256": {
+                    "$ref": "#/$defs/Sha256Hex"
+                  },
+                  "assetHash": {
+                    "type": "string",
+                    "pattern": "^[0-9a-f]{32}$"
+                  },
+                  "contentType": {
+                    "type": "string",
+                    "maxLength": 128
+                  }
+                }
+              }
+            },
+            "headers": {
+              "type": "string",
+              "maxLength": 100000
+            },
+            "config": {
+              "type": "object",
+              "additionalProperties": false,
+              "properties": {
+                "not_found_handling": {
+                  "type": "string",
+                  "enum": [
+                    "none",
+                    "404-page",
+                    "single-page-application"
+                  ]
+                },
+                "run_worker_first": {
+                  "type": "array",
+                  "items": {
+                    "type": "string",
+                    "pattern": "^/"
+                  }
+                }
+              }
+            }
+          }
+        },
+        "bindingTemplate": {
+          "type": "string",
+          "enum": [
+            "site",
+            "admin"
+          ]
+        }
+      }
+    }
+  }
+} as const;
+
+export const ReleaseStatementSchema = {
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$id": "https://cloudhal.dev/schemas/v1/release-statement.schema.json",
+  "title": "ReleaseStatement",
+  "description": "cloudhal.release-statement@1 (RFC-0046): the small canonical document a Cloudhal release signs, published as the release asset release-statement.json next to release-statement.sig (a raw 64-byte Ed25519 signature over the exact bytes, at most 4096 of them). It names the version, channel, commit and key id and pins the SHA-256 and size of manifest.json and of the tarball, so a remote deployer verifies one small file and then checks every other asset by digest. Canonical bytes: keys sorted recursively, 2-space indent, a trailing newline; a consumer verifies the bytes it downloaded and never re-serialises them. Portable on purpose: it references common only for SchemaVersion and Timestamp.",
+  "type": "object",
+  "additionalProperties": false,
+  "required": [
+    "schemaVersion",
+    "format",
+    "version",
+    "channel",
+    "securityRelease",
+    "commit",
+    "createdAt",
+    "signing",
+    "manifest",
+    "tarball"
+  ],
+  "properties": {
+    "schemaVersion": {
+      "$ref": "#/$defs/SchemaVersion"
+    },
+    "format": {
+      "type": "string",
+      "enum": [
+        "cloudhal.release-statement@1"
+      ]
+    },
+    "version": {
+      "$ref": "#/$defs/CloudhalVersion"
+    },
+    "channel": {
+      "type": "string",
+      "enum": [
+        "stable",
+        "next"
+      ]
+    },
+    "securityRelease": {
+      "type": "boolean"
+    },
+    "commit": {
+      "type": "string",
+      "pattern": "^[0-9a-f]{40}$"
+    },
+    "createdAt": {
+      "$ref": "#/$defs/Timestamp"
+    },
+    "signing": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": [
+        "keyId",
+        "algorithm",
+        "signature"
+      ],
+      "properties": {
+        "keyId": {
+          "type": "string",
+          "pattern": "^[a-z0-9][a-z0-9-]{2,63}$"
+        },
+        "algorithm": {
+          "type": "string",
+          "enum": [
+            "Ed25519"
+          ]
+        },
+        "signature": {
+          "type": "string",
+          "enum": [
+            "release-statement.sig"
+          ]
+        }
+      }
+    },
+    "manifest": {
+      "$ref": "#/$defs/StatementAsset"
+    },
+    "tarball": {
+      "$ref": "#/$defs/StatementAsset"
+    }
+  },
+  "$defs": {
+    "CloudhalVersion": {
+      "type": "string",
+      "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$"
+    },
+    "SchemaVersion": {
+      "type": "integer",
+      "description": "Version of this contract. Readers reject unknown versions; migrations move documents forward one version at a time.",
+      "enum": [
+        1
+      ]
+    },
+    "Sha256Hex": {
+      "type": "string",
+      "pattern": "^[0-9a-f]{64}$"
+    },
+    "StatementAsset": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": [
+        "name",
+        "sha256",
+        "bytes"
+      ],
+      "properties": {
+        "name": {
+          "type": "string",
+          "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"
+        },
+        "sha256": {
+          "$ref": "#/$defs/Sha256Hex"
+        },
+        "bytes": {
+          "type": "integer",
+          "minimum": 1,
+          "maximum": 104857600
+        }
+      }
+    },
+    "Timestamp": {
+      "type": "string",
+      "description": "RFC 3339 / ISO 8601 instant in UTC.",
+      "format": "date-time"
+    }
+  }
+} as const;
+
 export const schemas = {
   "project": ProjectSchema,
   "site": SiteSchema,
@@ -8657,4 +9487,6 @@ export const schemas = {
   "automation": AutomationSchema,
   "schedule-run": ScheduleRunSchema,
   "glossary": GlossarySchema,
+  "release-manifest": ReleaseManifestSchema,
+  "release-statement": ReleaseStatementSchema,
 } as const;
diff --git a/packages/core-contracts/src/generated/validators.d.ts b/packages/core-contracts/src/generated/validators.d.ts
index b279b8dc..ff7ae67d 100644
--- a/packages/core-contracts/src/generated/validators.d.ts
+++ b/packages/core-contracts/src/generated/validators.d.ts
@@ -6,7 +6,7 @@
  */
 
 import type { ErrorObject } from "ajv";
-import type { Project, Site, Page, Section, ContentEntry, Snapshot, SkillManifest, Taxonomy, Term, Redirect, Asset, Menu, AnalyticsEvent, AnalyticsBeacon, Comment, CommentSubmission, Release, Automation, ScheduleRun, Glossary } from "./index.ts";
+import type { Project, Site, Page, Section, ContentEntry, Snapshot, SkillManifest, Taxonomy, Term, Redirect, Asset, Menu, AnalyticsEvent, AnalyticsBeacon, Comment, CommentSubmission, Release, Automation, ScheduleRun, Glossary, ReleaseManifest, ReleaseStatement } from "./index.ts";
 
 export interface StandaloneValidator {
   (data: unknown): data is T;
@@ -33,3 +33,5 @@ export declare const validateRelease: StandaloneValidator;
 export declare const validateAutomation: StandaloneValidator;
 export declare const validateScheduleRun: StandaloneValidator;
 export declare const validateGlossary: StandaloneValidator;
+export declare const validateReleaseManifest: StandaloneValidator;
+export declare const validateReleaseStatement: StandaloneValidator;
diff --git a/packages/core-contracts/src/generated/validators.js b/packages/core-contracts/src/generated/validators.js
index 346dfa2a..b2d7dfba 100644
--- a/packages/core-contracts/src/generated/validators.js
+++ b/packages/core-contracts/src/generated/validators.js
@@ -43611,3 +43611,4311 @@ return errors === 0;
 }
 validate242.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};
 
+export const validateReleaseManifest = validate245;
+const schema573 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://cloudhal.dev/schemas/v1/release-manifest.schema.json","title":"ReleaseManifest","description":"cloudhal.release-manifest@1 (RFC-0046): the manifest of one Cloudhal release bundle, published as the release asset manifest.json next to the gzip tarball cloudhal-release-.tar.gz. A project release is signed through release-statement.json, which pins this file's SHA-256 and size (signing.signature = release-statement.sig); the legacy form manifest.sig, a raw 64-byte Ed25519 signature over these exact bytes, is for development keys only. It describes every built Worker module and static asset by size and SHA-256, the binding templates, feature levels, migration range and rollback facts a remote deployer needs to install, update or refuse a version without running a build. Portable on purpose: it references common only for SchemaVersion and Timestamp; every other definition is local.","type":"object","additionalProperties":false,"required":["schemaVersion","format","version","channel","securityRelease","commit","createdAt","contracts","levels","migrations","durableObjects","runtime","toolchain","theme","skills","workers","bindingTemplates","requiredScopes","optionalScopes","requiredCapabilities","optionalCapabilities","starter","features","planVersion","signing"],"properties":{"schemaVersion":{"$ref":"#/$defs/SchemaVersion"},"format":{"type":"string","enum":["cloudhal.release-manifest@1"]},"version":{"$ref":"#/$defs/CloudhalVersion"},"channel":{"type":"string","enum":["stable","next"]},"securityRelease":{"type":"boolean"},"commit":{"type":"string","pattern":"^[0-9a-f]{40}$"},"createdAt":{"$ref":"#/$defs/Timestamp"},"contracts":{"type":"object","additionalProperties":false,"required":["schemaVersion"],"properties":{"schemaVersion":{"type":"integer","enum":[1]}}},"levels":{"type":"object","additionalProperties":false,"required":["schedules","gates"],"properties":{"schedules":{"type":"integer","minimum":0},"gates":{"type":"integer","minimum":0}}},"migrations":{"type":"object","additionalProperties":false,"required":["first","last","count","names","compatibleWithPrevious"],"properties":{"first":{"$ref":"#/$defs/MigrationName"},"last":{"$ref":"#/$defs/MigrationName"},"count":{"type":"integer","minimum":1},"names":{"type":"array","minItems":1,"uniqueItems":true,"items":{"$ref":"#/$defs/MigrationName"}},"compatibleWithPrevious":{"type":"boolean"}}},"durableObjects":{"type":"object","additionalProperties":false,"required":["bindings","lifecycleChange"],"properties":{"bindings":{"type":"array","items":{"type":"object","additionalProperties":false,"required":["name","className","migration"],"properties":{"name":{"$ref":"#/$defs/BindingName"},"className":{"type":"string","pattern":"^[A-Z][A-Za-z0-9]*$"},"migration":{"type":"string","pattern":"^v[1-9][0-9]*$"}}}},"lifecycleChange":{"type":"boolean"}}},"runtime":{"type":"object","additionalProperties":false,"required":["compatibilityDate","compatibilityFlags"],"properties":{"compatibilityDate":{"type":"string","pattern":"^[0-9]{4}-[0-9]{2}-[0-9]{2}$"},"compatibilityFlags":{"type":"array","uniqueItems":true,"items":{"type":"string","pattern":"^[a-z0-9_]+$"}}}},"toolchain":{"type":"object","additionalProperties":{"type":"string","maxLength":64}},"theme":{"type":"object","additionalProperties":false,"required":["name","version","blockTypes","settingsSha256","designSha256"],"properties":{"name":{"type":"string","pattern":"^[a-z][a-z0-9-]{0,63}$"},"version":{"type":"string","maxLength":64},"blockTypes":{"type":"array","uniqueItems":true,"items":{"type":"string"}},"settingsSha256":{"$ref":"#/$defs/Sha256Hex"},"designSha256":{"$ref":"#/$defs/Sha256Hex"}}},"skills":{"type":"array","items":{"type":"object","additionalProperties":false,"required":["name","sha256"],"properties":{"name":{"type":"string","pattern":"^[a-z][a-z0-9-]{0,63}$"},"sha256":{"$ref":"#/$defs/Sha256Hex"}}}},"workers":{"type":"object","additionalProperties":false,"required":["site","admin"],"properties":{"site":{"$ref":"#/$defs/WorkerArtifact"},"admin":{"$ref":"#/$defs/WorkerArtifact"}}},"bindingTemplates":{"type":"object","additionalProperties":false,"required":["site","admin"],"properties":{"site":{"$ref":"#/$defs/BindingTemplate"},"admin":{"$ref":"#/$defs/BindingTemplate"}}},"requiredScopes":{"type":"array","uniqueItems":true,"items":{"$ref":"#/$defs/ScopeId"}},"optionalScopes":{"type":"array","uniqueItems":true,"items":{"$ref":"#/$defs/ScopeId"}},"requiredCapabilities":{"type":"array","uniqueItems":true,"items":{"$ref":"#/$defs/Capability"}},"optionalCapabilities":{"type":"array","uniqueItems":true,"items":{"$ref":"#/$defs/Capability"}},"starter":{"oneOf":[{"type":"object","additionalProperties":false,"required":["kind"],"properties":{"kind":{"type":"string","enum":["admin-api"]}}},{"type":"object","additionalProperties":false,"required":["kind","path","bytes","sha256","siteId"],"properties":{"kind":{"type":"string","enum":["bundle"]},"path":{"type":"string","pattern":"^starter/[A-Za-z0-9._-]+$"},"bytes":{"type":"integer","minimum":1},"sha256":{"$ref":"#/$defs/Sha256Hex"},"siteId":{"$ref":"#/$defs/SiteId"}}}]},"features":{"type":"object","additionalProperties":{"type":"boolean"},"required":["adminSitesAcceptsSiteId","healthReportsVersion","mediaTransformVar"],"properties":{"adminSitesAcceptsSiteId":{"type":"boolean"},"healthReportsVersion":{"type":"boolean"},"mediaTransformVar":{"type":"boolean"}}},"planVersion":{"type":"integer","minimum":1},"signing":{"type":"object","additionalProperties":false,"required":["keyId","algorithm","signature"],"properties":{"keyId":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{2,63}$"},"algorithm":{"type":"string","enum":["Ed25519"]},"signature":{"type":"string","enum":["manifest.sig","release-statement.sig"]}}}},"$defs":{"BindingName":{"type":"string","pattern":"^[A-Z][A-Z0-9_]*(_\\*)?$"},"BindingTemplate":{"type":"object","additionalProperties":false,"required":["bindings","vars","secrets"],"properties":{"bindings":{"type":"array","items":{"type":"object","additionalProperties":false,"required":["name","type","required"],"properties":{"name":{"$ref":"#/$defs/BindingName"},"type":{"type":"string","enum":["d1","r2_bucket","assets","service","durable_object_namespace","analytics_engine","analytics_sql","ratelimit","ai_search","send_email","ai","browser"]},"required":{"type":"boolean"},"aliases":{"type":"array","items":{"$ref":"#/$defs/BindingName"}}}}},"vars":{"type":"array","uniqueItems":true,"items":{"$ref":"#/$defs/BindingName"}},"secrets":{"type":"array","uniqueItems":true,"items":{"$ref":"#/$defs/SecretName"}},"cache":{"type":"boolean"},"cron":{"type":"string","pattern":"^[0-9*/,\\- ]+$"}}},"Capability":{"type":"string","enum":["account","workers","d1","r2","analytics","domains"]},"CloudhalVersion":{"type":"string","pattern":"^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$"},"MigrationName":{"type":"string","pattern":"^[0-9]{4}_[a-z0-9_]+$"},"SchemaVersion":{"type":"integer","description":"Version of this contract. Readers reject unknown versions; migrations move documents forward one version at a time.","enum":[1]},"ScopeId":{"type":"string","pattern":"^[a-z0-9-]+(\\.[a-z0-9-]+)+$"},"SecretName":{"type":"string","enum":["BETTER_AUTH_SECRET","MCP_STATE_KEY","CLOUDHAL_PREVIEW_SECRET","CLOUDHAL_PURGE_TOKEN","CLOUDHAL_DEPLOY_TOKEN","CLOUDHAL_SETUP_TOKEN","CLOUDHAL_COMMENTS_KEY"]},"Sha256Hex":{"type":"string","pattern":"^[0-9a-f]{64}$"},"SiteId":{"type":"string","pattern":"^site_[A-Za-z0-9]{6,32}$"},"Timestamp":{"type":"string","description":"RFC 3339 / ISO 8601 instant in UTC.","format":"date-time"},"WorkerArtifact":{"type":"object","additionalProperties":false,"required":["mainModule","modules","assets"],"properties":{"mainModule":{"type":"string","pattern":"^[A-Za-z0-9._/-]+$"},"modules":{"type":"array","minItems":1,"items":{"type":"object","additionalProperties":false,"required":["path","type","bytes","sha256"],"properties":{"path":{"type":"string","pattern":"^[A-Za-z0-9._-]+(/[A-Za-z0-9._-]+)*$"},"type":{"type":"string","enum":["esm","cjs","wasm","text","data"]},"bytes":{"type":"integer","minimum":1},"sha256":{"$ref":"#/$defs/Sha256Hex"}}}},"assets":{"type":"object","additionalProperties":false,"required":["count","bytes","files","headers"],"properties":{"count":{"type":"integer","minimum":0,"maximum":20000},"bytes":{"type":"integer","minimum":0},"files":{"type":"array","maxItems":20000,"items":{"type":"object","additionalProperties":false,"required":["path","bytes","sha256","assetHash","contentType"],"properties":{"path":{"type":"string","pattern":"^/[^\\s]*$","maxLength":1024},"bytes":{"type":"integer","minimum":0,"maximum":26214400},"sha256":{"$ref":"#/$defs/Sha256Hex"},"assetHash":{"type":"string","pattern":"^[0-9a-f]{32}$"},"contentType":{"type":"string","maxLength":128}}}},"headers":{"type":"string","maxLength":100000},"config":{"type":"object","additionalProperties":false,"properties":{"not_found_handling":{"type":"string","enum":["none","404-page","single-page-application"]},"run_worker_first":{"type":"array","items":{"type":"string","pattern":"^/"}}}}}},"bindingTemplate":{"type":"string","enum":["site","admin"]}}}}};
+const schema574 = {"type":"integer","description":"Version of this contract. Readers reject unknown versions; migrations move documents forward one version at a time.","enum":[1]};
+const schema575 = {"type":"string","pattern":"^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$"};
+const schema576 = {"type":"string","description":"RFC 3339 / ISO 8601 instant in UTC.","format":"date-time"};
+const schema577 = {"type":"string","pattern":"^[0-9]{4}_[a-z0-9_]+$"};
+const schema580 = {"type":"string","pattern":"^[A-Z][A-Z0-9_]*(_\\*)?$"};
+const schema581 = {"type":"string","pattern":"^[0-9a-f]{64}$"};
+const schema592 = {"type":"string","pattern":"^[a-z0-9-]+(\\.[a-z0-9-]+)+$"};
+const schema594 = {"type":"string","enum":["account","workers","d1","r2","analytics","domains"]};
+const schema597 = {"type":"string","pattern":"^site_[A-Za-z0-9]{6,32}$"};
+const pattern305 = new RegExp("^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$", "u");
+const pattern306 = new RegExp("^[0-9a-f]{40}$", "u");
+const pattern307 = new RegExp("^[0-9]{4}_[a-z0-9_]+$", "u");
+const pattern310 = new RegExp("^[A-Z][A-Z0-9_]*(_\\*)?$", "u");
+const pattern311 = new RegExp("^[A-Z][A-Za-z0-9]*$", "u");
+const pattern312 = new RegExp("^v[1-9][0-9]*$", "u");
+const pattern313 = new RegExp("^[0-9]{4}-[0-9]{2}-[0-9]{2}$", "u");
+const pattern314 = new RegExp("^[a-z0-9_]+$", "u");
+const pattern315 = new RegExp("^[a-z][a-z0-9-]{0,63}$", "u");
+const pattern331 = new RegExp("^[a-z0-9-]+(\\.[a-z0-9-]+)+$", "u");
+const pattern333 = new RegExp("^starter/[A-Za-z0-9._-]+$", "u");
+const pattern335 = new RegExp("^site_[A-Za-z0-9]{6,32}$", "u");
+const pattern336 = new RegExp("^[a-z0-9][a-z0-9-]{2,63}$", "u");
+const schema584 = {"type":"object","additionalProperties":false,"required":["mainModule","modules","assets"],"properties":{"mainModule":{"type":"string","pattern":"^[A-Za-z0-9._/-]+$"},"modules":{"type":"array","minItems":1,"items":{"type":"object","additionalProperties":false,"required":["path","type","bytes","sha256"],"properties":{"path":{"type":"string","pattern":"^[A-Za-z0-9._-]+(/[A-Za-z0-9._-]+)*$"},"type":{"type":"string","enum":["esm","cjs","wasm","text","data"]},"bytes":{"type":"integer","minimum":1},"sha256":{"$ref":"#/$defs/Sha256Hex"}}}},"assets":{"type":"object","additionalProperties":false,"required":["count","bytes","files","headers"],"properties":{"count":{"type":"integer","minimum":0,"maximum":20000},"bytes":{"type":"integer","minimum":0},"files":{"type":"array","maxItems":20000,"items":{"type":"object","additionalProperties":false,"required":["path","bytes","sha256","assetHash","contentType"],"properties":{"path":{"type":"string","pattern":"^/[^\\s]*$","maxLength":1024},"bytes":{"type":"integer","minimum":0,"maximum":26214400},"sha256":{"$ref":"#/$defs/Sha256Hex"},"assetHash":{"type":"string","pattern":"^[0-9a-f]{32}$"},"contentType":{"type":"string","maxLength":128}}}},"headers":{"type":"string","maxLength":100000},"config":{"type":"object","additionalProperties":false,"properties":{"not_found_handling":{"type":"string","enum":["none","404-page","single-page-application"]},"run_worker_first":{"type":"array","items":{"type":"string","pattern":"^/"}}}}}},"bindingTemplate":{"type":"string","enum":["site","admin"]}}};
+const pattern320 = new RegExp("^[A-Za-z0-9._/-]+$", "u");
+const pattern321 = new RegExp("^[A-Za-z0-9._-]+(/[A-Za-z0-9._-]+)*$", "u");
+const pattern323 = new RegExp("^/[^\\s]*$", "u");
+const pattern325 = new RegExp("^[0-9a-f]{32}$", "u");
+const pattern326 = new RegExp("^/", "u");
+
+function validate246(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){
+let vErrors = null;
+let errors = 0;
+const evaluated0 = validate246.evaluated;
+if(evaluated0.dynamicProps){
+evaluated0.props = undefined;
+}
+if(evaluated0.dynamicItems){
+evaluated0.items = undefined;
+}
+if(data && typeof data == "object" && !Array.isArray(data)){
+if(data.mainModule === undefined){
+const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "mainModule"},message:"must have required property '"+"mainModule"+"'"};
+if(vErrors === null){
+vErrors = [err0];
+}
+else {
+vErrors.push(err0);
+}
+errors++;
+}
+if(data.modules === undefined){
+const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "modules"},message:"must have required property '"+"modules"+"'"};
+if(vErrors === null){
+vErrors = [err1];
+}
+else {
+vErrors.push(err1);
+}
+errors++;
+}
+if(data.assets === undefined){
+const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "assets"},message:"must have required property '"+"assets"+"'"};
+if(vErrors === null){
+vErrors = [err2];
+}
+else {
+vErrors.push(err2);
+}
+errors++;
+}
+for(const key0 in data){
+if(!((((key0 === "mainModule") || (key0 === "modules")) || (key0 === "assets")) || (key0 === "bindingTemplate"))){
+const err3 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};
+if(vErrors === null){
+vErrors = [err3];
+}
+else {
+vErrors.push(err3);
+}
+errors++;
+}
+}
+if(data.mainModule !== undefined){
+let data0 = data.mainModule;
+if(typeof data0 === "string"){
+if(!pattern320.test(data0)){
+const err4 = {instancePath:instancePath+"/mainModule",schemaPath:"#/properties/mainModule/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9._/-]+$"},message:"must match pattern \""+"^[A-Za-z0-9._/-]+$"+"\""};
+if(vErrors === null){
+vErrors = [err4];
+}
+else {
+vErrors.push(err4);
+}
+errors++;
+}
+}
+else {
+const err5 = {instancePath:instancePath+"/mainModule",schemaPath:"#/properties/mainModule/type",keyword:"type",params:{type: "string"},message:"must be string"};
+if(vErrors === null){
+vErrors = [err5];
+}
+else {
+vErrors.push(err5);
+}
+errors++;
+}
+}
+if(data.modules !== undefined){
+let data1 = data.modules;
+if(Array.isArray(data1)){
+if(data1.length < 1){
+const err6 = {instancePath:instancePath+"/modules",schemaPath:"#/properties/modules/minItems",keyword:"minItems",params:{limit: 1},message:"must NOT have fewer than 1 items"};
+if(vErrors === null){
+vErrors = [err6];
+}
+else {
+vErrors.push(err6);
+}
+errors++;
+}
+const len0 = data1.length;
+for(let i0=0; i0=", limit: 1},message:"must be >= 1"};
+if(vErrors === null){
+vErrors = [err17];
+}
+else {
+vErrors.push(err17);
+}
+errors++;
+}
+}
+}
+if(data2.sha256 !== undefined){
+let data6 = data2.sha256;
+if(typeof data6 === "string"){
+if(!pattern66.test(data6)){
+const err18 = {instancePath:instancePath+"/modules/" + i0+"/sha256",schemaPath:"#/$defs/Sha256Hex/pattern",keyword:"pattern",params:{pattern: "^[0-9a-f]{64}$"},message:"must match pattern \""+"^[0-9a-f]{64}$"+"\""};
+if(vErrors === null){
+vErrors = [err18];
+}
+else {
+vErrors.push(err18);
+}
+errors++;
+}
+}
+else {
+const err19 = {instancePath:instancePath+"/modules/" + i0+"/sha256",schemaPath:"#/$defs/Sha256Hex/type",keyword:"type",params:{type: "string"},message:"must be string"};
+if(vErrors === null){
+vErrors = [err19];
+}
+else {
+vErrors.push(err19);
+}
+errors++;
+}
+}
+}
+else {
+const err20 = {instancePath:instancePath+"/modules/" + i0,schemaPath:"#/properties/modules/items/type",keyword:"type",params:{type: "object"},message:"must be object"};
+if(vErrors === null){
+vErrors = [err20];
+}
+else {
+vErrors.push(err20);
+}
+errors++;
+}
+}
+}
+else {
+const err21 = {instancePath:instancePath+"/modules",schemaPath:"#/properties/modules/type",keyword:"type",params:{type: "array"},message:"must be array"};
+if(vErrors === null){
+vErrors = [err21];
+}
+else {
+vErrors.push(err21);
+}
+errors++;
+}
+}
+if(data.assets !== undefined){
+let data7 = data.assets;
+if(data7 && typeof data7 == "object" && !Array.isArray(data7)){
+if(data7.count === undefined){
+const err22 = {instancePath:instancePath+"/assets",schemaPath:"#/properties/assets/required",keyword:"required",params:{missingProperty: "count"},message:"must have required property '"+"count"+"'"};
+if(vErrors === null){
+vErrors = [err22];
+}
+else {
+vErrors.push(err22);
+}
+errors++;
+}
+if(data7.bytes === undefined){
+const err23 = {instancePath:instancePath+"/assets",schemaPath:"#/properties/assets/required",keyword:"required",params:{missingProperty: "bytes"},message:"must have required property '"+"bytes"+"'"};
+if(vErrors === null){
+vErrors = [err23];
+}
+else {
+vErrors.push(err23);
+}
+errors++;
+}
+if(data7.files === undefined){
+const err24 = {instancePath:instancePath+"/assets",schemaPath:"#/properties/assets/required",keyword:"required",params:{missingProperty: "files"},message:"must have required property '"+"files"+"'"};
+if(vErrors === null){
+vErrors = [err24];
+}
+else {
+vErrors.push(err24);
+}
+errors++;
+}
+if(data7.headers === undefined){
+const err25 = {instancePath:instancePath+"/assets",schemaPath:"#/properties/assets/required",keyword:"required",params:{missingProperty: "headers"},message:"must have required property '"+"headers"+"'"};
+if(vErrors === null){
+vErrors = [err25];
+}
+else {
+vErrors.push(err25);
+}
+errors++;
+}
+for(const key2 in data7){
+if(!(((((key2 === "count") || (key2 === "bytes")) || (key2 === "files")) || (key2 === "headers")) || (key2 === "config"))){
+const err26 = {instancePath:instancePath+"/assets",schemaPath:"#/properties/assets/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key2},message:"must NOT have additional properties"};
+if(vErrors === null){
+vErrors = [err26];
+}
+else {
+vErrors.push(err26);
+}
+errors++;
+}
+}
+if(data7.count !== undefined){
+let data8 = data7.count;
+if(!(((typeof data8 == "number") && (!(data8 % 1) && !isNaN(data8))) && (isFinite(data8)))){
+const err27 = {instancePath:instancePath+"/assets/count",schemaPath:"#/properties/assets/properties/count/type",keyword:"type",params:{type: "integer"},message:"must be integer"};
+if(vErrors === null){
+vErrors = [err27];
+}
+else {
+vErrors.push(err27);
+}
+errors++;
+}
+if((typeof data8 == "number") && (isFinite(data8))){
+if(data8 > 20000 || isNaN(data8)){
+const err28 = {instancePath:instancePath+"/assets/count",schemaPath:"#/properties/assets/properties/count/maximum",keyword:"maximum",params:{comparison: "<=", limit: 20000},message:"must be <= 20000"};
+if(vErrors === null){
+vErrors = [err28];
+}
+else {
+vErrors.push(err28);
+}
+errors++;
+}
+if(data8 < 0 || isNaN(data8)){
+const err29 = {instancePath:instancePath+"/assets/count",schemaPath:"#/properties/assets/properties/count/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};
+if(vErrors === null){
+vErrors = [err29];
+}
+else {
+vErrors.push(err29);
+}
+errors++;
+}
+}
+}
+if(data7.bytes !== undefined){
+let data9 = data7.bytes;
+if(!(((typeof data9 == "number") && (!(data9 % 1) && !isNaN(data9))) && (isFinite(data9)))){
+const err30 = {instancePath:instancePath+"/assets/bytes",schemaPath:"#/properties/assets/properties/bytes/type",keyword:"type",params:{type: "integer"},message:"must be integer"};
+if(vErrors === null){
+vErrors = [err30];
+}
+else {
+vErrors.push(err30);
+}
+errors++;
+}
+if((typeof data9 == "number") && (isFinite(data9))){
+if(data9 < 0 || isNaN(data9)){
+const err31 = {instancePath:instancePath+"/assets/bytes",schemaPath:"#/properties/assets/properties/bytes/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};
+if(vErrors === null){
+vErrors = [err31];
+}
+else {
+vErrors.push(err31);
+}
+errors++;
+}
+}
+}
+if(data7.files !== undefined){
+let data10 = data7.files;
+if(Array.isArray(data10)){
+if(data10.length > 20000){
+const err32 = {instancePath:instancePath+"/assets/files",schemaPath:"#/properties/assets/properties/files/maxItems",keyword:"maxItems",params:{limit: 20000},message:"must NOT have more than 20000 items"};
+if(vErrors === null){
+vErrors = [err32];
+}
+else {
+vErrors.push(err32);
+}
+errors++;
+}
+const len1 = data10.length;
+for(let i1=0; i1 1024){
+const err39 = {instancePath:instancePath+"/assets/files/" + i1+"/path",schemaPath:"#/properties/assets/properties/files/items/properties/path/maxLength",keyword:"maxLength",params:{limit: 1024},message:"must NOT have more than 1024 characters"};
+if(vErrors === null){
+vErrors = [err39];
+}
+else {
+vErrors.push(err39);
+}
+errors++;
+}
+if(!pattern323.test(data12)){
+const err40 = {instancePath:instancePath+"/assets/files/" + i1+"/path",schemaPath:"#/properties/assets/properties/files/items/properties/path/pattern",keyword:"pattern",params:{pattern: "^/[^\\s]*$"},message:"must match pattern \""+"^/[^\\s]*$"+"\""};
+if(vErrors === null){
+vErrors = [err40];
+}
+else {
+vErrors.push(err40);
+}
+errors++;
+}
+}
+else {
+const err41 = {instancePath:instancePath+"/assets/files/" + i1+"/path",schemaPath:"#/properties/assets/properties/files/items/properties/path/type",keyword:"type",params:{type: "string"},message:"must be string"};
+if(vErrors === null){
+vErrors = [err41];
+}
+else {
+vErrors.push(err41);
+}
+errors++;
+}
+}
+if(data11.bytes !== undefined){
+let data13 = data11.bytes;
+if(!(((typeof data13 == "number") && (!(data13 % 1) && !isNaN(data13))) && (isFinite(data13)))){
+const err42 = {instancePath:instancePath+"/assets/files/" + i1+"/bytes",schemaPath:"#/properties/assets/properties/files/items/properties/bytes/type",keyword:"type",params:{type: "integer"},message:"must be integer"};
+if(vErrors === null){
+vErrors = [err42];
+}
+else {
+vErrors.push(err42);
+}
+errors++;
+}
+if((typeof data13 == "number") && (isFinite(data13))){
+if(data13 > 26214400 || isNaN(data13)){
+const err43 = {instancePath:instancePath+"/assets/files/" + i1+"/bytes",schemaPath:"#/properties/assets/properties/files/items/properties/bytes/maximum",keyword:"maximum",params:{comparison: "<=", limit: 26214400},message:"must be <= 26214400"};
+if(vErrors === null){
+vErrors = [err43];
+}
+else {
+vErrors.push(err43);
+}
+errors++;
+}
+if(data13 < 0 || isNaN(data13)){
+const err44 = {instancePath:instancePath+"/assets/files/" + i1+"/bytes",schemaPath:"#/properties/assets/properties/files/items/properties/bytes/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};
+if(vErrors === null){
+vErrors = [err44];
+}
+else {
+vErrors.push(err44);
+}
+errors++;
+}
+}
+}
+if(data11.sha256 !== undefined){
+let data14 = data11.sha256;
+if(typeof data14 === "string"){
+if(!pattern66.test(data14)){
+const err45 = {instancePath:instancePath+"/assets/files/" + i1+"/sha256",schemaPath:"#/$defs/Sha256Hex/pattern",keyword:"pattern",params:{pattern: "^[0-9a-f]{64}$"},message:"must match pattern \""+"^[0-9a-f]{64}$"+"\""};
+if(vErrors === null){
+vErrors = [err45];
+}
+else {
+vErrors.push(err45);
+}
+errors++;
+}
+}
+else {
+const err46 = {instancePath:instancePath+"/assets/files/" + i1+"/sha256",schemaPath:"#/$defs/Sha256Hex/type",keyword:"type",params:{type: "string"},message:"must be string"};
+if(vErrors === null){
+vErrors = [err46];
+}
+else {
+vErrors.push(err46);
+}
+errors++;
+}
+}
+if(data11.assetHash !== undefined){
+let data15 = data11.assetHash;
+if(typeof data15 === "string"){
+if(!pattern325.test(data15)){
+const err47 = {instancePath:instancePath+"/assets/files/" + i1+"/assetHash",schemaPath:"#/properties/assets/properties/files/items/properties/assetHash/pattern",keyword:"pattern",params:{pattern: "^[0-9a-f]{32}$"},message:"must match pattern \""+"^[0-9a-f]{32}$"+"\""};
+if(vErrors === null){
+vErrors = [err47];
+}
+else {
+vErrors.push(err47);
+}
+errors++;
+}
+}
+else {
+const err48 = {instancePath:instancePath+"/assets/files/" + i1+"/assetHash",schemaPath:"#/properties/assets/properties/files/items/properties/assetHash/type",keyword:"type",params:{type: "string"},message:"must be string"};
+if(vErrors === null){
+vErrors = [err48];
+}
+else {
+vErrors.push(err48);
+}
+errors++;
+}
+}
+if(data11.contentType !== undefined){
+let data16 = data11.contentType;
+if(typeof data16 === "string"){
+if(func2(data16) > 128){
+const err49 = {instancePath:instancePath+"/assets/files/" + i1+"/contentType",schemaPath:"#/properties/assets/properties/files/items/properties/contentType/maxLength",keyword:"maxLength",params:{limit: 128},message:"must NOT have more than 128 characters"};
+if(vErrors === null){
+vErrors = [err49];
+}
+else {
+vErrors.push(err49);
+}
+errors++;
+}
+}
+else {
+const err50 = {instancePath:instancePath+"/assets/files/" + i1+"/contentType",schemaPath:"#/properties/assets/properties/files/items/properties/contentType/type",keyword:"type",params:{type: "string"},message:"must be string"};
+if(vErrors === null){
+vErrors = [err50];
+}
+else {
+vErrors.push(err50);
+}
+errors++;
+}
+}
+}
+else {
+const err51 = {instancePath:instancePath+"/assets/files/" + i1,schemaPath:"#/properties/assets/properties/files/items/type",keyword:"type",params:{type: "object"},message:"must be object"};
+if(vErrors === null){
+vErrors = [err51];
+}
+else {
+vErrors.push(err51);
+}
+errors++;
+}
+}
+}
+else {
+const err52 = {instancePath:instancePath+"/assets/files",schemaPath:"#/properties/assets/properties/files/type",keyword:"type",params:{type: "array"},message:"must be array"};
+if(vErrors === null){
+vErrors = [err52];
+}
+else {
+vErrors.push(err52);
+}
+errors++;
+}
+}
+if(data7.headers !== undefined){
+let data17 = data7.headers;
+if(typeof data17 === "string"){
+if(func2(data17) > 100000){
+const err53 = {instancePath:instancePath+"/assets/headers",schemaPath:"#/properties/assets/properties/headers/maxLength",keyword:"maxLength",params:{limit: 100000},message:"must NOT have more than 100000 characters"};
+if(vErrors === null){
+vErrors = [err53];
+}
+else {
+vErrors.push(err53);
+}
+errors++;
+}
+}
+else {
+const err54 = {instancePath:instancePath+"/assets/headers",schemaPath:"#/properties/assets/properties/headers/type",keyword:"type",params:{type: "string"},message:"must be string"};
+if(vErrors === null){
+vErrors = [err54];
+}
+else {
+vErrors.push(err54);
+}
+errors++;
+}
+}
+if(data7.config !== undefined){
+let data18 = data7.config;
+if(data18 && typeof data18 == "object" && !Array.isArray(data18)){
+for(const key4 in data18){
+if(!((key4 === "not_found_handling") || (key4 === "run_worker_first"))){
+const err55 = {instancePath:instancePath+"/assets/config",schemaPath:"#/properties/assets/properties/config/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key4},message:"must NOT have additional properties"};
+if(vErrors === null){
+vErrors = [err55];
+}
+else {
+vErrors.push(err55);
+}
+errors++;
+}
+}
+if(data18.not_found_handling !== undefined){
+let data19 = data18.not_found_handling;
+if(typeof data19 !== "string"){
+const err56 = {instancePath:instancePath+"/assets/config/not_found_handling",schemaPath:"#/properties/assets/properties/config/properties/not_found_handling/type",keyword:"type",params:{type: "string"},message:"must be string"};
+if(vErrors === null){
+vErrors = [err56];
+}
+else {
+vErrors.push(err56);
+}
+errors++;
+}
+if(!(((data19 === "none") || (data19 === "404-page")) || (data19 === "single-page-application"))){
+const err57 = {instancePath:instancePath+"/assets/config/not_found_handling",schemaPath:"#/properties/assets/properties/config/properties/not_found_handling/enum",keyword:"enum",params:{allowedValues: schema584.properties.assets.properties.config.properties.not_found_handling.enum},message:"must be equal to one of the allowed values"};
+if(vErrors === null){
+vErrors = [err57];
+}
+else {
+vErrors.push(err57);
+}
+errors++;
+}
+}
+if(data18.run_worker_first !== undefined){
+let data20 = data18.run_worker_first;
+if(Array.isArray(data20)){
+const len2 = data20.length;
+for(let i2=0; i2 1){
+outer0:
+for(;i3--;){
+for(j0 = i3; j0--;){
+if(func0(data7[i3], data7[j0])){
+const err20 = {instancePath:instancePath+"/vars",schemaPath:"#/properties/vars/uniqueItems",keyword:"uniqueItems",params:{i: i3, j: j0},message:"must NOT have duplicate items (items ## "+j0+" and "+i3+" are identical)"};
+if(vErrors === null){
+vErrors = [err20];
+}
+else {
+vErrors.push(err20);
+}
+errors++;
+break outer0;
+}
+}
+}
+}
+}
+else {
+const err21 = {instancePath:instancePath+"/vars",schemaPath:"#/properties/vars/type",keyword:"type",params:{type: "array"},message:"must be array"};
+if(vErrors === null){
+vErrors = [err21];
+}
+else {
+vErrors.push(err21);
+}
+errors++;
+}
+}
+if(data.secrets !== undefined){
+let data9 = data.secrets;
+if(Array.isArray(data9)){
+const len3 = data9.length;
+for(let i4=0; i4 1){
+outer1:
+for(;i5--;){
+for(j1 = i5; j1--;){
+if(func0(data9[i5], data9[j1])){
+const err24 = {instancePath:instancePath+"/secrets",schemaPath:"#/properties/secrets/uniqueItems",keyword:"uniqueItems",params:{i: i5, j: j1},message:"must NOT have duplicate items (items ## "+j1+" and "+i5+" are identical)"};
+if(vErrors === null){
+vErrors = [err24];
+}
+else {
+vErrors.push(err24);
+}
+errors++;
+break outer1;
+}
+}
+}
+}
+}
+else {
+const err25 = {instancePath:instancePath+"/secrets",schemaPath:"#/properties/secrets/type",keyword:"type",params:{type: "array"},message:"must be array"};
+if(vErrors === null){
+vErrors = [err25];
+}
+else {
+vErrors.push(err25);
+}
+errors++;
+}
+}
+if(data.cache !== undefined){
+if(typeof data.cache !== "boolean"){
+const err26 = {instancePath:instancePath+"/cache",schemaPath:"#/properties/cache/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};
+if(vErrors === null){
+vErrors = [err26];
+}
+else {
+vErrors.push(err26);
+}
+errors++;
+}
+}
+if(data.cron !== undefined){
+let data12 = data.cron;
+if(typeof data12 === "string"){
+if(!pattern330.test(data12)){
+const err27 = {instancePath:instancePath+"/cron",schemaPath:"#/properties/cron/pattern",keyword:"pattern",params:{pattern: "^[0-9*/,\\- ]+$"},message:"must match pattern \""+"^[0-9*/,\\- ]+$"+"\""};
+if(vErrors === null){
+vErrors = [err27];
+}
+else {
+vErrors.push(err27);
+}
+errors++;
+}
+}
+else {
+const err28 = {instancePath:instancePath+"/cron",schemaPath:"#/properties/cron/type",keyword:"type",params:{type: "string"},message:"must be string"};
+if(vErrors === null){
+vErrors = [err28];
+}
+else {
+vErrors.push(err28);
+}
+errors++;
+}
+}
+}
+else {
+const err29 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};
+if(vErrors === null){
+vErrors = [err29];
+}
+else {
+vErrors.push(err29);
+}
+errors++;
+}
+validate249.errors = vErrors;
+return errors === 0;
+}
+validate249.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};
+
+
+function validate245(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){
+/*# sourceURL="https://cloudhal.dev/schemas/v1/release-manifest.schema.json" */;
+let vErrors = null;
+let errors = 0;
+const evaluated0 = validate245.evaluated;
+if(evaluated0.dynamicProps){
+evaluated0.props = undefined;
+}
+if(evaluated0.dynamicItems){
+evaluated0.items = undefined;
+}
+if(data && typeof data == "object" && !Array.isArray(data)){
+if(data.schemaVersion === undefined){
+const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schemaVersion"},message:"must have required property '"+"schemaVersion"+"'"};
+if(vErrors === null){
+vErrors = [err0];
+}
+else {
+vErrors.push(err0);
+}
+errors++;
+}
+if(data.format === undefined){
+const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "format"},message:"must have required property '"+"format"+"'"};
+if(vErrors === null){
+vErrors = [err1];
+}
+else {
+vErrors.push(err1);
+}
+errors++;
+}
+if(data.version === undefined){
+const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "version"},message:"must have required property '"+"version"+"'"};
+if(vErrors === null){
+vErrors = [err2];
+}
+else {
+vErrors.push(err2);
+}
+errors++;
+}
+if(data.channel === undefined){
+const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "channel"},message:"must have required property '"+"channel"+"'"};
+if(vErrors === null){
+vErrors = [err3];
+}
+else {
+vErrors.push(err3);
+}
+errors++;
+}
+if(data.securityRelease === undefined){
+const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "securityRelease"},message:"must have required property '"+"securityRelease"+"'"};
+if(vErrors === null){
+vErrors = [err4];
+}
+else {
+vErrors.push(err4);
+}
+errors++;
+}
+if(data.commit === undefined){
+const err5 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "commit"},message:"must have required property '"+"commit"+"'"};
+if(vErrors === null){
+vErrors = [err5];
+}
+else {
+vErrors.push(err5);
+}
+errors++;
+}
+if(data.createdAt === undefined){
+const err6 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "createdAt"},message:"must have required property '"+"createdAt"+"'"};
+if(vErrors === null){
+vErrors = [err6];
+}
+else {
+vErrors.push(err6);
+}
+errors++;
+}
+if(data.contracts === undefined){
+const err7 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "contracts"},message:"must have required property '"+"contracts"+"'"};
+if(vErrors === null){
+vErrors = [err7];
+}
+else {
+vErrors.push(err7);
+}
+errors++;
+}
+if(data.levels === undefined){
+const err8 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "levels"},message:"must have required property '"+"levels"+"'"};
+if(vErrors === null){
+vErrors = [err8];
+}
+else {
+vErrors.push(err8);
+}
+errors++;
+}
+if(data.migrations === undefined){
+const err9 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "migrations"},message:"must have required property '"+"migrations"+"'"};
+if(vErrors === null){
+vErrors = [err9];
+}
+else {
+vErrors.push(err9);
+}
+errors++;
+}
+if(data.durableObjects === undefined){
+const err10 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "durableObjects"},message:"must have required property '"+"durableObjects"+"'"};
+if(vErrors === null){
+vErrors = [err10];
+}
+else {
+vErrors.push(err10);
+}
+errors++;
+}
+if(data.runtime === undefined){
+const err11 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "runtime"},message:"must have required property '"+"runtime"+"'"};
+if(vErrors === null){
+vErrors = [err11];
+}
+else {
+vErrors.push(err11);
+}
+errors++;
+}
+if(data.toolchain === undefined){
+const err12 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "toolchain"},message:"must have required property '"+"toolchain"+"'"};
+if(vErrors === null){
+vErrors = [err12];
+}
+else {
+vErrors.push(err12);
+}
+errors++;
+}
+if(data.theme === undefined){
+const err13 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "theme"},message:"must have required property '"+"theme"+"'"};
+if(vErrors === null){
+vErrors = [err13];
+}
+else {
+vErrors.push(err13);
+}
+errors++;
+}
+if(data.skills === undefined){
+const err14 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "skills"},message:"must have required property '"+"skills"+"'"};
+if(vErrors === null){
+vErrors = [err14];
+}
+else {
+vErrors.push(err14);
+}
+errors++;
+}
+if(data.workers === undefined){
+const err15 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "workers"},message:"must have required property '"+"workers"+"'"};
+if(vErrors === null){
+vErrors = [err15];
+}
+else {
+vErrors.push(err15);
+}
+errors++;
+}
+if(data.bindingTemplates === undefined){
+const err16 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "bindingTemplates"},message:"must have required property '"+"bindingTemplates"+"'"};
+if(vErrors === null){
+vErrors = [err16];
+}
+else {
+vErrors.push(err16);
+}
+errors++;
+}
+if(data.requiredScopes === undefined){
+const err17 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "requiredScopes"},message:"must have required property '"+"requiredScopes"+"'"};
+if(vErrors === null){
+vErrors = [err17];
+}
+else {
+vErrors.push(err17);
+}
+errors++;
+}
+if(data.optionalScopes === undefined){
+const err18 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "optionalScopes"},message:"must have required property '"+"optionalScopes"+"'"};
+if(vErrors === null){
+vErrors = [err18];
+}
+else {
+vErrors.push(err18);
+}
+errors++;
+}
+if(data.requiredCapabilities === undefined){
+const err19 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "requiredCapabilities"},message:"must have required property '"+"requiredCapabilities"+"'"};
+if(vErrors === null){
+vErrors = [err19];
+}
+else {
+vErrors.push(err19);
+}
+errors++;
+}
+if(data.optionalCapabilities === undefined){
+const err20 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "optionalCapabilities"},message:"must have required property '"+"optionalCapabilities"+"'"};
+if(vErrors === null){
+vErrors = [err20];
+}
+else {
+vErrors.push(err20);
+}
+errors++;
+}
+if(data.starter === undefined){
+const err21 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "starter"},message:"must have required property '"+"starter"+"'"};
+if(vErrors === null){
+vErrors = [err21];
+}
+else {
+vErrors.push(err21);
+}
+errors++;
+}
+if(data.features === undefined){
+const err22 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "features"},message:"must have required property '"+"features"+"'"};
+if(vErrors === null){
+vErrors = [err22];
+}
+else {
+vErrors.push(err22);
+}
+errors++;
+}
+if(data.planVersion === undefined){
+const err23 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "planVersion"},message:"must have required property '"+"planVersion"+"'"};
+if(vErrors === null){
+vErrors = [err23];
+}
+else {
+vErrors.push(err23);
+}
+errors++;
+}
+if(data.signing === undefined){
+const err24 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "signing"},message:"must have required property '"+"signing"+"'"};
+if(vErrors === null){
+vErrors = [err24];
+}
+else {
+vErrors.push(err24);
+}
+errors++;
+}
+for(const key0 in data){
+if(!(func1.call(schema573.properties, key0))){
+const err25 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};
+if(vErrors === null){
+vErrors = [err25];
+}
+else {
+vErrors.push(err25);
+}
+errors++;
+}
+}
+if(data.schemaVersion !== undefined){
+let data0 = data.schemaVersion;
+if(!(((typeof data0 == "number") && (!(data0 % 1) && !isNaN(data0))) && (isFinite(data0)))){
+const err26 = {instancePath:instancePath+"/schemaVersion",schemaPath:"#/$defs/SchemaVersion/type",keyword:"type",params:{type: "integer"},message:"must be integer"};
+if(vErrors === null){
+vErrors = [err26];
+}
+else {
+vErrors.push(err26);
+}
+errors++;
+}
+if(!(data0 === 1)){
+const err27 = {instancePath:instancePath+"/schemaVersion",schemaPath:"#/$defs/SchemaVersion/enum",keyword:"enum",params:{allowedValues: schema574.enum},message:"must be equal to one of the allowed values"};
+if(vErrors === null){
+vErrors = [err27];
+}
+else {
+vErrors.push(err27);
+}
+errors++;
+}
+}
+if(data.format !== undefined){
+let data1 = data.format;
+if(typeof data1 !== "string"){
+const err28 = {instancePath:instancePath+"/format",schemaPath:"#/properties/format/type",keyword:"type",params:{type: "string"},message:"must be string"};
+if(vErrors === null){
+vErrors = [err28];
+}
+else {
+vErrors.push(err28);
+}
+errors++;
+}
+if(!(data1 === "cloudhal.release-manifest@1")){
+const err29 = {instancePath:instancePath+"/format",schemaPath:"#/properties/format/enum",keyword:"enum",params:{allowedValues: schema573.properties.format.enum},message:"must be equal to one of the allowed values"};
+if(vErrors === null){
+vErrors = [err29];
+}
+else {
+vErrors.push(err29);
+}
+errors++;
+}
+}
+if(data.version !== undefined){
+let data2 = data.version;
+if(typeof data2 === "string"){
+if(!pattern305.test(data2)){
+const err30 = {instancePath:instancePath+"/version",schemaPath:"#/$defs/CloudhalVersion/pattern",keyword:"pattern",params:{pattern: "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$"},message:"must match pattern \""+"^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$"+"\""};
+if(vErrors === null){
+vErrors = [err30];
+}
+else {
+vErrors.push(err30);
+}
+errors++;
+}
+}
+else {
+const err31 = {instancePath:instancePath+"/version",schemaPath:"#/$defs/CloudhalVersion/type",keyword:"type",params:{type: "string"},message:"must be string"};
+if(vErrors === null){
+vErrors = [err31];
+}
+else {
+vErrors.push(err31);
+}
+errors++;
+}
+}
+if(data.channel !== undefined){
+let data3 = data.channel;
+if(typeof data3 !== "string"){
+const err32 = {instancePath:instancePath+"/channel",schemaPath:"#/properties/channel/type",keyword:"type",params:{type: "string"},message:"must be string"};
+if(vErrors === null){
+vErrors = [err32];
+}
+else {
+vErrors.push(err32);
+}
+errors++;
+}
+if(!((data3 === "stable") || (data3 === "next"))){
+const err33 = {instancePath:instancePath+"/channel",schemaPath:"#/properties/channel/enum",keyword:"enum",params:{allowedValues: schema573.properties.channel.enum},message:"must be equal to one of the allowed values"};
+if(vErrors === null){
+vErrors = [err33];
+}
+else {
+vErrors.push(err33);
+}
+errors++;
+}
+}
+if(data.securityRelease !== undefined){
+if(typeof data.securityRelease !== "boolean"){
+const err34 = {instancePath:instancePath+"/securityRelease",schemaPath:"#/properties/securityRelease/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};
+if(vErrors === null){
+vErrors = [err34];
+}
+else {
+vErrors.push(err34);
+}
+errors++;
+}
+}
+if(data.commit !== undefined){
+let data5 = data.commit;
+if(typeof data5 === "string"){
+if(!pattern306.test(data5)){
+const err35 = {instancePath:instancePath+"/commit",schemaPath:"#/properties/commit/pattern",keyword:"pattern",params:{pattern: "^[0-9a-f]{40}$"},message:"must match pattern \""+"^[0-9a-f]{40}$"+"\""};
+if(vErrors === null){
+vErrors = [err35];
+}
+else {
+vErrors.push(err35);
+}
+errors++;
+}
+}
+else {
+const err36 = {instancePath:instancePath+"/commit",schemaPath:"#/properties/commit/type",keyword:"type",params:{type: "string"},message:"must be string"};
+if(vErrors === null){
+vErrors = [err36];
+}
+else {
+vErrors.push(err36);
+}
+errors++;
+}
+}
+if(data.createdAt !== undefined){
+let data6 = data.createdAt;
+if(typeof data6 === "string"){
+if(!(formats0.validate(data6))){
+const err37 = {instancePath:instancePath+"/createdAt",schemaPath:"#/$defs/Timestamp/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""};
+if(vErrors === null){
+vErrors = [err37];
+}
+else {
+vErrors.push(err37);
+}
+errors++;
+}
+}
+else {
+const err38 = {instancePath:instancePath+"/createdAt",schemaPath:"#/$defs/Timestamp/type",keyword:"type",params:{type: "string"},message:"must be string"};
+if(vErrors === null){
+vErrors = [err38];
+}
+else {
+vErrors.push(err38);
+}
+errors++;
+}
+}
+if(data.contracts !== undefined){
+let data7 = data.contracts;
+if(data7 && typeof data7 == "object" && !Array.isArray(data7)){
+if(data7.schemaVersion === undefined){
+const err39 = {instancePath:instancePath+"/contracts",schemaPath:"#/properties/contracts/required",keyword:"required",params:{missingProperty: "schemaVersion"},message:"must have required property '"+"schemaVersion"+"'"};
+if(vErrors === null){
+vErrors = [err39];
+}
+else {
+vErrors.push(err39);
+}
+errors++;
+}
+for(const key1 in data7){
+if(!(key1 === "schemaVersion")){
+const err40 = {instancePath:instancePath+"/contracts",schemaPath:"#/properties/contracts/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key1},message:"must NOT have additional properties"};
+if(vErrors === null){
+vErrors = [err40];
+}
+else {
+vErrors.push(err40);
+}
+errors++;
+}
+}
+if(data7.schemaVersion !== undefined){
+let data8 = data7.schemaVersion;
+if(!(((typeof data8 == "number") && (!(data8 % 1) && !isNaN(data8))) && (isFinite(data8)))){
+const err41 = {instancePath:instancePath+"/contracts/schemaVersion",schemaPath:"#/properties/contracts/properties/schemaVersion/type",keyword:"type",params:{type: "integer"},message:"must be integer"};
+if(vErrors === null){
+vErrors = [err41];
+}
+else {
+vErrors.push(err41);
+}
+errors++;
+}
+if(!(data8 === 1)){
+const err42 = {instancePath:instancePath+"/contracts/schemaVersion",schemaPath:"#/properties/contracts/properties/schemaVersion/enum",keyword:"enum",params:{allowedValues: schema573.properties.contracts.properties.schemaVersion.enum},message:"must be equal to one of the allowed values"};
+if(vErrors === null){
+vErrors = [err42];
+}
+else {
+vErrors.push(err42);
+}
+errors++;
+}
+}
+}
+else {
+const err43 = {instancePath:instancePath+"/contracts",schemaPath:"#/properties/contracts/type",keyword:"type",params:{type: "object"},message:"must be object"};
+if(vErrors === null){
+vErrors = [err43];
+}
+else {
+vErrors.push(err43);
+}
+errors++;
+}
+}
+if(data.levels !== undefined){
+let data9 = data.levels;
+if(data9 && typeof data9 == "object" && !Array.isArray(data9)){
+if(data9.schedules === undefined){
+const err44 = {instancePath:instancePath+"/levels",schemaPath:"#/properties/levels/required",keyword:"required",params:{missingProperty: "schedules"},message:"must have required property '"+"schedules"+"'"};
+if(vErrors === null){
+vErrors = [err44];
+}
+else {
+vErrors.push(err44);
+}
+errors++;
+}
+if(data9.gates === undefined){
+const err45 = {instancePath:instancePath+"/levels",schemaPath:"#/properties/levels/required",keyword:"required",params:{missingProperty: "gates"},message:"must have required property '"+"gates"+"'"};
+if(vErrors === null){
+vErrors = [err45];
+}
+else {
+vErrors.push(err45);
+}
+errors++;
+}
+for(const key2 in data9){
+if(!((key2 === "schedules") || (key2 === "gates"))){
+const err46 = {instancePath:instancePath+"/levels",schemaPath:"#/properties/levels/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key2},message:"must NOT have additional properties"};
+if(vErrors === null){
+vErrors = [err46];
+}
+else {
+vErrors.push(err46);
+}
+errors++;
+}
+}
+if(data9.schedules !== undefined){
+let data10 = data9.schedules;
+if(!(((typeof data10 == "number") && (!(data10 % 1) && !isNaN(data10))) && (isFinite(data10)))){
+const err47 = {instancePath:instancePath+"/levels/schedules",schemaPath:"#/properties/levels/properties/schedules/type",keyword:"type",params:{type: "integer"},message:"must be integer"};
+if(vErrors === null){
+vErrors = [err47];
+}
+else {
+vErrors.push(err47);
+}
+errors++;
+}
+if((typeof data10 == "number") && (isFinite(data10))){
+if(data10 < 0 || isNaN(data10)){
+const err48 = {instancePath:instancePath+"/levels/schedules",schemaPath:"#/properties/levels/properties/schedules/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};
+if(vErrors === null){
+vErrors = [err48];
+}
+else {
+vErrors.push(err48);
+}
+errors++;
+}
+}
+}
+if(data9.gates !== undefined){
+let data11 = data9.gates;
+if(!(((typeof data11 == "number") && (!(data11 % 1) && !isNaN(data11))) && (isFinite(data11)))){
+const err49 = {instancePath:instancePath+"/levels/gates",schemaPath:"#/properties/levels/properties/gates/type",keyword:"type",params:{type: "integer"},message:"must be integer"};
+if(vErrors === null){
+vErrors = [err49];
+}
+else {
+vErrors.push(err49);
+}
+errors++;
+}
+if((typeof data11 == "number") && (isFinite(data11))){
+if(data11 < 0 || isNaN(data11)){
+const err50 = {instancePath:instancePath+"/levels/gates",schemaPath:"#/properties/levels/properties/gates/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};
+if(vErrors === null){
+vErrors = [err50];
+}
+else {
+vErrors.push(err50);
+}
+errors++;
+}
+}
+}
+}
+else {
+const err51 = {instancePath:instancePath+"/levels",schemaPath:"#/properties/levels/type",keyword:"type",params:{type: "object"},message:"must be object"};
+if(vErrors === null){
+vErrors = [err51];
+}
+else {
+vErrors.push(err51);
+}
+errors++;
+}
+}
+if(data.migrations !== undefined){
+let data12 = data.migrations;
+if(data12 && typeof data12 == "object" && !Array.isArray(data12)){
+if(data12.first === undefined){
+const err52 = {instancePath:instancePath+"/migrations",schemaPath:"#/properties/migrations/required",keyword:"required",params:{missingProperty: "first"},message:"must have required property '"+"first"+"'"};
+if(vErrors === null){
+vErrors = [err52];
+}
+else {
+vErrors.push(err52);
+}
+errors++;
+}
+if(data12.last === undefined){
+const err53 = {instancePath:instancePath+"/migrations",schemaPath:"#/properties/migrations/required",keyword:"required",params:{missingProperty: "last"},message:"must have required property '"+"last"+"'"};
+if(vErrors === null){
+vErrors = [err53];
+}
+else {
+vErrors.push(err53);
+}
+errors++;
+}
+if(data12.count === undefined){
+const err54 = {instancePath:instancePath+"/migrations",schemaPath:"#/properties/migrations/required",keyword:"required",params:{missingProperty: "count"},message:"must have required property '"+"count"+"'"};
+if(vErrors === null){
+vErrors = [err54];
+}
+else {
+vErrors.push(err54);
+}
+errors++;
+}
+if(data12.names === undefined){
+const err55 = {instancePath:instancePath+"/migrations",schemaPath:"#/properties/migrations/required",keyword:"required",params:{missingProperty: "names"},message:"must have required property '"+"names"+"'"};
+if(vErrors === null){
+vErrors = [err55];
+}
+else {
+vErrors.push(err55);
+}
+errors++;
+}
+if(data12.compatibleWithPrevious === undefined){
+const err56 = {instancePath:instancePath+"/migrations",schemaPath:"#/properties/migrations/required",keyword:"required",params:{missingProperty: "compatibleWithPrevious"},message:"must have required property '"+"compatibleWithPrevious"+"'"};
+if(vErrors === null){
+vErrors = [err56];
+}
+else {
+vErrors.push(err56);
+}
+errors++;
+}
+for(const key3 in data12){
+if(!(((((key3 === "first") || (key3 === "last")) || (key3 === "count")) || (key3 === "names")) || (key3 === "compatibleWithPrevious"))){
+const err57 = {instancePath:instancePath+"/migrations",schemaPath:"#/properties/migrations/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key3},message:"must NOT have additional properties"};
+if(vErrors === null){
+vErrors = [err57];
+}
+else {
+vErrors.push(err57);
+}
+errors++;
+}
+}
+if(data12.first !== undefined){
+let data13 = data12.first;
+if(typeof data13 === "string"){
+if(!pattern307.test(data13)){
+const err58 = {instancePath:instancePath+"/migrations/first",schemaPath:"#/$defs/MigrationName/pattern",keyword:"pattern",params:{pattern: "^[0-9]{4}_[a-z0-9_]+$"},message:"must match pattern \""+"^[0-9]{4}_[a-z0-9_]+$"+"\""};
+if(vErrors === null){
+vErrors = [err58];
+}
+else {
+vErrors.push(err58);
+}
+errors++;
+}
+}
+else {
+const err59 = {instancePath:instancePath+"/migrations/first",schemaPath:"#/$defs/MigrationName/type",keyword:"type",params:{type: "string"},message:"must be string"};
+if(vErrors === null){
+vErrors = [err59];
+}
+else {
+vErrors.push(err59);
+}
+errors++;
+}
+}
+if(data12.last !== undefined){
+let data14 = data12.last;
+if(typeof data14 === "string"){
+if(!pattern307.test(data14)){
+const err60 = {instancePath:instancePath+"/migrations/last",schemaPath:"#/$defs/MigrationName/pattern",keyword:"pattern",params:{pattern: "^[0-9]{4}_[a-z0-9_]+$"},message:"must match pattern \""+"^[0-9]{4}_[a-z0-9_]+$"+"\""};
+if(vErrors === null){
+vErrors = [err60];
+}
+else {
+vErrors.push(err60);
+}
+errors++;
+}
+}
+else {
+const err61 = {instancePath:instancePath+"/migrations/last",schemaPath:"#/$defs/MigrationName/type",keyword:"type",params:{type: "string"},message:"must be string"};
+if(vErrors === null){
+vErrors = [err61];
+}
+else {
+vErrors.push(err61);
+}
+errors++;
+}
+}
+if(data12.count !== undefined){
+let data15 = data12.count;
+if(!(((typeof data15 == "number") && (!(data15 % 1) && !isNaN(data15))) && (isFinite(data15)))){
+const err62 = {instancePath:instancePath+"/migrations/count",schemaPath:"#/properties/migrations/properties/count/type",keyword:"type",params:{type: "integer"},message:"must be integer"};
+if(vErrors === null){
+vErrors = [err62];
+}
+else {
+vErrors.push(err62);
+}
+errors++;
+}
+if((typeof data15 == "number") && (isFinite(data15))){
+if(data15 < 1 || isNaN(data15)){
+const err63 = {instancePath:instancePath+"/migrations/count",schemaPath:"#/properties/migrations/properties/count/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};
+if(vErrors === null){
+vErrors = [err63];
+}
+else {
+vErrors.push(err63);
+}
+errors++;
+}
+}
+}
+if(data12.names !== undefined){
+let data16 = data12.names;
+if(Array.isArray(data16)){
+if(data16.length < 1){
+const err64 = {instancePath:instancePath+"/migrations/names",schemaPath:"#/properties/migrations/properties/names/minItems",keyword:"minItems",params:{limit: 1},message:"must NOT have fewer than 1 items"};
+if(vErrors === null){
+vErrors = [err64];
+}
+else {
+vErrors.push(err64);
+}
+errors++;
+}
+const len0 = data16.length;
+for(let i0=0; i0 1){
+outer0:
+for(;i1--;){
+for(j0 = i1; j0--;){
+if(func0(data16[i1], data16[j0])){
+const err67 = {instancePath:instancePath+"/migrations/names",schemaPath:"#/properties/migrations/properties/names/uniqueItems",keyword:"uniqueItems",params:{i: i1, j: j0},message:"must NOT have duplicate items (items ## "+j0+" and "+i1+" are identical)"};
+if(vErrors === null){
+vErrors = [err67];
+}
+else {
+vErrors.push(err67);
+}
+errors++;
+break outer0;
+}
+}
+}
+}
+}
+else {
+const err68 = {instancePath:instancePath+"/migrations/names",schemaPath:"#/properties/migrations/properties/names/type",keyword:"type",params:{type: "array"},message:"must be array"};
+if(vErrors === null){
+vErrors = [err68];
+}
+else {
+vErrors.push(err68);
+}
+errors++;
+}
+}
+if(data12.compatibleWithPrevious !== undefined){
+if(typeof data12.compatibleWithPrevious !== "boolean"){
+const err69 = {instancePath:instancePath+"/migrations/compatibleWithPrevious",schemaPath:"#/properties/migrations/properties/compatibleWithPrevious/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};
+if(vErrors === null){
+vErrors = [err69];
+}
+else {
+vErrors.push(err69);
+}
+errors++;
+}
+}
+}
+else {
+const err70 = {instancePath:instancePath+"/migrations",schemaPath:"#/properties/migrations/type",keyword:"type",params:{type: "object"},message:"must be object"};
+if(vErrors === null){
+vErrors = [err70];
+}
+else {
+vErrors.push(err70);
+}
+errors++;
+}
+}
+if(data.durableObjects !== undefined){
+let data19 = data.durableObjects;
+if(data19 && typeof data19 == "object" && !Array.isArray(data19)){
+if(data19.bindings === undefined){
+const err71 = {instancePath:instancePath+"/durableObjects",schemaPath:"#/properties/durableObjects/required",keyword:"required",params:{missingProperty: "bindings"},message:"must have required property '"+"bindings"+"'"};
+if(vErrors === null){
+vErrors = [err71];
+}
+else {
+vErrors.push(err71);
+}
+errors++;
+}
+if(data19.lifecycleChange === undefined){
+const err72 = {instancePath:instancePath+"/durableObjects",schemaPath:"#/properties/durableObjects/required",keyword:"required",params:{missingProperty: "lifecycleChange"},message:"must have required property '"+"lifecycleChange"+"'"};
+if(vErrors === null){
+vErrors = [err72];
+}
+else {
+vErrors.push(err72);
+}
+errors++;
+}
+for(const key4 in data19){
+if(!((key4 === "bindings") || (key4 === "lifecycleChange"))){
+const err73 = {instancePath:instancePath+"/durableObjects",schemaPath:"#/properties/durableObjects/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key4},message:"must NOT have additional properties"};
+if(vErrors === null){
+vErrors = [err73];
+}
+else {
+vErrors.push(err73);
+}
+errors++;
+}
+}
+if(data19.bindings !== undefined){
+let data20 = data19.bindings;
+if(Array.isArray(data20)){
+const len1 = data20.length;
+for(let i2=0; i2 1){
+const indices0 = {};
+for(;i4--;){
+let item0 = data28[i4];
+if(typeof item0 !== "string"){
+continue;
+}
+if(typeof indices0[item0] == "number"){
+j1 = indices0[item0];
+const err95 = {instancePath:instancePath+"/runtime/compatibilityFlags",schemaPath:"#/properties/runtime/properties/compatibilityFlags/uniqueItems",keyword:"uniqueItems",params:{i: i4, j: j1},message:"must NOT have duplicate items (items ## "+j1+" and "+i4+" are identical)"};
+if(vErrors === null){
+vErrors = [err95];
+}
+else {
+vErrors.push(err95);
+}
+errors++;
+break;
+}
+indices0[item0] = i4;
+}
+}
+}
+else {
+const err96 = {instancePath:instancePath+"/runtime/compatibilityFlags",schemaPath:"#/properties/runtime/properties/compatibilityFlags/type",keyword:"type",params:{type: "array"},message:"must be array"};
+if(vErrors === null){
+vErrors = [err96];
+}
+else {
+vErrors.push(err96);
+}
+errors++;
+}
+}
+}
+else {
+const err97 = {instancePath:instancePath+"/runtime",schemaPath:"#/properties/runtime/type",keyword:"type",params:{type: "object"},message:"must be object"};
+if(vErrors === null){
+vErrors = [err97];
+}
+else {
+vErrors.push(err97);
+}
+errors++;
+}
+}
+if(data.toolchain !== undefined){
+let data30 = data.toolchain;
+if(data30 && typeof data30 == "object" && !Array.isArray(data30)){
+for(const key7 in data30){
+let data31 = data30[key7];
+if(typeof data31 === "string"){
+if(func2(data31) > 64){
+const err98 = {instancePath:instancePath+"/toolchain/" + key7.replace(/~/g, "~0").replace(/\//g, "~1"),schemaPath:"#/properties/toolchain/additionalProperties/maxLength",keyword:"maxLength",params:{limit: 64},message:"must NOT have more than 64 characters"};
+if(vErrors === null){
+vErrors = [err98];
+}
+else {
+vErrors.push(err98);
+}
+errors++;
+}
+}
+else {
+const err99 = {instancePath:instancePath+"/toolchain/" + key7.replace(/~/g, "~0").replace(/\//g, "~1"),schemaPath:"#/properties/toolchain/additionalProperties/type",keyword:"type",params:{type: "string"},message:"must be string"};
+if(vErrors === null){
+vErrors = [err99];
+}
+else {
+vErrors.push(err99);
+}
+errors++;
+}
+}
+}
+else {
+const err100 = {instancePath:instancePath+"/toolchain",schemaPath:"#/properties/toolchain/type",keyword:"type",params:{type: "object"},message:"must be object"};
+if(vErrors === null){
+vErrors = [err100];
+}
+else {
+vErrors.push(err100);
+}
+errors++;
+}
+}
+if(data.theme !== undefined){
+let data32 = data.theme;
+if(data32 && typeof data32 == "object" && !Array.isArray(data32)){
+if(data32.name === undefined){
+const err101 = {instancePath:instancePath+"/theme",schemaPath:"#/properties/theme/required",keyword:"required",params:{missingProperty: "name"},message:"must have required property '"+"name"+"'"};
+if(vErrors === null){
+vErrors = [err101];
+}
+else {
+vErrors.push(err101);
+}
+errors++;
+}
+if(data32.version === undefined){
+const err102 = {instancePath:instancePath+"/theme",schemaPath:"#/properties/theme/required",keyword:"required",params:{missingProperty: "version"},message:"must have required property '"+"version"+"'"};
+if(vErrors === null){
+vErrors = [err102];
+}
+else {
+vErrors.push(err102);
+}
+errors++;
+}
+if(data32.blockTypes === undefined){
+const err103 = {instancePath:instancePath+"/theme",schemaPath:"#/properties/theme/required",keyword:"required",params:{missingProperty: "blockTypes"},message:"must have required property '"+"blockTypes"+"'"};
+if(vErrors === null){
+vErrors = [err103];
+}
+else {
+vErrors.push(err103);
+}
+errors++;
+}
+if(data32.settingsSha256 === undefined){
+const err104 = {instancePath:instancePath+"/theme",schemaPath:"#/properties/theme/required",keyword:"required",params:{missingProperty: "settingsSha256"},message:"must have required property '"+"settingsSha256"+"'"};
+if(vErrors === null){
+vErrors = [err104];
+}
+else {
+vErrors.push(err104);
+}
+errors++;
+}
+if(data32.designSha256 === undefined){
+const err105 = {instancePath:instancePath+"/theme",schemaPath:"#/properties/theme/required",keyword:"required",params:{missingProperty: "designSha256"},message:"must have required property '"+"designSha256"+"'"};
+if(vErrors === null){
+vErrors = [err105];
+}
+else {
+vErrors.push(err105);
+}
+errors++;
+}
+for(const key8 in data32){
+if(!(((((key8 === "name") || (key8 === "version")) || (key8 === "blockTypes")) || (key8 === "settingsSha256")) || (key8 === "designSha256"))){
+const err106 = {instancePath:instancePath+"/theme",schemaPath:"#/properties/theme/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key8},message:"must NOT have additional properties"};
+if(vErrors === null){
+vErrors = [err106];
+}
+else {
+vErrors.push(err106);
+}
+errors++;
+}
+}
+if(data32.name !== undefined){
+let data33 = data32.name;
+if(typeof data33 === "string"){
+if(!pattern315.test(data33)){
+const err107 = {instancePath:instancePath+"/theme/name",schemaPath:"#/properties/theme/properties/name/pattern",keyword:"pattern",params:{pattern: "^[a-z][a-z0-9-]{0,63}$"},message:"must match pattern \""+"^[a-z][a-z0-9-]{0,63}$"+"\""};
+if(vErrors === null){
+vErrors = [err107];
+}
+else {
+vErrors.push(err107);
+}
+errors++;
+}
+}
+else {
+const err108 = {instancePath:instancePath+"/theme/name",schemaPath:"#/properties/theme/properties/name/type",keyword:"type",params:{type: "string"},message:"must be string"};
+if(vErrors === null){
+vErrors = [err108];
+}
+else {
+vErrors.push(err108);
+}
+errors++;
+}
+}
+if(data32.version !== undefined){
+let data34 = data32.version;
+if(typeof data34 === "string"){
+if(func2(data34) > 64){
+const err109 = {instancePath:instancePath+"/theme/version",schemaPath:"#/properties/theme/properties/version/maxLength",keyword:"maxLength",params:{limit: 64},message:"must NOT have more than 64 characters"};
+if(vErrors === null){
+vErrors = [err109];
+}
+else {
+vErrors.push(err109);
+}
+errors++;
+}
+}
+else {
+const err110 = {instancePath:instancePath+"/theme/version",schemaPath:"#/properties/theme/properties/version/type",keyword:"type",params:{type: "string"},message:"must be string"};
+if(vErrors === null){
+vErrors = [err110];
+}
+else {
+vErrors.push(err110);
+}
+errors++;
+}
+}
+if(data32.blockTypes !== undefined){
+let data35 = data32.blockTypes;
+if(Array.isArray(data35)){
+const len3 = data35.length;
+for(let i5=0; i5 1){
+const indices1 = {};
+for(;i6--;){
+let item1 = data35[i6];
+if(typeof item1 !== "string"){
+continue;
+}
+if(typeof indices1[item1] == "number"){
+j2 = indices1[item1];
+const err112 = {instancePath:instancePath+"/theme/blockTypes",schemaPath:"#/properties/theme/properties/blockTypes/uniqueItems",keyword:"uniqueItems",params:{i: i6, j: j2},message:"must NOT have duplicate items (items ## "+j2+" and "+i6+" are identical)"};
+if(vErrors === null){
+vErrors = [err112];
+}
+else {
+vErrors.push(err112);
+}
+errors++;
+break;
+}
+indices1[item1] = i6;
+}
+}
+}
+else {
+const err113 = {instancePath:instancePath+"/theme/blockTypes",schemaPath:"#/properties/theme/properties/blockTypes/type",keyword:"type",params:{type: "array"},message:"must be array"};
+if(vErrors === null){
+vErrors = [err113];
+}
+else {
+vErrors.push(err113);
+}
+errors++;
+}
+}
+if(data32.settingsSha256 !== undefined){
+let data37 = data32.settingsSha256;
+if(typeof data37 === "string"){
+if(!pattern66.test(data37)){
+const err114 = {instancePath:instancePath+"/theme/settingsSha256",schemaPath:"#/$defs/Sha256Hex/pattern",keyword:"pattern",params:{pattern: "^[0-9a-f]{64}$"},message:"must match pattern \""+"^[0-9a-f]{64}$"+"\""};
+if(vErrors === null){
+vErrors = [err114];
+}
+else {
+vErrors.push(err114);
+}
+errors++;
+}
+}
+else {
+const err115 = {instancePath:instancePath+"/theme/settingsSha256",schemaPath:"#/$defs/Sha256Hex/type",keyword:"type",params:{type: "string"},message:"must be string"};
+if(vErrors === null){
+vErrors = [err115];
+}
+else {
+vErrors.push(err115);
+}
+errors++;
+}
+}
+if(data32.designSha256 !== undefined){
+let data38 = data32.designSha256;
+if(typeof data38 === "string"){
+if(!pattern66.test(data38)){
+const err116 = {instancePath:instancePath+"/theme/designSha256",schemaPath:"#/$defs/Sha256Hex/pattern",keyword:"pattern",params:{pattern: "^[0-9a-f]{64}$"},message:"must match pattern \""+"^[0-9a-f]{64}$"+"\""};
+if(vErrors === null){
+vErrors = [err116];
+}
+else {
+vErrors.push(err116);
+}
+errors++;
+}
+}
+else {
+const err117 = {instancePath:instancePath+"/theme/designSha256",schemaPath:"#/$defs/Sha256Hex/type",keyword:"type",params:{type: "string"},message:"must be string"};
+if(vErrors === null){
+vErrors = [err117];
+}
+else {
+vErrors.push(err117);
+}
+errors++;
+}
+}
+}
+else {
+const err118 = {instancePath:instancePath+"/theme",schemaPath:"#/properties/theme/type",keyword:"type",params:{type: "object"},message:"must be object"};
+if(vErrors === null){
+vErrors = [err118];
+}
+else {
+vErrors.push(err118);
+}
+errors++;
+}
+}
+if(data.skills !== undefined){
+let data39 = data.skills;
+if(Array.isArray(data39)){
+const len4 = data39.length;
+for(let i7=0; i7 1){
+outer1:
+for(;i9--;){
+for(j3 = i9; j3--;){
+if(func0(data49[i9], data49[j3])){
+const err138 = {instancePath:instancePath+"/requiredScopes",schemaPath:"#/properties/requiredScopes/uniqueItems",keyword:"uniqueItems",params:{i: i9, j: j3},message:"must NOT have duplicate items (items ## "+j3+" and "+i9+" are identical)"};
+if(vErrors === null){
+vErrors = [err138];
+}
+else {
+vErrors.push(err138);
+}
+errors++;
+break outer1;
+}
+}
+}
+}
+}
+else {
+const err139 = {instancePath:instancePath+"/requiredScopes",schemaPath:"#/properties/requiredScopes/type",keyword:"type",params:{type: "array"},message:"must be array"};
+if(vErrors === null){
+vErrors = [err139];
+}
+else {
+vErrors.push(err139);
+}
+errors++;
+}
+}
+if(data.optionalScopes !== undefined){
+let data51 = data.optionalScopes;
+if(Array.isArray(data51)){
+const len6 = data51.length;
+for(let i10=0; i10 1){
+outer2:
+for(;i11--;){
+for(j4 = i11; j4--;){
+if(func0(data51[i11], data51[j4])){
+const err142 = {instancePath:instancePath+"/optionalScopes",schemaPath:"#/properties/optionalScopes/uniqueItems",keyword:"uniqueItems",params:{i: i11, j: j4},message:"must NOT have duplicate items (items ## "+j4+" and "+i11+" are identical)"};
+if(vErrors === null){
+vErrors = [err142];
+}
+else {
+vErrors.push(err142);
+}
+errors++;
+break outer2;
+}
+}
+}
+}
+}
+else {
+const err143 = {instancePath:instancePath+"/optionalScopes",schemaPath:"#/properties/optionalScopes/type",keyword:"type",params:{type: "array"},message:"must be array"};
+if(vErrors === null){
+vErrors = [err143];
+}
+else {
+vErrors.push(err143);
+}
+errors++;
+}
+}
+if(data.requiredCapabilities !== undefined){
+let data53 = data.requiredCapabilities;
+if(Array.isArray(data53)){
+const len7 = data53.length;
+for(let i12=0; i12 1){
+outer3:
+for(;i13--;){
+for(j5 = i13; j5--;){
+if(func0(data53[i13], data53[j5])){
+const err146 = {instancePath:instancePath+"/requiredCapabilities",schemaPath:"#/properties/requiredCapabilities/uniqueItems",keyword:"uniqueItems",params:{i: i13, j: j5},message:"must NOT have duplicate items (items ## "+j5+" and "+i13+" are identical)"};
+if(vErrors === null){
+vErrors = [err146];
+}
+else {
+vErrors.push(err146);
+}
+errors++;
+break outer3;
+}
+}
+}
+}
+}
+else {
+const err147 = {instancePath:instancePath+"/requiredCapabilities",schemaPath:"#/properties/requiredCapabilities/type",keyword:"type",params:{type: "array"},message:"must be array"};
+if(vErrors === null){
+vErrors = [err147];
+}
+else {
+vErrors.push(err147);
+}
+errors++;
+}
+}
+if(data.optionalCapabilities !== undefined){
+let data55 = data.optionalCapabilities;
+if(Array.isArray(data55)){
+const len8 = data55.length;
+for(let i14=0; i14 1){
+outer4:
+for(;i15--;){
+for(j6 = i15; j6--;){
+if(func0(data55[i15], data55[j6])){
+const err150 = {instancePath:instancePath+"/optionalCapabilities",schemaPath:"#/properties/optionalCapabilities/uniqueItems",keyword:"uniqueItems",params:{i: i15, j: j6},message:"must NOT have duplicate items (items ## "+j6+" and "+i15+" are identical)"};
+if(vErrors === null){
+vErrors = [err150];
+}
+else {
+vErrors.push(err150);
+}
+errors++;
+break outer4;
+}
+}
+}
+}
+}
+else {
+const err151 = {instancePath:instancePath+"/optionalCapabilities",schemaPath:"#/properties/optionalCapabilities/type",keyword:"type",params:{type: "array"},message:"must be array"};
+if(vErrors === null){
+vErrors = [err151];
+}
+else {
+vErrors.push(err151);
+}
+errors++;
+}
+}
+if(data.starter !== undefined){
+let data57 = data.starter;
+const _errs138 = errors;
+let valid51 = false;
+let passing0 = null;
+const _errs139 = errors;
+if(data57 && typeof data57 == "object" && !Array.isArray(data57)){
+if(data57.kind === undefined){
+const err152 = {instancePath:instancePath+"/starter",schemaPath:"#/properties/starter/oneOf/0/required",keyword:"required",params:{missingProperty: "kind"},message:"must have required property '"+"kind"+"'"};
+if(vErrors === null){
+vErrors = [err152];
+}
+else {
+vErrors.push(err152);
+}
+errors++;
+}
+for(const key12 in data57){
+if(!(key12 === "kind")){
+const err153 = {instancePath:instancePath+"/starter",schemaPath:"#/properties/starter/oneOf/0/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key12},message:"must NOT have additional properties"};
+if(vErrors === null){
+vErrors = [err153];
+}
+else {
+vErrors.push(err153);
+}
+errors++;
+}
+}
+if(data57.kind !== undefined){
+let data58 = data57.kind;
+if(typeof data58 !== "string"){
+const err154 = {instancePath:instancePath+"/starter/kind",schemaPath:"#/properties/starter/oneOf/0/properties/kind/type",keyword:"type",params:{type: "string"},message:"must be string"};
+if(vErrors === null){
+vErrors = [err154];
+}
+else {
+vErrors.push(err154);
+}
+errors++;
+}
+if(!(data58 === "admin-api")){
+const err155 = {instancePath:instancePath+"/starter/kind",schemaPath:"#/properties/starter/oneOf/0/properties/kind/enum",keyword:"enum",params:{allowedValues: schema573.properties.starter.oneOf[0].properties.kind.enum},message:"must be equal to one of the allowed values"};
+if(vErrors === null){
+vErrors = [err155];
+}
+else {
+vErrors.push(err155);
+}
+errors++;
+}
+}
+}
+else {
+const err156 = {instancePath:instancePath+"/starter",schemaPath:"#/properties/starter/oneOf/0/type",keyword:"type",params:{type: "object"},message:"must be object"};
+if(vErrors === null){
+vErrors = [err156];
+}
+else {
+vErrors.push(err156);
+}
+errors++;
+}
+var _valid0 = _errs139 === errors;
+if(_valid0){
+valid51 = true;
+passing0 = 0;
+var props0 = true;
+}
+const _errs144 = errors;
+if(data57 && typeof data57 == "object" && !Array.isArray(data57)){
+if(data57.kind === undefined){
+const err157 = {instancePath:instancePath+"/starter",schemaPath:"#/properties/starter/oneOf/1/required",keyword:"required",params:{missingProperty: "kind"},message:"must have required property '"+"kind"+"'"};
+if(vErrors === null){
+vErrors = [err157];
+}
+else {
+vErrors.push(err157);
+}
+errors++;
+}
+if(data57.path === undefined){
+const err158 = {instancePath:instancePath+"/starter",schemaPath:"#/properties/starter/oneOf/1/required",keyword:"required",params:{missingProperty: "path"},message:"must have required property '"+"path"+"'"};
+if(vErrors === null){
+vErrors = [err158];
+}
+else {
+vErrors.push(err158);
+}
+errors++;
+}
+if(data57.bytes === undefined){
+const err159 = {instancePath:instancePath+"/starter",schemaPath:"#/properties/starter/oneOf/1/required",keyword:"required",params:{missingProperty: "bytes"},message:"must have required property '"+"bytes"+"'"};
+if(vErrors === null){
+vErrors = [err159];
+}
+else {
+vErrors.push(err159);
+}
+errors++;
+}
+if(data57.sha256 === undefined){
+const err160 = {instancePath:instancePath+"/starter",schemaPath:"#/properties/starter/oneOf/1/required",keyword:"required",params:{missingProperty: "sha256"},message:"must have required property '"+"sha256"+"'"};
+if(vErrors === null){
+vErrors = [err160];
+}
+else {
+vErrors.push(err160);
+}
+errors++;
+}
+if(data57.siteId === undefined){
+const err161 = {instancePath:instancePath+"/starter",schemaPath:"#/properties/starter/oneOf/1/required",keyword:"required",params:{missingProperty: "siteId"},message:"must have required property '"+"siteId"+"'"};
+if(vErrors === null){
+vErrors = [err161];
+}
+else {
+vErrors.push(err161);
+}
+errors++;
+}
+for(const key13 in data57){
+if(!(((((key13 === "kind") || (key13 === "path")) || (key13 === "bytes")) || (key13 === "sha256")) || (key13 === "siteId"))){
+const err162 = {instancePath:instancePath+"/starter",schemaPath:"#/properties/starter/oneOf/1/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key13},message:"must NOT have additional properties"};
+if(vErrors === null){
+vErrors = [err162];
+}
+else {
+vErrors.push(err162);
+}
+errors++;
+}
+}
+if(data57.kind !== undefined){
+let data59 = data57.kind;
+if(typeof data59 !== "string"){
+const err163 = {instancePath:instancePath+"/starter/kind",schemaPath:"#/properties/starter/oneOf/1/properties/kind/type",keyword:"type",params:{type: "string"},message:"must be string"};
+if(vErrors === null){
+vErrors = [err163];
+}
+else {
+vErrors.push(err163);
+}
+errors++;
+}
+if(!(data59 === "bundle")){
+const err164 = {instancePath:instancePath+"/starter/kind",schemaPath:"#/properties/starter/oneOf/1/properties/kind/enum",keyword:"enum",params:{allowedValues: schema573.properties.starter.oneOf[1].properties.kind.enum},message:"must be equal to one of the allowed values"};
+if(vErrors === null){
+vErrors = [err164];
+}
+else {
+vErrors.push(err164);
+}
+errors++;
+}
+}
+if(data57.path !== undefined){
+let data60 = data57.path;
+if(typeof data60 === "string"){
+if(!pattern333.test(data60)){
+const err165 = {instancePath:instancePath+"/starter/path",schemaPath:"#/properties/starter/oneOf/1/properties/path/pattern",keyword:"pattern",params:{pattern: "^starter/[A-Za-z0-9._-]+$"},message:"must match pattern \""+"^starter/[A-Za-z0-9._-]+$"+"\""};
+if(vErrors === null){
+vErrors = [err165];
+}
+else {
+vErrors.push(err165);
+}
+errors++;
+}
+}
+else {
+const err166 = {instancePath:instancePath+"/starter/path",schemaPath:"#/properties/starter/oneOf/1/properties/path/type",keyword:"type",params:{type: "string"},message:"must be string"};
+if(vErrors === null){
+vErrors = [err166];
+}
+else {
+vErrors.push(err166);
+}
+errors++;
+}
+}
+if(data57.bytes !== undefined){
+let data61 = data57.bytes;
+if(!(((typeof data61 == "number") && (!(data61 % 1) && !isNaN(data61))) && (isFinite(data61)))){
+const err167 = {instancePath:instancePath+"/starter/bytes",schemaPath:"#/properties/starter/oneOf/1/properties/bytes/type",keyword:"type",params:{type: "integer"},message:"must be integer"};
+if(vErrors === null){
+vErrors = [err167];
+}
+else {
+vErrors.push(err167);
+}
+errors++;
+}
+if((typeof data61 == "number") && (isFinite(data61))){
+if(data61 < 1 || isNaN(data61)){
+const err168 = {instancePath:instancePath+"/starter/bytes",schemaPath:"#/properties/starter/oneOf/1/properties/bytes/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};
+if(vErrors === null){
+vErrors = [err168];
+}
+else {
+vErrors.push(err168);
+}
+errors++;
+}
+}
+}
+if(data57.sha256 !== undefined){
+let data62 = data57.sha256;
+if(typeof data62 === "string"){
+if(!pattern66.test(data62)){
+const err169 = {instancePath:instancePath+"/starter/sha256",schemaPath:"#/$defs/Sha256Hex/pattern",keyword:"pattern",params:{pattern: "^[0-9a-f]{64}$"},message:"must match pattern \""+"^[0-9a-f]{64}$"+"\""};
+if(vErrors === null){
+vErrors = [err169];
+}
+else {
+vErrors.push(err169);
+}
+errors++;
+}
+}
+else {
+const err170 = {instancePath:instancePath+"/starter/sha256",schemaPath:"#/$defs/Sha256Hex/type",keyword:"type",params:{type: "string"},message:"must be string"};
+if(vErrors === null){
+vErrors = [err170];
+}
+else {
+vErrors.push(err170);
+}
+errors++;
+}
+}
+if(data57.siteId !== undefined){
+let data63 = data57.siteId;
+if(typeof data63 === "string"){
+if(!pattern335.test(data63)){
+const err171 = {instancePath:instancePath+"/starter/siteId",schemaPath:"#/$defs/SiteId/pattern",keyword:"pattern",params:{pattern: "^site_[A-Za-z0-9]{6,32}$"},message:"must match pattern \""+"^site_[A-Za-z0-9]{6,32}$"+"\""};
+if(vErrors === null){
+vErrors = [err171];
+}
+else {
+vErrors.push(err171);
+}
+errors++;
+}
+}
+else {
+const err172 = {instancePath:instancePath+"/starter/siteId",schemaPath:"#/$defs/SiteId/type",keyword:"type",params:{type: "string"},message:"must be string"};
+if(vErrors === null){
+vErrors = [err172];
+}
+else {
+vErrors.push(err172);
+}
+errors++;
+}
+}
+}
+else {
+const err173 = {instancePath:instancePath+"/starter",schemaPath:"#/properties/starter/oneOf/1/type",keyword:"type",params:{type: "object"},message:"must be object"};
+if(vErrors === null){
+vErrors = [err173];
+}
+else {
+vErrors.push(err173);
+}
+errors++;
+}
+var _valid0 = _errs144 === errors;
+if(_valid0 && valid51){
+valid51 = false;
+passing0 = [passing0, 1];
+}
+else {
+if(_valid0){
+valid51 = true;
+passing0 = 1;
+if(props0 !== true){
+props0 = true;
+}
+}
+}
+if(!valid51){
+const err174 = {instancePath:instancePath+"/starter",schemaPath:"#/properties/starter/oneOf",keyword:"oneOf",params:{passingSchemas: passing0},message:"must match exactly one schema in oneOf"};
+if(vErrors === null){
+vErrors = [err174];
+}
+else {
+vErrors.push(err174);
+}
+errors++;
+}
+else {
+errors = _errs138;
+if(vErrors !== null){
+if(_errs138){
+vErrors.length = _errs138;
+}
+else {
+vErrors = null;
+}
+}
+}
+}
+if(data.features !== undefined){
+let data64 = data.features;
+if(data64 && typeof data64 == "object" && !Array.isArray(data64)){
+if(data64.adminSitesAcceptsSiteId === undefined){
+const err175 = {instancePath:instancePath+"/features",schemaPath:"#/properties/features/required",keyword:"required",params:{missingProperty: "adminSitesAcceptsSiteId"},message:"must have required property '"+"adminSitesAcceptsSiteId"+"'"};
+if(vErrors === null){
+vErrors = [err175];
+}
+else {
+vErrors.push(err175);
+}
+errors++;
+}
+if(data64.healthReportsVersion === undefined){
+const err176 = {instancePath:instancePath+"/features",schemaPath:"#/properties/features/required",keyword:"required",params:{missingProperty: "healthReportsVersion"},message:"must have required property '"+"healthReportsVersion"+"'"};
+if(vErrors === null){
+vErrors = [err176];
+}
+else {
+vErrors.push(err176);
+}
+errors++;
+}
+if(data64.mediaTransformVar === undefined){
+const err177 = {instancePath:instancePath+"/features",schemaPath:"#/properties/features/required",keyword:"required",params:{missingProperty: "mediaTransformVar"},message:"must have required property '"+"mediaTransformVar"+"'"};
+if(vErrors === null){
+vErrors = [err177];
+}
+else {
+vErrors.push(err177);
+}
+errors++;
+}
+for(const key14 in data64){
+if(!(((key14 === "adminSitesAcceptsSiteId") || (key14 === "healthReportsVersion")) || (key14 === "mediaTransformVar"))){
+if(typeof data64[key14] !== "boolean"){
+const err178 = {instancePath:instancePath+"/features/" + key14.replace(/~/g, "~0").replace(/\//g, "~1"),schemaPath:"#/properties/features/additionalProperties/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};
+if(vErrors === null){
+vErrors = [err178];
+}
+else {
+vErrors.push(err178);
+}
+errors++;
+}
+}
+}
+if(data64.adminSitesAcceptsSiteId !== undefined){
+if(typeof data64.adminSitesAcceptsSiteId !== "boolean"){
+const err179 = {instancePath:instancePath+"/features/adminSitesAcceptsSiteId",schemaPath:"#/properties/features/properties/adminSitesAcceptsSiteId/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};
+if(vErrors === null){
+vErrors = [err179];
+}
+else {
+vErrors.push(err179);
+}
+errors++;
+}
+}
+if(data64.healthReportsVersion !== undefined){
+if(typeof data64.healthReportsVersion !== "boolean"){
+const err180 = {instancePath:instancePath+"/features/healthReportsVersion",schemaPath:"#/properties/features/properties/healthReportsVersion/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};
+if(vErrors === null){
+vErrors = [err180];
+}
+else {
+vErrors.push(err180);
+}
+errors++;
+}
+}
+if(data64.mediaTransformVar !== undefined){
+if(typeof data64.mediaTransformVar !== "boolean"){
+const err181 = {instancePath:instancePath+"/features/mediaTransformVar",schemaPath:"#/properties/features/properties/mediaTransformVar/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};
+if(vErrors === null){
+vErrors = [err181];
+}
+else {
+vErrors.push(err181);
+}
+errors++;
+}
+}
+}
+else {
+const err182 = {instancePath:instancePath+"/features",schemaPath:"#/properties/features/type",keyword:"type",params:{type: "object"},message:"must be object"};
+if(vErrors === null){
+vErrors = [err182];
+}
+else {
+vErrors.push(err182);
+}
+errors++;
+}
+}
+if(data.planVersion !== undefined){
+let data69 = data.planVersion;
+if(!(((typeof data69 == "number") && (!(data69 % 1) && !isNaN(data69))) && (isFinite(data69)))){
+const err183 = {instancePath:instancePath+"/planVersion",schemaPath:"#/properties/planVersion/type",keyword:"type",params:{type: "integer"},message:"must be integer"};
+if(vErrors === null){
+vErrors = [err183];
+}
+else {
+vErrors.push(err183);
+}
+errors++;
+}
+if((typeof data69 == "number") && (isFinite(data69))){
+if(data69 < 1 || isNaN(data69)){
+const err184 = {instancePath:instancePath+"/planVersion",schemaPath:"#/properties/planVersion/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};
+if(vErrors === null){
+vErrors = [err184];
+}
+else {
+vErrors.push(err184);
+}
+errors++;
+}
+}
+}
+if(data.signing !== undefined){
+let data70 = data.signing;
+if(data70 && typeof data70 == "object" && !Array.isArray(data70)){
+if(data70.keyId === undefined){
+const err185 = {instancePath:instancePath+"/signing",schemaPath:"#/properties/signing/required",keyword:"required",params:{missingProperty: "keyId"},message:"must have required property '"+"keyId"+"'"};
+if(vErrors === null){
+vErrors = [err185];
+}
+else {
+vErrors.push(err185);
+}
+errors++;
+}
+if(data70.algorithm === undefined){
+const err186 = {instancePath:instancePath+"/signing",schemaPath:"#/properties/signing/required",keyword:"required",params:{missingProperty: "algorithm"},message:"must have required property '"+"algorithm"+"'"};
+if(vErrors === null){
+vErrors = [err186];
+}
+else {
+vErrors.push(err186);
+}
+errors++;
+}
+if(data70.signature === undefined){
+const err187 = {instancePath:instancePath+"/signing",schemaPath:"#/properties/signing/required",keyword:"required",params:{missingProperty: "signature"},message:"must have required property '"+"signature"+"'"};
+if(vErrors === null){
+vErrors = [err187];
+}
+else {
+vErrors.push(err187);
+}
+errors++;
+}
+for(const key15 in data70){
+if(!(((key15 === "keyId") || (key15 === "algorithm")) || (key15 === "signature"))){
+const err188 = {instancePath:instancePath+"/signing",schemaPath:"#/properties/signing/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key15},message:"must NOT have additional properties"};
+if(vErrors === null){
+vErrors = [err188];
+}
+else {
+vErrors.push(err188);
+}
+errors++;
+}
+}
+if(data70.keyId !== undefined){
+let data71 = data70.keyId;
+if(typeof data71 === "string"){
+if(!pattern336.test(data71)){
+const err189 = {instancePath:instancePath+"/signing/keyId",schemaPath:"#/properties/signing/properties/keyId/pattern",keyword:"pattern",params:{pattern: "^[a-z0-9][a-z0-9-]{2,63}$"},message:"must match pattern \""+"^[a-z0-9][a-z0-9-]{2,63}$"+"\""};
+if(vErrors === null){
+vErrors = [err189];
+}
+else {
+vErrors.push(err189);
+}
+errors++;
+}
+}
+else {
+const err190 = {instancePath:instancePath+"/signing/keyId",schemaPath:"#/properties/signing/properties/keyId/type",keyword:"type",params:{type: "string"},message:"must be string"};
+if(vErrors === null){
+vErrors = [err190];
+}
+else {
+vErrors.push(err190);
+}
+errors++;
+}
+}
+if(data70.algorithm !== undefined){
+let data72 = data70.algorithm;
+if(typeof data72 !== "string"){
+const err191 = {instancePath:instancePath+"/signing/algorithm",schemaPath:"#/properties/signing/properties/algorithm/type",keyword:"type",params:{type: "string"},message:"must be string"};
+if(vErrors === null){
+vErrors = [err191];
+}
+else {
+vErrors.push(err191);
+}
+errors++;
+}
+if(!(data72 === "Ed25519")){
+const err192 = {instancePath:instancePath+"/signing/algorithm",schemaPath:"#/properties/signing/properties/algorithm/enum",keyword:"enum",params:{allowedValues: schema573.properties.signing.properties.algorithm.enum},message:"must be equal to one of the allowed values"};
+if(vErrors === null){
+vErrors = [err192];
+}
+else {
+vErrors.push(err192);
+}
+errors++;
+}
+}
+if(data70.signature !== undefined){
+let data73 = data70.signature;
+if(typeof data73 !== "string"){
+const err193 = {instancePath:instancePath+"/signing/signature",schemaPath:"#/properties/signing/properties/signature/type",keyword:"type",params:{type: "string"},message:"must be string"};
+if(vErrors === null){
+vErrors = [err193];
+}
+else {
+vErrors.push(err193);
+}
+errors++;
+}
+if(!((data73 === "manifest.sig") || (data73 === "release-statement.sig"))){
+const err194 = {instancePath:instancePath+"/signing/signature",schemaPath:"#/properties/signing/properties/signature/enum",keyword:"enum",params:{allowedValues: schema573.properties.signing.properties.signature.enum},message:"must be equal to one of the allowed values"};
+if(vErrors === null){
+vErrors = [err194];
+}
+else {
+vErrors.push(err194);
+}
+errors++;
+}
+}
+}
+else {
+const err195 = {instancePath:instancePath+"/signing",schemaPath:"#/properties/signing/type",keyword:"type",params:{type: "object"},message:"must be object"};
+if(vErrors === null){
+vErrors = [err195];
+}
+else {
+vErrors.push(err195);
+}
+errors++;
+}
+}
+}
+else {
+const err196 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};
+if(vErrors === null){
+vErrors = [err196];
+}
+else {
+vErrors.push(err196);
+}
+errors++;
+}
+validate245.errors = vErrors;
+return errors === 0;
+}
+validate245.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};
+
+export const validateReleaseStatement = validate252;
+const schema598 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://cloudhal.dev/schemas/v1/release-statement.schema.json","title":"ReleaseStatement","description":"cloudhal.release-statement@1 (RFC-0046): the small canonical document a Cloudhal release signs, published as the release asset release-statement.json next to release-statement.sig (a raw 64-byte Ed25519 signature over the exact bytes, at most 4096 of them). It names the version, channel, commit and key id and pins the SHA-256 and size of manifest.json and of the tarball, so a remote deployer verifies one small file and then checks every other asset by digest. Canonical bytes: keys sorted recursively, 2-space indent, a trailing newline; a consumer verifies the bytes it downloaded and never re-serialises them. Portable on purpose: it references common only for SchemaVersion and Timestamp.","type":"object","additionalProperties":false,"required":["schemaVersion","format","version","channel","securityRelease","commit","createdAt","signing","manifest","tarball"],"properties":{"schemaVersion":{"$ref":"#/$defs/SchemaVersion"},"format":{"type":"string","enum":["cloudhal.release-statement@1"]},"version":{"$ref":"#/$defs/CloudhalVersion"},"channel":{"type":"string","enum":["stable","next"]},"securityRelease":{"type":"boolean"},"commit":{"type":"string","pattern":"^[0-9a-f]{40}$"},"createdAt":{"$ref":"#/$defs/Timestamp"},"signing":{"type":"object","additionalProperties":false,"required":["keyId","algorithm","signature"],"properties":{"keyId":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{2,63}$"},"algorithm":{"type":"string","enum":["Ed25519"]},"signature":{"type":"string","enum":["release-statement.sig"]}}},"manifest":{"$ref":"#/$defs/StatementAsset"},"tarball":{"$ref":"#/$defs/StatementAsset"}},"$defs":{"CloudhalVersion":{"type":"string","pattern":"^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$"},"SchemaVersion":{"type":"integer","description":"Version of this contract. Readers reject unknown versions; migrations move documents forward one version at a time.","enum":[1]},"Sha256Hex":{"type":"string","pattern":"^[0-9a-f]{64}$"},"StatementAsset":{"type":"object","additionalProperties":false,"required":["name","sha256","bytes"],"properties":{"name":{"type":"string","pattern":"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"},"sha256":{"$ref":"#/$defs/Sha256Hex"},"bytes":{"type":"integer","minimum":1,"maximum":104857600}}},"Timestamp":{"type":"string","description":"RFC 3339 / ISO 8601 instant in UTC.","format":"date-time"}}};
+const schema599 = {"type":"integer","description":"Version of this contract. Readers reject unknown versions; migrations move documents forward one version at a time.","enum":[1]};
+const schema600 = {"type":"string","pattern":"^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$"};
+const schema601 = {"type":"string","description":"RFC 3339 / ISO 8601 instant in UTC.","format":"date-time"};
+const schema602 = {"type":"object","additionalProperties":false,"required":["name","sha256","bytes"],"properties":{"name":{"type":"string","pattern":"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"},"sha256":{"$ref":"#/$defs/Sha256Hex"},"bytes":{"type":"integer","minimum":1,"maximum":104857600}}};
+const schema603 = {"type":"string","pattern":"^[0-9a-f]{64}$"};
+const pattern340 = new RegExp("^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$", "u");
+
+function validate253(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){
+let vErrors = null;
+let errors = 0;
+const evaluated0 = validate253.evaluated;
+if(evaluated0.dynamicProps){
+evaluated0.props = undefined;
+}
+if(evaluated0.dynamicItems){
+evaluated0.items = undefined;
+}
+if(data && typeof data == "object" && !Array.isArray(data)){
+if(data.name === undefined){
+const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "name"},message:"must have required property '"+"name"+"'"};
+if(vErrors === null){
+vErrors = [err0];
+}
+else {
+vErrors.push(err0);
+}
+errors++;
+}
+if(data.sha256 === undefined){
+const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "sha256"},message:"must have required property '"+"sha256"+"'"};
+if(vErrors === null){
+vErrors = [err1];
+}
+else {
+vErrors.push(err1);
+}
+errors++;
+}
+if(data.bytes === undefined){
+const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "bytes"},message:"must have required property '"+"bytes"+"'"};
+if(vErrors === null){
+vErrors = [err2];
+}
+else {
+vErrors.push(err2);
+}
+errors++;
+}
+for(const key0 in data){
+if(!(((key0 === "name") || (key0 === "sha256")) || (key0 === "bytes"))){
+const err3 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};
+if(vErrors === null){
+vErrors = [err3];
+}
+else {
+vErrors.push(err3);
+}
+errors++;
+}
+}
+if(data.name !== undefined){
+let data0 = data.name;
+if(typeof data0 === "string"){
+if(!pattern340.test(data0)){
+const err4 = {instancePath:instancePath+"/name",schemaPath:"#/properties/name/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"},message:"must match pattern \""+"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"+"\""};
+if(vErrors === null){
+vErrors = [err4];
+}
+else {
+vErrors.push(err4);
+}
+errors++;
+}
+}
+else {
+const err5 = {instancePath:instancePath+"/name",schemaPath:"#/properties/name/type",keyword:"type",params:{type: "string"},message:"must be string"};
+if(vErrors === null){
+vErrors = [err5];
+}
+else {
+vErrors.push(err5);
+}
+errors++;
+}
+}
+if(data.sha256 !== undefined){
+let data1 = data.sha256;
+if(typeof data1 === "string"){
+if(!pattern66.test(data1)){
+const err6 = {instancePath:instancePath+"/sha256",schemaPath:"#/$defs/Sha256Hex/pattern",keyword:"pattern",params:{pattern: "^[0-9a-f]{64}$"},message:"must match pattern \""+"^[0-9a-f]{64}$"+"\""};
+if(vErrors === null){
+vErrors = [err6];
+}
+else {
+vErrors.push(err6);
+}
+errors++;
+}
+}
+else {
+const err7 = {instancePath:instancePath+"/sha256",schemaPath:"#/$defs/Sha256Hex/type",keyword:"type",params:{type: "string"},message:"must be string"};
+if(vErrors === null){
+vErrors = [err7];
+}
+else {
+vErrors.push(err7);
+}
+errors++;
+}
+}
+if(data.bytes !== undefined){
+let data2 = data.bytes;
+if(!(((typeof data2 == "number") && (!(data2 % 1) && !isNaN(data2))) && (isFinite(data2)))){
+const err8 = {instancePath:instancePath+"/bytes",schemaPath:"#/properties/bytes/type",keyword:"type",params:{type: "integer"},message:"must be integer"};
+if(vErrors === null){
+vErrors = [err8];
+}
+else {
+vErrors.push(err8);
+}
+errors++;
+}
+if((typeof data2 == "number") && (isFinite(data2))){
+if(data2 > 104857600 || isNaN(data2)){
+const err9 = {instancePath:instancePath+"/bytes",schemaPath:"#/properties/bytes/maximum",keyword:"maximum",params:{comparison: "<=", limit: 104857600},message:"must be <= 104857600"};
+if(vErrors === null){
+vErrors = [err9];
+}
+else {
+vErrors.push(err9);
+}
+errors++;
+}
+if(data2 < 1 || isNaN(data2)){
+const err10 = {instancePath:instancePath+"/bytes",schemaPath:"#/properties/bytes/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};
+if(vErrors === null){
+vErrors = [err10];
+}
+else {
+vErrors.push(err10);
+}
+errors++;
+}
+}
+}
+}
+else {
+const err11 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};
+if(vErrors === null){
+vErrors = [err11];
+}
+else {
+vErrors.push(err11);
+}
+errors++;
+}
+validate253.errors = vErrors;
+return errors === 0;
+}
+validate253.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};
+
+
+function validate252(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){
+/*# sourceURL="https://cloudhal.dev/schemas/v1/release-statement.schema.json" */;
+let vErrors = null;
+let errors = 0;
+const evaluated0 = validate252.evaluated;
+if(evaluated0.dynamicProps){
+evaluated0.props = undefined;
+}
+if(evaluated0.dynamicItems){
+evaluated0.items = undefined;
+}
+if(data && typeof data == "object" && !Array.isArray(data)){
+if(data.schemaVersion === undefined){
+const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "schemaVersion"},message:"must have required property '"+"schemaVersion"+"'"};
+if(vErrors === null){
+vErrors = [err0];
+}
+else {
+vErrors.push(err0);
+}
+errors++;
+}
+if(data.format === undefined){
+const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "format"},message:"must have required property '"+"format"+"'"};
+if(vErrors === null){
+vErrors = [err1];
+}
+else {
+vErrors.push(err1);
+}
+errors++;
+}
+if(data.version === undefined){
+const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "version"},message:"must have required property '"+"version"+"'"};
+if(vErrors === null){
+vErrors = [err2];
+}
+else {
+vErrors.push(err2);
+}
+errors++;
+}
+if(data.channel === undefined){
+const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "channel"},message:"must have required property '"+"channel"+"'"};
+if(vErrors === null){
+vErrors = [err3];
+}
+else {
+vErrors.push(err3);
+}
+errors++;
+}
+if(data.securityRelease === undefined){
+const err4 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "securityRelease"},message:"must have required property '"+"securityRelease"+"'"};
+if(vErrors === null){
+vErrors = [err4];
+}
+else {
+vErrors.push(err4);
+}
+errors++;
+}
+if(data.commit === undefined){
+const err5 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "commit"},message:"must have required property '"+"commit"+"'"};
+if(vErrors === null){
+vErrors = [err5];
+}
+else {
+vErrors.push(err5);
+}
+errors++;
+}
+if(data.createdAt === undefined){
+const err6 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "createdAt"},message:"must have required property '"+"createdAt"+"'"};
+if(vErrors === null){
+vErrors = [err6];
+}
+else {
+vErrors.push(err6);
+}
+errors++;
+}
+if(data.signing === undefined){
+const err7 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "signing"},message:"must have required property '"+"signing"+"'"};
+if(vErrors === null){
+vErrors = [err7];
+}
+else {
+vErrors.push(err7);
+}
+errors++;
+}
+if(data.manifest === undefined){
+const err8 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "manifest"},message:"must have required property '"+"manifest"+"'"};
+if(vErrors === null){
+vErrors = [err8];
+}
+else {
+vErrors.push(err8);
+}
+errors++;
+}
+if(data.tarball === undefined){
+const err9 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "tarball"},message:"must have required property '"+"tarball"+"'"};
+if(vErrors === null){
+vErrors = [err9];
+}
+else {
+vErrors.push(err9);
+}
+errors++;
+}
+for(const key0 in data){
+if(!(func1.call(schema598.properties, key0))){
+const err10 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};
+if(vErrors === null){
+vErrors = [err10];
+}
+else {
+vErrors.push(err10);
+}
+errors++;
+}
+}
+if(data.schemaVersion !== undefined){
+let data0 = data.schemaVersion;
+if(!(((typeof data0 == "number") && (!(data0 % 1) && !isNaN(data0))) && (isFinite(data0)))){
+const err11 = {instancePath:instancePath+"/schemaVersion",schemaPath:"#/$defs/SchemaVersion/type",keyword:"type",params:{type: "integer"},message:"must be integer"};
+if(vErrors === null){
+vErrors = [err11];
+}
+else {
+vErrors.push(err11);
+}
+errors++;
+}
+if(!(data0 === 1)){
+const err12 = {instancePath:instancePath+"/schemaVersion",schemaPath:"#/$defs/SchemaVersion/enum",keyword:"enum",params:{allowedValues: schema599.enum},message:"must be equal to one of the allowed values"};
+if(vErrors === null){
+vErrors = [err12];
+}
+else {
+vErrors.push(err12);
+}
+errors++;
+}
+}
+if(data.format !== undefined){
+let data1 = data.format;
+if(typeof data1 !== "string"){
+const err13 = {instancePath:instancePath+"/format",schemaPath:"#/properties/format/type",keyword:"type",params:{type: "string"},message:"must be string"};
+if(vErrors === null){
+vErrors = [err13];
+}
+else {
+vErrors.push(err13);
+}
+errors++;
+}
+if(!(data1 === "cloudhal.release-statement@1")){
+const err14 = {instancePath:instancePath+"/format",schemaPath:"#/properties/format/enum",keyword:"enum",params:{allowedValues: schema598.properties.format.enum},message:"must be equal to one of the allowed values"};
+if(vErrors === null){
+vErrors = [err14];
+}
+else {
+vErrors.push(err14);
+}
+errors++;
+}
+}
+if(data.version !== undefined){
+let data2 = data.version;
+if(typeof data2 === "string"){
+if(!pattern305.test(data2)){
+const err15 = {instancePath:instancePath+"/version",schemaPath:"#/$defs/CloudhalVersion/pattern",keyword:"pattern",params:{pattern: "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$"},message:"must match pattern \""+"^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$"+"\""};
+if(vErrors === null){
+vErrors = [err15];
+}
+else {
+vErrors.push(err15);
+}
+errors++;
+}
+}
+else {
+const err16 = {instancePath:instancePath+"/version",schemaPath:"#/$defs/CloudhalVersion/type",keyword:"type",params:{type: "string"},message:"must be string"};
+if(vErrors === null){
+vErrors = [err16];
+}
+else {
+vErrors.push(err16);
+}
+errors++;
+}
+}
+if(data.channel !== undefined){
+let data3 = data.channel;
+if(typeof data3 !== "string"){
+const err17 = {instancePath:instancePath+"/channel",schemaPath:"#/properties/channel/type",keyword:"type",params:{type: "string"},message:"must be string"};
+if(vErrors === null){
+vErrors = [err17];
+}
+else {
+vErrors.push(err17);
+}
+errors++;
+}
+if(!((data3 === "stable") || (data3 === "next"))){
+const err18 = {instancePath:instancePath+"/channel",schemaPath:"#/properties/channel/enum",keyword:"enum",params:{allowedValues: schema598.properties.channel.enum},message:"must be equal to one of the allowed values"};
+if(vErrors === null){
+vErrors = [err18];
+}
+else {
+vErrors.push(err18);
+}
+errors++;
+}
+}
+if(data.securityRelease !== undefined){
+if(typeof data.securityRelease !== "boolean"){
+const err19 = {instancePath:instancePath+"/securityRelease",schemaPath:"#/properties/securityRelease/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};
+if(vErrors === null){
+vErrors = [err19];
+}
+else {
+vErrors.push(err19);
+}
+errors++;
+}
+}
+if(data.commit !== undefined){
+let data5 = data.commit;
+if(typeof data5 === "string"){
+if(!pattern306.test(data5)){
+const err20 = {instancePath:instancePath+"/commit",schemaPath:"#/properties/commit/pattern",keyword:"pattern",params:{pattern: "^[0-9a-f]{40}$"},message:"must match pattern \""+"^[0-9a-f]{40}$"+"\""};
+if(vErrors === null){
+vErrors = [err20];
+}
+else {
+vErrors.push(err20);
+}
+errors++;
+}
+}
+else {
+const err21 = {instancePath:instancePath+"/commit",schemaPath:"#/properties/commit/type",keyword:"type",params:{type: "string"},message:"must be string"};
+if(vErrors === null){
+vErrors = [err21];
+}
+else {
+vErrors.push(err21);
+}
+errors++;
+}
+}
+if(data.createdAt !== undefined){
+let data6 = data.createdAt;
+if(typeof data6 === "string"){
+if(!(formats0.validate(data6))){
+const err22 = {instancePath:instancePath+"/createdAt",schemaPath:"#/$defs/Timestamp/format",keyword:"format",params:{format: "date-time"},message:"must match format \""+"date-time"+"\""};
+if(vErrors === null){
+vErrors = [err22];
+}
+else {
+vErrors.push(err22);
+}
+errors++;
+}
+}
+else {
+const err23 = {instancePath:instancePath+"/createdAt",schemaPath:"#/$defs/Timestamp/type",keyword:"type",params:{type: "string"},message:"must be string"};
+if(vErrors === null){
+vErrors = [err23];
+}
+else {
+vErrors.push(err23);
+}
+errors++;
+}
+}
+if(data.signing !== undefined){
+let data7 = data.signing;
+if(data7 && typeof data7 == "object" && !Array.isArray(data7)){
+if(data7.keyId === undefined){
+const err24 = {instancePath:instancePath+"/signing",schemaPath:"#/properties/signing/required",keyword:"required",params:{missingProperty: "keyId"},message:"must have required property '"+"keyId"+"'"};
+if(vErrors === null){
+vErrors = [err24];
+}
+else {
+vErrors.push(err24);
+}
+errors++;
+}
+if(data7.algorithm === undefined){
+const err25 = {instancePath:instancePath+"/signing",schemaPath:"#/properties/signing/required",keyword:"required",params:{missingProperty: "algorithm"},message:"must have required property '"+"algorithm"+"'"};
+if(vErrors === null){
+vErrors = [err25];
+}
+else {
+vErrors.push(err25);
+}
+errors++;
+}
+if(data7.signature === undefined){
+const err26 = {instancePath:instancePath+"/signing",schemaPath:"#/properties/signing/required",keyword:"required",params:{missingProperty: "signature"},message:"must have required property '"+"signature"+"'"};
+if(vErrors === null){
+vErrors = [err26];
+}
+else {
+vErrors.push(err26);
+}
+errors++;
+}
+for(const key1 in data7){
+if(!(((key1 === "keyId") || (key1 === "algorithm")) || (key1 === "signature"))){
+const err27 = {instancePath:instancePath+"/signing",schemaPath:"#/properties/signing/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key1},message:"must NOT have additional properties"};
+if(vErrors === null){
+vErrors = [err27];
+}
+else {
+vErrors.push(err27);
+}
+errors++;
+}
+}
+if(data7.keyId !== undefined){
+let data8 = data7.keyId;
+if(typeof data8 === "string"){
+if(!pattern336.test(data8)){
+const err28 = {instancePath:instancePath+"/signing/keyId",schemaPath:"#/properties/signing/properties/keyId/pattern",keyword:"pattern",params:{pattern: "^[a-z0-9][a-z0-9-]{2,63}$"},message:"must match pattern \""+"^[a-z0-9][a-z0-9-]{2,63}$"+"\""};
+if(vErrors === null){
+vErrors = [err28];
+}
+else {
+vErrors.push(err28);
+}
+errors++;
+}
+}
+else {
+const err29 = {instancePath:instancePath+"/signing/keyId",schemaPath:"#/properties/signing/properties/keyId/type",keyword:"type",params:{type: "string"},message:"must be string"};
+if(vErrors === null){
+vErrors = [err29];
+}
+else {
+vErrors.push(err29);
+}
+errors++;
+}
+}
+if(data7.algorithm !== undefined){
+let data9 = data7.algorithm;
+if(typeof data9 !== "string"){
+const err30 = {instancePath:instancePath+"/signing/algorithm",schemaPath:"#/properties/signing/properties/algorithm/type",keyword:"type",params:{type: "string"},message:"must be string"};
+if(vErrors === null){
+vErrors = [err30];
+}
+else {
+vErrors.push(err30);
+}
+errors++;
+}
+if(!(data9 === "Ed25519")){
+const err31 = {instancePath:instancePath+"/signing/algorithm",schemaPath:"#/properties/signing/properties/algorithm/enum",keyword:"enum",params:{allowedValues: schema598.properties.signing.properties.algorithm.enum},message:"must be equal to one of the allowed values"};
+if(vErrors === null){
+vErrors = [err31];
+}
+else {
+vErrors.push(err31);
+}
+errors++;
+}
+}
+if(data7.signature !== undefined){
+let data10 = data7.signature;
+if(typeof data10 !== "string"){
+const err32 = {instancePath:instancePath+"/signing/signature",schemaPath:"#/properties/signing/properties/signature/type",keyword:"type",params:{type: "string"},message:"must be string"};
+if(vErrors === null){
+vErrors = [err32];
+}
+else {
+vErrors.push(err32);
+}
+errors++;
+}
+if(!(data10 === "release-statement.sig")){
+const err33 = {instancePath:instancePath+"/signing/signature",schemaPath:"#/properties/signing/properties/signature/enum",keyword:"enum",params:{allowedValues: schema598.properties.signing.properties.signature.enum},message:"must be equal to one of the allowed values"};
+if(vErrors === null){
+vErrors = [err33];
+}
+else {
+vErrors.push(err33);
+}
+errors++;
+}
+}
+}
+else {
+const err34 = {instancePath:instancePath+"/signing",schemaPath:"#/properties/signing/type",keyword:"type",params:{type: "object"},message:"must be object"};
+if(vErrors === null){
+vErrors = [err34];
+}
+else {
+vErrors.push(err34);
+}
+errors++;
+}
+}
+if(data.manifest !== undefined){
+if(!(validate253(data.manifest, {instancePath:instancePath+"/manifest",parentData:data,parentDataProperty:"manifest",rootData,dynamicAnchors}))){
+vErrors = vErrors === null ? validate253.errors : vErrors.concat(validate253.errors);
+errors = vErrors.length;
+}
+}
+if(data.tarball !== undefined){
+if(!(validate253(data.tarball, {instancePath:instancePath+"/tarball",parentData:data,parentDataProperty:"tarball",rootData,dynamicAnchors}))){
+vErrors = vErrors === null ? validate253.errors : vErrors.concat(validate253.errors);
+errors = vErrors.length;
+}
+}
+}
+else {
+const err35 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};
+if(vErrors === null){
+vErrors = [err35];
+}
+else {
+vErrors.push(err35);
+}
+errors++;
+}
+validate252.errors = vErrors;
+return errors === 0;
+}
+validate252.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};
+
diff --git a/packages/core-contracts/src/validate.ts b/packages/core-contracts/src/validate.ts
index b2edd457..b51e4e56 100644
--- a/packages/core-contracts/src/validate.ts
+++ b/packages/core-contracts/src/validate.ts
@@ -14,6 +14,8 @@ import type {
   Project,
   Redirect,
   Release,
+  ReleaseManifest,
+  ReleaseStatement,
   ScheduleRun,
   Section,
   Site,
@@ -46,6 +48,8 @@ export interface ContractTypes {
   automation: Automation;
   "schedule-run": ScheduleRun;
   glossary: Glossary;
+  "release-manifest": ReleaseManifest;
+  "release-statement": ReleaseStatement;
 }
 
 const validators: { [K in RootSchemaName]: v.StandaloneValidator } = {
@@ -69,6 +73,8 @@ const validators: { [K in RootSchemaName]: v.StandaloneValidator | tr -d '\n' | sha256sum` for these ASCII schemas.
+ */
+import { createHash, createPublicKey, generateKeyPairSync, sign, verify } from "node:crypto";
+import { readdir, readFile } from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it } from "vitest";
+import { INDEX_EXPORTS } from "../scripts/schema-list.ts";
+import { isValid } from "../src/validate.ts";
+
+/** Shared with every portable copy of the two roots; update all of them in the same change. */
+export const RELEASE_SCHEMA_FINGERPRINTS = {
+  "release-manifest": "620fa5848e9bd82f885cd72f7fba65529050bc23b3ccf6ec1ef1860dd2aa60aa",
+  "release-statement": "462eab7d3ca77f0cd2107d533c8f3e8ac201db65e8162cac6ff2b7ab96a38e59",
+} as const;
+
+type Json = null | boolean | number | string | Json[] | { [k: string]: Json };
+type JsonObject = { [k: string]: Json };
+
+export function canonicalJson(value: Json): string {
+  if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
+  if (value !== null && typeof value === "object") {
+    const keys = Object.keys(value).sort();
+    return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k] as Json)}`).join(",")}}`;
+  }
+  return JSON.stringify(value);
+}
+
+export function schemaFingerprint(schema: JsonObject): string {
+  const { $id: _id, description: _description, ...rest } = schema;
+  return createHash("sha256").update(canonicalJson(rest), "utf8").digest("hex");
+}
+
+/** RFC-0046 §1 file bytes: keys sorted recursively, 2-space indent, trailing newline. */
+function sortedDeep(value: Json): Json {
+  if (Array.isArray(value)) return value.map(sortedDeep);
+  if (value !== null && typeof value === "object") {
+    const out: JsonObject = {};
+    for (const k of Object.keys(value).sort()) out[k] = sortedDeep(value[k] as Json);
+    return out;
+  }
+  return value;
+}
+const fileBytes = (doc: Json) => Buffer.from(`${JSON.stringify(sortedDeep(doc), null, 2)}\n`);
+
+const here = path.dirname(fileURLToPath(import.meta.url));
+const PKG = path.resolve(here, "..");
+const REPO = path.resolve(PKG, "../..");
+const readJson = async (file: string) => JSON.parse(await readFile(file, "utf8")) as JsonObject;
+const loadSchema = (name: string) => readJson(path.join(PKG, `schemas/${name}.schema.json`));
+async function fixtures(kind: "valid" | "invalid", name: string): Promise<[string, JsonObject][]> {
+  const dir = path.join(PKG, "fixtures", kind, name);
+  const files = (await readdir(dir)).filter((f) => f.endsWith(".json")).sort();
+  return Promise.all(files.map(async (f) => [f, await readJson(path.join(dir, f))] as const));
+}
+
+const manifest = await loadSchema("release-manifest");
+const statement = await loadSchema("release-statement");
+const ROOTS = Object.keys(
+  RELEASE_SCHEMA_FINGERPRINTS,
+) as (keyof typeof RELEASE_SCHEMA_FINGERPRINTS)[];
+
+describe("release schema fingerprints (portable copies stay identical)", () => {
+  it.each(ROOTS)("%s matches the shared constant", async (name) => {
+    expect(schemaFingerprint(await loadSchema(name))).toBe(RELEASE_SCHEMA_FINGERPRINTS[name]);
+  });
+
+  it.each(ROOTS)("%s has this repository's permanent $id (ADR-0037)", async (name) => {
+    expect((await loadSchema(name)).$id).toBe(
+      `https://cloudhal.dev/schemas/v1/${name}.schema.json`,
+    );
+  });
+
+  it("ignores only the root $id and description", () => {
+    const base = schemaFingerprint(statement);
+    expect(schemaFingerprint({ ...statement, $id: "https://example.test/x" })).toBe(base);
+    expect(schemaFingerprint({ ...statement, description: "other wording" })).toBe(base);
+    expect(schemaFingerprint({ ...statement, title: "Other" })).not.toBe(base);
+    const defs = statement.$defs as JsonObject;
+    expect(
+      schemaFingerprint({ ...statement, $defs: { ...defs, Extra: { type: "string" } } }),
+    ).not.toBe(base);
+  });
+
+  it("canonical JSON sorts keys recursively and keeps array order", () => {
+    expect(canonicalJson({ b: [2, 1, { d: 1, c: "x" }], a: null })).toBe(
+      '{"a":null,"b":[2,1,{"c":"x","d":1}]}',
+    );
+  });
+
+  it("references common only for SchemaVersion and Timestamp", () => {
+    for (const root of [manifest, statement]) {
+      const refs = [...JSON.stringify(root).matchAll(/"\$ref":"(common[^"]*)"/g)].map((m) => m[1]);
+      expect(new Set(refs)).toEqual(
+        new Set(["common.schema.json#/$defs/SchemaVersion", "common.schema.json#/$defs/Timestamp"]),
+      );
+    }
+  });
+});
+
+describe("release-statement shape (RFC-0046 §2)", () => {
+  const props = statement.properties as { [k: string]: JsonObject };
+  const defs = statement.$defs as { [k: string]: JsonObject };
+  const signingOf = (root: JsonObject) =>
+    ((root.properties as { [k: string]: JsonObject }).signing as { properties: JsonObject })
+      .properties as { [k: string]: { enum?: Json[] } };
+
+  it("copies CloudhalVersion and Sha256Hex byte for byte from the manifest", () => {
+    const mdefs = manifest.$defs as JsonObject;
+    expect(JSON.stringify(defs.CloudhalVersion)).toBe(JSON.stringify(mdefs.CloudhalVersion));
+    expect(JSON.stringify(defs.Sha256Hex)).toBe(JSON.stringify(mdefs.Sha256Hex));
+  });
+
+  it("is signed only in the statement form; the manifest admits both forms", () => {
+    const signing = signingOf(statement);
+    const msigning = signingOf(manifest);
+    expect(signing.signature?.enum).toEqual(["release-statement.sig"]);
+    expect(signing.algorithm?.enum).toEqual(["Ed25519"]);
+    expect(msigning.signature?.enum).toEqual(["manifest.sig", "release-statement.sig"]);
+    // Both documents must name the same key, so they share one key id grammar.
+    expect(JSON.stringify(signing.keyId)).toBe(JSON.stringify(msigning.keyId));
+  });
+
+  it("names exactly the RFC fields, all required", () => {
+    expect(statement.required).toEqual([
+      "schemaVersion",
+      "format",
+      "version",
+      "channel",
+      "securityRelease",
+      "commit",
+      "createdAt",
+      "signing",
+      "manifest",
+      "tarball",
+    ]);
+    expect(Object.keys(props).sort()).toEqual([...(statement.required as string[])].sort());
+    expect(props.format?.enum).toEqual(["cloudhal.release-statement@1"]);
+    expect(props.channel?.enum).toEqual(["stable", "next"]);
+    const asset = defs.StatementAsset?.properties as { [k: string]: JsonObject };
+    expect(asset.bytes).toEqual({ type: "integer", minimum: 1, maximum: 104857600 });
+  });
+});
+
+describe("release fixtures", () => {
+  const PRERELEASE = /-/;
+
+  it("valid statements cover stable, next and security releases and keep the RFC invariants", async () => {
+    const valid = await fixtures("valid", "release-statement");
+    expect(valid.map(([f]) => f)).toEqual(["next-prerelease.json", "security.json", "stable.json"]);
+    for (const [file, doc] of valid) {
+      const version = doc.version as string;
+      const m = doc.manifest as JsonObject;
+      const t = doc.tarball as JsonObject;
+      expect(m.name, file).toBe("manifest.json");
+      expect(t.name, file).toBe(`cloudhal-release-${version}.tar.gz`);
+      expect(m.bytes as number, file).toBeLessThanOrEqual(1_900_000);
+      expect(doc.channel === "next", file).toBe(PRERELEASE.test(version));
+      expect(fileBytes(doc).length, file).toBeLessThanOrEqual(4096);
+      expect(doc.createdAt as string, file).toMatch(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/);
+    }
+    expect(valid.find(([f]) => f === "security.json")?.[1].securityRelease).toBe(true);
+    expect(valid.find(([f]) => f === "next-prerelease.json")?.[1].channel).toBe("next");
+  });
+
+  it("invalid statements cover every refused shape", async () => {
+    const names = (await fixtures("invalid", "release-statement")).map(([f]) => f);
+    for (const required of [
+      "missing-tarball.json",
+      "bad-format.json",
+      "sig-name-wrong.json",
+      "key-id-bad.json",
+      "sha-short.json",
+      "bytes-zero.json",
+      "extra-field.json",
+    ])
+      expect(names).toContain(required);
+  });
+
+  it("a statement form manifest exists and validates; a manifest.sig statement does not", async () => {
+    const valid = await fixtures("valid", "release-manifest");
+    const signed = valid.filter(
+      ([, doc]) => (doc.signing as JsonObject).signature === "release-statement.sig",
+    );
+    expect(signed.length).toBeGreaterThan(0);
+    for (const [, doc] of signed) expect(isValid("release-manifest", doc)).toBe(true);
+    const [, stable] = (await fixtures("valid", "release-statement"))[2] as [string, JsonObject];
+    const asManifestSig = {
+      ...stable,
+      signing: { ...(stable.signing as JsonObject), signature: "manifest.sig" },
+    };
+    expect(isValid("release-statement", stable)).toBe(true);
+    expect(isValid("release-statement", asManifestSig)).toBe(false);
+  });
+});
+
+describe("package root exports of the release roots", async () => {
+  const index = await readFile(path.join(PKG, "src/generated/index.ts"), "utf8");
+  const exported = (root: string) => {
+    const line = index.split("\n").find((l) => l.endsWith(` from "./${root}.ts";`));
+    return line?.match(/^export type \{ (.*) \} from/)?.[1]?.split(", ") ?? [];
+  };
+
+  it("re-export only the release-specific names, never the portable local $defs", () => {
+    expect(exported("release-manifest")).toEqual([
+      "ReleaseManifest",
+      "WorkerArtifact",
+      "BindingTemplate",
+    ]);
+    expect(exported("release-statement")).toEqual(["ReleaseStatement", "StatementAsset"]);
+    expect(INDEX_EXPORTS["release-manifest"]).toEqual(exported("release-manifest"));
+    expect(INDEX_EXPORTS["release-statement"]).toEqual(exported("release-statement"));
+    const all = [...index.matchAll(/^export type \{ (.*) \} from/gm)].flatMap((m) =>
+      (m[1] as string).split(", "),
+    );
+    for (const local of Object.keys(manifest.$defs as JsonObject)) {
+      if (!["WorkerArtifact", "BindingTemplate"].includes(local)) expect(all).not.toContain(local);
+    }
+  });
+});
+
+/** SPKI DER of an Ed25519 public key: this 12-byte prefix plus the raw 32 bytes. */
+const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
+const keyFromRaw = (raw: Buffer) =>
+  createPublicKey({ key: Buffer.concat([ED25519_SPKI_PREFIX, raw]), format: "der", type: "spki" });
+
+describe("statement signatures (RFC-0046 §2, with a throwaway key)", () => {
+  it("verify over the exact bytes and fail after any change, including re-serialisation", async () => {
+    const { privateKey, publicKey } = generateKeyPairSync("ed25519");
+    const raw = publicKey.export({ format: "der", type: "spki" }).subarray(12);
+    expect(raw.length).toBe(32);
+    const [, doc] = (await fixtures("valid", "release-statement"))[2] as [string, JsonObject];
+    const bytes = fileBytes(doc);
+    const sig = sign(null, bytes, privateKey);
+    expect(sig.length).toBe(64);
+    expect(verify(null, bytes, keyFromRaw(raw), sig)).toBe(true);
+
+    const reserialised = Buffer.from(JSON.stringify(JSON.parse(bytes.toString("utf8"))));
+    expect(reserialised.equals(bytes)).toBe(false);
+    expect(verify(null, reserialised, keyFromRaw(raw), sig)).toBe(false);
+    const tampered = Buffer.from(bytes);
+    tampered[tampered.indexOf("0.8.0") + 4] = "1".charCodeAt(0);
+    expect(verify(null, tampered, keyFromRaw(raw), sig)).toBe(false);
+  });
+});
+
+describe("RFC-0046 and the release key list", async () => {
+  const rfc = await readFile(path.join(REPO, "docs/rfcs/0046-release-bundles.md"), "utf8");
+  const keys = await readFile(path.join(REPO, "docs/security/release-signing-keys.md"), "utf8");
+  const releaseWorkflow = await readFile(path.join(REPO, ".github/workflows/release.yml"), "utf8");
+  const KEY_ID = "cloudhal-release-2026a";
+  const PUBLIC_KEY = "7az3pPc4oT0AOfRcdfOZZQkM6XAGHo2jXEdDZ1qEVR8=";
+
+  it("is a product-neutral proposal", () => {
+    expect(rfc).toMatch(/^# RFC-0046: Signed release bundles\n/);
+    expect(rfc).toContain("\n- Status: Proposed\n");
+    for (const doc of [
+      rfc,
+      keys,
+      JSON.stringify(manifest.description),
+      JSON.stringify(statement.description),
+    ])
+      expect(doc).not.toMatch(/clavius/i);
+    expect(rfc).not.toMatch(/SLSA Build L3/i);
+  });
+
+  it("pins the active key, whose other published forms agree", () => {
+    const row = keys.split("\n").find((l) => l.startsWith(`| \`${KEY_ID}\``));
+    expect(row).toContain(`\`${PUBLIC_KEY}\``);
+    expect(row).toContain("| active |");
+    const raw = Buffer.from(PUBLIC_KEY, "base64");
+    expect(raw.length).toBe(32);
+    const key = keyFromRaw(raw);
+    expect(key.asymmetricKeyType).toBe("ed25519");
+    expect(keys).toContain(raw.toString("hex"));
+    expect(keys).toContain(createHash("sha256").update(raw).digest("hex"));
+    const pem = (key.export({ format: "pem", type: "spki" }) as string).trim().split("\n");
+    for (const line of pem) expect(keys).toContain(line);
+  });
+
+  it("binds the signed statement to the requested version and channel", () => {
+    const verification = rfc.slice(
+      rfc.indexOf("Verification by a consumer"),
+      rfc.indexOf("### 3."),
+    );
+    const steps = verification.match(/^\d\. .*$/gm) ?? [];
+    expect(steps[2]).toMatch(/^3\. Bind the statement to the request \(invariant 7\)/);
+    expect(steps[2]).toContain("`version` equals the requested version");
+    expect(steps[2]).toContain("`channel` equals the requested channel");
+    expect(rfc).toMatch(/^7\. \*\*The statement is for the release the consumer asked for\.\*\*/m);
+    expect(rfc).toMatch(/^4\. Verification hard-fails.*binding the statement to the request/m);
+    expect(keys).toContain("if (statement.version !== version) throw");
+    expect(keys).toContain(
+      'if (statement.channel !== (version.includes("-") ? "next" : "stable"))',
+    );
+  });
+
+  it("re-checks the draft's assets immediately before publishing it", () => {
+    const publication = rfc.slice(rfc.indexOf("### 6."), rfc.indexOf("### 7."));
+    const steps = (publication.match(/^\d\. \*\*[^*]+\*\*/gm) ?? []).map((l) => l.slice(3));
+    expect(steps).toEqual([
+      "**Tag**",
+      "**Draft**",
+      "**Attach and re-check**",
+      "**npm publish**",
+      "**Re-check before publishing**",
+      "**Publish the release**",
+    ]);
+    expect(publication).toContain("the run refuses to publish, re-uploads");
+  });
+
+  it("claims two factors only for consumers that verify the attestation", () => {
+    expect(rfc).toContain("**Two factors for a consumer that verifies both.**");
+    expect(rfc).toContain("relies on the release key alone");
+    expect(rfc).not.toMatch(/Together they defeat theft/);
+    // bundle-sign checks out nothing: its pin is inline in the job, not read from the doc.
+    expect(rfc).toContain("written inline in the `bundle-sign` job");
+  });
+
+  it("describes custody and revocation as they are", () => {
+    expect(keys).not.toMatch(/The private key exists only as the secret/);
+    expect(keys).not.toMatch(/No other job, workflow, repository, machine or service/);
+    expect(keys).not.toMatch(/re-signed with another key/);
+    expect(keys).toContain("can never be re-signed. The remedy is a new version");
+    expect(keys).toContain("the maintainer moves the generated file to offline storage");
+  });
+
+  it.skipIf(!releaseWorkflow.includes("bundle-sign:"))(
+    "the bundle-sign job pins the same key id and public key inline",
+    () => {
+      expect(releaseWorkflow).toContain(KEY_ID);
+      expect(releaseWorkflow).toContain(PUBLIC_KEY);
+    },
+  );
+
+  it("documents the three hand checks", () => {
+    expect(keys).toContain("crypto");
+    expect(keys).toContain(ED25519_SPKI_PREFIX.toString("hex"));
+    expect(keys).toContain("gh release verify-asset");
+    expect(keys).toContain(
+      "--signer-workflow cloudhal-dev/cloudhal/.github/workflows/bundle-build.yml",
+    );
+  });
+});
diff --git a/scripts/publish-ci.ts b/scripts/publish-ci.ts
index e331d101..2a188c0b 100644
--- a/scripts/publish-ci.ts
+++ b/scripts/publish-ci.ts
@@ -1,100 +1,300 @@
 /**
  * Publishes a release from CI (`.github/workflows/release.yml`, after the `npm-publish`
- * environment's approval): npm trusted publishing (OIDC) with provenance, then one `v`
- * tag and one GitHub Release for the whole fixed group.
+ * environment's approval), in the order immutable GitHub Releases require (RFC-0046 §6;
+ * `scripts/release-publish-plan.ts` plans it and resumes a half-done run):
  *
- *   node scripts/publish-ci.ts --plan      # prints `publish=true|false` and `version=` for $GITHUB_OUTPUT
- *   node scripts/publish-ci.ts             # build, smoke-test, publish what npm lacks, tag, release
+ *   1 tag `v` on HEAD (= the signed statement's commit)
+ *   2 `gh release create --draft --verify-tag` (never Latest)
+ *   3 upload the five bundle assets to the draft (`--clobber`), re-read it, compare names + digests
+ *   4 smoke-test and npm publish the fixed group (trusted publishing, OIDC, provenance)
+ *   5 re-check the draft's assets   6 `gh release edit --draft=false` (`--latest` only when highest)
  *
- * Re-runnable: packages npm already has are skipped, an existing tag or release is left alone.
- * The tarballs published are the ones the smoke test installed (`smoke-pack.ts --tarballs`).
+ *   node scripts/publish-ci.ts --plan [--dry-run]   # `publish=`, `version=`, `dry_run=`,
+ *                                                   # `security_release=` for $GITHUB_OUTPUT
+ *   node scripts/publish-ci.ts [--bundle-dir ] [--dry-run]
+ *
+ * With `--bundle-dir`, nothing is created before the bundle checks out: the five files are there,
+ * the statement pins the manifest and the tarball, and `release-statement.sig` verifies with the
+ * pinned release key (a dry run: with the `dryrun-` key bundle-sign generated, whose public
+ * half reaches this job as RELEASE_DRYRUN_PUBLIC_KEY). Without it (repository variable
+ * RELEASE_BUNDLES off) the same order runs with no assets. `--dry-run` (workflow_dispatch
+ * `dry_run`) drafts `v-dryrun.` on HEAD without pushing a tag, uploads and
+ * re-checks the assets, runs `npm publish --dry-run` (see `npmFlags`), and deletes the draft.
+ * Node built-ins only (the `plan` job runs it without a pnpm install).
  */
 import { execFileSync } from "node:child_process";
+import { createHash } from "node:crypto";
 import { appendFileSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
 import os from "node:os";
 import path from "node:path";
 import { fileURLToPath } from "node:url";
-import { onNpm, readPackages, releaseNotes, sharedVersion, unpublished } from "./release-plan.ts";
+import { parseArgs } from "node:util";
+import { verifySignature } from "./release-bundle/signature.ts";
+import {
+  isSecurityRelease,
+  onNpm,
+  type Pkg,
+  readPackages,
+  releaseNotes,
+  sharedVersion,
+  unpublished,
+} from "./release-plan.ts";
+import {
+  type LocalAsset,
+  type NpmPublish,
+  type PublishInput,
+  type PublishPort,
+  type ReleaseView,
+  runPublish,
+  tagFor,
+} from "./release-publish-plan.ts";
 
 const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+const REPO = process.env.GITHUB_REPOSITORY ?? "cloudhal-dev/cloudhal";
+const BOT = [
+  "-c",
+  "user.name=github-actions[bot]",
+  "-c",
+  "user.email=41898282+github-actions[bot]@users.noreply.github.com",
+];
 const sh = (cmd: string, args: string[], quiet = false) =>
-  execFileSync(cmd, args, { cwd: ROOT, encoding: "utf8", stdio: quiet ? "pipe" : "inherit" });
+  execFileSync(cmd, args, { cwd: ROOT, encoding: "utf8", stdio: quiet ? "pipe" : "inherit" }) ?? "";
+
+/**
+ * The release key `bundle-sign` signs with (docs/security/release-signing-keys.md). It equals
+ * release.yml's PINNED_KEY_ID and PINNED_PUBLIC_KEY; release-workflows.test.ts keeps them equal.
+ */
+export const RELEASE_KEY = {
+  "cloudhal-release-2026a": "7az3pPc4oT0AOfRcdfOZZQkM6XAGHo2jXEdDZ1qEVR8=",
+} as const;
 
-const pkgs = readPackages(ROOT);
-const version = sharedVersion(pkgs);
-const missing = await unpublished(pkgs, onNpm);
+/** The one key a run's statement may be signed with: the release key, or the dry run's own. */
+export function publicKeysFor(o: {
+  dryRun: boolean;
+  runId?: string | undefined;
+  dryRunPublicKey?: string | undefined;
+}): Record {
+  if (!o.dryRun) return { ...RELEASE_KEY };
+  if (!o.runId || !/^\d+$/.test(o.runId)) throw new Error("a dry run needs GITHUB_RUN_ID");
+  if (!o.dryRunPublicKey) throw new Error("a dry run needs RELEASE_DRYRUN_PUBLIC_KEY");
+  return { [`dryrun-${o.runId}`]: o.dryRunPublicKey };
+}
+
+/** The five assets of a signed bundle directory, checked against its statement and signature. */
+export function loadBundle(
+  dir: string,
+  version: string,
+  publicKeys: Readonly>,
+): NonNullable {
+  const names = [
+    `cloudhal-release-${version}.tar.gz`,
+    "manifest.json",
+    "release-statement.json",
+    "release-statement.sig",
+    `cloudhal-release-${version}.sigstore.json`,
+  ];
+  const assets: LocalAsset[] = names.map((name) => {
+    const file = path.join(dir, name);
+    const bytes = readFileSync(file);
+    return {
+      name,
+      file,
+      bytes: bytes.length,
+      sha256: createHash("sha256").update(bytes).digest("hex"),
+    };
+  });
+  const [tarball, manifest, , sig] = assets as [LocalAsset, LocalAsset, LocalAsset, LocalAsset];
+  if (sig.bytes !== 64) throw new Error("release-statement.sig is not a 64-byte signature");
+  const statementBytes = readFileSync(path.join(dir, "release-statement.json"));
+  const statement = JSON.parse(new TextDecoder().decode(statementBytes)) as {
+    version: string;
+    channel: string;
+    commit: string;
+    manifest: { sha256: string; bytes: number };
+    tarball: { sha256: string; bytes: number };
+    signing?: { keyId?: unknown };
+  };
+  // Before anything is created or uploaded: a release is immutable once published.
+  const keyId = statement.signing?.keyId;
+  if (typeof keyId !== "string") throw new Error("release-statement.json names no signing key");
+  verifySignature(statementBytes, readFileSync(sig.file), keyId, publicKeys);
+  for (const [a, want] of [
+    [manifest, statement.manifest],
+    [tarball, statement.tarball],
+  ] as const)
+    if (a.sha256 !== want?.sha256 || a.bytes !== want.bytes)
+      throw new Error(`${a.name} is not the file the statement signs`);
+  return { statement, assets };
+}
 
-if (process.argv.includes("--plan")) {
-  const out = `publish=${missing.length > 0}\nversion=${version}\n`;
-  if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, out);
-  console.log(missing.length ? `to publish: ${missing.map((p) => p.name).join(", ")}` : out);
-  process.exit(0);
+/** The commit the remote tag names (peeled), or null. */
+function remoteTagCommit(tag: string): string | null {
+  const out = sh(
+    "git",
+    ["ls-remote", "--tags", "origin", `refs/tags/${tag}`, `refs/tags/${tag}^{}`],
+    true,
+  );
+  const lines = out
+    .split("\n")
+    .filter(Boolean)
+    .map((l) => l.split("\t") as [string, string]);
+  const peeled = lines.find(([, ref]) => ref.endsWith("^{}"));
+  return (peeled ?? lines[0])?.[0] ?? null;
 }
 
-if (missing.length) {
+async function npmPublish(pkgs: readonly Pkg[], publishes: readonly NpmPublish[], dryRun: boolean) {
   const tarballs = mkdtempSync(path.join(os.tmpdir(), "cloudhal-release-"));
   // Builds, packs and installs every package from scratch with npm; fails the release on error.
   sh(process.execPath, ["scripts/smoke-pack.ts", "--tarballs", tarballs]);
   const files = readdirSync(tarballs);
-  for (const p of missing) {
+  for (const { name, flags } of publishes) {
+    const p = pkgs.find((x) => x.name === name);
+    if (!p) throw new Error(`${name} is not a package of the fixed group`);
     const tgz = `${p.name.replace("@", "").replace("/", "-")}-${p.version}.tgz`;
     if (!files.includes(tgz)) throw new Error(`no tarball ${tgz} in ${tarballs}`);
-    // OIDC: npm exchanges the workflow's identity token; provenance is attached automatically.
-    sh("npm", [
-      "publish",
-      path.join(tarballs, tgz),
-      "--access",
-      "public",
-      "--provenance",
-      "--ignore-scripts",
-    ]);
+    if (flags.includes("--force"))
+      console.log(`publish-ci: npm has ${name}@${p.version}; --force skips that check only`);
+    sh("npm", ["publish", path.join(tarballs, tgz), ...flags]);
   }
+  if (dryRun) return;
   for (let i = 0; i < 30 && (await unpublished(pkgs, onNpm)).length; i++)
     await new Promise((r) => setTimeout(r, 10_000));
   // `npm publish` succeeded for each, so the registry has them; it can take longer than five
-  // minutes to serve one (0.3.8, 0.3.10). That must not keep the tag and the release back.
+  // minutes to serve one (0.3.8, 0.3.10). That must not keep the release back.
   const left = await unpublished(pkgs, onNpm);
   if (left.length)
-    console.warn(`npm does not serve yet: ${left.map((p) => p.name).join(", ")}; tagging anyway`);
-} else console.log(`npm already has every package at ${version}`);
+    console.warn(
+      `npm does not serve yet: ${left.map((p) => p.name).join(", ")}; publishing anyway`,
+    );
+}
 
-const tag = `v${version}`;
-if (!sh("git", ["tag", "--list", tag], true).trim()) {
-  sh("git", [
-    "-c",
-    "user.name=github-actions[bot]",
-    "-c",
-    "user.email=41898282+github-actions[bot]@users.noreply.github.com",
-    "tag",
-    "-a",
-    tag,
-    "-m",
-    `Cloudhal ${version}`,
-  ]);
-  sh("git", ["push", "origin", tag]);
+function releasesOf(tag: string): ReleaseView[] {
+  const pages = JSON.parse(
+    sh("gh", ["api", "--paginate", "--slurp", `repos/${REPO}/releases?per_page=100`], true),
+  ) as { tag_name: string; draft: boolean; prerelease: boolean; assets: RemoteAssetJson[] }[][];
+  return pages
+    .flat()
+    .filter((r) => r.tag_name === tag)
+    .map((r) => ({
+      draft: r.draft,
+      prerelease: r.prerelease,
+      assets: r.assets.map((a) => ({ name: a.name, digest: a.digest ?? null, size: a.size })),
+    }));
+}
+interface RemoteAssetJson {
+  name: string;
+  digest?: string | null;
+  size: number;
 }
-let released = true;
-try {
-  sh("gh", ["release", "view", tag], true);
-} catch {
-  released = false;
+
+function githubPort(pkgs: readonly Pkg[], version: string, dryRun: boolean): PublishPort {
+  return {
+    pushTag(tag, commit) {
+      if (!sh("git", ["tag", "--list", tag], true).trim())
+        sh("git", [...BOT, "tag", "-a", tag, commit, "-m", `Cloudhal ${version}`]);
+      sh("gh", ["auth", "setup-git"]); // the checkout keeps no credentials
+      sh("git", ["push", "origin", `refs/tags/${tag}`]);
+    },
+    releases: releasesOf,
+    createDraft(tag, o) {
+      const changelogs = pkgs.map((p) =>
+        readFileSync(path.join(ROOT, "packages", p.dir, "CHANGELOG.md"), "utf8"),
+      );
+      const notes = path.join(os.tmpdir(), `cloudhal-${tag}-notes.md`);
+      writeFileSync(notes, releaseNotes(changelogs, version));
+      const title = dryRun
+        ? `Cloudhal ${version} (dry run, deleted after the run)`
+        : `Cloudhal ${version}`;
+      sh("gh", [
+        "release",
+        "create",
+        tag,
+        "--draft",
+        "--latest=false",
+        "--title",
+        title,
+        "--notes-file",
+        notes,
+        ...(o.prerelease ? ["--prerelease"] : []),
+        ...(o.target ? ["--target", o.target] : ["--verify-tag"]),
+      ]);
+    },
+    upload: (tag, assets) =>
+      sh("gh", ["release", "upload", tag, ...assets.map((a) => a.file), "--clobber"]),
+    deleteAsset: (tag, name) => sh("gh", ["release", "delete-asset", tag, name, "--yes"]),
+    npmPublish: (publishes, dry) => npmPublish(pkgs, publishes, dry),
+    publishRelease: (tag, o) =>
+      sh("gh", [
+        "release",
+        "edit",
+        tag,
+        "--draft=false",
+        o.latest ? "--latest" : "--latest=false",
+        o.prerelease ? "--prerelease" : "--prerelease=false",
+      ]),
+    deleteRelease: (tag) => sh("gh", ["release", "delete", tag, "--yes"]),
+    log: (m) => console.log(`publish-ci: ${m}`),
+  };
 }
-if (!released) {
-  const changelogs = pkgs.map((p) =>
-    readFileSync(path.join(ROOT, "packages", p.dir, "CHANGELOG.md"), "utf8"),
+
+export async function main(argv: readonly string[]): Promise {
+  const { values: v } = parseArgs({
+    args: [...argv],
+    options: {
+      plan: { type: "boolean" },
+      "dry-run": { type: "boolean" },
+      "bundle-dir": { type: "string" },
+    },
+  });
+  const dryRun = v["dry-run"] === true || process.env.RELEASE_DRY_RUN === "true";
+  const pkgs = readPackages(ROOT);
+  const version = sharedVersion(pkgs);
+  const missing = await unpublished(pkgs, onNpm);
+  if (v.plan) {
+    const changelogs = pkgs.map((p) =>
+      readFileSync(path.join(ROOT, "packages", p.dir, "CHANGELOG.md"), "utf8"),
+    );
+    const security = isSecurityRelease(changelogs, version);
+    const out = `publish=${dryRun || missing.length > 0}\nversion=${version}\ndry_run=${dryRun}\nsecurity_release=${security}\n`;
+    if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, out);
+    console.log(missing.length ? `to publish: ${missing.map((p) => p.name).join(", ")}` : out);
+    return;
+  }
+  const runNumber = process.env.GITHUB_RUN_NUMBER;
+  const tag = tagFor({ version, dryRun, ...(runNumber ? { runNumber } : {}) });
+  const port = githubPort(pkgs, version, dryRun);
+  const steps = await runPublish(port, {
+    version,
+    head: sh("git", ["rev-parse", "HEAD"], true).trim(),
+    dryRun,
+    ...(runNumber ? { runNumber } : {}),
+    bundle: v["bundle-dir"]
+      ? loadBundle(
+          v["bundle-dir"],
+          version,
+          publicKeysFor({
+            dryRun,
+            runId: process.env.GITHUB_RUN_ID,
+            dryRunPublicKey: process.env.RELEASE_DRYRUN_PUBLIC_KEY,
+          }),
+        )
+      : null,
+    tagCommit: dryRun ? null : remoteTagCommit(tag),
+    releases: port.releases(tag),
+    packages: pkgs.map((p) => p.name),
+    npmMissing: missing.map((p) => p.name),
+    tags: sh("git", ["tag", "--list", "v*"], true).split("\n").filter(Boolean),
+  });
+  console.log(
+    steps.length ? `${dryRun ? "dry run of" : "released"} ${tag}` : `${tag}: nothing left to do`,
   );
-  const notes = path.join(os.tmpdir(), `cloudhal-${version}-notes.md`);
-  writeFileSync(notes, releaseNotes(changelogs, version));
-  sh("gh", [
-    "release",
-    "create",
-    tag,
-    "--title",
-    `Cloudhal ${version}`,
-    "--notes-file",
-    notes,
-    "--verify-tag",
-    "--latest",
-  ]);
 }
-console.log(`released ${tag}`);
+
+if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+  try {
+    await main(process.argv.slice(2));
+  } catch (e) {
+    console.error(`publish-ci: ${(e as Error).message}`);
+    process.exitCode = 1;
+  }
+}
diff --git a/scripts/release-bundle.ts b/scripts/release-bundle.ts
new file mode 100644
index 00000000..61ff992d
--- /dev/null
+++ b/scripts/release-bundle.ts
@@ -0,0 +1,167 @@
+/**
+ * Release bundles (RFC-0046): the standard theme's built site and admin Workers as one
+ * deterministic tarball, its `manifest.json` and the unsigned `release-statement.json`.
+ *
+ *   node scripts/release-bundle.ts build --version  --project  --studio 
+ *     --migrations-dir  --commit  --key-id  --out  [--security-release]
+ *   node scripts/release-bundle.ts verify --dir  --version  [--channel stable|next]
+ *     [--public-key =]   # with a key, release-statement.sig must verify
+ *
+ * `build` reads what `cloudhal deploy --dry-run` left in `--project` (scripts/release-bundle/
+ * read-build.ts), takes `createdAt` from the commit's committer time (whole seconds, UTC), refuses a
+ * part that contains a value of the project's `.cloudhal/deploy/secrets.json`, validates both
+ * documents and verifies its own output before it writes anything. Two builds of the same build
+ * output and commit are byte-identical. It never signs: `release.yml`'s `bundle-sign` job does.
+ * It prints names, sizes, SHA-256 and the key id, never key material or a secret.
+ */
+import { execFileSync } from "node:child_process";
+import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { parseArgs } from "node:util";
+import { buildReleaseBundle, writeReleaseBundle } from "./release-bundle/build.ts";
+import { sha256 } from "./release-bundle/manifest.ts";
+import { migrationNames, readBuildOutput, readDeploySecrets } from "./release-bundle/read-build.ts";
+import { verifyBundle } from "./release-bundle/verify.ts";
+
+const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+const VERSION = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$/;
+
+const USAGE = `usage:
+  node scripts/release-bundle.ts build --version  --project  --studio 
+    --migrations-dir  --commit  --key-id  --out  [--security-release]
+  node scripts/release-bundle.ts verify --dir  --version  [--channel stable|next]
+    [--public-key =]`;
+
+const OPTIONS = {
+  version: { type: "string" },
+  project: { type: "string" },
+  studio: { type: "string" },
+  "migrations-dir": { type: "string" },
+  commit: { type: "string" },
+  "key-id": { type: "string" },
+  out: { type: "string" },
+  dir: { type: "string" },
+  channel: { type: "string" },
+  "public-key": { type: "string" },
+  "security-release": { type: "boolean" },
+  help: { type: "boolean" },
+} as const;
+
+type Values = ReturnType>["values"];
+
+/** The committer time of `commit`, as whole-second UTC (`2026-11-12T08:00:00Z`). */
+export function committedAt(commit: string, repo = ROOT): string {
+  let iso: string;
+  try {
+    iso = execFileSync("git", ["log", "-1", "--format=%cI", commit], {
+      cwd: repo,
+      encoding: "utf8",
+      stdio: ["ignore", "pipe", "pipe"],
+    }).trim();
+  } catch {
+    throw new Error(`git does not know the commit ${commit}`);
+  }
+  const ms = Date.parse(iso);
+  if (Number.isNaN(ms)) throw new Error(`git gave no committer time for ${commit}`);
+  return new Date(ms).toISOString().replace(/\.\d{3}Z$/, "Z");
+}
+
+function versionOf(file: string): string {
+  try {
+    return String((JSON.parse(readFileSync(file, "utf8")) as { version?: unknown }).version);
+  } catch {
+    return "unknown";
+  }
+}
+
+/** `toolchain` entries beyond `producer` and `starter` (RFC-0046 §3). */
+export function toolchainOf(project: string): Record {
+  return {
+    node: process.versions.node,
+    cli: versionOf(path.join(ROOT, "packages/cli/package.json")),
+    astro: versionOf(path.join(project, "node_modules/astro/package.json")),
+    wrangler: versionOf(path.join(project, "node_modules/wrangler/package.json")),
+  };
+}
+
+function need(v: Values, ...names: (keyof Values)[]): void {
+  const missing = names.filter((n) => !v[n]);
+  if (missing.length) throw new Error(`missing --${missing.join(", --")}\n${USAGE}`);
+  if (v.version && !VERSION.test(v.version)) throw new Error(`not a version: ${v.version}`);
+}
+
+async function build(v: Values): Promise {
+  need(v, "version", "project", "studio", "migrations-dir", "commit", "key-id", "out");
+  const project = v.project as string;
+  const secretsFile = path.join(project, ".cloudhal/deploy/secrets.json");
+  if (!existsSync(secretsFile))
+    throw new Error(
+      `${secretsFile} is missing: run cloudhal deploy --dry-run in the project first`,
+    );
+  const commit = v.commit as string;
+  const bundle = await buildReleaseBundle(
+    readBuildOutput({ project, studio: v.studio as string }),
+    {
+      version: v.version as string,
+      commit,
+      createdAt: committedAt(commit),
+      keyId: v["key-id"] as string,
+      migrations: migrationNames(v["migrations-dir"] as string),
+      securityRelease: v["security-release"] ?? false,
+      toolchain: toolchainOf(project),
+    },
+    readDeploySecrets(secretsFile),
+  );
+  // Self-check in a scratch directory first, so a bad bundle never reaches --out.
+  const scratch = mkdtempSync(path.join(os.tmpdir(), "release-bundle-"));
+  try {
+    writeReleaseBundle(bundle, scratch);
+    await verifyBundle(scratch, { version: v.version as string });
+  } finally {
+    rmSync(scratch, { recursive: true, force: true });
+  }
+  for (const p of writeReleaseBundle(bundle, v.out as string)) {
+    const bytes = readFileSync(p);
+    console.log(`wrote ${p} (${bytes.length} bytes, sha256 ${sha256(bytes)})`);
+  }
+  console.log(`${bundle.parts.length} parts; key id ${bundle.statement.signing.keyId}; unsigned`);
+}
+
+async function verify(v: Values): Promise {
+  need(v, "dir", "version");
+  const pk = v["public-key"];
+  const eq = pk ? pk.indexOf("=") : -1;
+  if (pk && eq < 1) throw new Error("--public-key must be =");
+  const r = await verifyBundle(v.dir as string, {
+    version: v.version as string,
+    ...(v.channel ? { channel: v.channel } : {}),
+    ...(pk ? { signed: true, publicKeys: { [pk.slice(0, eq)]: pk.slice(eq + 1) } } : {}),
+  });
+  const s = r.statement;
+  console.log(
+    `ok: ${s.version} (${s.channel}) commit ${s.commit}; ${r.parts} parts; tarball ${r.tarballBytes} bytes; ` +
+      `${pk ? `signature verified with ${s.signing.keyId}` : "signature not checked"}`,
+  );
+}
+
+export async function main(argv: readonly string[]): Promise {
+  const [command, ...rest] = argv;
+  const { values } = parseArgs({ args: rest, options: OPTIONS });
+  if (values.help || (command !== "build" && command !== "verify")) {
+    console.error(USAGE);
+    return values.help ? 0 : 2;
+  }
+  await (command === "build" ? build(values) : verify(values));
+  return 0;
+}
+
+if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+  try {
+    process.exitCode = await main(process.argv.slice(2));
+  } catch (e) {
+    console.error(`release-bundle: ${(e as Error).message}`);
+    process.exitCode = 1;
+  }
+}
diff --git a/scripts/release-bundle/build.ts b/scripts/release-bundle/build.ts
new file mode 100644
index 00000000..1e1c5162
--- /dev/null
+++ b/scripts/release-bundle/build.ts
@@ -0,0 +1,118 @@
+/**
+ * Builds a release bundle in memory (RFC-0046 §4 step 4): the manifest, the sorted tarball and
+ * the **unsigned** statement, each validated with the precompiled `@cloudhal/core-contracts`
+ * validators and checked against §7's limits. A part (or the manifest, which carries the
+ * `_headers` text) that contains a value of the project's generated deploy secrets is refused;
+ * the error names the part and the secret's position, never the value.
+ */
+import { mkdirSync, writeFileSync } from "node:fs";
+import path from "node:path";
+import { assertValid } from "../../packages/core-contracts/src/index.ts";
+import {
+  bundleEntries,
+  canonicalJson,
+  checkLimits,
+  LIMITS,
+  manifestOf,
+  type ReleaseFacts,
+} from "./manifest.ts";
+import type { BuildOutput, SourceFile } from "./read-build.ts";
+import {
+  MANIFEST_FILE,
+  STATEMENT_FILE,
+  type Statement,
+  statementOf,
+  tarballName,
+} from "./statement.ts";
+import { gzipBytes, tarArchive } from "./tar.ts";
+
+export interface ReleaseBundle {
+  readonly manifest: Readonly>;
+  readonly manifestBytes: Uint8Array;
+  readonly tarball: Uint8Array;
+  readonly statement: Statement;
+  readonly statementBytes: Uint8Array;
+  /** The three unsigned assets by file name. */
+  readonly files: Readonly>;
+  /** Bundle paths of every part, in tar (sorted) order. */
+  readonly parts: readonly string[];
+}
+
+/** Project keys and dry-run keys; a `dev-` key belongs to the legacy `manifest.sig` form. */
+const RELEASE_KEY_ID = /^(?!dev-)[a-z0-9][a-z0-9-]{2,63}$/;
+const COMMIT = /^[0-9a-f]{40}$/;
+const WHOLE_SECOND_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
+
+/** Refuses any part (or the manifest) that contains one of `secrets`. */
+export function scanForSecrets(
+  parts: readonly SourceFile[],
+  manifestBytes: Uint8Array,
+  secrets: readonly string[],
+): void {
+  const needles = secrets.map((s) => Buffer.from(s, "utf8"));
+  const all = [...parts, { path: MANIFEST_FILE, bytes: manifestBytes }];
+  for (const part of all) {
+    const hay = Buffer.from(part.bytes.buffer, part.bytes.byteOffset, part.bytes.byteLength);
+    const hit = needles.findIndex((n) => hay.indexOf(n) !== -1);
+    if (hit !== -1)
+      throw new Error(
+        `${part.path} contains generated deploy secret #${hit + 1}; a bundle must carry no secret`,
+      );
+  }
+}
+
+function checkFacts(facts: ReleaseFacts): void {
+  if (!COMMIT.test(facts.commit)) throw new Error("--commit must be 40 lowercase hex digits");
+  if (!WHOLE_SECOND_UTC.test(facts.createdAt) || Number.isNaN(Date.parse(facts.createdAt)))
+    throw new Error("createdAt must be a whole-second UTC time (YYYY-MM-DDTHH:MM:SSZ)");
+  if (!RELEASE_KEY_ID.test(facts.keyId))
+    throw new Error(`--key-id must match ${RELEASE_KEY_ID} (dev- keys sign manifest.sig only)`);
+}
+
+/** The manifest, the tarball and the unsigned statement of `build` (see the file comment). */
+export async function buildReleaseBundle(
+  build: BuildOutput,
+  facts: ReleaseFacts,
+  secrets: readonly string[],
+): Promise {
+  checkFacts(facts);
+  const manifest = manifestOf(build, facts);
+  assertValid("release-manifest", manifest);
+  const manifestBytes = new TextEncoder().encode(canonicalJson(manifest));
+  if (manifestBytes.length > LIMITS.manifestBytes)
+    throw new Error(
+      `the manifest is ${manifestBytes.length} bytes; at most ${LIMITS.manifestBytes}`,
+    );
+  const entries = bundleEntries(build);
+  checkLimits(build, entries);
+  scanForSecrets(entries, manifestBytes, secrets);
+  const mtime = Date.parse(facts.createdAt) / 1000;
+  const tarball = await gzipBytes(tarArchive(entries, mtime));
+  if (tarball.length > LIMITS.tarballBytes)
+    throw new Error(`the tarball is ${tarball.length} bytes; at most ${LIMITS.tarballBytes}`);
+  const { statement, bytes: statementBytes } = statementOf(manifest, manifestBytes, tarball);
+  assertValid("release-statement", statement);
+  return {
+    manifest,
+    manifestBytes,
+    tarball,
+    statement,
+    statementBytes,
+    files: {
+      [tarballName(facts.version)]: tarball,
+      [MANIFEST_FILE]: manifestBytes,
+      [STATEMENT_FILE]: statementBytes,
+    },
+    parts: entries.map((e) => e.path),
+  };
+}
+
+/** Writes the three unsigned assets into `outDir`; returns their paths. */
+export function writeReleaseBundle(bundle: ReleaseBundle, outDir: string): string[] {
+  mkdirSync(outDir, { recursive: true });
+  return Object.entries(bundle.files).map(([name, bytes]) => {
+    const p = path.join(outDir, name);
+    writeFileSync(p, bytes);
+    return p;
+  });
+}
diff --git a/scripts/release-bundle/manifest.ts b/scripts/release-bundle/manifest.ts
new file mode 100644
index 00000000..f5f7625a
--- /dev/null
+++ b/scripts/release-bundle/manifest.ts
@@ -0,0 +1,236 @@
+/**
+ * The release manifest (`cloudhal.release-manifest@1`, RFC-0046 §3) of a build output, its
+ * canonical bytes, the part list of the tarball and the normative limits (§7).
+ */
+import { createHash } from "node:crypto";
+import type { BuildOutput, SourceFile, WorkerBuild } from "./read-build.ts";
+import {
+  DEFAULT_RUNTIME,
+  PRODUCER,
+  STANDARD_MANIFEST_FIELDS,
+  STANDARD_STARTER,
+} from "./standard.ts";
+
+/** RFC-0046 §7. */
+export const LIMITS = {
+  manifestBytes: 1_900_000,
+  statementBytes: 4096,
+  tarballBytes: 104_857_600,
+  partBytes: 64 * 1024 * 1024,
+  assetBytes: 25 * 1024 * 1024,
+  assetsPerWorker: 20_000,
+  tarEntries: 30_000,
+} as const;
+
+export const sha256 = (b: Uint8Array | string): string =>
+  createHash("sha256").update(b).digest("hex");
+
+const MODULE_TYPES: Readonly> = {
+  mjs: "esm",
+  js: "esm",
+  cjs: "cjs",
+  wasm: "wasm",
+  txt: "text",
+  html: "text",
+};
+
+const CONTENT_TYPES: Readonly> = {
+  html: "text/html",
+  css: "text/css",
+  js: "text/javascript",
+  mjs: "text/javascript",
+  json: "application/json",
+  svg: "image/svg+xml",
+  png: "image/png",
+  jpg: "image/jpeg",
+  jpeg: "image/jpeg",
+  gif: "image/gif",
+  webp: "image/webp",
+  avif: "image/avif",
+  ico: "image/x-icon",
+  woff2: "font/woff2",
+  woff: "font/woff",
+  ttf: "font/ttf",
+  txt: "text/plain",
+  xml: "application/xml",
+  wasm: "application/wasm",
+  webmanifest: "application/manifest+json",
+};
+
+const extOf = (p: string) => {
+  const base = p.split("/").pop() ?? p;
+  return base.includes(".") ? (base.split(".").pop() ?? "").toLowerCase() : "";
+};
+
+/** `sha256(base64(bytes) + extension)[0:32]` (Workers direct upload's formula, no dot). */
+export function assetHash(p: string, bytes: Uint8Array): string {
+  return sha256(`${Buffer.from(bytes).toString("base64")}${extOf(p)}`).slice(0, 32);
+}
+
+function workerArtifact(w: WorkerBuild, role: "site" | "admin") {
+  const files = w.assets.map((a) => ({
+    path: a.path,
+    bytes: a.bytes.length,
+    sha256: sha256(a.bytes),
+    assetHash: assetHash(a.path, a.bytes),
+    contentType: CONTENT_TYPES[extOf(a.path)] ?? "application/octet-stream",
+  }));
+  return {
+    mainModule: w.mainModule,
+    modules: w.modules.map((m) => ({
+      path: m.path,
+      type: MODULE_TYPES[extOf(m.path)] ?? "data",
+      bytes: m.bytes.length,
+      sha256: sha256(m.bytes),
+    })),
+    assets: {
+      count: files.length,
+      bytes: files.reduce((s, f) => s + f.bytes, 0),
+      files,
+      headers: w.headers,
+      ...(w.config ? { config: w.config } : {}),
+    },
+    bindingTemplate: role,
+  };
+}
+
+/** Keys sorted recursively by UTF-16 code unit, 2-space indent, trailing newline (§1). */
+export function canonicalJson(value: unknown): string {
+  const sort = (v: unknown): unknown => {
+    if (Array.isArray(v)) return v.map(sort);
+    if (v && typeof v === "object")
+      return Object.fromEntries(
+        Object.keys(v as object)
+          .sort()
+          .map((k) => [k, sort((v as Record)[k])]),
+      );
+    return v;
+  };
+  return `${JSON.stringify(sort(value), null, 2)}\n`;
+}
+
+/** `vX.Y.Z-
` ⇔ `next` (§6 channel rule). */
+export const channelOf = (version: string): "stable" | "next" =>
+  version.includes("-") ? "next" : "stable";
+
+export interface ReleaseFacts {
+  readonly version: string;
+  /** The release commit (40 hex), which the tag names. */
+  readonly commit: string;
+  /** Its committer time in whole seconds, `YYYY-MM-DDTHH:MM:SSZ`. */
+  readonly createdAt: string;
+  readonly keyId: string;
+  readonly migrations: readonly string[];
+  readonly securityRelease?: boolean;
+  readonly compatibleWithPrevious?: boolean;
+  readonly planVersion?: number;
+  /** `toolchain` beyond `producer` and `starter`: node, cli, astro, wrangler. */
+  readonly toolchain: Readonly>;
+}
+
+/** The manifest document (not yet validated) of `build` for `facts`. */
+export function manifestOf(build: BuildOutput, facts: ReleaseFacts): Record {
+  const names = [...facts.migrations];
+  if (names.length === 0) throw new Error("a release needs at least one migration name");
+  return {
+    schemaVersion: 1,
+    format: "cloudhal.release-manifest@1",
+    version: facts.version,
+    channel: channelOf(facts.version),
+    securityRelease: facts.securityRelease ?? false,
+    commit: facts.commit,
+    createdAt: facts.createdAt,
+    ...STANDARD_MANIFEST_FIELDS,
+    migrations: {
+      first: names[0],
+      last: names[names.length - 1],
+      count: names.length,
+      names,
+      compatibleWithPrevious: facts.compatibleWithPrevious ?? true,
+    },
+    runtime: build.runtime ?? DEFAULT_RUNTIME,
+    toolchain: { ...facts.toolchain, producer: PRODUCER, starter: STANDARD_STARTER },
+    theme: {
+      name: STANDARD_STARTER,
+      version: facts.version,
+      blockTypes: [],
+      settingsSha256: sha256(""),
+      designSha256: sha256(""),
+    },
+    workers: {
+      site: workerArtifact(build.site, "site"),
+      admin: workerArtifact(build.admin, "admin"),
+    },
+    starter: build.starter
+      ? {
+          kind: "bundle",
+          path: `starter/${build.starter.file.path}`,
+          bytes: build.starter.file.bytes.length,
+          sha256: sha256(build.starter.file.bytes),
+          siteId: build.starter.siteId,
+        }
+      : { kind: "admin-api" },
+    planVersion: facts.planVersion ?? 1,
+    signing: { keyId: facts.keyId, algorithm: "Ed25519", signature: "release-statement.sig" },
+  };
+}
+
+const byPath = (a: SourceFile, b: SourceFile) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
+
+/** The tarball's entries: every part under its bundle path, sorted by path (§7). */
+export function bundleEntries(build: BuildOutput): SourceFile[] {
+  const out: SourceFile[] = [];
+  for (const role of ["site", "admin"] as const) {
+    for (const m of build[role].modules)
+      out.push({ path: `${role}/modules/${m.path}`, bytes: m.bytes });
+    for (const a of build[role].assets)
+      out.push({ path: `${role}/assets${a.path}`, bytes: a.bytes });
+  }
+  if (build.starter)
+    out.push({ path: `starter/${build.starter.file.path}`, bytes: build.starter.file.bytes });
+  return out.sort(byPath);
+}
+
+/** Refuses a build outside §7's limits; the message names the part, never its content. */
+export function checkLimits(build: BuildOutput, entries: readonly SourceFile[]): void {
+  if (entries.length > LIMITS.tarEntries)
+    throw new Error(`${entries.length} parts; a release holds at most ${LIMITS.tarEntries}`);
+  for (const role of ["site", "admin"] as const) {
+    const n = build[role].assets.length;
+    if (n > LIMITS.assetsPerWorker)
+      throw new Error(`${role}: ${n} assets; a Worker serves at most ${LIMITS.assetsPerWorker}`);
+    for (const a of build[role].assets)
+      if (a.bytes.length > LIMITS.assetBytes)
+        throw new Error(`${role}/assets${a.path} is above ${LIMITS.assetBytes} bytes`);
+  }
+  const seen = new Set();
+  for (const e of entries) {
+    if (e.bytes.length > LIMITS.partBytes)
+      throw new Error(`${e.path} is above ${LIMITS.partBytes} bytes`);
+    if (seen.has(e.path)) throw new Error(`${e.path} appears twice`);
+    seen.add(e.path);
+  }
+}
+
+/** The bundle path → `{bytes, sha256}` of every part a manifest lists. */
+export function manifestParts(manifest: {
+  workers: Record<
+    "site" | "admin",
+    {
+      modules: readonly { path: string; bytes: number; sha256: string }[];
+      assets: { files: readonly { path: string; bytes: number; sha256: string }[] };
+    }
+  >;
+  starter: { kind: string; path?: string; bytes?: number; sha256?: string };
+}): Map {
+  const out = new Map();
+  for (const role of ["site", "admin"] as const) {
+    const w = manifest.workers[role];
+    for (const m of w.modules) out.set(`${role}/modules/${m.path}`, m);
+    for (const f of w.assets.files) out.set(`${role}/assets${f.path}`, f);
+  }
+  const s = manifest.starter;
+  if (s.kind === "bundle" && s.path && s.bytes !== undefined && s.sha256)
+    out.set(s.path, { bytes: s.bytes, sha256: s.sha256 });
+  return out;
+}
diff --git a/scripts/release-bundle/read-build.ts b/scripts/release-bundle/read-build.ts
new file mode 100644
index 00000000..3b20b0c1
--- /dev/null
+++ b/scripts/release-bundle/read-build.ts
@@ -0,0 +1,178 @@
+/**
+ * Reads the build output `cloudhal deploy --dry-run` leaves in a project (RFC-0046 §4 step 3):
+ *
+ *   /.cloudhal/deploy/out/site/**   site modules (`entry.mjs`, `chunks/*.mjs`), else dist/server
+ *   /dist/client/**                 site assets (`_headers` → headers text, `.assetsignore`)
+ *   /dist/server/wrangler.json      compatibility date and flags (optional)
+ *   /.cloudhal/deploy/out/admin/**  the admin module (`worker.js`)
+ *   /**                              Studio assets (`apps/studio/dist`; `*.map` dropped)
+ *
+ * Source maps, READMEs and Wrangler's own files are never parts. A `_redirects` file is refused:
+ * the manifest has no field for it, so a bundle without it would serve a different site.
+ */
+import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
+import path from "node:path";
+import { ADMIN_CONFIG, type AssetsConfig, SITE_CONFIG } from "./standard.ts";
+
+export interface SourceFile {
+  /** Module path (`chunks/a.mjs`), asset path (`/favicon.svg`) or starter file name. */
+  readonly path: string;
+  readonly bytes: Uint8Array;
+}
+
+export interface WorkerBuild {
+  readonly mainModule: string;
+  readonly modules: readonly SourceFile[];
+  readonly assets: readonly SourceFile[];
+  /** The `_headers` text (sent as `assets.config._headers`, never an asset). */
+  readonly headers: string;
+  readonly config?: AssetsConfig;
+}
+
+export interface BuildOutput {
+  readonly site: WorkerBuild;
+  readonly admin: WorkerBuild;
+  readonly starter?: { readonly file: SourceFile; readonly siteId: string };
+  readonly runtime?: { readonly compatibilityDate: string; readonly compatibilityFlags: string[] };
+}
+
+export interface BuildLayout {
+  readonly project: string;
+  /** Studio assets (`apps/studio/dist`); none → the admin Worker serves no assets. */
+  readonly studio?: string;
+  readonly starter?: { readonly file: string; readonly siteId: string };
+}
+
+const SKIP_MODULE = /(^|\/)(README\.md|wrangler\.json|\.dev\.vars|\.assetsignore)$|\.map$/;
+const SKIP_ASSET = new Set(["_headers", ".assetsignore", "wrangler.json", ".dev.vars"]);
+
+/** Every regular file under `dir`, as `/`-separated paths sorted per directory. */
+export function walk(dir: string, rel = ""): string[] {
+  const out: string[] = [];
+  for (const name of readdirSync(path.join(dir, rel)).sort()) {
+    const r = rel ? `${rel}/${name}` : name;
+    const st = statSync(path.join(dir, r));
+    if (st.isDirectory()) out.push(...walk(dir, r));
+    else if (st.isFile()) out.push(r);
+  }
+  return out;
+}
+
+const escapeRe = (s: string) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*");
+
+/** `.assetsignore` lines → a matcher (`*` within a segment, `**` across; no `/` = basename). */
+export function ignoreMatcher(text: string): (rel: string) => boolean {
+  const rules = text
+    .split(/\r?\n/)
+    .map((l) => l.trim())
+    .filter((l) => l && !l.startsWith("#"))
+    .map((l) => {
+      const p = l.startsWith("/") ? l.slice(1) : l;
+      const re = p.split("**").map(escapeRe).join(".*");
+      return { re: new RegExp(`^${re}$`), base: !p.includes("/") };
+    });
+  return (rel) => rules.some((r) => r.re.test(r.base ? (rel.split("/").pop() ?? rel) : rel));
+}
+
+export function readModules(dir: string): SourceFile[] {
+  return walk(dir)
+    .filter((r) => !SKIP_MODULE.test(r))
+    .map((r) => ({ path: r, bytes: readFileSync(path.join(dir, r)) }));
+}
+
+export function readAssets(
+  dir: string,
+  extraIgnore = "",
+): { assets: SourceFile[]; headers: string } {
+  if (existsSync(path.join(dir, "_redirects")))
+    throw new Error(
+      `${dir}/_redirects: a release manifest cannot carry redirects; remove the file from the build`,
+    );
+  const ignoreFile = path.join(dir, ".assetsignore");
+  const ignored = ignoreMatcher(
+    `${existsSync(ignoreFile) ? readFileSync(ignoreFile, "utf8") : ""}\n${extraIgnore}`,
+  );
+  const headersFile = path.join(dir, "_headers");
+  const assets = walk(dir)
+    .filter((r) => !SKIP_ASSET.has(r) && !ignored(r))
+    .map((r) => ({ path: `/${r}`, bytes: readFileSync(path.join(dir, r)) }));
+  return { assets, headers: existsSync(headersFile) ? readFileSync(headersFile, "utf8") : "" };
+}
+
+function firstDir(...dirs: string[]): string {
+  const d = dirs.find((x) => existsSync(x));
+  if (!d) throw new Error(`none of these exist: ${dirs.join(", ")}`);
+  return d;
+}
+
+/** Compatibility date and flags of the site's generated `wrangler.json`, if any. */
+export function runtimeOf(wranglerJson: string): BuildOutput["runtime"] | undefined {
+  if (!existsSync(wranglerJson)) return undefined;
+  const w = JSON.parse(readFileSync(wranglerJson, "utf8")) as {
+    compatibility_date?: unknown;
+    compatibility_flags?: unknown;
+  };
+  if (typeof w.compatibility_date !== "string") return undefined;
+  const flags = Array.isArray(w.compatibility_flags)
+    ? w.compatibility_flags.filter((f): f is string => typeof f === "string")
+    : [];
+  return { compatibilityDate: w.compatibility_date, compatibilityFlags: [...new Set(flags)] };
+}
+
+const mainOf = (mods: readonly SourceFile[], name: string) => {
+  if (!mods.some((m) => m.path === name)) throw new Error(`no ${name} in the build output`);
+  return name;
+};
+
+/** Reads a Cloudhal build output (see the file comment for the layout). */
+export function readBuildOutput(layout: BuildLayout): BuildOutput {
+  const p = layout.project;
+  const siteModules = readModules(
+    firstDir(path.join(p, ".cloudhal/deploy/out/site"), path.join(p, "dist/server")),
+  );
+  const site = readAssets(firstDir(path.join(p, "dist/client")));
+  const adminModules = readModules(firstDir(path.join(p, ".cloudhal/deploy/out/admin")));
+  const studio = layout.studio ? readAssets(layout.studio, "*.map") : { assets: [], headers: "" };
+  const runtime = runtimeOf(path.join(p, "dist/server/wrangler.json"));
+  const starter = layout.starter;
+  return {
+    site: {
+      mainModule: mainOf(siteModules, "entry.mjs"),
+      modules: siteModules,
+      ...site,
+      config: SITE_CONFIG,
+    },
+    admin: {
+      mainModule: mainOf(adminModules, "worker.js"),
+      modules: adminModules,
+      ...studio,
+      config: ADMIN_CONFIG,
+    },
+    ...(runtime ? { runtime } : {}),
+    ...(starter
+      ? {
+          starter: {
+            file: { path: path.basename(starter.file), bytes: readFileSync(starter.file) },
+            siteId: starter.siteId,
+          },
+        }
+      : {}),
+  };
+}
+
+/** Migration names (`NNNN_name`) of a `@cloudhal/storage`-style migrations directory. */
+export function migrationNames(dir: string): string[] {
+  return readdirSync(dir)
+    .map((f) => /^(\d{4}_[a-z0-9_]+)\.(ts|js|sql)$/.exec(f)?.[1])
+    .filter((n): n is string => n !== undefined)
+    .sort();
+}
+
+/**
+ * The values of the project's generated deploy secrets (`.cloudhal/deploy/secrets.json`): kept in
+ * memory only, to refuse a part that contains one. Never printed.
+ */
+export function readDeploySecrets(file: string): string[] {
+  const doc = JSON.parse(readFileSync(file, "utf8")) as Record;
+  return Object.values(doc).filter((v): v is string => typeof v === "string" && v.length >= 16);
+}
diff --git a/scripts/release-bundle/signature.ts b/scripts/release-bundle/signature.ts
new file mode 100644
index 00000000..fb88f747
--- /dev/null
+++ b/scripts/release-bundle/signature.ts
@@ -0,0 +1,30 @@
+/**
+ * The Ed25519 check of `release-statement.sig` against a pinned raw public key (RFC-0046 §2
+ * step 2). Node built-ins only, so `scripts/publish-ci.ts` can check the signed bundle before it
+ * uploads anything, without a package install; `verify.ts` uses it for the full consumer order.
+ */
+import { createPublicKey, verify as edVerify } from "node:crypto";
+import { SIGNATURE_FILE } from "./statement.ts";
+
+const SPKI_ED25519 = Buffer.from("302a300506032b6570032100", "hex");
+
+/** Ed25519 over the exact statement bytes with the pinned raw public key of its key id. */
+export function verifySignature(
+  statementBytes: Uint8Array,
+  signature: Uint8Array,
+  keyId: string,
+  publicKeys: Readonly>,
+): void {
+  const raw = Object.hasOwn(publicKeys, keyId) ? publicKeys[keyId] : undefined;
+  if (!raw) throw new Error(`no pinned public key for ${keyId}`);
+  const pub = Buffer.from(raw, "base64");
+  if (pub.length !== 32) throw new Error(`the pinned key for ${keyId} is not 32 bytes`);
+  if (signature.length !== 64) throw new Error(`${SIGNATURE_FILE} is not 64 bytes`);
+  const key = createPublicKey({
+    key: Buffer.concat([SPKI_ED25519, pub]),
+    format: "der",
+    type: "spki",
+  });
+  if (!edVerify(null, statementBytes, key, signature))
+    throw new Error(`${SIGNATURE_FILE} does not verify with ${keyId}`);
+}
diff --git a/scripts/release-bundle/standard.ts b/scripts/release-bundle/standard.ts
new file mode 100644
index 00000000..ba1a41b8
--- /dev/null
+++ b/scripts/release-bundle/standard.ts
@@ -0,0 +1,108 @@
+/**
+ * The manifest facts of the standard Cloudhal release (RFC-0046 §3): what a remote deployer needs
+ * to know before it uploads the standard `cloudhal init` starter. They mirror the CLI that builds
+ * the bundle: `packages/cli/src/cloudflare/config.ts` (COMPATIBILITY_DATE, the admin
+ * `run_worker_first`, the SchedulerClock v1 and BuildRoom v2 Durable Objects, the minute cron) and
+ * `packages/cli/src/commands/deploy/state.ts` (SECRET_KEYS, SITE_SECRET_KEYS).
+ * `packages/cli/test/release-bundle.test.ts` fails when the CLI moves and this file does not.
+ */
+
+export interface AssetsConfig {
+  readonly not_found_handling?: "none" | "404-page" | "single-page-application";
+  readonly run_worker_first?: readonly string[];
+}
+
+export const SITE_CONFIG: AssetsConfig = { not_found_handling: "none" };
+export const ADMIN_CONFIG: AssetsConfig = {
+  not_found_handling: "none",
+  run_worker_first: ["/api/*", "/mcp", "/mcp/*", "/.well-known/*"],
+};
+export const DEFAULT_RUNTIME = {
+  compatibilityDate: "2026-10-01",
+  compatibilityFlags: ["nodejs_compat"],
+} as const;
+
+/** The admin Worker's secrets (`SECRET_KEYS`). */
+export const ADMIN_SECRETS = [
+  "BETTER_AUTH_SECRET",
+  "MCP_STATE_KEY",
+  "CLOUDHAL_PREVIEW_SECRET",
+  "CLOUDHAL_PURGE_TOKEN",
+  "CLOUDHAL_DEPLOY_TOKEN",
+  "CLOUDHAL_SETUP_TOKEN",
+  "CLOUDHAL_COMMENTS_KEY",
+] as const;
+/** The site Worker's secrets (`SITE_SECRET_KEYS`). */
+export const SITE_SECRETS = [
+  "CLOUDHAL_PREVIEW_SECRET",
+  "CLOUDHAL_COMMENTS_KEY",
+  "CLOUDHAL_PURGE_TOKEN",
+] as const;
+
+export const DURABLE_OBJECTS = [
+  { name: "SCHEDULER_CLOCK", className: "SchedulerClock", migration: "v1" },
+  { name: "BUILD_ROOMS", className: "BuildRoom", migration: "v2" },
+] as const;
+
+export const ADMIN_CRON = "* * * * *";
+
+const b = (name: string, type: string, required: boolean) => ({ name, type, required });
+
+/** Every manifest field that does not depend on the build output or the release. */
+export const STANDARD_MANIFEST_FIELDS: Readonly> = {
+  contracts: { schemaVersion: 1 },
+  levels: { schedules: 1, gates: 2 },
+  durableObjects: { bindings: DURABLE_OBJECTS, lifecycleChange: false },
+  skills: [],
+  bindingTemplates: {
+    site: {
+      bindings: [
+        b("DB", "d1", true),
+        b("BLOBS", "r2_bucket", true),
+        b("ANALYTICS", "analytics_engine", false),
+        b("ANALYTICS_RL", "ratelimit", false),
+        b("COMMENTS_RL", "ratelimit", false),
+      ],
+      vars: ["CLOUDHAL_SITE_ID", "CLOUDHAL_SITE_HOSTS"],
+      secrets: SITE_SECRETS,
+      cache: true,
+    },
+    admin: {
+      bindings: [
+        b("DB", "d1", true),
+        b("BLOBS", "r2_bucket", true),
+        b("SITE", "service", true),
+        b("ASSETS", "assets", true),
+        b("SCHEDULER_CLOCK", "durable_object_namespace", true),
+        b("BUILD_ROOMS", "durable_object_namespace", true),
+        b("ANALYTICS_SQL", "analytics_sql", false),
+        b("EMAIL", "send_email", false),
+        b("AI", "ai", false),
+        b("BROWSER", "browser", false),
+      ],
+      vars: [
+        "CLOUDHAL_SCHEDULER",
+        "CLOUDHAL_TICK_BUDGET",
+        "CLOUDHAL_AUTOMATIONS",
+        "CLOUDHAL_COMMENTS_AVAILABLE",
+        "CLOUDHAL_SITE_ID",
+      ],
+      secrets: ADMIN_SECRETS,
+      cron: ADMIN_CRON,
+    },
+  },
+  requiredScopes: ["account-settings.read", "workers-scripts.edit", "d1.write", "workers-r2.write"],
+  optionalScopes: ["account-analytics.read", "workers-routes.write", "dns.write"],
+  requiredCapabilities: ["account", "workers", "d1", "r2"],
+  optionalCapabilities: ["analytics", "domains"],
+  features: {
+    adminSitesAcceptsSiteId: false,
+    healthReportsVersion: false,
+    mediaTransformVar: false,
+  },
+};
+
+/** The starter `cloudhal init` copies when none is named. */
+export const STANDARD_STARTER = "blog";
+/** `toolchain.producer` of every bundle this builder writes. */
+export const PRODUCER = "cloudhal-release-bundle";
diff --git a/scripts/release-bundle/statement.ts b/scripts/release-bundle/statement.ts
new file mode 100644
index 00000000..dc6dd734
--- /dev/null
+++ b/scripts/release-bundle/statement.ts
@@ -0,0 +1,113 @@
+/**
+ * The release statement (`cloudhal.release-statement@1`, RFC-0046 §2): the small canonical
+ * document that pins the manifest and the tarball by name, SHA-256 and size, and the only file the
+ * project key signs. The builder writes it unsigned; `bundle-sign` signs its exact bytes.
+ */
+import { canonicalJson, channelOf, LIMITS, sha256 } from "./manifest.ts";
+
+export const STATEMENT_FILE = "release-statement.json";
+export const SIGNATURE_FILE = "release-statement.sig";
+export const MANIFEST_FILE = "manifest.json";
+export const tarballName = (version: string): string => `cloudhal-release-${version}.tar.gz`;
+export const sigstoreName = (version: string): string =>
+  `cloudhal-release-${version}.sigstore.json`;
+
+/** The five assets of a release (§1), in upload order. */
+export const releaseAssetNames = (version: string): string[] => [
+  tarballName(version),
+  MANIFEST_FILE,
+  STATEMENT_FILE,
+  SIGNATURE_FILE,
+  sigstoreName(version),
+];
+
+export interface StatementAsset {
+  readonly name: string;
+  readonly sha256: string;
+  readonly bytes: number;
+}
+
+export interface Statement {
+  readonly schemaVersion: 1;
+  readonly format: "cloudhal.release-statement@1";
+  readonly version: string;
+  readonly channel: "stable" | "next";
+  readonly securityRelease: boolean;
+  readonly commit: string;
+  readonly createdAt: string;
+  readonly signing: {
+    readonly keyId: string;
+    readonly algorithm: "Ed25519";
+    readonly signature: "release-statement.sig";
+  };
+  readonly manifest: StatementAsset;
+  readonly tarball: StatementAsset;
+}
+
+/** The fields the statement shares with the manifest (§2 invariant 5). */
+interface ManifestFacts {
+  readonly version: string;
+  readonly channel: "stable" | "next";
+  readonly securityRelease: boolean;
+  readonly commit: string;
+  readonly createdAt: string;
+  readonly signing: { readonly keyId: string };
+}
+
+const asset = (name: string, bytes: Uint8Array): StatementAsset => ({
+  name,
+  sha256: sha256(bytes),
+  bytes: bytes.length,
+});
+
+/** The statement of `manifest` (its bytes) and `tarball`, as a document and canonical bytes. */
+export function statementOf(
+  manifest: ManifestFacts,
+  manifestBytes: Uint8Array,
+  tarball: Uint8Array,
+): { statement: Statement; bytes: Uint8Array } {
+  const statement: Statement = {
+    schemaVersion: 1,
+    format: "cloudhal.release-statement@1",
+    version: manifest.version,
+    channel: manifest.channel,
+    securityRelease: manifest.securityRelease,
+    commit: manifest.commit,
+    createdAt: manifest.createdAt,
+    signing: { keyId: manifest.signing.keyId, algorithm: "Ed25519", signature: SIGNATURE_FILE },
+    manifest: asset(MANIFEST_FILE, manifestBytes),
+    tarball: asset(tarballName(manifest.version), tarball),
+  };
+  const bytes = new TextEncoder().encode(canonicalJson(statement));
+  checkStatement(statement, bytes.length);
+  return { statement, bytes };
+}
+
+/** §2 invariants 1, 2, 3 and 6, which the schema cannot express. */
+export function checkStatement(s: Statement, serialisedBytes: number): void {
+  if (s.manifest.name !== MANIFEST_FILE)
+    throw new Error(`statement.manifest.name is not ${MANIFEST_FILE}`);
+  if (s.tarball.name !== tarballName(s.version))
+    throw new Error(`statement.tarball.name is not ${tarballName(s.version)}`);
+  if (s.manifest.bytes > LIMITS.manifestBytes)
+    throw new Error(`the manifest is above ${LIMITS.manifestBytes} bytes`);
+  if (s.channel !== channelOf(s.version))
+    throw new Error(`channel ${s.channel} does not match version ${s.version}`);
+  if (serialisedBytes > LIMITS.statementBytes)
+    throw new Error(`the statement is above ${LIMITS.statementBytes} bytes`);
+}
+
+/** §2 invariants 4 and 5: the statement and the manifest describe the same release. */
+export function checkStatementManifest(s: Statement, m: ManifestFacts): void {
+  if (s.signing.keyId !== m.signing.keyId)
+    throw new Error("the statement and the manifest name different keys");
+  for (const k of ["version", "channel", "securityRelease", "commit", "createdAt"] as const)
+    if (s[k] !== m[k]) throw new Error(`the statement's ${k} is not the manifest's`);
+}
+
+/** §2 invariant 7: the statement is for the version (and channel) that was asked for. */
+export function checkRequested(s: Statement, version: string, channel?: string): void {
+  if (s.version !== version) throw new Error(`the statement is for ${s.version}, not ${version}`);
+  const want = channel ?? channelOf(version);
+  if (s.channel !== want) throw new Error(`the statement is for channel ${s.channel}, not ${want}`);
+}
diff --git a/scripts/release-bundle/tar-read.ts b/scripts/release-bundle/tar-read.ts
new file mode 100644
index 00000000..ec242fe7
--- /dev/null
+++ b/scripts/release-bundle/tar-read.ts
@@ -0,0 +1,110 @@
+/**
+ * Reads a release tarball back for `verify` (RFC-0046 §7): it accepts exactly what `tar.ts`
+ * writes and refuses everything else. Gzip must carry the fixed header (no flags, mtime 0, OS
+ * 255); tar headers must have a valid checksum, `ustar\0` + `00`, mode 0644, uid/gid 0, no owner
+ * names, link name or prefix, the release's mtime, and typeflag `0` (or `x` with a single PAX
+ * `path` record for the entry that follows). Links, directories and every other type are refused.
+ */
+import { gunzipCapped } from "../../packages/cli/src/archive/gzip.ts";
+import { LIMITS } from "./manifest.ts";
+import type { SourceFile } from "./read-build.ts";
+
+const dec = new TextDecoder("utf-8", { fatal: true, ignoreBOM: false });
+
+/** The largest decompressed tar a release may hold: every part at its limit is far beyond it. */
+export const MAX_TAR_BYTES = 1024 * 1024 * 1024;
+
+const GZIP_FIXED = [0x1f, 0x8b, 8, 0, 0, 0, 0, 0];
+
+/** Refuses a gzip stream whose header is not the fixed one (§7). */
+export function checkGzipHeader(gz: Uint8Array): void {
+  if (gz.length < 18) throw new Error("the tarball is not gzip");
+  for (let i = 0; i < GZIP_FIXED.length; i++)
+    if (gz[i] !== GZIP_FIXED[i]) throw new Error("the tarball's gzip header is not the fixed one");
+  if (gz[9] !== 255) throw new Error("the tarball's gzip OS byte is not 255");
+}
+
+async function* once(bytes: Uint8Array): AsyncGenerator {
+  yield bytes;
+}
+
+export async function gunzipBytes(gz: Uint8Array, max = MAX_TAR_BYTES): Promise {
+  const chunks: Uint8Array[] = [];
+  for await (const c of gunzipCapped(once(gz), max)) chunks.push(c);
+  return new Uint8Array(Buffer.concat(chunks));
+}
+
+const str = (h: Uint8Array, at: number, len: number) => {
+  const s = h.subarray(at, at + len);
+  const end = s.indexOf(0);
+  return dec.decode(end >= 0 ? s.subarray(0, end) : s);
+};
+
+function num(h: Uint8Array, at: number, len: number, what: string): number {
+  const s = str(h, at, len).trim();
+  if (!/^[0-7]{1,11}$/.test(s)) throw new Error(`a tar header has a bad ${what} field`);
+  return Number.parseInt(s, 8);
+}
+
+function checksumOk(h: Uint8Array): boolean {
+  let sum = 0;
+  for (let i = 0; i < 512; i++) sum += i >= 148 && i < 156 ? 0x20 : (h[i] as number);
+  return num(h, 148, 8, "checksum") === sum;
+}
+
+function checkHeader(h: Uint8Array, mtime: number): { name: string; size: number; flag: string } {
+  if (!checksumOk(h)) throw new Error("a tar header has a bad checksum");
+  if (str(h, 257, 6) !== "ustar" || h[262] !== 0 || str(h, 263, 2) !== "00")
+    throw new Error("a tar header is not ustar");
+  const flag = h[156] === 0 ? "0" : String.fromCharCode(h[156] as number);
+  if (flag !== "0" && flag !== "x") throw new Error(`a tar entry has type ${flag}: not a file`);
+  if (num(h, 100, 8, "mode") !== 0o644) throw new Error("a tar entry's mode is not 0644");
+  if (num(h, 108, 8, "uid") !== 0 || num(h, 116, 8, "gid") !== 0)
+    throw new Error("a tar entry's uid or gid is not 0");
+  if (num(h, 136, 12, "mtime") !== mtime) throw new Error("a tar entry's mtime is not createdAt");
+  if (str(h, 157, 100) || str(h, 265, 32) || str(h, 297, 32) || str(h, 345, 155))
+    throw new Error("a tar entry has a link name, an owner name or a prefix");
+  return { name: str(h, 0, 100), size: num(h, 124, 12, "size"), flag };
+}
+
+/** The `path` of a PAX header holding exactly one `path` record. */
+function paxPath(data: Uint8Array): string {
+  const text = dec.decode(data);
+  const sp = text.indexOf(" ");
+  const len = Number(text.slice(0, sp));
+  if (sp < 1 || len !== data.length || !text.endsWith("\n") || !text.startsWith(`${len} path=`))
+    throw new Error("a PAX header is not a single path record");
+  return text.slice(sp + 6, -1);
+}
+
+/** The entries of `tar`, in archive order, checked against §7 (see the file comment). */
+export function readTar(tar: Uint8Array, mtime: number): SourceFile[] {
+  const out: SourceFile[] = [];
+  let at = 0;
+  let pending: string | null = null;
+  for (;;) {
+    if (at + 512 > tar.length) throw new Error("the tar archive is truncated");
+    const h = tar.subarray(at, at + 512);
+    if (h.every((x) => x === 0)) break;
+    const { name, size, flag } = checkHeader(h, mtime);
+    const data = tar.subarray(at + 512, at + 512 + size);
+    if (data.length !== size) throw new Error("the tar archive is truncated");
+    at += 512 + Math.ceil(size / 512) * 512;
+    if (flag === "x") {
+      if (pending !== null) throw new Error("two PAX headers in a row");
+      pending = paxPath(data);
+      continue;
+    }
+    const path = pending ?? name;
+    if (pending !== null && pending.slice(-100) !== name)
+      throw new Error(`${path}: the ustar name is not the end of the PAX path`);
+    pending = null;
+    out.push({ path, bytes: data });
+    if (out.length > LIMITS.tarEntries)
+      throw new Error(`the tarball has more than ${LIMITS.tarEntries} entries`);
+  }
+  if (pending !== null) throw new Error("a PAX header ends the archive");
+  if (tar.subarray(at, at + 1024).length !== 1024 || tar.subarray(at).some((x) => x !== 0))
+    throw new Error("the tar archive does not end with zero blocks only");
+  return out;
+}
diff --git a/scripts/release-bundle/tar.ts b/scripts/release-bundle/tar.ts
new file mode 100644
index 00000000..e4c1d1f9
--- /dev/null
+++ b/scripts/release-bundle/tar.ts
@@ -0,0 +1,87 @@
+/**
+ * The release tarball's byte format (RFC-0046 §7): ustar entries, regular files only, mode 0644,
+ * uid/gid 0, empty user and group names, one fixed mtime (the release's `createdAt`), sorted by
+ * path, a PAX `path` record before any entry whose name is longer than 100 bytes or not printable
+ * ASCII, two zero blocks at the end; then gzip with RFC-0039's fixed parameters and header
+ * (`gzipFixed`: mtime 0, no name, OS 255). The same parts give the same bytes on every run.
+ */
+import { gzipFixed } from "../../packages/cli/src/archive/gzip.ts";
+import type { SourceFile } from "./read-build.ts";
+
+const enc = new TextEncoder();
+
+function field(h: Uint8Array, at: number, len: number, value: string): void {
+  h.set(enc.encode(value).subarray(0, len), at);
+}
+
+function octal(h: Uint8Array, at: number, len: number, value: number): void {
+  field(h, at, len, `${value.toString(8).padStart(len - 1, "0")}\0`);
+}
+
+/** One 512-byte ustar header: mode 0644, uid/gid 0, no owner names, link name or prefix. */
+export function tarHeader(name: string, size: number, flag: "0" | "x", mtime: number): Uint8Array {
+  const h = new Uint8Array(512);
+  field(h, 0, 100, name);
+  octal(h, 100, 8, 0o644);
+  octal(h, 108, 8, 0);
+  octal(h, 116, 8, 0);
+  octal(h, 124, 12, size);
+  octal(h, 136, 12, mtime);
+  h.fill(0x20, 148, 156);
+  h[156] = flag.charCodeAt(0);
+  field(h, 257, 6, "ustar\0");
+  field(h, 263, 2, "00");
+  let sum = 0;
+  for (const x of h) sum += x;
+  field(h, 148, 8, `${sum.toString(8).padStart(6, "0")}\0 `);
+  return h;
+}
+
+/** One PAX `path` record: `" path=\n"`, `len` counting itself. */
+export function paxRecord(p: string): Uint8Array {
+  const body = ` path=${p}\n`;
+  const n = enc.encode(body).length;
+  let len = n + 1;
+  while (n + String(len).length !== len) len = n + String(len).length;
+  return enc.encode(`${len}${body}`);
+}
+
+const pad = (n: number) => new Uint8Array((512 - (n % 512)) % 512);
+
+/** Whether `p` needs a PAX `path` record (longer than the 100-byte name field, or not ASCII). */
+export function needsPax(p: string): boolean {
+  if (enc.encode(p).length > 100) return true;
+  for (let i = 0; i < p.length; i++) {
+    const c = p.charCodeAt(i);
+    if (c < 0x20 || c > 0x7e) return true;
+  }
+  return false;
+}
+
+/** The ustar archive of `entries` in the order given (callers sort them), two zero end blocks. */
+export function tarArchive(entries: readonly SourceFile[], mtime: number): Uint8Array {
+  if (!Number.isSafeInteger(mtime) || mtime < 0)
+    throw new Error("tar mtime must be a whole second");
+  const out: Uint8Array[] = [];
+  for (const e of entries) {
+    if (needsPax(e.path)) {
+      const rec = paxRecord(e.path);
+      out.push(tarHeader("././@PaxHeader", rec.length, "x", mtime), rec, pad(rec.length));
+    }
+    out.push(tarHeader(e.path.slice(-100), e.bytes.length, "0", mtime), e.bytes);
+    out.push(pad(e.bytes.length));
+  }
+  out.push(new Uint8Array(1024));
+  return Buffer.concat(out);
+}
+
+async function* once(bytes: Uint8Array): AsyncGenerator {
+  yield bytes;
+}
+
+/** `bytes` gzip-compressed with RFC-0039's fixed parameters and header. */
+export async function gzipBytes(bytes: Uint8Array): Promise {
+  const chunks: Uint8Array[] = [];
+  for await (const c of gzipFixed(once(bytes))) chunks.push(c);
+  return new Uint8Array(Buffer.concat(chunks));
+}
diff --git a/scripts/release-bundle/verify.ts b/scripts/release-bundle/verify.ts
new file mode 100644
index 00000000..7590254c
--- /dev/null
+++ b/scripts/release-bundle/verify.ts
@@ -0,0 +1,114 @@
+/**
+ * Verifies a release bundle directory the way a consumer would (RFC-0046 §2), plus the producer's
+ * own format checks: canonical bytes, both schemas, §2's invariants (including invariant 7 against
+ * the requested version and channel), §7's limits and tar format, every listed part present with
+ * its size and SHA-256, no unlisted entry, and, when asked, the Ed25519 signature with a pinned key.
+ * The builder runs it over its own unsigned output; `release-bundle.ts verify` runs it on demand.
+ */
+import { existsSync, readFileSync } from "node:fs";
+import path from "node:path";
+import { assertValid, type ReleaseManifest } from "../../packages/core-contracts/src/index.ts";
+import { canonicalJson, LIMITS, manifestParts, sha256 } from "./manifest.ts";
+import { verifySignature } from "./signature.ts";
+import {
+  checkRequested,
+  checkStatement,
+  checkStatementManifest,
+  MANIFEST_FILE,
+  SIGNATURE_FILE,
+  STATEMENT_FILE,
+  type Statement,
+  type StatementAsset,
+  tarballName,
+} from "./statement.ts";
+import { checkGzipHeader, gunzipBytes, readTar } from "./tar-read.ts";
+
+export interface VerifyOptions {
+  /** The version the caller asked for (§2 invariant 7). */
+  readonly version: string;
+  /** The channel the caller asked for; default the one the version implies. */
+  readonly channel?: string;
+  /** Require `release-statement.sig` and verify it with `publicKeys[keyId]` (raw, base64). */
+  readonly signed?: boolean;
+  readonly publicKeys?: Readonly>;
+}
+
+export interface VerifyResult {
+  readonly statement: Statement;
+  readonly parts: number;
+  readonly tarballBytes: number;
+}
+
+function read(dir: string, name: string, max: number): Uint8Array {
+  const p = path.join(dir, name);
+  if (!existsSync(p)) throw new Error(`${name} is missing`);
+  const bytes = readFileSync(p);
+  if (bytes.length > max) throw new Error(`${name} is above ${max} bytes`);
+  return bytes;
+}
+
+const text = (b: Uint8Array) =>
+  new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(b);
+
+/** The document of `bytes`, refused unless they are its canonical serialisation. */
+function canonicalDoc(name: string, bytes: Uint8Array): unknown {
+  const doc = JSON.parse(text(bytes)) as unknown;
+  if (canonicalJson(doc) !== text(bytes)) throw new Error(`${name} is not canonical JSON`);
+  return doc;
+}
+
+function checkAsset(name: string, bytes: Uint8Array, want: StatementAsset): void {
+  if (bytes.length !== want.bytes || sha256(bytes) !== want.sha256)
+    throw new Error(`${name} does not match the statement's size and SHA-256`);
+}
+
+/** Every tar entry is a listed part with its size and SHA-256, and every listed part is there. */
+export function checkParts(
+  entries: readonly { path: string; bytes: Uint8Array }[],
+  manifest: ReleaseManifest,
+): void {
+  const listed = manifestParts(manifest);
+  let previous = "";
+  for (const e of entries) {
+    if (e.path <= previous) throw new Error(`${e.path}: tar entries are not sorted and unique`);
+    previous = e.path;
+    const want = listed.get(e.path);
+    if (!want) throw new Error(`${e.path} is in the tarball but not in the manifest`);
+    if (e.bytes.length !== want.bytes || sha256(e.bytes) !== want.sha256)
+      throw new Error(`${e.path} does not match the manifest's size and SHA-256`);
+    listed.delete(e.path);
+  }
+  const left = [...listed.keys()];
+  if (left.length) throw new Error(`${left[0]} is in the manifest but not in the tarball`);
+}
+
+/** Verifies the bundle in `dir` (see the file comment); throws on the first problem. */
+export async function verifyBundle(dir: string, o: VerifyOptions): Promise {
+  const statementBytes = read(dir, STATEMENT_FILE, LIMITS.statementBytes);
+  const doc = canonicalDoc(STATEMENT_FILE, statementBytes);
+  if (o.signed) {
+    const keyId = (doc as { signing?: { keyId?: unknown } }).signing?.keyId;
+    const sig = read(dir, SIGNATURE_FILE, 64);
+    verifySignature(statementBytes, sig, String(keyId), o.publicKeys ?? {});
+  }
+  assertValid("release-statement", doc);
+  const statement = doc as Statement;
+  checkRequested(statement, o.version, o.channel);
+  checkStatement(statement, statementBytes.length);
+
+  const manifestBytes = read(dir, MANIFEST_FILE, LIMITS.manifestBytes);
+  checkAsset(MANIFEST_FILE, manifestBytes, statement.manifest);
+  const manifest = canonicalDoc(MANIFEST_FILE, manifestBytes);
+  assertValid("release-manifest", manifest);
+  checkStatementManifest(statement, manifest);
+  if (manifest.signing.signature !== SIGNATURE_FILE)
+    throw new Error(`${MANIFEST_FILE} does not name ${SIGNATURE_FILE}`);
+
+  const name = tarballName(o.version);
+  const tarball = read(dir, name, LIMITS.tarballBytes);
+  checkAsset(name, tarball, statement.tarball);
+  checkGzipHeader(tarball);
+  const entries = readTar(await gunzipBytes(tarball), Date.parse(statement.createdAt) / 1000);
+  checkParts(entries, manifest);
+  return { statement, parts: entries.length, tarballBytes: tarball.length };
+}
diff --git a/scripts/release-plan.ts b/scripts/release-plan.ts
index 6aa08502..bfdba618 100644
--- a/scripts/release-plan.ts
+++ b/scripts/release-plan.ts
@@ -54,6 +54,34 @@ export async function unpublished(
 /** Lines the fixed group writes into every changelog: dependency bumps, not changes. */
 const DEPENDENCY_ONLY = /^- (?:Updated dependencies|(?:@cloudhal\/[a-z-]+|create-cloudhal)@\d)/;
 
+/** The entries under `## ` of one changelog, without their changeset hash. */
+function versionEntries(text: string, version: string): string[] {
+  const start = text.indexOf(`\n## ${version}\n`);
+  if (start === -1) return [];
+  const rest = text.slice(start + version.length + 5);
+  const end = rest.search(/\n## /);
+  return rest
+    .slice(0, end === -1 ? undefined : end)
+    .split(/\n(?=- )/)
+    .map((block) => block.trim())
+    .filter((entry) => entry.startsWith("- "))
+    .map((entry) => entry.replace(/^- [0-9a-f]{7,40}: /, "- "));
+}
+
+/**
+ * How a changeset marks its release a security release (`securityRelease` in the bundle's
+ * manifest and statement, RFC-0046): its summary starts with exactly this. The release PR shows it
+ * in the changelog diff, so the reviewers who merge it see the flag.
+ */
+export const SECURITY_RELEASE_MARKER = "**Security release.**";
+
+/** Whether any package's `## ` entries carry `SECURITY_RELEASE_MARKER`. */
+export function isSecurityRelease(changelogs: readonly string[], version: string): boolean {
+  return changelogs.some((text) =>
+    versionEntries(text, version).some((e) => e.startsWith(`- ${SECURITY_RELEASE_MARKER}`)),
+  );
+}
+
 /**
  * Release notes for `version`: every entry under `## ` in the packages' changelogs,
  * once each, without the "Updated dependencies" lines the fixed group adds to every package.
@@ -61,21 +89,12 @@ const DEPENDENCY_ONLY = /^- (?:Updated dependencies|(?:@cloudhal\/[a-z-]+|create
 export function releaseNotes(changelogs: readonly string[], version: string): string {
   const seen = new Set();
   const entries: string[] = [];
-  for (const text of changelogs) {
-    const start = text.indexOf(`\n## ${version}\n`);
-    if (start === -1) continue;
-    const rest = text.slice(start + version.length + 5);
-    const section = rest.slice(0, rest.search(/\n## /) === -1 ? undefined : rest.search(/\n## /));
-    for (const block of section.split(/\n(?=- )/)) {
-      const entry = block.trim();
-      if (!entry.startsWith("- ") || DEPENDENCY_ONLY.test(entry)) continue;
-      const text = entry.replace(/^- [0-9a-f]{7,40}: /, "- ");
-      if (!seen.has(text)) {
-        seen.add(text);
-        entries.push(text);
-      }
+  for (const text of changelogs)
+    for (const entry of versionEntries(text, version)) {
+      if (DEPENDENCY_ONLY.test(entry) || seen.has(entry)) continue;
+      seen.add(entry);
+      entries.push(entry);
     }
-  }
   return [
     `All \`@cloudhal/*\` packages and \`create-cloudhal\` at **${version}**.`,
     "",
diff --git a/scripts/release-publish-plan.ts b/scripts/release-publish-plan.ts
new file mode 100644
index 00000000..8aa05b48
--- /dev/null
+++ b/scripts/release-publish-plan.ts
@@ -0,0 +1,344 @@
+/**
+ * The publication order of a release on immutable GitHub Releases (RFC-0046 §6), as a pure
+ * planner plus a driver over an injected port, used by `scripts/publish-ci.ts` and its tests:
+ *
+ *   1 tag `v` on the release commit (= HEAD = the statement's commit)
+ *   2 draft release (`--verify-tag`, never Latest)
+ *   3 upload the five assets while it is a draft (`--clobber`), re-read, compare names + digests
+ *   4 npm publish
+ *   5 re-check the draft immediately before publishing
+ *   6 publish the release (`--latest` only for the highest stable version)
+ *
+ * `planPublish` looks at what exists and returns the steps still to run, so a rerun resumes from
+ * the first incomplete one. A published release is never changed: one that lacks or differs in an
+ * asset is refused (the fix is a new version). Without a bundle the same order runs with no assets,
+ * and both checks require that the draft has none. A dry run uses `v-dryrun.`, pushes no tag
+ * (a draft with `--target` creates none), runs `npm publish --dry-run` (with `--force` for a version
+ * npm already has: see `npmFlags`) and deletes its draft.
+ * Node built-ins only: the release workflow runs the planning part without a pnpm install.
+ */
+
+export interface LocalAsset {
+  readonly name: string;
+  /** SHA-256, lowercase hex. */
+  readonly sha256: string;
+  readonly bytes: number;
+  /** Where publish-ci reads it from. */
+  readonly file: string;
+}
+
+export interface RemoteAsset {
+  readonly name: string;
+  /** GitHub's `sha256:`; null for assets uploaded before GitHub recorded digests. */
+  readonly digest: string | null;
+  readonly size: number;
+}
+
+export interface ReleaseView {
+  readonly draft: boolean;
+  readonly prerelease: boolean;
+  readonly assets: readonly RemoteAsset[];
+}
+
+export interface PublishInput {
+  readonly version: string;
+  /** The checked-out commit (`git rev-parse HEAD`). */
+  readonly head: string;
+  readonly dryRun: boolean;
+  /** `GITHUB_RUN_NUMBER`, for the dry run's tag. */
+  readonly runNumber?: string;
+  /** The signed bundle (`--bundle-dir`), or null when `RELEASE_BUNDLES` is off. */
+  readonly bundle: {
+    readonly statement: {
+      readonly version: string;
+      readonly channel: string;
+      readonly commit: string;
+    };
+    readonly assets: readonly LocalAsset[];
+  } | null;
+  /** The commit the remote tag names, or null when there is no tag. */
+  readonly tagCommit: string | null;
+  /** Every release (draft or not) whose tag name is the tag. */
+  readonly releases: readonly ReleaseView[];
+  /** Every package of the fixed group, in publish order (a dry run publishes them all with `--dry-run`). */
+  readonly packages: readonly string[];
+  /** Packages npm does not have yet. */
+  readonly npmMissing: readonly string[];
+  /** Every `v*` tag name, for the Latest decision. */
+  readonly tags: readonly string[];
+}
+
+export type Step =
+  | { readonly kind: "push-tag"; readonly tag: string; readonly commit: string }
+  | {
+      readonly kind: "create-draft";
+      readonly tag: string;
+      readonly prerelease: boolean;
+      /** Dry run: the commit the draft points at, since no tag exists. */
+      readonly target?: string;
+    }
+  | { readonly kind: "sync-assets"; readonly tag: string }
+  | { readonly kind: "check-assets"; readonly tag: string; readonly before: "npm" | "publish" }
+  | {
+      readonly kind: "npm-publish";
+      readonly packages: readonly NpmPublish[];
+      readonly dryRun: boolean;
+    }
+  | {
+      readonly kind: "publish-release";
+      readonly tag: string;
+      readonly latest: boolean;
+      readonly prerelease: boolean;
+    }
+  | { readonly kind: "delete-draft"; readonly tag: string };
+
+export class PublishRefusal extends Error {
+  override readonly name = "PublishRefusal";
+}
+
+/** One `npm publish  ...flags` call. */
+export interface NpmPublish {
+  readonly name: string;
+  readonly flags: readonly string[];
+}
+
+/**
+ * The flags of one `npm publish` of a packed tarball. npm (11 and 12) asks the registry even with
+ * `--dry-run` and refuses a version it already has ("You cannot publish over the previously
+ * published versions") or one not above its highest ("Cannot implicitly apply the latest tag"),
+ * unless `--force`; with `--dry-run` it never uploads. So a dry run passes `--force` exactly for a
+ * version npm already has, and keeps the real checks for one it lacks. A real publish never
+ * forces and never republishes. npm 12 also refuses a prerelease without `--tag`: it goes to `next`.
+ */
+export function npmFlags(o: { prerelease: boolean; dryRun: boolean; onNpm: boolean }): string[] {
+  if (o.onNpm && !o.dryRun) throw new PublishRefusal("npm already has this version");
+  return [
+    "--access",
+    "public",
+    "--ignore-scripts",
+    ...(o.prerelease ? ["--tag", "next"] : []),
+    // OIDC: npm exchanges the workflow's identity token; provenance is attached automatically.
+    ...(o.dryRun ? ["--dry-run", ...(o.onNpm ? ["--force"] : [])] : ["--provenance"]),
+  ];
+}
+
+const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-[0-9A-Za-z.-]+)?$/;
+const STABLE = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
+
+/** Whether `version` is stable and no stable `v*` tag is higher (the Latest rule). */
+export function isHighestStable(version: string, tags: readonly string[]): boolean {
+  if (!STABLE.test(`v${version}`)) return false;
+  const key = (t: string) => t.slice(1).split(".").map(Number) as [number, number, number];
+  const [a, b, c] = key(`v${version}`);
+  return tags
+    .filter((t) => STABLE.test(t))
+    .map(key)
+    .every(([x, y, z]) => x < a || (x === a && (y < b || (y === b && z <= c))));
+}
+
+export const tagFor = (input: Pick): string => {
+  if (!input.dryRun) return `v${input.version}`;
+  if (!input.runNumber || !/^\d+$/.test(input.runNumber))
+    throw new PublishRefusal("a dry run needs GITHUB_RUN_NUMBER");
+  return `v${input.version}-dryrun.${input.runNumber}`;
+};
+
+export interface AssetProblems {
+  readonly missing: readonly string[];
+  readonly unexpected: readonly string[];
+  readonly mismatched: readonly string[];
+}
+
+/** How the release's assets differ from exactly the expected names and digests. */
+export function assetProblems(
+  expected: readonly LocalAsset[],
+  release: ReleaseView,
+): AssetProblems {
+  const remote = new Map(release.assets.map((a) => [a.name, a]));
+  const want = new Set(expected.map((a) => a.name));
+  return {
+    missing: expected.filter((a) => !remote.has(a.name)).map((a) => a.name),
+    unexpected: release.assets.filter((a) => !want.has(a.name)).map((a) => a.name),
+    mismatched: expected
+      .filter((a) => {
+        const r = remote.get(a.name);
+        return r !== undefined && (r.digest !== `sha256:${a.sha256}` || r.size !== a.bytes);
+      })
+      .map((a) => a.name),
+  };
+}
+
+export const clean = (p: AssetProblems): boolean =>
+  p.missing.length + p.unexpected.length + p.mismatched.length === 0;
+
+export function describeProblems(p: AssetProblems): string {
+  const parts = [
+    p.missing.length ? `missing ${p.missing.join(", ")}` : "",
+    p.unexpected.length ? `unexpected ${p.unexpected.join(", ")}` : "",
+    p.mismatched.length ? `wrong digest ${p.mismatched.join(", ")}` : "",
+  ];
+  return parts.filter(Boolean).join("; ");
+}
+
+/** The bundle belongs to this release and this commit (RFC-0046 §6 step 1, §2 invariant 7). */
+function checkBundle(input: PublishInput): void {
+  const b = input.bundle;
+  if (!b) return;
+  const s = b.statement;
+  if (s.version !== input.version)
+    throw new PublishRefusal(`the bundle is for ${s.version}, not ${input.version}`);
+  if (s.channel !== (input.version.includes("-") ? "next" : "stable"))
+    throw new PublishRefusal(`the bundle's channel ${s.channel} does not match ${input.version}`);
+  if (s.commit !== input.head)
+    throw new PublishRefusal(`the bundle was built from ${s.commit}, but HEAD is ${input.head}`);
+}
+
+/** The steps still to run (see the file comment); throws `PublishRefusal` when none may. */
+export function planPublish(input: PublishInput): Step[] {
+  if (!SEMVER.test(input.version)) throw new PublishRefusal(`not a version: ${input.version}`);
+  checkBundle(input);
+  const tag = tagFor(input);
+  const prerelease = input.version.includes("-");
+  const expected = input.bundle?.assets ?? [];
+  if (input.releases.length > 1)
+    throw new PublishRefusal(`${input.releases.length} releases use the tag ${tag}; keep one`);
+  const release = input.releases[0] ?? null;
+  const steps: Step[] = [];
+  if (input.dryRun) {
+    if (release && !release.draft)
+      throw new PublishRefusal(`${tag} is published; a dry run never is`);
+    if (!release) steps.push({ kind: "create-draft", tag, prerelease, target: input.head });
+    steps.push({ kind: "sync-assets", tag }, { kind: "check-assets", tag, before: "npm" });
+    const packages = input.packages.map((name) => ({
+      name,
+      flags: npmFlags({ prerelease, dryRun: true, onNpm: !input.npmMissing.includes(name) }),
+    }));
+    steps.push({ kind: "npm-publish", packages, dryRun: true });
+    steps.push({ kind: "check-assets", tag, before: "publish" }, { kind: "delete-draft", tag });
+    return steps;
+  }
+  if (input.tagCommit === null) steps.push({ kind: "push-tag", tag, commit: input.head });
+  else if (input.tagCommit !== input.head)
+    throw new PublishRefusal(
+      `${tag} points at ${input.tagCommit}, not the release commit ${input.head}`,
+    );
+  const packages = input.npmMissing.map((name) => ({
+    name,
+    flags: npmFlags({ prerelease, dryRun: false, onNpm: false }),
+  }));
+  const npm: Step[] = packages.length ? [{ kind: "npm-publish", packages, dryRun: false }] : [];
+  if (release && !release.draft) {
+    const p = assetProblems(expected, release);
+    if (!clean(p))
+      throw new PublishRefusal(
+        `${tag} is published and immutable, and its assets are not the bundle's (${describeProblems(p)}); release a new version`,
+      );
+    return [...steps, ...npm];
+  }
+  if (!release) steps.push({ kind: "create-draft", tag, prerelease });
+  steps.push({ kind: "sync-assets", tag }, { kind: "check-assets", tag, before: "npm" }, ...npm);
+  steps.push({ kind: "check-assets", tag, before: "publish" });
+  steps.push({
+    kind: "publish-release",
+    tag,
+    latest: isHighestStable(input.version, input.tags),
+    prerelease,
+  });
+  return steps;
+}
+
+/** What the driver needs from GitHub, git and npm. */
+export interface PublishPort {
+  pushTag(tag: string, commit: string): void;
+  releases(tag: string): ReleaseView[];
+  createDraft(tag: string, o: { prerelease: boolean; target?: string }): void;
+  upload(tag: string, assets: readonly LocalAsset[]): void;
+  deleteAsset(tag: string, name: string): void;
+  npmPublish(packages: readonly NpmPublish[], dryRun: boolean): Promise;
+  publishRelease(tag: string, o: { latest: boolean; prerelease: boolean }): void;
+  deleteRelease(tag: string): void;
+  log(message: string): void;
+}
+
+function draftOf(port: PublishPort, tag: string): ReleaseView {
+  const found = port.releases(tag);
+  const r = found.length === 1 ? found[0] : undefined;
+  if (!r) throw new PublishRefusal(`expected one release ${tag}, found ${found.length}`);
+  if (!r.draft) throw new PublishRefusal(`${tag} is no longer a draft; refusing to change it`);
+  return r;
+}
+
+/** Makes the draft hold exactly `expected`: deletes strays, uploads what is missing or wrong. */
+function syncAssets(port: PublishPort, tag: string, expected: readonly LocalAsset[]): void {
+  const p = assetProblems(expected, draftOf(port, tag));
+  for (const name of p.unexpected) port.deleteAsset(tag, name);
+  const upload = expected.filter(
+    (a) => p.missing.includes(a.name) || p.mismatched.includes(a.name),
+  );
+  if (upload.length) port.upload(tag, upload);
+}
+
+/** Re-reads the draft; on a mismatch re-uploads while it is still a draft and checks once more. */
+function checkAssets(port: PublishPort, tag: string, expected: readonly LocalAsset[]): void {
+  let p = assetProblems(expected, draftOf(port, tag));
+  if (clean(p)) return;
+  port.log(`${tag}: the draft's assets changed (${describeProblems(p)}); re-uploading`);
+  syncAssets(port, tag, expected);
+  p = assetProblems(expected, draftOf(port, tag));
+  if (!clean(p))
+    throw new PublishRefusal(`${tag}: the draft's assets are still wrong (${describeProblems(p)})`);
+}
+
+/** Runs one step through `port`. */
+async function runStep(
+  port: PublishPort,
+  step: Step,
+  expected: readonly LocalAsset[],
+): Promise {
+  port.log(`step ${step.kind}`);
+  switch (step.kind) {
+    case "push-tag":
+      return port.pushTag(step.tag, step.commit);
+    case "create-draft":
+      return port.createDraft(step.tag, {
+        prerelease: step.prerelease,
+        ...(step.target ? { target: step.target } : {}),
+      });
+    case "sync-assets":
+      return syncAssets(port, step.tag, expected);
+    case "check-assets":
+      return checkAssets(port, step.tag, expected);
+    case "npm-publish":
+      return port.npmPublish(step.packages, step.dryRun);
+    case "publish-release":
+      return port.publishRelease(step.tag, { latest: step.latest, prerelease: step.prerelease });
+    case "delete-draft":
+      return port.deleteRelease(step.tag);
+  }
+}
+
+/**
+ * Deletes a dry run's draft after a failure, best effort (the failure is what gets reported). A
+ * cancelled or timed-out job never gets here: release.yml's `publish` job ends with an `always()`
+ * step that deletes any `-dryrun.` draft left.
+ */
+function cleanUpDryRun(port: PublishPort, tag: string): void {
+  try {
+    if (port.releases(tag).some((r) => r.draft)) port.deleteRelease(tag);
+  } catch {
+    port.log(`${tag}: could not delete the dry run's draft; delete it by hand`);
+  }
+}
+
+/** Runs `planPublish(input)`'s steps through `port`; a dry run deletes its draft even on failure. */
+export async function runPublish(port: PublishPort, input: PublishInput): Promise {
+  const steps = planPublish(input);
+  const expected = input.bundle?.assets ?? [];
+  try {
+    for (const step of steps) await runStep(port, step, expected);
+  } catch (e) {
+    if (input.dryRun) cleanUpDryRun(port, tagFor(input));
+    throw e;
+  }
+  return steps;
+}