diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8fa998e..f17df4c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -91,6 +91,10 @@ jobs: name: npm-package path: packages/agent-cli/artifacts/* if-no-files-found: error + # npm trusted publishing does not support self-hosted runners + # (https://docs.npmjs.com/trusted-publishers/). This job authenticates with + # the organization NPM_TOKEN secret instead. That drops OIDC trusted + # publishing and automatic provenance. publish: needs: build if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && vars.BLOXWAP_PUBLISH == '1' @@ -101,7 +105,8 @@ jobs: cancel-in-progress: false permissions: contents: write - id-token: write + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} steps: - uses: actions/checkout@v4 with: @@ -115,6 +120,8 @@ jobs: bun-version: '1.3.10' - name: Recheck tagged main ancestry before publishing run: node packages/agent-cli/scripts/verify-release.mjs + - name: Verify npm publish authentication + run: node packages/agent-cli/scripts/check-publish-auth.mjs - uses: actions/download-artifact@v4 with: pattern: native-* @@ -126,11 +133,9 @@ jobs: path: release - name: Prepare release checksums run: node packages/agent-cli/scripts/release-checksums.mjs release - - name: Publish the tested tarball with provenance + - name: Publish the tested tarball run: node packages/agent-cli/scripts/publish-artifact.mjs release - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: Verify published integrity and provenance + - name: Verify published integrity run: node packages/agent-cli/scripts/verify-publication.mjs release - name: Verify public npm install and bunx run: npm run test:package diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 08a0fad..63c1dfa 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -20,11 +20,11 @@ Node 22.12 minimum. | #33–#36 | New approval verified before signer handoff/old revocation, resumable rotation and immediate local revocation tests; fsynced cross-process hash-chain with intent/decision/signature digest/response/fill linkage; tamper/redaction tests and linked full-lifecycle records. CLI/RPC pre-schema hex/mnemonic detector and threat model. | | #37–#38 | Skill source/frontmatter/error-catalog tests and every documented command input parsed through actual CLI schemas offline; user-owned Privy, ordered external approvals, bounded polling, exchange reconciliation and supported-test-route guidance; install/custody README, generated command reference and static Pages site. The skill deliverable is complete; the unproven coding-agent testnet session is tracked explicitly in #39. | | #39 | Offline CLI/SDK/RPC lifecycle plus fifteen-stage testnet harness with external wallet approval adapter or separate CI-only disposable signer; exact prices, correlated events, post-rotation trading, partial-rotation cleanup and zero orders/agents/positions verification. Schema 2 reports/CI summaries validate stage and service evidence; Relay remains mocked. Missing configuration fails explicitly. Nightly live evidence and the independent coding-agent skill session remain pending. | -| #40–#42 | Script-free packed global installation and installed bunx smoke, executable bin, license and matching skill versions; Node 22.12/24 install CI; Linux/macOS ARM/x64 native binary smoke; changesets/tagged-main/version/provenance/asset pipeline with candidate-only manual runs. Publishing and public registry verification await npm configuration. | +| #40–#42 | Script-free packed global installation and installed bunx smoke, executable bin, license and matching skill versions; Node 22.12/24 install CI; Linux/macOS ARM/x64 native binary smoke; changesets/tagged-main/version/token-publish/asset pipeline with candidate-only manual runs. Publishing and public registry verification await the `BLOXWAP_PUBLISH` gate. | | #49 | Token guard rejects declarations anywhere in formatted/minified CSS. Unit regression and a mutation of global.css prove docs:check rejects the issue's exact same-line copy. | No package publication or credentialed account trading is claimed by this review. Close the live criteria only with an actual run ID, commit, lifecycle report and -event/audit artifacts, or a published version with verified integrity/provenance +event/audit artifacts, or a published version with verified integrity and public installation evidence. Mocks, missing-secret failures and candidate artifacts are not substitutes for those records. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 7aa7990..7fb74cd 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -43,7 +43,7 @@ | #38 | done | Install/testnet README, custody architecture, schema-generated command reference and static docs site with gated Pages workflow. | | #39 | partial | Fifteen-stage testnet harness supports external wallet signing and separate CI-only disposable keys, exact exchange reconciliation, correlated WebSocket/follow/replay events, post-rotation trading, audit verification, partial-rotation cleanup and zero orders/agents/positions checks. Schema 2 reports and CI summaries require evidence. Offline regressions pass; nightly live journals and the independent coding-agent skill session transferred from #37 still require dedicated QA configuration and evidence. | | #40 | partial | Script-free tarball global install and actual installed bunx help/schema smoke pass locally; executable/skill/license checks and Node 22.12/24 macOS/Linux CI. Public registry installation awaits publication. | -| #41 | partial | Validated Changesets patch workflow with optional version PR, native binary smoke/checksums, tagged-main version gates, exact tested tarball publication with provenance and registry verification. Manual candidates cannot publish. Actual release needs npm authentication and the publishing gate; organization policy blocks automatic version PRs, so the default workflow uploads a reviewable patch. | +| #41 | partial | Validated Changesets patch workflow with optional version PR, native binary smoke/checksums, tagged-main version gates, exact tested tarball publication and registry verification. Publish runs on the Mac mini fleet with the organization `NPM_TOKEN` (no OIDC trusted publishing or automatic provenance). Manual candidates cannot publish. Actual release needs the publishing gate; organization policy blocks automatic version PRs, so the default workflow uploads a reviewable patch. | | #42 | done | Final packaged flow audited and corrected (network/config isolation, exact loss/exposure/receipt checks, trigger preservation, readiness and stream errors). Skill shipped/embedded in npm and compiled CLI; Claude/Codex installation with CODEX_HOME, version synchronization, idempotency and overwrite protection. | | #49 | done | Docs token guard checks declarations anywhere on a line; regression coverage includes formatted and minified radius, motion, density and type copies. The issue's same-line mutation makes docs:check fail. | @@ -74,7 +74,7 @@ account trading were used for this review. The validated version-patch workflow prepared 0.2.0, consuming the pending changesets and synchronizing package/lock/skill versions and generated docs. -This is release preparation; public npm installation/provenance is still pending. +This is release preparation; public npm installation is still pending. ## Testnet harness — 2026-10-18 diff --git a/docs/ci-runners.md b/docs/ci-runners.md index 811bd6f..07f1c4d 100644 --- a/docs/ci-runners.md +++ b/docs/ci-runners.md @@ -11,7 +11,16 @@ The labels are case-sensitive. Register each runner with exactly `self-hosted`, `macOS`, and `ARM64`. Do not use GitHub-hosted `ubuntu-*` or `macos-*` images. Jobs may stay queued when the fleet is busy. That is expected: the Mac minis are -the only runners. +the only runners. Register them in runner group **JWS** as macOS user `admin`. + +## Trusted publishing is unavailable + +[npm trusted publishing does not support self-hosted runners](https://docs.npmjs.com/trusted-publishers/). +The release `publish` job therefore authenticates with the organization +`NPM_TOKEN` secret (`NODE_AUTH_TOKEN`). That **drops OIDC trusted publishing +and automatic provenance**. Do not bind a trusted publisher to `release.yml`. + +GitHub Pages deploy still uses OIDC (`id-token: write`) on the minis. ## Required software @@ -24,10 +33,16 @@ Install these on the runner account. Workflows never use `sudo` or `apt`. | GitHub Actions runner | Must stay logged in as a user that can run Colima and Docker without privilege escalation | | Xcode Command Line Tools | `git`, `clang`, `tar`, `unzip`, `curl` | | Homebrew + `gnu-tar` | `actions/upload-pages-artifact` needs `gtar` (`brew install gnu-tar`) | +| `gh` | Optional local debugging; already on the JWS minis | +| `gnupg` | Already on the JWS minis | | Colima + Docker CLI | Linux containers for binary and install smokes | | Node.js 24 | Workflows also pin Node with `actions/setup-node` | | Bun 1.3.10 | Native compiles and package smokes; workflows also pin Bun with `oven-sh/setup-bun` | +`actions/setup-python` cannot install on these minis (`/Users/runner` is not +writable). This repository does not need Python; if a job ever does, use +Homebrew `python@3.13` on `PATH` instead. + One-time Colima VM (persistent; later jobs run `colima status || colima start`): ```sh @@ -94,7 +109,8 @@ The runner needs outbound access to: ## How jobs use the Mac mini -- **CI / version / docs deploy / npm publish** run natively on darwin-arm64. +- **CI / version / docs deploy** run natively on darwin-arm64. **npm publish** + also runs natively and uses `NPM_TOKEN` (no provenance). - **Release** cross-compiles `linux-x64`, `linux-arm64`, `darwin-x64`, and `darwin-arm64` with Bun on the Mac. Linux binaries are smoke-tested in `linux/amd64` and `linux/arm64` containers. The `darwin-x64` binary is diff --git a/docs/releases.md b/docs/releases.md index a8e2ef6..5e08d30 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -36,30 +36,24 @@ binary uses Bun's baseline target; binaries do not load incidental `.env` or ## Configure publishing +[npm trusted publishing does not support self-hosted runners](https://docs.npmjs.com/trusted-publishers/). +The gated publish job therefore authenticates with the organization `NPM_TOKEN` +secret as `NODE_AUTH_TOKEN`. That **drops OIDC trusted publishing and automatic +provenance**. Do not bind a trusted publisher to `release.yml` on these Mac minis. + As of 2026-10-16, `@bloxwap/agent-cli` is not publicly available on npm (registry -404), no `BLOXWAP_PUBLISH` variable or `NPM_TOKEN` secret is configured, and the -`npm-release` GitHub environment does not exist. The install and publication -criteria remain pending until an actual public release passes registry smoke. -No version tag, npm publication or GitHub release was created by this audit. +404) and the `npm-release` GitHub environment / `BLOXWAP_PUBLISH` gate were not +configured. The organization now shares a granular `NPM_TOKEN` (publish rights +for `@bloxwap` packages) with this repository. Publication still awaits the +environment and `BLOXWAP_PUBLISH=1` gate, plus an actual public registry smoke. Before enabling publication: -1. Confirm npm write access to the `@bloxwap` scope and the package name. Configure - a granular npm token as the `NPM_TOKEN` secret in the `npm-release` environment - for the initial publication, or configure an authorized npm trusted publisher. - A local `npm whoami` does not verify OIDC trusted-publisher permissions. +1. Confirm the organization `NPM_TOKEN` secret is available to this repository + and can publish `@bloxwap/agent-cli`. The publish job checks the token + (`whoami`, then collaborator write access, or a first-publish 404). 2. Create the `npm-release` GitHub environment and apply the desired tag/reviewer restrictions before setting the repository variable `BLOXWAP_PUBLISH=1`. -3. Prefer npm trusted publishing after the package exists. Bind it to organization - `bloxwap`, repository `agent-cli`, workflow **`release.yml`**, and environment - **`npm-release`**, with direct `npm publish` allowed. Node 24 includes a supported - npm CLI (trusted publishing requires npm 11.5.1+ and Node 22.14+). Do not leave a - token configured after migrating fully to OIDC. New trusted publisher bindings - must complete their first successful publish within npm's activation window. - -See the current [npm trusted publishing documentation](https://docs.npmjs.com/trusted-publishers/) -for scope ownership, activation and authentication details. The package's -repository metadata identifies the public GitHub source required for provenance. ## Publish a reviewed version @@ -81,9 +75,9 @@ The workflow rejects mismatched, prerelease or unreviewed branch tags. A tag wit publishing disabled still builds candidate artifacts; enabling the gate later requires rerunning the existing tag's workflow, not moving the tag. -After all four native candidates succeed, the gated publish job downloads the -exact tested tarball, computes `SHA256SUMS`, publishes it with `--provenance`, and -verifies registry integrity plus the provenance attestation. It then tests a +After the candidate job succeeds, the gated publish job downloads the +exact tested tarball, computes `SHA256SUMS`, publishes it with `NPM_TOKEN`, and +verifies registry integrity. It then tests a public global npm install and bunx registry execution before creating generated GitHub release notes and attaching the npm tarball, four binaries, SKILL.md, CHANGELOG.md, LICENSE and checksums. Downloaded binaries need `chmod +x` before @@ -93,6 +87,6 @@ execution. Verify downloads with `sha256sum -c SHA256SUMS` (Linux) or A published npm version is immutable. If publication succeeds but a later step fails, inspect the registry and failure before rerunning the job; keep the tag in place. The publish step accepts an existing version only when its registry hash -matches the tested archive and its provenance is present, then continues the -remaining release steps. Authentication and network errors never count as an -unpublished version. A conflicting existing archive stops the workflow. +matches the tested archive, then continues the remaining release steps. +Authentication and network errors never count as an unpublished version. A +conflicting existing archive stops the workflow. diff --git a/packages/agent-cli/scripts/check-publish-auth.mjs b/packages/agent-cli/scripts/check-publish-auth.mjs new file mode 100644 index 0000000..b3b619a --- /dev/null +++ b/packages/agent-cli/scripts/check-publish-auth.mjs @@ -0,0 +1,83 @@ +// Verify the token npm publish will use, even when the current version is +// already published. Never print or persist the token. +// npm trusted publishing is not available on self-hosted runners: +// https://docs.npmjs.com/trusted-publishers/ +import { readFile } from 'node:fs/promises'; +import { pathToFileURL } from 'node:url'; + +export function readPublishToken(env = process.env) { + const token = env.NODE_AUTH_TOKEN || env.NPM_TOKEN; + if (typeof token !== 'string' || token.length === 0) { + throw new Error( + 'NPM_TOKEN is missing. Set the organization NPM_TOKEN secret with permission to publish @bloxwap/agent-cli.', + ); + } + return token; +} + +export function assertCollaboratorWrite(name, username, access) { + const permission = access?.[username]; + if ( + permission !== 'write' && + permission !== 'read-write' && + permission !== true + ) { + throw new Error(`npm token for ${username} cannot publish ${name}.`); + } +} + +export async function verifyPublishAuth({ + token = readPublishToken(), + name, + fetchImpl = fetch, +} = {}) { + const whoami = await fetchImpl('https://registry.npmjs.org/-/whoami', { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(30_000), + }); + if (!whoami.ok) { + throw new Error( + `npm token authentication failed (HTTP ${whoami.status}). Check the organization NPM_TOKEN secret.`, + ); + } + const identity = await whoami.json(); + if (typeof identity.username !== 'string' || !identity.username) { + throw new Error('npm whoami did not return a username.'); + } + + const escapedName = name.replace('/', '%2f'); + const collaborators = await fetchImpl( + `https://registry.npmjs.org/-/package/${escapedName}/collaborators`, + { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(30_000), + }, + ); + if (collaborators.status === 404) { + return { + username: identity.username, + unpublished: true, + message: `npm accepted the organization NPM_TOKEN for ${name} as ${identity.username}; the package is not on the registry yet.`, + }; + } + if (!collaborators.ok) { + throw new Error( + `npm collaborator check failed (HTTP ${collaborators.status}). The NPM_TOKEN secret must be able to publish ${name}.`, + ); + } + const access = await collaborators.json(); + assertCollaboratorWrite(name, identity.username, access); + return { + username: identity.username, + unpublished: false, + message: `npm accepted the organization NPM_TOKEN for ${name} as ${identity.username}.`, + }; +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + const { name } = JSON.parse( + await readFile(new URL('../package.json', import.meta.url), 'utf8'), + ); + const result = await verifyPublishAuth({ name }); + console.log(result.message); +} diff --git a/packages/agent-cli/scripts/publication.mjs b/packages/agent-cli/scripts/publication.mjs index e8bedef..d0079da 100644 --- a/packages/agent-cli/scripts/publication.mjs +++ b/packages/agent-cli/scripts/publication.mjs @@ -38,11 +38,14 @@ export function readRegistryDist(spec, run = execFileSync) { return JSON.parse(output); } +export function npmPublishArgs(tarball) { + return ['publish', tarball, '--access', 'public', '--ignore-scripts']; +} + export function verifyPublication(dist, integrity) { assert.equal( dist?.integrity, integrity, 'Published tarball differs from tested release artifact', ); - assert.ok(dist?.attestations?.url, 'npm provenance attestation is missing'); } diff --git a/packages/agent-cli/scripts/publish-artifact.mjs b/packages/agent-cli/scripts/publish-artifact.mjs index d4b05d4..a2eb342 100644 --- a/packages/agent-cli/scripts/publish-artifact.mjs +++ b/packages/agent-cli/scripts/publish-artifact.mjs @@ -1,5 +1,6 @@ import { execFileSync } from 'node:child_process'; import { + npmPublishArgs, readRegistryDist, releaseArtifact, verifyPublication, @@ -10,19 +11,8 @@ const published = readRegistryDist(artifact.spec); if (published) { verifyPublication(published, artifact.integrity); console.log( - `Already published identical provenanced artifact: ${artifact.spec}; continuing release`, + `Already published identical artifact: ${artifact.spec}; continuing release`, ); } else { - execFileSync( - 'npm', - [ - 'publish', - artifact.tarball, - '--provenance', - '--access', - 'public', - '--ignore-scripts', - ], - { stdio: 'inherit' }, - ); + execFileSync('npm', npmPublishArgs(artifact.tarball), { stdio: 'inherit' }); } diff --git a/packages/agent-cli/scripts/verify-publication.mjs b/packages/agent-cli/scripts/verify-publication.mjs index a3e0598..c7b7370 100644 --- a/packages/agent-cli/scripts/verify-publication.mjs +++ b/packages/agent-cli/scripts/verify-publication.mjs @@ -10,7 +10,7 @@ for (let attempt = 0; attempt < 6; attempt++) { try { verifyPublication(readRegistryDist(artifact.spec), artifact.integrity); console.log( - `PASS: ${artifact.spec} registry integrity and provenance attestation`, + `PASS: ${artifact.spec} registry integrity`, ); failure = undefined; break; diff --git a/packages/agent-cli/test/publication.test.mjs b/packages/agent-cli/test/publication.test.mjs index 0f182f0..04ccd0a 100644 --- a/packages/agent-cli/test/publication.test.mjs +++ b/packages/agent-cli/test/publication.test.mjs @@ -1,8 +1,16 @@ import { expect, test, vi } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; import { + npmPublishArgs, readRegistryDist, verifyPublication, } from '../scripts/publication.mjs'; +import { + assertCollaboratorWrite, + readPublishToken, + verifyPublishAuth, +} from '../scripts/check-publish-auth.mjs'; test('only an explicit npm registry 404 is treated as an unpublished version', () => { const missing = vi.fn(() => { @@ -38,14 +46,87 @@ test('registry distribution is parsed without changing its integrity', () => { expect(readRegistryDist('package', () => JSON.stringify(dist))).toEqual(dist); expect(() => verifyPublication(dist, 'sha512-example')).not.toThrow(); }); -test('an existing version must match the tested archive and have provenance', () => { +test('an existing version must match the tested archive', () => { expect(() => verifyPublication({ integrity: 'sha512-other' }, 'sha512-example'), ).toThrow('differs'); expect(() => verifyPublication({ integrity: 'sha512-example' }, 'sha512-example'), - ).toThrow('provenance'); + ).not.toThrow(); expect(() => verifyPublication(undefined, 'sha512-example')).toThrow( 'differs', ); }); +test('token publish does not request npm provenance', () => { + expect(npmPublishArgs('release.tgz')).toEqual([ + 'publish', + 'release.tgz', + '--access', + 'public', + '--ignore-scripts', + ]); + expect(npmPublishArgs('release.tgz')).not.toContain('--provenance'); +}); +test('publish auth fails closed without a token and does not print secrets', () => { + expect(() => + readPublishToken({ NODE_AUTH_TOKEN: '', NPM_TOKEN: '' }), + ).toThrow('NPM_TOKEN is missing'); + const script = fileURLToPath( + new URL('../scripts/check-publish-auth.mjs', import.meta.url), + ); + const result = spawnSync(process.execPath, [script], { + encoding: 'utf8', + env: { ...process.env, NODE_AUTH_TOKEN: '', NPM_TOKEN: '' }, + }); + expect(result.status).not.toBe(0); + expect(`${result.stdout}\n${result.stderr}`).toMatch(/NPM_TOKEN is missing/); + expect(`${result.stdout}\n${result.stderr}`).not.toMatch( + /npm_[A-Za-z0-9]{8,}/, + ); +}); +test('publish auth accepts write access and first-publish 404 without network', async () => { + assertCollaboratorWrite('@bloxwap/agent-cli', 'ci-bot', { + 'ci-bot': 'write', + }); + expect(() => + assertCollaboratorWrite('@bloxwap/agent-cli', 'ci-bot', { + 'ci-bot': 'read', + }), + ).toThrow('cannot publish'); + + const ok = (body, status = 200) => ({ + ok: status >= 200 && status < 300, + status, + json: async () => body, + }); + const firstPublish = await verifyPublishAuth({ + token: 'npm_testtoken', + name: '@bloxwap/agent-cli', + fetchImpl: async (url) => { + if (String(url).endsWith('/-/whoami')) + return ok({ username: 'bloxwap-ci' }); + return ok({}, 404); + }, + }); + expect(firstPublish.unpublished).toBe(true); + expect(firstPublish.message).toMatch(/not on the registry yet/); + + const published = await verifyPublishAuth({ + token: 'npm_testtoken', + name: '@bloxwap/agent-cli', + fetchImpl: async (url) => { + if (String(url).endsWith('/-/whoami')) + return ok({ username: 'bloxwap-ci' }); + return ok({ 'bloxwap-ci': 'read-write' }); + }, + }); + expect(published.unpublished).toBe(false); + + await expect( + verifyPublishAuth({ + token: 'npm_testtoken', + name: '@bloxwap/agent-cli', + fetchImpl: async () => ok({}, 401), + }), + ).rejects.toThrow('npm token authentication failed'); +});