Repository navigation
feat(release): sign an update manifest when a current key is provisioned - #528
Merged
Merged
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
BunsDev
added a commit
that referenced
this pull request
Oct 3, 2026
Addresses the independent review of #528. - generate-update-signing-key: the Git-checkout refusal no longer fails open. Git runs with every GIT_* variable stripped; only exit 128 with "not a git repository" counts as outside, and a missing git, dubious ownership or any other result refuses. A Git-independent backstop refuses any target with a .git path segment or an ancestor holding a .git file or directory. writeSync's byte count is checked, and a partial key file is removed. - update-manifest build refuses a published_at more than the 300-second clock-skew allowance in the future (reason published_at_in_future). - release.yml: the verify job exports the verified signed tag object SHA; the publish job's manifest build compares it with git rev-parse "$RELEASE_TAG^{tag}" before reading the tagger date, and fails closed on a mismatch or a missing value. - docs/RELEASE.md records how the implementation differs from the design record (Node Ed25519, the .sig envelope, an unencrypted PKCS#8 PEM held only in the protected release environment) and the exact asset names, and documents tagger clock skew and early-tag window shortening. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Legacy-tag recovery and key-generation failure handling currently violate the intended fail-closed behavior.
Review effort: Balanced
Findings: 2
Open (3)
What changed in this PR
Adds an opt-in Ed25519-signed update manifest to the protected release pipeline.
Changes:
- Adds canonical manifest generation, signing, verification, and key generation.
- Integrates optional manifest assets into release publication.
- Documents and tests key provisioning, rotation, verification, and asset handling.
| File | Description |
|---|---|
scripts/update-manifest.mjs |
Implements manifest lifecycle and CLI. |
scripts/update-manifest.d.mts |
Declares manifest APIs and types. |
scripts/generate-update-signing-key.mjs |
Generates owner-managed signing keys. |
scripts/generate-update-signing-key.d.mts |
Declares key-generation APIs. |
release/update-manifest-keys.json |
Adds inactive public-key configuration. |
docs/RELEASE.md |
Documents signing operations and recovery. |
agent/manifest.yaml |
Classifies signing files as R4. |
.github/workflows/release.yml |
Adds conditional signing and publication. |
__tests__/updateManifest.test.ts |
Tests manifest and key behavior. |
__tests__/releaseWorkflow.test.ts |
Tests workflow gating and asset sets. |
__tests__/releaseDocs.test.ts |
Tests release documentation coverage. |
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Adds the release side of the notify-only update channel (#477, item 3.2). - scripts/update-manifest.mjs: status/build/sign/verify with Node's built-in Ed25519. The manifest is canonical JSON (sorted keys, no whitespace, UTF-8, one trailing LF) and the signature covers exactly its bytes. The .sig envelope is canonical JSON carrying algorithm, key_id, schema and the base64 signature. Key id = first 8 bytes of SHA-256 over the raw public key. verify checks the signature before parsing and reports one bounded reason. - scripts/generate-update-signing-key.mjs: owner-only key generation. Writes the private PEM with mode 0600, refuses overwrites and Git working trees, and never prints the private key. - release/update-manifest-keys.json: current/next public key slots, empty. - release.yml publish job: resolve activation, build, sign (the only step that sees UPDATE_MANIFEST_SIGNING_KEY), self-verify. With no current key it emits a notice and publishes the existing three assets; with one, a missing secret or failed verification blocks publication and the exact asset set becomes five. - docs/RELEASE.md: format, activation rules, provisioning, rotation and revocation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Addresses the independent review of #528. - generate-update-signing-key: the Git-checkout refusal no longer fails open. Git runs with every GIT_* variable stripped; only exit 128 with "not a git repository" counts as outside, and a missing git, dubious ownership or any other result refuses. A Git-independent backstop refuses any target with a .git path segment or an ancestor holding a .git file or directory. writeSync's byte count is checked, and a partial key file is removed. - update-manifest build refuses a published_at more than the 300-second clock-skew allowance in the future (reason published_at_in_future). - release.yml: the verify job exports the verified signed tag object SHA; the publish job's manifest build compares it with git rev-parse "$RELEASE_TAG^{tag}" before reading the tagger date, and fails closed on a mismatch or a missing value. - docs/RELEASE.md records how the implementation differs from the design record (Node Ed25519, the .sig envelope, an unencrypted PKCS#8 PEM held only in the protected release environment) and the exact asset names, and documents tagger clock skew and early-tag window shortening. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
BunsDev
force-pushed
the
feat/update-manifest-signing
branch
from
October 3, 2026 03:53
c141323 to
ea4939a
Compare
Addresses the remaining #528 review threads. - release.yml: manual recovery checks out the requested tag's source, and pre-feature tags (v0.0.1, v0.0.2) have neither the keys file nor the script. A tag without release/update-manifest-keys.json is now an explicit legacy tag: inactive, with a notice. A tag with the keys file runs the resolver, taking scripts/update-manifest.mjs from GITHUB_WORKFLOW_SHA when the tag lacks it, and fails closed if it cannot. Every later step runs the resolved $UPDATE_MANIFEST_SCRIPT. - Both scripts now detect direct invocation through the entry's real path, so a symlinked location (e.g. /var -> /private/var) still runs main(). - generate-update-signing-key: derives the public entry before creating the file, loops over short writes, checks the final size and mode, and unlinks the target on every post-open failure, including a failed close. It reports EKEYCLEANUP if the removal itself fails. - Tests: replaceAll for the CRLF case; the non-Ed25519 rejection uses 2048-bit RSA and Ed448 instead of 1024-bit RSA; injected write-failure cases; legacy and feature-era tag resolution. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 tasks
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Summary
This is the release side of the notify-only update channel (#477, plan item 3.2, first half). Each release can now publish a signed
update-manifest.json. The desktop verifier and banner come in a later PR.The feature ships inactive.
release/update-manifest-keys.jsonstarts withcurrent: null, so releases behave exactly as they do today until the owner provisions a key.Owner decisions this implements (2026-10-03)
UPDATE_MANIFEST_SIGNING_KEYsecret in the existing protectedreleaseenvironment. Only the sign step'senvsees it.crypto.sign(null, …)). No minisign and no new third-party tool or action.Format
update-manifest.json: canonical JSON with sorted keys, no insignificant whitespace,JSON.stringifystring escaping, integers only, UTF-8 with no BOM, and exactly one trailing LF. Fields:schema: 1version,tag, and the 40-charactersource_shaartifacts.{aarch64,x86_64}.{file,sha256}, which must matchSHA256SUMSpublished_at, the signed tag's tagger time, so a retried run reproduces the same bytesexpires_at, at most 30 days afterpublished_atupdate-manifest.json.sig: a canonical JSON envelope,{"algorithm":"ed25519","key_id":"<16 hex>","schema":1,"signature":"<padded base64 of 64 bytes>"}plus a trailing LF.{schema:1, current:{keyId,publicKey}|null, next:…|null}, wherepublicKeyis the base64 raw key. Anextwithout acurrent, the same key in both slots, or a key id that does not match its key is treated as malformed.verify: checks the signature before parsing anything. On failure it prints one bounded reason:keys_malformed,no_trusted_keys,signature_malformed,unknown_key_id,signature_invalid,manifest_malformed,manifest_not_canonical,manifest_not_yet_valid,manifest_expiredormanifest_mismatch.Differences from the design record
The dated design record is unchanged. Per the owner's 2026-10-03 decision, this PR differs from it in three ways, all recorded in docs/RELEASE.md:
update-manifest.json.sigenvelope, not a.minisigfile.releaseenvironment secret, instead of a password-protected key.The future desktop verifier must expect exactly
update-manifest.jsonandupdate-manifest.json.sig.Activation and fail-closed rules (publish job)
release/update-manifest-keys.json(such as v0.0.1 or v0.0.2, during a manual recovery run frommain) predates the feature. It is inactive, posts a notice, and publishes the existing three assets.GITHUB_WORKFLOW_SHA, and fails closed if it can't read it. Later steps run the resolved$UPDATE_MANIFEST_SCRIPT.current: null, it writesactive=false, emits a::noticeannotation, and publishes the existing three assets.active == 'true':git rev-parse "$RELEASE_TAG^{tag}") equals the signed tag object theverifyjob checked, and fails closed on a mismatch or a missing value. Only then does it read the tagger date. It uses--expires-in-days 30.published_atmore than 300 seconds in the future withpublished_at_in_future. The docs cover tagger clock skew, which would otherwise fail self-verification withmanifest_not_yet_valid, and how a tag created early shortens the 30-day window.--key-envvalue that is not an environment variable name, so a PEM can never be passed on argv. It also refuses a key that is not the keys file'scurrentkey.SHA256SUMS.RELEASE_ASSETSarray. That is 3 names when inactive and 5 when active (addingupdate-manifest.jsonandupdate-manifest.json.sig), with no wildcard. An unresolved activation value fails closed.The diff stays inside the
publishjob's checksum and asset-check area. It does not touchnotify-homebrew(#527) or add a workflow (#526).Owner steps to provision the key
The full procedure is in docs/RELEASE.md under "Update manifest signing".
On a trusted machine, from a clean checkout of
main:The script writes the key with mode 0600, refuses to overwrite a file, and prints only the public entry and key id.
It refuses to write inside a Git checkout, and that refusal fails closed:
GIT_*variable removed.git, dubious ownership or any other result is refused..gitsegment, or any ancestor holding a.gitfile or directory.Writes are complete or leave nothing behind. The script loops over short writes and checks the final mode and size. It removes the file on any failure after creating it, including a failed close.
Upload the private key.
ghreads it from stdin:Then confirm the secret is listed with
gh secret list --env release.Store an offline recovery copy, then delete the plaintext file.
Open a PR that sets
currentinrelease/update-manifest-keys.jsonto the printed entry. Merge it only after step 2.Rotation (stage the new key in
next, then promote it and swap the secret) and revocation are documented in the same section.Risk
R4. This touches signing, the release workflow, and a credential boundary.
Verification
All runs were on head
7a33a717, rebased onmainwith #525 and #526. It includes the fixes from the independent review and the review threads:pnpm vitest --run __tests__/updateManifest.test.ts __tests__/releaseWorkflow.test.ts __tests__/releaseDocs.test.ts: all pass (34 + 48 + 27 = 109).GIT_DIR,GIT_CEILING_DIRECTORIESandGIT_WORK_TREE; nogitonPATH; dubious ownership; a target inside.git/; and a lyinggitstopped by the.gitcheck./bin/bash, with a stateful fakegh.secrets.UPDATE_MANIFEST_SIGNING_KEY, used only in the sign step), and step order and gating.bash ./scripts/agent-check fast(typecheck plus the repository contract) passed.pnpm docs:focus:checkpassed.actionlint .github/workflows/release.ymlreports only the 3 SC2129 style notes already present onmain, all in other jobs.pnpm test(on the earlier head32775b82; not re-run for the review fixes): 5777 passed. One timeout inrecoveryHarness.test.tsunder full-suite load; that file passes on its own (16/16).Proof gap: no protected release run has exercised the active path. The first release after the key is provisioned is that evidence.
Rollback
Revert this PR. Until a key is provisioned, the inactive path already publishes exactly the previous three-asset set. After provisioning, setting
currentback tonullturns signing off again without a code change.Refs #477
🤖 Generated with Claude Code