Skip to content

ci: isolate and verify Homebrew bottle publication - #881

Closed
brandonpayton wants to merge 18 commits into
mainfrom
fix/homebrew-cache-isolation
Closed

ci: isolate and verify Homebrew bottle publication#881
brandonpayton wants to merge 18 commits into
mainfrom
fix/homebrew-cache-isolation

Conversation

@brandonpayton

@brandonpayton brandonpayton commented Jul 11, 2026

Copy link
Copy Markdown
Member

Purpose

Make Kandelo Homebrew bottle publication safe to activate by ensuring Formula and package build code never shares a runner with GHCR or tap-write credentials.

This is platform-owned publisher work in Automattic/kandelo. It changes no tap Formula, package recipe, kernel ABI, syscall, or host ABI, and it must not be auto-merged.

Root Cause

The earlier reusable workflow executed Formula Ruby, upstream build scripts, GHCR upload, browser validation, sidecar generation, and tap publication on one runner. A Formula build can modify the workspace, GITHUB_ENV, GITHUB_PATH, HOME state, helper scripts, or background processes. Removing tokens from the build step environment did not prevent that state from reaching a later credentialed step.

The old path also trusted raw bottle JSON across the boundary, reused locally built bytes as publication evidence after upload, accepted browser skips caused by missing ABI assets, left ORAS auth in shared state, and could persist raw stderr in tap failure reports.

Change

The publisher now resolves immutable Kandelo/tap commits once, then runs every (formula, arch) entry through four fresh runners:

  1. build-and-test has only read authority. It runs reviewed Homebrew plus Formula/package code and emits exactly three bounded data files: a gzip bottle, raw bottle JSON, and a strict manifest.
  2. upload-bottle has packages: write but no tap-write authority. It revalidates the handoff before exposing GITHUB_TOKEN, uploads through an ephemeral ORAS registry config, revalidates a strict receipt, and emits only that receipt.
  3. verify-bottle is read-only. It reconstructs minimal canonical bottle JSON, preserves Homebrew relocation-cellar semantics separately from Kandelo's install cellar, fetches the complete ABI runtime graph, and uses anonymous GHCR readback as runtime evidence for write publications. Dry runs use the exact local bottle. The strict Chromium smoke fails on missing URLs, Vite overlays, skips, flaky results, or anything other than one pass.
  4. finalize-tap has contents: write but no package-write authority. It validates the complete publication payload as inert data before checking out with push credentials. The validator rejects extras, symlinks, oversized files, identity drift, Formula changes outside bottle do, duplicate selected tags, sibling-tag drift, and root/digest/cellar mismatches. Failed attempts use a separate clean tap checkout and record bounded stage outcomes without raw stderr.

Additional controls:

  • production calls are fixed to protected first-party repository_dispatch callers and both repositories at main;
  • the bottle root is derived from the tap identity and cannot be caller-selected;
  • Homebrew and every external action are pinned to reviewed commits;
  • no Actions dependency cache is restored into publication;
  • dry and write callers grant the reusable workflow's maximum permission ceiling, while every called job explicitly downgrades itself; dry runs never schedule upload or finalization;
  • the trust checker closes over the exact job graph, permissions, step arrays, handoffs, credential locations, anonymous readback, complete ABI fetch, browser reporter, final validator, and failure checkout;
  • maintenance validates its caller/mode, exposes only rebuild/rollback, and requires an attached refreshed main branch for writes.

Paired Tap Caller

Automattic/kandelo-homebrew#22 provides the protected Publish Kandelo bottles and dry-run callers. Its current head is a7d084da0c2ecd3a526881ce0c4508f8ee6e39b6; its trust check is green.

Do not merge or enable that privileged caller until #885 supplies lossless under-lock peer/sibling composition and refreshed Formula-source drift rejection. New GHCR packages are private by default; changing each package to public is a separate explicit approval boundary because anonymous readback must succeed before tap finalization.

Validation

Run through scripts/dev-shell.sh on head bca9393a3:

  • bash scripts/test-homebrew-publish-workflow.sh: passed, including bounded handoffs, control-character rejection, gzip-only enforcement, receipt binding, duplicate/sibling Formula-tag attacks, last-green failure behavior, explicit push refs, and adversarial workflow mutations;
  • ruby scripts/check-homebrew-publish-workflow-trust.rb: passed;
  • Ruby and Bash syntax checks: passed;
  • actionlint on both reusable Homebrew workflows and staging-build.yml: passed;
  • npx vitest run host/test/homebrew-public-bottle-verifier.test.ts: 3 passed;
  • focused Chromium regression for strict Vite-overlay rejection: 1 passed;
  • anonymous live readback of the public hello bottle: SHA-256 b31c5b52e72da1686d8d95cdfe04883e400a273d4cc3d7e15eda95ba5a57183d, 104143 bytes;
  • JSON parsing and git diff --check: passed.

The complete write publication was not run locally because it requires GitHub-hosted package/tap credentials and the activation gates above. Hosted CI is the evidence for the full runner graph. No ABI bump or snapshot update is required.

@brandonpayton

Copy link
Copy Markdown
Member Author

CI triage: the failed staging gate did not execute this workflow change. test-gate-prepare stopped in unchanged ABI-16 --fetch-only materialization because the published index cache keys for lamp, node-vfs, shell, and wordpress no longer match the checkout (64/68 packages resolved). This is the same baseline failure recorded on #880, before any test suite starts.

The focused workflow evidence remains the portable-Ruby YAML parse, actionlint, diff checks, and the cross-repository audit showing no remaining bottle-publisher actions/cache consumer or key-prefix restore path. The full staging gate still needs a rerun after the binary baseline is repaired or ABI-18 becomes the selected baseline.

@brandonpayton
brandonpayton force-pushed the fix/homebrew-cache-isolation branch from ebfe621 to fb2499e Compare July 11, 2026 20:30
@brandonpayton

Copy link
Copy Markdown
Member Author

Rebased the unchanged cache-isolation patch onto merged ABI 18 main at 2c083e143618e4974a637b649e6221c64f0dd936.

The prior red test-gate-prepare was baseline drift, not a cache-isolation failure: the stale branch selected binaries-abi-v16 and rejected four archives whose cache-key inputs had changed. binaries-abi-v18 is now published, so the ordinary staging gate is rerunning against the current release.

The stable patch ID is unchanged before/after rebase: 55d6e700e7b2423a4eb736efe8d26fe40c9c31ec. The PR still removes only the reusable publisher's Cargo/xtask Actions cache consumer and documents that trust boundary.

This was referenced Jul 11, 2026
@brandonpayton

Copy link
Copy Markdown
Member Author

Final devil/security review at 3e7d28a682bbc894f59d50b074ebe9909a5e6550: two independent reviews ACCEPT. Both replayed mutations across permissions, extra jobs/actions, direct dispatch, selected refs, checkout wiring/order, shell injection, validation short-circuiting, runners, caches, secrets, and Determinate source-url provenance overrides; all were rejected by the structured trust contract. Local validation through scripts/dev-shell.sh passed the Ruby/Psych checker, focused shell suite, Ruby/Bash syntax, actionlint, and diff checks. This remains a main-repository PR for Brandon review; hosted CI is authoritative once complete.

@brandonpayton

Copy link
Copy Markdown
Member Author

Publisher trust follow-up at exact head c8ace4064e4253df802d055a55362d39f85caab7:

Independent review found that the privileged workflows still executed mutable Checkout and Upload Artifact tags while the structured trust test treated those strings as pinned. This head preserves the exact currently resolved action commits while removing tag mutability:

  • actions/checkout@v6.0.2 -> de0fac2e4500dabe0009e67214ff5f5447ce83dd
  • actions/upload-artifact@v7 -> 043fb46d1a93c77aae656e7c1c64a875d1fc6a0a

All six checkout steps and the diagnostic upload now use those full SHAs. The common trust contract additionally rejects every non-local uses: ref that is not a 40-hex commit, with a mutation self-test, so future tag drift fails closed.

Focused validation through scripts/dev-shell.sh: structured Psych trust suite/wrapper, Ruby and Bash syntax, actionlint 1.7.12 on both reusable workflows plus staging, and git diff --check pass. An independent follow-up review confirms the exact remote head and clean worktree. Hosted CI is restarting for this security correction; no merge is requested before it completes.

@brandonpayton

Copy link
Copy Markdown
Member Author

Publication snapshot follow-up at exact head c8b95043d0993d2d027da8a5d0f6a4c9dba46bc7:

Review found that validating the names main was insufficient: plan and matrix jobs independently checked those branches out, so concurrent tap or Kandelo merges could make one dispatch plan commit A and publish commit B. The plan now resolves both checked-out commits once, validates them as 40-hex SHAs, and exports kandelo-sha and tap-sha. Every matrix checkout is bound to those exact outputs.

The structured trust test closes this wiring: plan outputs, the commit-resolution script, and both build checkout refs are exact contracts; mutation back to an arbitrary or branch ref is rejected. This applies equally to read-only branch dry runs and write publication, producing one coherent source snapshot per dispatch.

Focused validation through scripts/dev-shell.sh: structured trust suite/wrapper, Ruby syntax, actionlint 1.7.12, and git diff --check pass. Worktree and remote are clean. Hosted CI is restarting on this final review correction.

@brandonpayton

Copy link
Copy Markdown
Member Author

Closed-world trust correction at exact head 4d905f4de8a0cf32656d63a7a1c1595ab713fc92:

Dynamic mutation proved that the prior checker accepted an appended static token-exfiltration step in the publisher and a static unreviewed push step in maintenance rollback. Those steps used no GitHub expression and changed no action list, so the earlier selective checks did not see them.

The checker now binds canonical, recursively key-sorted JSON digests for every step mapping in the publisher plan, publisher build, and maintenance rollback. It also exact-checks top-level keys, workflow names, concurrency, complete workflow_call inputs, and the full maintenance reusable-call input map. Readable semantic assertions remain in place.

New self-mutations require rejection of:

  • static publisher token exfiltration;
  • static maintenance push;
  • top-level BASH_ENV injection;
  • extra publisher or maintenance inputs;
  • concurrency cancellation;
  • existing action, cache, permission, checkout, ref, and shell-expression attacks.

The previously accepted static backdoors now fail closed. Through scripts/dev-shell.sh, the structured trust suite/wrapper, Ruby syntax, actionlint 1.7.12, and git diff --check pass; worktree and remote are clean. Hosted CI is restarting on this exact head.

Run formula builds, GHCR upload, anonymous verification, and tap finalization on separate runners. Carry only bounded, validated bottle data across those trust boundaries and require anonymous byte-for-byte readback before publication.
@brandonpayton brandonpayton changed the title ci: constrain bottle publication to reviewed code and caller authority ci: isolate and verify Homebrew bottle publication Jul 12, 2026
@brandonpayton

Copy link
Copy Markdown
Member Author

Superseded by #936, which retains this PR as patch-equivalent purpose commits in the consolidated Homebrew publisher batch and reruns the combined validation on current main.

@brandonpayton
brandonpayton deleted the fix/homebrew-cache-isolation branch July 13, 2026 22:58
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