Skip to content

feat(release-verify): add @cloudhal/release-verify (RFC-0046 §11a, #227) - #232

Merged
Madraka merged 2 commits into
mainfrom
claude/release-verify-pkg
Nov 14, 2026
Merged

Madraka merged 2 commits into
mainfrom
claude/release-verify-pkg

Conversation

@Madraka

@Madraka Madraka commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

What

This adds the @cloudhal/release-verify package, as the accepted design in RFC-0046 §11a describes. It is the first of the two #227 PRs. cloudhal deploy --release comes in a second PR after this one merges, and it stays unshippable until #226's signed keys.json exists (§11b).

⚠️ Owner steps: do them before the version PR that first releases this package is merged. That is the next release, when #224 is regenerated with this changeset. Until then the planner leaves the package out, so main stays green and nothing publishes @cloudhal/[email protected]. Once its version PR lands, publish-ci publishes it with the rest of the fixed group, and that publish fails without a trusted-publisher entry. See Owner steps below.

The package (packages/release-verify)

  • Worker-safe root entry. It uses WebCrypto Ed25519, DecompressionStream("gzip"), and an incremental SHA-256: Workers' native crypto.DigestStream, otherwise @noble/hashes 2.4.0, pinned in the catalog. A test walks the root entry's import graph and fails on any node: import. Only ./attestation uses Node.
  • Runtime dependencies, exactly the ones §11a lists: @noble/hashes, ajv (only dist/runtime/equal and ucs2length) and ajv-formats (its formats module). Nothing constructs Ajv. A test fails when the generated validators import anything else, or contain new Function or eval.
  • Generated validators for exactly release-manifest and release-statement, plus their contract types, in src/generated/.
    • They are written by the package's own gen:ts (scripts/gen-validators.ts).
    • Turbo's gen:ts now has dependsOn: ["^gen:ts"], so the package's generator runs after core-contracts'.
    • Drift is checked in three places: by test/package.test.ts, by the CI contract-drift job, and by pnpm drift.
    • The package depends on @cloudhal/core-contracts only as a dev dependency, for generation.
  • verifyRelease runs §2's six steps in order, then a two-pass tarball read:
    • the first pass checks the size and SHA-256 against the statement before anything is extracted;
    • entries() streams the second pass. It yields an entry only after the entry's path is safe and its size and SHA-256 equal its listing. It refuses unlisted entries and §7 format violations, and, at the end, missing entries.
    • The tarball is never held whole. Each entry's size is checked against its listing before its bytes are buffered.
  • entry-path. Every manifest listing is checked before the tarball is read, and every tar name before its entry is used. Refused: empty, . and .. segments, a backslash, control characters, a leading / (assets need exactly one), more than 512 bytes, and case-fold duplicates within one Worker's modules or assets (NFC, then upper-casing and lower-casing). The check is a single pass plus split("/"), with no regex (rule 13). A test runs it on 100k characters.
  • Stable error codes (ERROR_CODES, 19 of them), as §11a lists them. key-expired is deliberately absent: it waits for Release signing phase 2: signed keys.json and yanked.json, key expiry, KMS custody (RFC-0046 §9) #226 (§11c point 5).
  • PINNED_KEYS holds cloudhal-release-2026a. It is frozen and kept equal to the key doc's table by a test. Trust can only be reduced: revoked: [...] withdraws a key, and passing a whole keys map is the only way to trust a key outside the table.
  • missingScopes compares exactly, with §3's single rule: workers-scripts.admin covers workers-scripts.edit.
  • releaseAssetUrls gives all five assets, including the Sigstore bundle. The version must match a bounded, anchored pattern, so it cannot change the URL path.
  • ./attestation (Node only) works in three stages:
    • Probe: gh --version must be 2.68.0 or newer, every flag must appear in gh attestation verify --help, and gh auth status must pass. Otherwise the result is unavailable, with the required version named.
    • Verify: the exact §11a command runs for the tarball, the manifest and the statement, with --bundle when the Sigstore asset is present.
    • Failure: a non-zero exit, a signal or a 60 s timeout is failed.

Elsewhere

  • scripts/release-bundle/verify.ts: a signed bundle now also passes verifyRelease entry by entry, so consumer verification has one implementation (§11a). The producer's own checks are unchanged.
  • Publishing: the package is added to scripts/build-packages.ts PACKAGES, to tsconfig.build.json, and to the .changeset/config.json fixed group. Its publishConfig comes from --write-publish-config. release-plan.test.ts now expects 14 packages.
  • scripts/build-packages.ts: the generator is in DEV_ONLY, so it never ships.
  • scripts/code-size/measure.ts: ignores packages/release-verify/src/generated/, as it already does core-contracts' generated code.
  • Changeset: release-verify-package.md.
  • CI: a new release-verify-v0-9-0 job runs gh release download v0.9.0 and then the opt-in test/real-tarball.test.ts (CLOUDHAL_RELEASE_ASSETS). Ordinary test runs skip it and never use the network.
  • biome.json: excludes test/fixtures/v0.9.0, and a .gitattributes there sets -text. The real statement, signature and manifest (about 250 KB) are byte-exact signed data, so nothing may reformat or re-encode them.
  • Docs: an AGENTS.md layout entry, a README, and a pointer from docs/security/release-signing-keys.md to the package.

Tests (run locally, one at a time)

packages/release-verify: pnpm exec vitest run passed 52, with 1 skipped (the opt-in test). The suites:

  • verify-real: the committed v0.9.0 files pass steps 1 to 5 with the pinned key. These are refused:
    • a flipped byte in the statement, the signature or the manifest;
    • a wrong key, an unknown key or a malformed key;
    • a key revoked by its status, or by revoked;
    • another version or channel;
    • a planVersion above what the caller supports;
    • an oversized statement or a short signature.
  • verify-synthetic: synthetic bundles, written by the producer's own tar and gzip code and signed with a throwaway key, cover:
    • invariant 5;
    • the legacy manifest.sig form;
    • statement invariants 1, 2 and 3;
    • a tampered or extended tarball;
    • an unlisted, missing or changed entry;
    • every entry-path rule, in listings and in tar names, refused before the tarball is read;
    • the gzip header, mtime and end blocks;
    • directory entries and the part limit;
    • streams in 7-byte chunks, read twice;
    • a consumer that stops early.
  • crypto: both SHA-256 paths (noble, and the DigestStream adapter with a stand-in shaped like workerd's) against the NIST vectors, including a million as, and against node:crypto on random splits. Ed25519 is checked against node:crypto signatures.
  • tar: more than 30,000 entries; the decompression cap; corrupt and truncated gzip; every source form; admit running before an entry is buffered; and a 100k adversarial run.
  • attestation: a scripted gh covering the exact arguments, --bundle, every unavailable and failed path, and malformed input.
  • package: the key doc, the producer's limits and names, generated-file drift, the validators' imports, Worker-safety, missingScopes and releaseAssetUrls.

Also run locally:

  • The real v0.9.0 tarball (downloaded into a scratch directory): real-tarball.test.ts passed, and every listed part verified.
  • The real gh 2.102.0: verifyAttestation on the real v0.9.0 assets returned verified. With a wrong commit it returned failed, and gh's error message became the reason.
  • packages/cli: release-bundle-verify 9/9, release-bundle 18/18, release-bundle-publish 8/8, release-bundle-sign 3/3, release-plan 6/6, packaging 10/10.
  • Checks: typecheck and biome are clean on the package and the changed files. node scripts/build-packages.ts --check is ok.
  • Build: pnpm build:packages built every package, and every published exports/bin target resolves. The new package's dist holds the generated validators.
  • Size: pnpm size:check is ok.
  • Lint: pnpm lint reports no errors.

Review round 1 (aaf9a12)

  • Tar names: one byte rule.
    • ustarName is the longest suffix of at most 100 UTF-8 bytes, cut on a code-point boundary. A PAX record is written exactly when needsPax says so.
    • Both functions live in the package, and scripts/release-bundle/tar.ts imports them. The verifier compares raw name bytes, refuses a PAX record that no name needs, and refuses a PAX header that is not ././@PaxHeader.
    • ASCII output is byte-identical, so the golden vectors still pass.
    • Tested on long names with 2-byte, 3-byte and 4-byte characters, cut both on a character boundary and in the middle of one. Both the producer path and the signed synthetic path are covered.
  • Planner (releaseGroup).
    • The rule: a group package that npm has never released (nothing, or only 0.0.0-placeholder.*) and whose changelog has no ## <version> entry is left out and logged. A missing CHANGELOG.md reads as no entry. Packages npm has already released are never left out.
    • release-plan.test.ts builds this exact state: 14 packages at 0.9.0, and release-verify with no changelog and nothing (or the placeholder) on npm. It shows publish=false, and that a workflow_dispatch dry run never includes the package. It also covers the state after the version PR, where the package is released.
    • node scripts/publish-ci.ts --plan on this branch against live npm logs the left-out package and prints publish=false. With RELEASE_DRY_RUN=true it prints publish=true without the package.
  • The producer runs the consumer's rules.
    • The unsigned self-check (verifyBundle, run by release-bundle.ts build) now runs checkManifestDoc and the second-pass entry reader: paths, case folding, mainModule, limits and tar rules.
    • publish-ci runs the full verifyRelease on the signed bundle before it creates or uploads anything. The package is loaded lazily, because the plan job runs without an install.
    • The inline bundle-sign scripts can't import the package (that job has no checkout), so they stay as they are.
    • Tests: a build with /favicon.svg plus /FAVICON.svg, a path over 512 bytes, and a .. segment each fail the unsigned self-check. publish-ci's consumer check accepts the signed bundle and refuses a wrong key.
  • Streams.
    • The second pass is now a stream chain: a source wrapper, then the gzip-header check, then DecompressionStream. Every exit cancels it and releases the caller's stream directly: the end, a refusal, or an early stop. readTar closes its source in finally.
    • Source I/O errors pass through unchanged.
    • Tests use a source that delivers the tarball and then never ends. They show it is cancelled after an early break and after an entry-mismatch. Both tests fail with the cleanup removed. An I/O error in pass 2 comes out as the same error object.
  • Low items:
    • missingScopes uses Object.hasOwn (tested with constructor, __proto__ and toString).
    • Attestation prints exit N when stderr is empty, and passes absolute paths to gh (tested).
    • foldPath is now NFC, lower, upper, lower, NFC (tested on ẞ/ß/SS, ΐ/Ϊ́, and composed/decomposed é).
    • starter.bytes must be within the part limit, and mainModule must be a safe path and a listed module.
    • New tests for invariant 4 (the key id), invariant 1 (tarball.name), and one-sided channel, securityRelease and createdAt changes.
    • The key doc now says what verifyRelease and verifyAttestation each cover, and that gh release verify-asset stays manual.
    • The comments in keys.ts and limits.ts now name the right test file.

Tests, run locally one at a time:

  • packages/release-verify: 62 passed, 1 skipped (opt-in). The opt-in real v0.9.0 tarball test passed again.
  • packages/cli: release-bundle-verify 12, release-bundle 18, release-bundle-publish 9, release-bundle-sign 3, release-plan 9, release-publish-plan 17, packaging 10, release-workflows 14.
  • Both typechecks are clean, pnpm lint shows no errors, and size:check and build-packages --check are ok.

Owner steps (required before the package's first version PR is merged)

  1. Placeholder. Publish @cloudhal/[email protected] by hand, with only a README and --tag placeholder. Then run npm deprecate @cloudhal/[email protected] "placeholder; install a released version".
    • npm trusted publishing can only be set up on a package that already exists.
    • A real group version published by hand would carry no provenance, and planPublish would skip it from then on.
  2. Trusted publisher. On npm, add the trusted-publisher entry for @cloudhal/release-verify: repository cloudhal-dev/cloudhal, workflow release.yml, environment npm-publish, the same as every other package in the group.
  3. The first real version then comes from release.yml with --provenance, together with the rest of the fixed group. Nothing in this PR publishes anything.

Refs #227 (the issue stays open for cloudhal deploy --release)

🤖 Generated with Claude Code

Madraka and others added 2 commits November 14, 2026 15:15
The one verifier of Cloudhal release bundles, Worker-safe:

- verifyRelease runs RFC-0046 §2's six steps in order (sizes, Ed25519 with
  a trusted key, the request binding, both precompiled validators and the
  invariants, the manifest's digest and listings, the tarball's digest).
  entries() then streams the tarball a second time and yields each entry
  only once its path is safe and its size and SHA-256 match its listing.
- entry-path refuses empty, . and .. segments, backslashes, control
  characters, overlong paths and case-fold duplicates. It is a linear
  scanner with no regex (rule 13).
- 19 stable error codes; PINNED_KEYS (frozen, equal to the key doc, trust
  can only be reduced); missingScopes with §3's admin-covers-edit rule;
  releaseAssetUrls for the five assets.
- ./attestation (Node only): probes for gh 2.68.0 or newer with every
  flag and a signed-in session, else "unavailable"; runs the exact §11a
  command with --bundle; any non-zero exit, signal or timeout is "failed".
- Runtime dependencies: @noble/hashes (when there is no DigestStream),
  plus the ajv runtime helpers and ajv-formats that the generated
  validators import. The validators come from the package's own gen:ts
  (after core-contracts', via turbo ^gen:ts) and are drift-checked by a
  test, by the CI contract-drift job and by pnpm drift.
- Tests: the committed real v0.9.0 statement, signature and manifest, and
  synthetic bundles signed with a throwaway key. The opt-in real-tarball
  test runs in a new CI job that downloads v0.9.0.
- The package joins the fixed group (PACKAGES, tsconfig.build.json,
  .changeset/config.json), with a changeset.
- scripts/release-bundle/verify.ts runs it for signed bundles.

Refs #227

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Madraka <[email protected]>
- One byte rule for tar names. The ustar name field is the longest
  suffix of at most 100 UTF-8 bytes, cut on a code-point boundary
  (ustarName). The PAX record is written exactly when needsPax says so.
  The producer imports both from the package, and the verifier compares
  raw bytes. Long non-ASCII names cut on and between character
  boundaries now verify.
- Release planner: a group package that npm has never released (nothing,
  or only the placeholder) and whose changelog has no "## <version>"
  entry is left out of the release and logged. main stays green after
  this merge and nothing publishes release-verify 0.9.0, dry runs
  included. A missing CHANGELOG.md reads as no entry.
- The producer runs the consumer's rules. The unsigned self-check runs
  checkManifestDoc and the second-pass entry reader, and publish-ci runs
  the full verifyRelease before it creates or uploads anything (loaded
  lazily: the plan job has no install).
- The second pass closes its stream chain on every exit: the end, a
  refusal, or a consumer that stops early. Errors from the source pass
  through unchanged.
- Low items:
  - missingScopes uses Object.hasOwn;
  - attestation prints "exit N" and passes gh absolute paths;
  - foldPath is NFC, lower, upper, lower, NFC;
  - the starter part limit and mainModule (a safe path and a listed
    module) are checked;
  - new tests for invariants 1 and 4 and one-sided fact changes;
  - the key doc states what each call covers;
  - test file references are fixed.

Refs #227

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Madraka <[email protected]>
@Madraka
Madraka merged commit d41e6ed into main Nov 14, 2026
11 checks passed
@Madraka
Madraka deleted the claude/release-verify-pkg branch November 14, 2026 13:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant