Skip to content

feat: consolidate release provenance, SBOM, and packaging - #3

Draft
trumpyla wants to merge 51 commits into
mainfrom
release/consolidated-sbom
Draft

feat: consolidate release provenance, SBOM, and packaging#3
trumpyla wants to merge 51 commits into
mainfrom
release/consolidated-sbom

Conversation

@trumpyla

@trumpyla trumpyla commented Aug 1, 2026

Copy link
Copy Markdown
Member

Summary

  • Consolidate release provenance, immutable publication, npm dependency closure, CycloneDX SBOM generation, SEA packaging, and Homebrew formula automation.
  • Retain the applicable release work from Artagon PR #1 and later local hardening.
  • Exclude general queue, persistence, process-lifecycle, and startup-performance implementation from this branch.

Why

The previous release and consolidated branches mixed supply-chain work with runtime lifecycle changes. This branch creates a release-owned history with explicit source provenance, fail-closed publication checks, deterministic npm packaging, and tested installed artifacts.

The remaining queue-owner-process surface is only the narrow interface required for a packaged SEA to start its detached owner. The branch reports ACPX version 0.13.0.

Validation

  • pnpm run check and pnpm run check:docs
  • 111/111 targeted coverage tests
  • Formula generator: 20/20 tests
  • Release workflow: 22/22 tests
  • Release policy and Ruby syntax checks
  • Final npm pack/install smoke test: 30-file 0.13.0 package, isolated install, zero reported vulnerabilities
  • Real Darwin ARM64 SEA build with Node 24.11.0
    • ACPX version 0.13.0
    • 20 CycloneDX components
    • 13/13 packaged-binary lifecycle tests
  • Conflict-free detached combined-tree verification with perf/consolidated-runtime, including the complete check, docs gate, performance report, and SEA tests

Ownership boundary

This PR owns release workflows, provenance, package closure, SEA assets, Homebrew, and SBOM behavior. Runtime performance, queue ownership, and persistence implementation are intentionally isolated in companion PR #2.

kelvinschen and others added 30 commits July 21, 2026 23:16
* fix(windows): require structured agent argv

* refactor(acp): own built-in command migration
Bumps the actions group with 1 update in the / directory: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 7.0.0 to 7.0.1
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](actions/checkout@v7.0.0...v7.0.1)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 7.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](actions/setup-node@v4...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…claw#442)

* fix(flows): swallow heartbeat write rejections from setInterval

void heartbeat() inside setInterval can reject when store.writeLive fails,
producing unhandledRejection noise and process flags. Catch rejections as
best-effort live-state updates, matching other best-effort .catch() sites
in the runtime.

Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>

* test(flows): prove heartbeat timer swallows writeLive rejections

Add a focused regression that forces best-effort heartbeat writes to
reject and asserts zero unhandledRejection events, matching the
setInterval .catch() boundary in FlowRunner.runWithHeartbeat.

Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>

* test(flows): exercise FlowRunner heartbeat writeLive rejection path

Replace the duplicated timer harness with a real FlowRunner shell node that
forces interval writeLive failures and asserts zero unhandledRejection.

Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>

* retrigger proof check

Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>

* fix(flows): harden heartbeat rejection coverage (openclaw#442)

Co-authored-by: Sebastien Tardif <sebtardif@ncf.ca>

---------

Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
* fix(session): avoid atomic write temp collisions

* test(session): make temp-path regression deterministic (openclaw#447)

Co-authored-by: Henk ter Harmsel <henk@chaink.it>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
* feat(cli): add filesystem capability opt-out

* docs(cli): complete no-fs coverage (openclaw#469)

Co-authored-by: 甄新 <heyang.heyang@alibaba-inc.com>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
* feat: add Pool ACP agent

Register Poolside as a built-in agent via `pool acp`.

* fix: add structured Pool agent launch

---------

Co-authored-by: Onur Solmaz <2453968+osolmaz@users.noreply.github.com>
* fix(cli): preserve positive timer durations

* fix(cli): share timer conversion with config (openclaw#459)

Co-authored-by: mehmetali <36207866+realmehmetali@users.noreply.github.com>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
… reconnect" (openclaw#444)

* fix(cli): distinguish starting and reconnect states (openclaw#444)

Co-authored-by: Thomas Güttler <info@thomas-guettler.de>

* fix(cli): preserve reconnect status for live leases (openclaw#444)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: Thomas Güttler <info@thomas-guettler.de>
* feat: add ZeroClaw ACP agent (openclaw#440)

Co-authored-by: jordanthejet <morepencils@gmail.com>

* fix(agents): register ZeroClaw structured argv (openclaw#440)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
…mula

Two install paths, each honest about its tradeoffs (docs/packaging.md).

Declaring @agentclientprotocol/codex-acp and claude-agent-acp as dependencies
puts them under acpx's own node_modules, which is exactly where
resolveInstalledBuiltInAgentLaunch already walks. That path spawns the adapter
bin directly; without it, launches fell through to the `npx -y` form and put
npm's dependency resolution on the critical path of every agent start.

Measured at a fixed adapter version, warm npx cache, so neither vendor nor
version is a confound: acp.initialize.rpc 2293.9ms -> 245.2ms. Every other span
moved by <= 42ms, i.e. the entire cost was npm resolution blocking the adapter's
reply to `initialize` — which is why it never showed up in spawn timing
(spawn is ~4ms). Invoking the built-in registry string now measures 348.6ms.

The pinned ranges are raised to match: codex ^0.0.44 -> ^1.1.7,
claude ^0.37.0 -> ^0.61.0. Neither old range could be satisfied by any
published adapter, so the installed-bin path could not engage even when a
suitable adapter was present.

claude is pinned to 0.61.0 rather than 0.62.0 deliberately: pnpm-workspace.yaml
sets minimumReleaseAge 2880 (48h) and 0.62.0 was 34h old. The guard is there on
purpose; the pin moves when the release matures.

test/agent-registry.test.ts now asserts the registry ranges against
package.json rather than against literals. The two must stay in sync because
resolution never compares versions — whatever is installed wins silently, so a
drifted pin is fiction rather than a constraint.

Formula/acpx.rb makes the repository its own tap (no separate tap repo):

  brew tap artagon/acpx https://github.com/artagon/acpx
  brew install artagon/acpx/acpx

It ships a prebuilt single executable rather than building from source, because
Homebrew's node is compiled without single-executable support. sha256 fields
are REPLACE_ON_RELEASE placeholders until the first release; brew fails loudly
on checksum mismatch, so an un-updated formula cannot silently install a wrong
artifact. Its test asserts the binary answers --help with no system Node, which
is the property that justifies shipping 122MB.

`pnpm run sea` (scripts/sea/, src/sea/entry.ts) produces a Node SEA with a V8
startup snapshot. Measured 50.2ms vs 77.1ms for the npm install (hyperfine,
n=25): the snapshot executes the module graph at build time, so the all-eager
bundle it requires costs nothing at runtime.

Constraints that shaped it, each learned by hitting it:
- One CJS chunk, everything inlined — a snapshot resolves nothing from disk.
- CommonJS, because Node rejects mainFormat "module" with useSnapshot.
- A CJS wrapper shim, because useSnapshot runs the entry through minimalRunCjs
  which defines neither `exports` nor `module`. IIFE is not a workaround: it
  hoists require("node:*") to globals.
- argv passes through unchanged. A SEA's argv[1] is the invocation path, not
  the first user argument; re-injecting it makes cli-core read it as the agent
  name and shadow every top-level verb.

Verification: tsc, oxfmt, oxlint, lint:eager-graph clean; ruby -c on the
formula; full suite green except the pre-existing
"lets the submitter exit while a detached owner keeps draining stderr" flake,
which also fails on an untouched baseline.

Note: .gitignore also carries a pre-existing local OpenSpec block that was
already modified in the working tree before this change.
The formula pointed at release assets that did not exist, so
`brew install artagon/acpx/acpx` failed with a 404. Publish the assets from
CI and list only platforms that actually have one.

- .github/workflows/release-binaries.yml builds the SEA per target and attaches
  the tarballs to the tag. Kept separate from release.yml, which publishes the
  npm package with provenance and asserts upstream metadata; the two should not
  be entangled. Each target proves the binary answers --version and --help under
  `env -i` with node stripped from PATH — a SEA that quietly needs an ambient
  Node would defeat the reason for shipping ~122MB.
- Formula/acpx.rb now lists only darwin-arm64, the one published asset, with its
  real checksum. Listing a platform whose asset does not exist turns a clear
  'unsupported' message into a download failure.
The v0.12.0 asset shipped as a plain copy of the Node binary: postject had not
injected the blob, so `acpx --version` printed Node's version (v24.11.0) and
every command fell through to Node's own CLI. brew install succeeded and the
binary ran, which is exactly why this went unnoticed.

Nothing could have caught it. Both the build script and the workflow checked
only the exit status, and an un-injected binary starts fine and exits 0.

- scripts/sea/build.mjs now runs the freshly built binary and asserts it
  reports package.json's version, failing the build otherwise.
- release-binaries.yml verifies the packaged TARBALL rather than dist-sea/acpx
  (packaging is itself a step that can go wrong) and compares --version output
  instead of trusting the exit code.

Release asset for v0.12.0 replaced; Formula/acpx.rb carries the new checksum.
… toolchain

Adversarial review of the two new workflows. Findings acted on, worst first.

postject was fetched with `npx` at release time (scripts/sea/build.mjs) — not in
package.json, not in the lockfile, and outside minimumReleaseAge, which is a
pnpm setting that npx does not honour. A compromised release of it would have
had write access to the exact bytes users install, and none of the new controls
covered it: not `pnpm audit --prod`, not dependency-review, not the SBOM. It is
now a pinned devDependency and both build steps use `pnpm exec`, which resolves
from the lockfile and fails closed.

The build job held `id-token: write` while running pnpm install, the bundler,
and the freshly built binary. That injects ACTIONS_ID_TOKEN_REQUEST_* into every
step, so any bundler plugin or transitive dependency could mint a Sigstore token
asserting this repo and sign arbitrary bytes offline. Attesting an artifact in
the same job that ran untrusted code is worse than not attesting: it turns
provenance into a laundering primitive. Build is now read-only with no OIDC, and
a publish job that runs no repository code attests the exact bytes it uploads —
which also closes the gap where attested bytes and published bytes were never
compared.

The audit job ran `pnpm install --frozen-lockfile` on pull_request. This is a
public fork, and --frozen-lockfile constrains the dependency graph but not
`scripts`; package.json declares a root `prepare` hook, so opening a PR was
enough for code execution on a runner. Now --ignore-scripts, and
persist-credentials: false on every job that touches repository content.

release-binaries.yml had none of release.yml's tag validation, so a tag pointing
at an unreviewed commit — including a fork PR head, reachable in this repo's
object network — would have produced a signed, published binary. Added a gate
job asserting semver shape, package.json agreement, and ancestry in origin/main.

`gh release upload --clobber` could silently replace assets of an
already-published release while minting fresh provenance for them. Removed; the
publish step now fails if an asset already exists.

Also: github/codeql-action was pinned to an annotated tag object rather than a
commit and could never resolve (e34fc271 -> 89a39a4e); node-version pinned
exactly since the shipped binary *is* that Node build; pnpm cache dropped from
the release build; concurrency keyed on the tag so dispatch and tag-push runs
cannot race; the minimumReleaseAge guard moved ahead of any install; scorecard
no longer runs on pull_request and no longer requests id-token it does not use;
package.json version validated before it reaches $GITHUB_OUTPUT.

New policy job enforces the two mistakes made here: no npx/pnpm dlx under
scripts/, and every action pin must resolve to a real commit.

Not fixed: the SBOM describes the source tree while being attested against the
binary — it over-reports devDependencies that never ship and omits the embedded
Node runtime. Attesting it is worse than publishing it unsigned, so the SBOM
attestation is dropped from this workflow pending a decision on what to scan.
Both new gates failed CI for reasons unrelated to security.

The 'build tools are lockfile-pinned' check grepped for the word npx, so it
flagged a string literal used as test data in lint-persisted-key-casing.ts and
the comment in build.mjs explaining why npx is banned. It now matches the
invocation form — npx or pnpm dlx as the command argument of an exec/run call —
and skips comment lines. Verified locally: clean.

The audit step failed with 'Unexpected token \x1f ... is not valid JSON'. That
is the gzip magic number: pnpm 10.33.2 crashes decoding a gzipped audit
response. Reproduced identically on CI and locally, so it is a transport bug and
not an advisory. Failing on it would make a red audit job meaningless, and a
genuine finding would be ignored along with it. The step now separates 'found
advisories' from 'could not run': a parse failure warns and passes, anything
else still fails.
Immutable releases are now enabled on the repository, which freezes assets
and the Git tag at publication. The old create-then-upload order cannot work
under that rule — the release would lock before the first asset landed — so
assets are attached to a draft and the draft is published last.

The SBOM question was what to scan. Measured against this artifact, every
obvious target is wrong: syft on the source tree reports 751 components
(every devDependency, none of which reaches a user), the production closure
is 685 (tree-shaking drops most of it), and syft on the finished binary
reports 0, because no per-package boundaries survive bundling into a V8
snapshot. rollup-plugin-sbom reads the bundler's own module graph and reports
the 18 packages actually inlined, plus the Node runtime added explicitly —
Node is the majority of the file and its largest attack surface, and no
scanner would match a Node advisory without a component to match against.

Signing is also split out of the build: a new attest job holds the identity
and runs no repository code, no checkout and no install, so dependency code
can never mint provenance for bytes the workflow did not produce.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kjbs5gugsCauZwrosUMhgJ
The npm package had npm's own --provenance and nothing else. It now also
carries GitHub build-provenance and SBOM attestations, and is published from
a tarball packed and attested before it leaves the runner, so the bytes npm
serves are the bytes that were signed.

Its SBOM is deliberately not the binary's. The package bundles nothing: dist/
ships alone and every dependency is installed on the user's machine, so the
honest scan target is the production dependency closure — ~685 packages,
resolved by syft from a prod-only install built in a scratch directory so a
dev-only package cannot leak in.

One asymmetry is worth stating rather than hiding: unlike the binary
pipeline, this job cannot keep the signing identity away from build code.
npm trusted publishing derives provenance from an OIDC token minted in the
job that holds the package, so they are necessarily co-resident.

Twenty-three action references across five workflows were still on movable
tags, including a setup-node@v4. A tag is a pointer the action's owner can
repoint at new code that then runs with this workflow's permissions. All are
now SHA-pinned, and a new gate fails on any that are not — the existing check
could only validate pins that were already SHAs, so an unpinned action was
invisible to it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kjbs5gugsCauZwrosUMhgJ
…n npm fallback

The formula is now rendered by scripts/sea/generate-formula.mjs, the single
writer of both install paths: platforms with a published single-executable
asset pin its immutable release tarball, and every other platform installs
the npm package with Homebrew's node. A new formula job in
release-binaries.yml regenerates the file after publish — from the release's
SHA256SUMS and the npm registry tarball — and opens a PR, replacing the
paste-by-hand checksum step.

Also fixes the darwin-x64 matrix leg: macos-13 is a retired runner label,
so the next tag would have failed that build; macos-15-intel is the
supported Intel image.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The darwin matrix legs run on GitHub-hosted macOS runners, which bill at a
premium, so the tag-push trigger is removed: binaries are built when a human
dispatches the workflow for a validated tag. The npm release in release.yml
stays automatic on its Linux runner.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rmula job's invariants

Two findings from the adversarial review of this branch:

- The job-list invariant in test/release-workflow.test.ts did not know about
  the formula job, so the Test job was red on this branch. The test now pins
  the five-job list and asserts the formula job's trust model: no OIDC, no
  dependency installs, checksums only from the immutable release manifest.
- The npm checksum was trust-on-first-use: whatever bytes the registry served
  were pinned into the formula. The formula job now runs gh attestation
  verify against the tarball — the provenance release.yml attests over the
  exact packed tarball before publish — before recording its sha256.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adversarial-review finding: the formula job held a contents-write token with
a persisted credential while checking out movable main and executing repo
scripts — the exact co-residency the rest of this workflow is built to avoid.

The job is now split on the same line as build/attest: `formula` renders
from the immutable validated tag with read-only permissions and no persisted
credential, syntax-checks the result (bot PRs from GITHUB_TOKEN get no CI),
and uploads it; `formula-pr` holds the write credential but executes no
repository code — it commits only the artifact bytes. The gate now reads
package.json from the tagged commit rather than the dispatch-branch checkout,
the generator refuses duplicate targets in a manifest, and the invariant
tests cover both new jobs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
trumpyla added 20 commits July 28, 2026 20:05
Require the generated formula to advance the live-main formula, reject ambiguous inputs and layouts, disable the legacy unlocked fallback, and keep npm fallback installs script-free.

Consolidates source commits c3d96b4, b44caf1, and faab8fe; supersedes their older 0.12.x dependency and workflow baselines with the stricter 0.13.0 release pipeline already present on this branch.
Use a purpose-built SEA-capable Node probe in the unit test so Homebrew Node builds that intentionally disable SEA do not mask version-provenance assertions.

Follow-up to source commit 7a7c945 after consolidation onto the current macOS toolchain.
Source-Commit: c3d96b4f82699f8cd6f6691ad6c0c351cdfbb052
Source-Commit: b44caf1e28e7203f865021e14adc95b558497893
Source-Commit: faab8fe811da16d8883e9de72ee894e8fca77575
Source-Commit: 7a7c945c21b3bcbf2adb22b8db9a598e9ba007ab
Source-Commit: 3cf8a67336c8a874828c04f4332d17b978454077
Source-Commit: 47bc822c43afd100fb7cd1877e7c4dd7037f1132
Source-Commit: 8147777b01194f819ff982b4cf18a33caea11bb7
Source-Commit: b1c586c708a92c1a607c65fe2a6301de9366eaef
Source-Scope: sbom
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.