From f26d604656665f10c18f5ce5646ff3d2d181a39d Mon Sep 17 00:00:00 2001 From: grishencorp Date: Mon, 21 Sep 2026 22:23:51 -0300 Subject: [PATCH 1/2] Prepare gated release-level OIDC publication of verified artifacts --- .github/workflows/release-direct.yml | 255 +++++++++++ AGENTS.md | 7 + docs/release-channels.md | 6 + docs/security/npm-direct-release.md | 122 +++++ docs/security/npm-release-security.md | 8 + scripts/approve-owner-npm-release.mjs | 154 +++++-- scripts/npm-registry-artifact.mjs | 71 +++ scripts/publish-direct-npm-release.mjs | 262 +++++++++++ scripts/stage-npm-release-artifacts.mjs | 104 +++-- scripts/verify-npm-release-governance.mjs | 89 +++- scripts/verify-npm-release-provenance.mjs | 30 +- tests/approve-owner-release-profile.test.mjs | 114 +++++ tests/direct-npm-release.test.mjs | 449 +++++++++++++++++++ tests/verify-npm-release-governance.test.mjs | 106 ++++- 14 files changed, 1670 insertions(+), 107 deletions(-) create mode 100644 .github/workflows/release-direct.yml create mode 100644 docs/security/npm-direct-release.md create mode 100644 scripts/npm-registry-artifact.mjs create mode 100644 scripts/publish-direct-npm-release.mjs create mode 100644 tests/approve-owner-release-profile.test.mjs create mode 100644 tests/direct-npm-release.test.mjs diff --git a/.github/workflows/release-direct.yml b/.github/workflows/release-direct.yml new file mode 100644 index 00000000..618e7c49 --- /dev/null +++ b/.github/workflows/release-direct.yml @@ -0,0 +1,255 @@ +name: Release verified packages via OIDC + +on: + workflow_dispatch: + inputs: + dist_tag: + description: npm distribution tag for the verified release + required: true + default: next + type: choice + options: + - next + - latest + dry_run: + description: prepare and verify immutable tarballs without publishing them + required: false + default: true + type: boolean + scope: + description: Publication cohort + required: true + default: all + type: choice + options: + - public-consumer + - all + +permissions: + contents: read + +concurrency: + group: release-packages + cancel-in-progress: false + +jobs: + prepare: + if: >- + github.ref == 'refs/heads/main' && + github.actor_id == '207043696' && + github.actor == 'douglas-grishen' && + github.triggering_actor == 'douglas-grishen' && + (inputs.dry_run || vars.AGENTPLAT_NPM_DIRECT_RELEASE_ENABLED == 'true') + env: + NPM_PACKAGE_SCOPE: ${{ inputs.scope }} + runs-on: ubuntu-latest + timeout-minutes: 90 + permissions: + contents: read + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: agentplat_release + POSTGRES_USER: agentplat_release + POSTGRES_PASSWORD: agentplat_release + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U agentplat_release -d agentplat_release" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 24.20.0 + package-manager-cache: false + - run: corepack enable + - name: Install dependencies without registry credentials + run: pnpm install --frozen-lockfile + env: + NPM_CONFIG_USERCONFIG: /dev/null + - name: Prepare required public terminology denylist + shell: bash + env: + AGENTPLAT_PUBLIC_DENYLIST: ${{ secrets.AGENTPLAT_PUBLIC_DENYLIST }} + run: | + if [ -z "${AGENTPLAT_PUBLIC_DENYLIST:-}" ]; then + echo "The release terminology denylist secret is required." >&2 + exit 1 + fi + umask 077 + denylist_path="${RUNNER_TEMP}/agentplat-public-terminology.txt" + printf '%s\n' "${AGENTPLAT_PUBLIC_DENYLIST}" > "${denylist_path}" + echo "AGENTPLAT_PUBLIC_DENYLIST_FILE=${denylist_path}" >> "${GITHUB_ENV}" + - name: Run release audit and full verification + run: | + pnpm run audit:public:release + pnpm run audit:dependencies:production + if [ "${NPM_PACKAGE_SCOPE}" = "public-consumer" ]; then + pnpm --filter @agentplat/collective-runtime... --filter @agentplat/audit... build + pnpm --filter @agentplat/collective-runtime --filter @agentplat/audit type-check + else + pnpm run check + pnpm run verify:mesh-postgres-faults + pnpm run verify:mesh-soak -- --messages 9 --repetitions 2 + pnpm run benchmark:mesh-adapters + fi + env: + AGENTPLAT_POSTGRES_TEST: "1" + NPM_CONFIG_USERCONFIG: /dev/null + PGHOST: 127.0.0.1 + PGPORT: "5432" + PGDATABASE: agentplat_release + PGUSER: agentplat_release + PGPASSWORD: agentplat_release + - name: Prepare immutable npm release artifacts + run: node scripts/prepare-npm-release-artifacts.mjs + env: + AGENTPLAT_RELEASE_ARTIFACT_DIRECTORY: release-artifacts + NPM_CONFIG_USERCONFIG: /dev/null + NPM_DIST_TAG: ${{ inputs.dist_tag }} + NPM_PACKAGE_SCOPE: ${{ inputs.scope }} + - name: Verify the exact release artifacts + run: | + if [ "${NPM_PACKAGE_SCOPE}" = "public-consumer" ]; then + pnpm run verify:public-consumer + else + pnpm run verify:pack + fi + env: + AGENTPLAT_PREPACKED_TARBALL_DIRECTORY: ${{ github.workspace }}/release-artifacts + NPM_CONFIG_USERCONFIG: /dev/null + - name: Upload immutable npm release artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: npm-release-${{ github.sha }}-${{ inputs.scope }}-${{ inputs.dist_tag }} + path: release-artifacts/ + if-no-files-found: error + compression-level: 0 + # Human staged approval and post-publication verification can span days. + retention-days: 30 + include-hidden-files: false + + publish: + if: ${{ !inputs.dry_run && vars.AGENTPLAT_NPM_DIRECT_RELEASE_ENABLED == 'true' }} + needs: prepare + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: npm-release + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 1 + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 24.20.0 + package-manager-cache: false + - name: Download the same verified cohort + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: npm-release-${{ github.sha }}-${{ inputs.scope }}-${{ inputs.dist_tag }} + path: release-artifacts + - name: Publish exact tarballs with scoped OIDC + run: node scripts/publish-direct-npm-release.mjs + env: + AGENTPLAT_NPM_DIRECT_RELEASE_ENABLED: ${{ vars.AGENTPLAT_NPM_DIRECT_RELEASE_ENABLED }} + AGENTPLAT_NPM_DIRECT_PUBLISH_CONFIRMED: ${{ vars.AGENTPLAT_NPM_DIRECT_PUBLISH_CONFIRMED }} + AGENTPLAT_RELEASE_ARTIFACT_DIRECTORY: release-artifacts + NPM_CONFIG_USERCONFIG: /dev/null + NPM_DIST_TAG: ${{ inputs.dist_tag }} + - name: Retain publication progress even after partial failure + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: npm-direct-report-${{ github.sha }}-${{ inputs.scope }}-${{ inputs.dist_tag }} + path: direct-release-report.json + if-no-files-found: ignore + retention-days: 30 + + verify: + needs: publish + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: agentplat_release_verify + POSTGRES_USER: agentplat_release_verify + POSTGRES_PASSWORD: agentplat_release_verify + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U agentplat_release_verify -d agentplat_release_verify" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + AGENTPLAT_RELEASE_ARTIFACT_DIRECTORY: release-artifacts + AGENTPLAT_SOURCE_COMMIT: ${{ github.sha }} + AGENTPLAT_RELEASE_WORKFLOW_PATH: .github/workflows/release-direct.yml + NPM_DIST_TAG: ${{ inputs.dist_tag }} + NPM_CONFIG_USERCONFIG: /dev/null + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 24.20.0 + package-manager-cache: false + - run: corepack enable + - run: pnpm install --frozen-lockfile + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: npm-release-${{ github.sha }}-${{ inputs.scope }}-${{ inputs.dist_tag }} + path: release-artifacts + - name: Verify registry integrity, signatures, tag and source provenance + run: node scripts/verify-npm-release-provenance.mjs + - name: Verify the public-consumer registry cohort + if: ${{ inputs.scope == 'public-consumer' }} + run: pnpm run verify:public-consumer + env: + AGENTPLAT_PUBLIC_CONSUMER_SOURCE: registry + - name: Verify complete coordinated registry distribution + if: ${{ inputs.scope == 'all' }} + run: node scripts/npm-distribution-readiness.mjs --require-complete --tag "$NPM_DIST_TAG" + - name: Verify portable registry consumer + if: ${{ inputs.scope == 'all' }} + run: pnpm run verify:registry-consumer + env: + AGENTPLAT_REGISTRY_CONSUMER_PM: pnpm + AGENTPLAT_REGISTRY_CONSUMER_PROFILE: portable + - name: Verify durable registry consumer + if: ${{ inputs.scope == 'all' }} + run: pnpm run verify:registry-consumer + env: + AGENTPLAT_REGISTRY_CONSUMER_PM: pnpm + AGENTPLAT_REGISTRY_CONSUMER_PROFILE: postgres + PGHOST: 127.0.0.1 + PGPORT: "5432" + PGDATABASE: agentplat_release_verify + PGUSER: agentplat_release_verify + PGPASSWORD: agentplat_release_verify + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + if: ${{ inputs.scope == 'all' }} + with: + node-version: 22.22.0 + package-manager-cache: false + - name: Verify npm registry consumer on Node 22 + if: ${{ inputs.scope == 'all' }} + run: node scripts/verify-registry-consumer.mjs + env: + AGENTPLAT_REGISTRY_CONSUMER_PM: npm + AGENTPLAT_REGISTRY_CONSUMER_PROFILE: portable diff --git a/AGENTS.md b/AGENTS.md index f773354b..91c73fba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,3 +39,10 @@ with `node scripts/approve-owner-npm-release.mjs RUN_ID`; do not ask him to name another reviewer. Other initiators still require separate owner review. Preserve required CI, protected main, stage-only publishing, staged-byte review and npm 2FA. See `docs/security/npm-release-security.md` for the exact boundary. + + +The release-level OIDC alternative in `docs/security/npm-direct-release.md` is +prepared but NOT activated. Do not enable its flags, create its trusted publisher +relationships or publish a new cohort without the owner's final activation +approval. Once explicitly enabled, its own single-release approval and exact +artifact checks replace per-package staged approval for that profile only. diff --git a/docs/release-channels.md b/docs/release-channels.md index f5195749..53d98949 100644 --- a/docs/release-channels.md +++ b/docs/release-channels.md @@ -47,3 +47,9 @@ version's `latest` tag. This caveat applies to the first publication of `@agentplat/trust`, as it did when the four Agent Mesh packages were introduced in Alpha 1. Consumers should still install the coordinated channel explicitly while the framework is in preview. + + +A disabled-by-default [release-level OIDC profile](security/npm-direct-release.md) +prepares one verified cohort for one protected deployment approval. It requires +explicit activation of separate npm publisher permissions; the existing staged +release workflow remains the active default until that activation. diff --git a/docs/security/npm-direct-release.md b/docs/security/npm-direct-release.md new file mode 100644 index 00000000..f5f5dca0 --- /dev/null +++ b/docs/security/npm-direct-release.md @@ -0,0 +1,122 @@ +# Release-level OIDC publication (prepared, not activated) + +The owner requested preparation of this alternative after interactive staged +approval failed to reuse a passkey across the coordinated package cohort. +This document is the reviewable activation proposal. No npm publisher permissions, +GitHub environments or enablement variables are changed by merging the code. + +## Change in authority + +The existing `release.yml` / `npm-production` relationship remains stage-only. +A separate `release-direct.yml` / `npm-release` relationship would allow public +publication from CI after one protected deployment approval for the exact release. +The package registry would no longer require a separate passkey approval for +each package through this relationship. Account 2FA stays enabled; no long-lived +npm token is created. This deliberately changes the current security boundary: +trusted CI becomes able to publish publicly after release-level authorization. +A compromise of that authorized workflow/environment is therefore a publication +risk; it must not be described as equivalent to per-package human approval. + +## Execution and checks + +1. Manual dispatch is restricted to the owner's immutable GitHub identity, both + initial actor and rerun actor, on `main`. Dry-run is the default. Non-dry runs + also require the repository enablement flag. +2. An unprivileged job installs the frozen lockfile, performs the public and + dependency audits, builds, tests and checks the selected release scope. Full + scope retains the existing complete checks, PostgreSQL faults, soak and adapter + benchmark. The exact packed tarballs are independently consumed before upload. +3. `npm-release` gates publication once for the whole prepared artifact. The owner + may approve his own requested release, following the standing owner policy; + no second person is required. Other accounts cannot initiate this profile. +4. The OIDC job installs no project dependencies and executes no build. It verifies + the source commit, version, cohort, sizes, SHA-512, package identity and absence + of lifecycle hooks for EVERY archive before publishing any. It refuses symlinks + and extra or missing files. npm runs from an isolated directory with user/global + configuration disabled, explicit public registry, `--ignore-scripts` and + `--provenance`. Only the previously verified tarballs are passed to npm. +5. Existing versions are skipped only when their actual downloaded bytes, registry + ECDSA signature, distribution tag, workflow provenance and source commit match + this exact release. Any disagreement or registry uncertainty stops before new + publication. Dependencies are published first, using the existing topological + package ordering. A command failure stops before dependent packages. +6. Read-only verification checks public registry bytes, ECDSA signatures, provenance + fields and tags, then exercises clean pnpm/npm consumers (including the durable + PostgreSQL profile and Node 22). These results are required before announcing + a completed release. Provenance fields come from npm's attestation endpoint; + this verifier is not an independent implementation of Sigstore certificate / + transparency-log verification. + +npm has no atomic transaction spanning 65 packages. A network failure can leave +part of a cohort public. Rerun the SAME original workflow/commit/artifact; the +publisher verifies existing versions rather than rebuilding, republishing or +silently repairing tags. A mismatched existing version requires investigation. +The progress report is diagnostic; registry evidence determines resume behavior. + +## Activation proposal — requires owner's final authorization + +After the reviewed code is integrated and required CI passes: + +- Create `npm-release` with administrator bypass disabled and exactly GitHub user + `douglas-grishen` (ID `207043696`) as reviewer; allow that owner to approve his own + release. Use **selected branches and tags**, with exactly one **branch** rule + named `main`. Do not use a wildcard or a tag rule. The workflow additionally + requires GitHub to report `main` as protected. +- For each of the 65 already-existing npm packages, add a trusted publisher tied + to organization `Agentplat`, repository `agentplat`, workflow filename + `release-direct.yml`, environment `npm-release`, with direct `npm publish` + permission. Retain the original stage-only publisher as a separate relationship. + Verify the actual package settings; do not assume that saving a name validates + the relationship. This initial setup may require npm account authentication. +- Only after those relationships are checked, set the environment variable + `AGENTPLAT_NPM_DIRECT_PUBLISH_CONFIRMED=true` in `npm-release`. +- Enable last: set repository variable `AGENTPLAT_NPM_DIRECT_RELEASE_ENABLED=true`. + Run `AGENTPLAT_NPM_RELEASE_MODE=direct node scripts/verify-npm-release-governance.mjs` + using the authenticated maintainer CLI. It checks the owner, exact main-only + deployment rule, enablement flags and existing main/Actions protections. +- Prepare a fresh coordinated version and run a dry-run first. Then dispatch the + same reviewed main source with `dry_run=false`, tag `next`, scope `all`. + After preparation passes, approve the one environment deployment, or use + `node scripts/approve-owner-npm-release.mjs RUN_ID --direct` on the owner's + behalf. It refuses foreign original/rerun actors, failed preparation, another + workflow, disabled flags or a different environment. + +The flags are not proof of npm permissions: a maintainer must verify the package +publisher settings. Only npm's actual OIDC exchange can establish the final +provider configuration works. That live test has not been performed for this +prepared profile, and cannot be claimed from fixture tests. + +## Existing beta.9 and migration + +Beta.9 is already partially public, and other beta.9 versions remain staged. +Staged and public versions share npm's version uniqueness constraint. The new +publisher must not overwrite them, automatically reject staging, unpublish a +version or move old tags to disguise this partial release. + +Use a fresh coordinated version, proposed `0.3.0-beta.10`, after checking current +registry availability. Bump all package manifests through the existing version +script, add the supported release-line entry, regenerate/verify the lockfile if +needed, and produce a NEW manifest from the approved source. No such version +bump or publication is included in this preparation change. Historical paper +and beta.9 artifacts keep their original source references. Handling leftover +private staging is a separate, explicitly authorized cleanup. + +## Disable / rollback + +Set `AGENTPLAT_NPM_DIRECT_RELEASE_ENABLED=false` to block new direct runs. Cancel +any still-running publication separately: changing a variable cannot undo a job +already underway. Revoke the `release-direct.yml` publisher relationships if +required. Already-public npm versions are immutable and are not rolled back. +The legacy staged workflow remains available for a separately prepared cohort. + +## Validation and sources + +Tests cover disabled flags, foreign actors/workflows/branches, incomplete archives, +changed bytes, lifecycle scripts, symlinks, dependency order/cycles, publication +failure, exact reruns and altered signatures/provenance/tags. Simulated publisher +calls never contact npm for writes. The cryptographic byte/signature reader was +also checked read-only against already-public beta.9 packages. + +- [npm trusted publishing and allowed actions](https://docs.npmjs.com/trusted-publishers/) +- [npm staged publishing](https://docs.npmjs.com/staged-publishing/) +- [npm registry signature format](https://docs.npmjs.com/about-registry-signatures/) diff --git a/docs/security/npm-release-security.md b/docs/security/npm-release-security.md index 1286beec..69936ecc 100644 --- a/docs/security/npm-release-security.md +++ b/docs/security/npm-release-security.md @@ -4,6 +4,14 @@ to npm. **Status:** implemented repository controls plus explicitly identified external configuration. +## Prepared alternative (not activated) + +A release-level OIDC profile has been prepared at the owner's request. See +[npm direct release](npm-direct-release.md) for its gates, tests, migration and +explicit activation proposal. It remains disabled until the owner authorizes +npm permission changes and the enablement flags are set. The stage-only rules +below remain the active policy for `release.yml` / `npm-production`. + ## Security invariant No CI job may make an AgentPlat package publicly installable. CI may only place diff --git a/scripts/approve-owner-npm-release.mjs b/scripts/approve-owner-npm-release.mjs index 9ea03328..32c79621 100644 --- a/scripts/approve-owner-npm-release.mjs +++ b/scripts/approve-owner-npm-release.mjs @@ -1,25 +1,129 @@ -import assert from 'node:assert/strict'; -import {execFileSync} from 'node:child_process'; -import {canOwnerApproveRelease} from './npm-owner-review-exception.mjs'; -const runId=process.argv[2];assert.match(runId??'',/^\d+$/,'supply a release run ID'); -const repo='repos/Agentplat/agentplat'; -const get=p=>JSON.parse(execFileSync('gh',['api',p],{encoding:'utf8'})); -const run=get(`${repo}/actions/runs/${runId}`),viewer=get('user'); -assert(canOwnerApproveRelease({actor:run.actor,triggeringActor:run.triggering_actor,viewer}),'Automatic review is limited to owner-initiated and owner-rerun releases'); -assert.equal(run.head_branch,'main');assert.equal(run.path,'.github/workflows/release.yml'); -assert.equal(run.event,'workflow_dispatch'); -const jobs=get(`${repo}/actions/runs/${runId}/jobs`).jobs; -assert(jobs.some(j=>j.name==='prepare'&&j.conclusion==='success'),'Exact artifacts must pass preparation first'); -const env=get(`${repo}/environments/npm-production`); -assert.equal(env.can_admins_bypass,false); -assert.equal(env.deployment_branch_policy.protected_branches,true); -assert.equal(env.deployment_branch_policy.custom_branch_policies,false); -const review=env.protection_rules.find(r=>r.type==='required_reviewers'); -assert.equal(review.prevent_self_review,false); -assert.deepEqual(review.reviewers.map(r=>({type:r.type,id:r.reviewer.id})),[{type:'User',id:viewer.id}]); -const pending=get(`${repo}/actions/runs/${runId}/pending_deployments`); -const target=pending.find(p=>p.environment.name==='npm-production'); -assert(target?.current_user_can_approve,'Owner approval must be available for this pending environment'); -execFileSync('gh',['api','--method','POST',`${repo}/actions/runs/${runId}/pending_deployments`,'--input','-'],{ - input:JSON.stringify({environment_ids:[target.environment.id],state:'approved',comment:'Standing owner authorization: owner-initiated release; independently prepared exact artifacts. npm staged byte review and 2FA remain required.'}),stdio:['pipe','pipe','pipe']}); -console.log('Owner-initiated staging approved; npm publication still requires staged-byte approval and 2FA.'); +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import { canOwnerApproveRelease } from "./npm-owner-review-exception.mjs"; + +export async function approveOwnerNpmRelease({ + runId, + mode = "staged", + api = githubApi, +}) { + assert.match(String(runId ?? ""), /^\d+$/, "supply a release run ID"); + assert(["staged", "direct"].includes(mode), "Unknown release approval mode"); + const direct = mode === "direct", + environment = direct ? "npm-release" : "npm-production"; + const workflow = direct + ? ".github/workflows/release-direct.yml" + : ".github/workflows/release.yml"; + const repo = "repos/Agentplat/agentplat"; + const run = api(`${repo}/actions/runs/${runId}`), + viewer = api("user"); + assert( + canOwnerApproveRelease({ + actor: run.actor, + triggeringActor: run.triggering_actor, + viewer, + }), + "Automatic review is limited to owner-initiated and owner-rerun releases", + ); + assert.equal(run.head_branch, "main"); + assert.equal(run.path, workflow); + assert.equal(run.event, "workflow_dispatch"); + const jobs = api(`${repo}/actions/runs/${runId}/jobs?per_page=100`).jobs; + assert( + jobs.some( + (j) => + j.name === "prepare" && + j.status === "completed" && + j.conclusion === "success", + ), + "Exact artifacts must pass preparation first", + ); + const env = api(`${repo}/environments/${environment}`); + assert.equal(env.can_admins_bypass, false); + if (direct) { + assert.equal( + api(`${repo}/actions/variables/AGENTPLAT_NPM_DIRECT_RELEASE_ENABLED`) + .value, + "true", + ); + assert.equal( + api( + `${repo}/environments/${environment}/variables/AGENTPLAT_NPM_DIRECT_PUBLISH_CONFIRMED`, + ).value, + "true", + ); + assert.equal(env.deployment_branch_policy.protected_branches, false); + assert.equal(env.deployment_branch_policy.custom_branch_policies, true); + const policies = api( + `${repo}/environments/${environment}/deployment-branch-policies`, + ).branch_policies; + assert.deepEqual( + policies.map((p) => ({ name: p.name, type: p.type })), + [{ name: "main", type: "branch" }], + ); + } else { + assert.equal(env.deployment_branch_policy.protected_branches, true); + assert.equal(env.deployment_branch_policy.custom_branch_policies, false); + } + const review = env.protection_rules.find( + (r) => r.type === "required_reviewers", + ); + assert.equal(review.prevent_self_review, false); + assert.deepEqual( + review.reviewers.map((r) => ({ type: r.type, id: r.reviewer.id })), + [{ type: "User", id: viewer.id }], + ); + const pending = api(`${repo}/actions/runs/${runId}/pending_deployments`); + const target = pending.find((p) => p.environment.name === environment); + assert( + target?.current_user_can_approve, + "Owner approval must be available for this pending environment", + ); + api(`${repo}/actions/runs/${runId}/pending_deployments`, { + environment_ids: [target.environment.id], + state: "approved", + comment: direct + ? "Owner-authorized release-level approval of the exact prepared cohort. Direct OIDC publication is explicitly enabled." + : "Standing owner authorization: owner-initiated release; exact artifacts prepared. npm staged-byte review and 2FA remain required.", + }); + return { mode, environment, approved: true }; +} +function githubApi(endpoint, body) { + const args = [ + "api", + ...(body ? ["--method", "POST"] : []), + endpoint, + ...(body ? ["--input", "-"] : []), + ]; + const text = execFileSync("gh", args, { + encoding: "utf8", + input: body ? JSON.stringify(body) : undefined, + stdio: ["pipe", "pipe", "pipe"], + }); + return text.trim() ? JSON.parse(text) : undefined; +} +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + try { + assert( + process.argv.length <= 4 && + (!process.argv[3] || process.argv[3] === "--direct"), + "Usage: approve-owner-npm-release.mjs RUN_ID [--direct]", + ); + console.log( + JSON.stringify( + await approveOwnerNpmRelease({ + runId: process.argv[2], + mode: process.argv[3] ? "direct" : "staged", + }), + ), + ); + } catch (e) { + console.error(e.message); + process.exitCode = 1; + } +} diff --git a/scripts/npm-registry-artifact.mjs b/scripts/npm-registry-artifact.mjs new file mode 100644 index 00000000..b2f82059 --- /dev/null +++ b/scripts/npm-registry-artifact.mjs @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import { createHash, createPublicKey, verify } from "node:crypto"; + +export function publicRegistryUrl(value) { + const u = new URL(value); + assert.equal(u.origin, "https://registry.npmjs.org"); + assert.equal(u.username, ""); + assert.equal(u.password, ""); + return u.href; +} + +export function verifyRegistrySignature({ + artifact, + dist, + keys, + now = Date.now(), +}) { + assert.equal( + dist.integrity, + artifact.integrity, + "Registry integrity mismatch", + ); + const payload = Buffer.from( + `${artifact.name}@${artifact.version}:${artifact.integrity}`, + ); + const valid = (dist.signatures ?? []).some((signature) => { + const key = keys?.keys?.find((k) => k.keyid === signature.keyid); + if ( + !key || + key.keytype !== "ecdsa-sha2-nistp256" || + key.scheme !== "ecdsa-sha2-nistp256" + ) + return false; + if (key.expires != null && !(Date.parse(key.expires) > now)) return false; + try { + return verify( + "sha256", + payload, + createPublicKey({ + key: Buffer.from(key.key, "base64"), + type: "spki", + format: "der", + }), + Buffer.from(signature.sig, "base64"), + ); + } catch { + return false; + } + }); + assert(valid, `No valid npm registry signature for ${artifact.name}`); +} + +export async function verifyRegistryArtifact({ + artifact, + dist, + keys, + fetchImplementation = fetch, +}) { + verifyRegistrySignature({ artifact, dist, keys }); + const r = await fetchImplementation(publicRegistryUrl(dist.tarball), { + redirect: "error", + }); + assert(r.ok, "Unable to download registry tarball: " + artifact.name); + const bytes = Buffer.from(await r.arrayBuffer()); + assert.equal(bytes.length, artifact.size, "Registry tarball size mismatch"); + assert.equal( + "sha512-" + createHash("sha512").update(bytes).digest("base64"), + artifact.integrity, + "Registry tarball bytes mismatch", + ); +} diff --git a/scripts/publish-direct-npm-release.mjs b/scripts/publish-direct-npm-release.mjs new file mode 100644 index 00000000..2a1b4a3d --- /dev/null +++ b/scripts/publish-direct-npm-release.mjs @@ -0,0 +1,262 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { readFile, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { loadPublicPackageCatalog } from "./public-package-catalog.mjs"; +import { + selectPublishablePackages, + topologicalPackages, +} from "./publish-packages.mjs"; +import { RELEASE_ARTIFACT_MANIFEST } from "./prepare-npm-release-artifacts.mjs"; +import { + validateReleaseArtifactManifest, + verifyReleaseArtifactFiles, +} from "./stage-npm-release-artifacts.mjs"; +import { + verifyRegistryArtifact, + publicRegistryUrl, +} from "./npm-registry-artifact.mjs"; +import { validateRegistryPackageEvidence } from "./verify-npm-release-provenance.mjs"; + +export const DIRECT_WORKFLOW = ".github/workflows/release-direct.yml"; +export function assertDirectReleaseContext(e) { + assert.equal( + e.AGENTPLAT_NPM_DIRECT_RELEASE_ENABLED, + "true", + "Direct release has not been enabled", + ); + assert.equal( + e.AGENTPLAT_NPM_DIRECT_PUBLISH_CONFIRMED, + "true", + "npm direct OIDC permissions have not been confirmed", + ); + assert.equal(e.GITHUB_EVENT_NAME, "workflow_dispatch"); + assert.equal(e.GITHUB_REF, "refs/heads/main"); + assert.equal(e.GITHUB_REF_PROTECTED, "true"); + assert.equal(e.GITHUB_REPOSITORY, "Agentplat/agentplat"); + assert.equal(e.RUNNER_ENVIRONMENT, "github-hosted"); + assert.equal( + e.GITHUB_WORKFLOW_REF, + `Agentplat/agentplat/${DIRECT_WORKFLOW}@refs/heads/main`, + ); + assert.equal(e.GITHUB_ACTOR, "douglas-grishen"); + assert.equal(e.GITHUB_ACTOR_ID, "207043696"); + assert.equal(e.GITHUB_TRIGGERING_ACTOR, "douglas-grishen"); + assert.match(e.GITHUB_SHA ?? "", /^[0-9a-f]{40}$/); + assert.ok( + e.ACTIONS_ID_TOKEN_REQUEST_URL && e.ACTIONS_ID_TOKEN_REQUEST_TOKEN, + "GitHub OIDC is required", + ); + assert.equal( + e.NODE_AUTH_TOKEN, + undefined, + "Long-lived npm tokens are prohibited", + ); + assert.equal(e.NPM_TOKEN, undefined, "Long-lived npm tokens are prohibited"); + assert(["next", "latest"].includes(e.NPM_DIST_TAG)); +} + +export function publicationOrder(packed) { + const manifests = new Map(packed.map((p) => [p.name, p])); + assert.equal(manifests.size, packed.length, "Duplicate packed package"); + return topologicalPackages( + packed.map((p) => ({ name: p.name })), + manifests, + ).map((p) => p.name); +} + +// A rerun may skip a version only after checking its exact bytes, tag and origin. +export async function inspectPublishedArtifact({ + artifact, + manifest, + fetchImplementation = fetch, +}) { + const r = await fetchImplementation( + `https://registry.npmjs.org/${encodeURIComponent(artifact.name)}`, + { headers: { accept: "application/json" } }, + ); + assert( + r.ok, + `Package must already exist and be readable: ${artifact.name} (${r.status})`, + ); + const packument = await r.json(); + assert.equal(packument.name, artifact.name); + const v = packument.versions?.[artifact.version]; + if (!v) return false; + const keysResponse = await fetchImplementation( + "https://registry.npmjs.org/-/npm/v1/keys", + { redirect: "error" }, + ); + assert(keysResponse.ok, "Unable to read npm signing keys"); + const attest = await fetchImplementation( + publicRegistryUrl(v.dist?.attestations?.url), + { redirect: "error" }, + ); + assert(attest.ok, "Unable to verify existing provenance: " + artifact.name); + validateRegistryPackageEvidence({ + artifact, + attestations: await attest.json(), + dist: v.dist, + distributionTags: packument["dist-tags"], + expectedCommit: manifest.sourceCommit, + expectedDistTag: manifest.distTag, + expectedWorkflowPath: DIRECT_WORKFLOW, + }); + await verifyRegistryArtifact({ + artifact, + dist: v.dist, + keys: await keysResponse.json(), + fetchImplementation, + }); + return true; +} + +export async function publishDirectNpmRelease({ + root = process.cwd(), + environment = process.env, + fetchImplementation = fetch, + execute = run, +} = {}) { + assertDirectReleaseContext(environment); + const artifactDirectory = path.resolve( + root, + environment.AGENTPLAT_RELEASE_ARTIFACT_DIRECTORY ?? "release-artifacts", + ); + assert( + artifactDirectory.startsWith(path.resolve(root) + path.sep), + "Artifacts must be inside the checkout", + ); + const manifest = JSON.parse( + await readFile( + path.join(artifactDirectory, RELEASE_ARTIFACT_MANIFEST), + "utf8", + ), + ); + const catalog = await loadPublicPackageCatalog(root); + const names = selectPublishablePackages({ + catalog, + root, + scope: manifest.scope, + }) + .map((p) => p.name) + .sort(); + validateReleaseArtifactManifest(manifest, { + expectedCommit: environment.GITHUB_SHA, + expectedDistTag: environment.NPM_DIST_TAG, + expectedPackageNames: names, + }); + assert.equal( + manifest.releaseVersion, + JSON.parse(await readFile(path.join(root, "package.json"), "utf8")).version, + ); + assert( + !(manifest.releaseVersion.includes("-") && manifest.distTag === "latest"), + "Prereleases must not promote latest", + ); + const clean = { + ...environment, + NPM_CONFIG_USERCONFIG: "/dev/null", + NPM_CONFIG_GLOBALCONFIG: "/dev/null", + NPM_CONFIG_IGNORE_SCRIPTS: "true", + }; + delete clean.npm_config_userconfig; + delete clean.npm_config_globalconfig; + delete clean.npm_config_ignore_scripts; + // Validate the ENTIRE archive cohort before any public mutation. + const packed = await verifyReleaseArtifactFiles({ + artifactDirectory, + manifest, + environment: clean, + }); + const order = publicationOrder(packed), + artifacts = new Map(manifest.artifacts.map((a) => [a.name, a])); + const existing = new Set(); + for (const artifact of manifest.artifacts) + if ( + await inspectPublishedArtifact({ + artifact, + manifest, + fetchImplementation, + }) + ) + existing.add(artifact.name); + const cwd = await mkdtemp( + path.join(os.tmpdir(), "agentplat-direct-publish-"), + ); + const result = { + sourceCommit: manifest.sourceCommit, + manifestDigest: manifest.manifestDigest, + releaseVersion: manifest.releaseVersion, + distTag: manifest.distTag, + packages: [], + }; + const report = path.join(root, "direct-release-report.json"); + try { + const version = execute("npm", ["--version"], { + cwd, + environment: clean, + }).stdout.trim(); + assert(/^\d+\.\d+\.\d+$/.test(version), "Invalid npm version"); + const [major, minor] = version.split(".").map(Number); + assert(major > 11 || (major === 11 && minor >= 15), "npm >=11.15 required"); + for (const name of order) { + const artifact = artifacts.get(name); + if (!existing.has(name)) + execute( + "npm", + [ + "publish", + path.join(artifactDirectory, artifact.filename), + "--ignore-scripts", + "--access", + "public", + "--tag", + manifest.distTag, + "--provenance", + "--registry=https://registry.npmjs.org/", + "--@agentplat:registry=https://registry.npmjs.org/", + ], + { cwd, environment: clean, stdio: "inherit" }, + ); + result.packages.push({ + name, + status: existing.has(name) ? "verified-existing" : "published", + }); + await writeFile(report, JSON.stringify(result, null, 2) + "\n"); + } + } finally { + await rm(cwd, { recursive: true, force: true }); + } + console.log( + `Direct OIDC publication completed for ${result.packages.length} packages; post-publication verification is still required.`, + ); + return result; +} +function run(command, args, { cwd, environment, stdio = "pipe" }) { + const r = spawnSync(command, args, { + cwd, + env: environment, + encoding: "utf8", + stdio, + }); + if (r.error) throw r.error; + assert.equal( + r.status, + 0, + `${command} failed; stop and rerun the same immutable release after diagnosis`, + ); + return r; +} +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + try { + await publishDirectNpmRelease(); + } catch (e) { + console.error(e.message); + process.exitCode = 1; + } +} diff --git a/scripts/stage-npm-release-artifacts.mjs b/scripts/stage-npm-release-artifacts.mjs index 92f587ba..b591078e 100644 --- a/scripts/stage-npm-release-artifacts.mjs +++ b/scripts/stage-npm-release-artifacts.mjs @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { assertStageReviewPolicy } from "./npm-owner-review-exception.mjs"; import { createHash } from "node:crypto"; import { spawnSync } from "node:child_process"; -import { readFile, readdir } from "node:fs/promises"; +import { readFile, readdir, lstat } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { loadPublicPackageCatalog } from "./public-package-catalog.mjs"; @@ -87,44 +87,11 @@ export async function stageNpmReleaseArtifacts({ assertStageReviewPolicy({ manifest, environment }); - const expectedFiles = [ - RELEASE_ARTIFACT_MANIFEST, - ...manifest.artifacts.map((artifact) => artifact.filename), - ].sort(compareAscii); - assert.deepEqual( - (await readdir(artifactDirectory)).sort(compareAscii), - expectedFiles, - "Release artifact directory contains missing or unexpected files", - ); - - for (const artifact of manifest.artifacts) { - const tarballPath = path.join(artifactDirectory, artifact.filename); - const contents = await readFile(tarballPath); - assert.equal( - contents.byteLength, - artifact.size, - `${artifact.name} size mismatch`, - ); - assert.equal( - `sha512-${createHash("sha512").update(contents).digest("base64")}`, - artifact.integrity, - `${artifact.name} integrity mismatch`, - ); - const packedManifest = JSON.parse( - run("tar", ["-xOzf", tarballPath, "package/package.json"], { - environment: scrubAuthentication(environment), - }).stdout, - ); - assert.equal(packedManifest.name, artifact.name); - assert.equal(packedManifest.version, artifact.version); - for (const scriptName of Object.keys(packedManifest.scripts ?? {})) { - assert.equal( - PROHIBITED_LIFECYCLE_SCRIPTS.has(scriptName), - false, - `${artifact.name} packed forbidden lifecycle script ${scriptName}`, - ); - } - } + await verifyReleaseArtifactFiles({ + artifactDirectory, + manifest, + environment, + }); assertSupportedNpm( run("npm", ["--version"], { environment: scrubAuthentication(environment) }) @@ -167,6 +134,65 @@ export async function stageNpmReleaseArtifacts({ ); } +export async function verifyReleaseArtifactFiles({ + artifactDirectory, + manifest, + environment, +}) { + const packedManifests = []; + assert.ok( + (await lstat(artifactDirectory)).isDirectory() && + !(await lstat(artifactDirectory)).isSymbolicLink(), + "Artifact directory must be a real directory", + ); + const expectedFiles = [ + RELEASE_ARTIFACT_MANIFEST, + ...manifest.artifacts.map((artifact) => artifact.filename), + ].sort(compareAscii); + assert.deepEqual( + (await readdir(artifactDirectory)).sort(compareAscii), + expectedFiles, + "Release artifact directory contains missing or unexpected files", + ); + + for (const artifact of manifest.artifacts) { + const tarballPath = path.join(artifactDirectory, artifact.filename); + const stat = await lstat(tarballPath); + assert.ok( + stat.isFile() && !stat.isSymbolicLink(), + "Artifact must be a regular file", + ); + const contents = await readFile(tarballPath); + assert.equal( + contents.byteLength, + artifact.size, + `${artifact.name} size mismatch`, + ); + assert.equal( + `sha512-${createHash("sha512").update(contents).digest("base64")}`, + artifact.integrity, + `${artifact.name} integrity mismatch`, + ); + const packedManifest = JSON.parse( + run("tar", ["-xOzf", tarballPath, "package/package.json"], { + environment: scrubAuthentication(environment), + }).stdout, + ); + packedManifests.push(packedManifest); + assert.equal(packedManifest.name, artifact.name); + assert.equal(packedManifest.version, artifact.version); + for (const scriptName of Object.keys(packedManifest.scripts ?? {})) { + assert.equal( + PROHIBITED_LIFECYCLE_SCRIPTS.has(scriptName), + false, + `${artifact.name} packed forbidden lifecycle script ${scriptName}`, + ); + } + } + + return packedManifests; +} + export function validateReleaseArtifactManifest( manifest, { expectedCommit, expectedDistTag, expectedPackageNames }, diff --git a/scripts/verify-npm-release-governance.mjs b/scripts/verify-npm-release-governance.mjs index b8f087c9..6cdd7dd5 100644 --- a/scripts/verify-npm-release-governance.mjs +++ b/scripts/verify-npm-release-governance.mjs @@ -20,6 +20,9 @@ const OWNER_PR_REVIEW_EXCEPTION = Object.freeze({ export function analyzeNpmReleaseGovernance({ environment, environmentVariables, + repositoryVariables, + deploymentBranchPolicies, + releaseMode = "staged", repositorySecrets, actionsPermissions, mainBranchRules, @@ -28,6 +31,8 @@ export function analyzeNpmReleaseGovernance({ distTag = "next", }) { const findings = []; + assert.ok(["staged", "direct"].includes(releaseMode)); + const direct = releaseMode === "direct"; const variable = (name) => environmentVariables?.variables?.find((entry) => entry.name === name) ?.value; @@ -40,9 +45,9 @@ export function analyzeNpmReleaseGovernance({ ownerReviewVersion, ownerReviewLogin, }); - if ((ownerReviewVersion || ownerReviewLogin) && !ownerException) + if (!direct && (ownerReviewVersion || ownerReviewLogin) && !ownerException) findings.push("npm_owner_review_exception_scope_mismatch"); - if (environment?.name !== "npm-production") { + if (environment?.name !== (direct ? "npm-release" : "npm-production")) { findings.push("npm_production_environment_missing"); } else { if (environment.can_admins_bypass !== false) { @@ -52,16 +57,20 @@ export function analyzeNpmReleaseGovernance({ (rule) => rule.type === "required_reviewers", ); const ownerReviewer = - ownerException && + (direct || ownerException) && reviewerRule?.reviewers?.length === 1 && reviewerRule.reviewers[0].type === "User" && reviewerRule.reviewers[0].reviewer?.login === NPM_OWNER_REVIEW_EXCEPTION.ownerLogin && - (ownerReviewVersion !== NPM_OWNER_REVIEW_EXCEPTION.standingMode || - reviewerRule.reviewers[0].reviewer?.id === NPM_OWNER_REVIEW_EXCEPTION.ownerId); + ((!direct && + ownerReviewVersion !== NPM_OWNER_REVIEW_EXCEPTION.standingMode) || + reviewerRule.reviewers[0].reviewer?.id === + NPM_OWNER_REVIEW_EXCEPTION.ownerId); if ( !reviewerRule || - (reviewerRule.prevent_self_review !== true && !ownerReviewer) + (direct + ? !ownerReviewer || reviewerRule.prevent_self_review !== false + : reviewerRule.prevent_self_review !== true && !ownerReviewer) ) { findings.push("npm_environment_independent_review_missing"); } @@ -71,20 +80,33 @@ export function analyzeNpmReleaseGovernance({ ) { findings.push("npm_environment_reviewer_missing"); } - if ( - environment.deployment_branch_policy?.protected_branches !== true || - environment.deployment_branch_policy?.custom_branch_policies !== false - ) { - findings.push("npm_environment_protected_branch_policy_missing"); - } - if ( - !environmentVariables?.variables?.some( - (variable) => - variable.name === "AGENTPLAT_NPM_STAGE_ONLY_CONFIRMED" && - variable.value === "true", + if (direct) { + if ( + environment.deployment_branch_policy?.protected_branches !== false || + environment.deployment_branch_policy?.custom_branch_policies !== true || + deploymentBranchPolicies?.branch_policies?.length !== 1 || + deploymentBranchPolicies.branch_policies[0].name !== "main" || + deploymentBranchPolicies.branch_policies[0].type !== "branch" ) - ) { - findings.push("npm_stage_only_confirmation_variable_missing"); + findings.push("npm_direct_main_only_policy_missing"); + if (variable("AGENTPLAT_NPM_DIRECT_PUBLISH_CONFIRMED") !== "true") + findings.push("npm_direct_publisher_confirmation_missing"); + if ( + !repositoryVariables?.variables?.some( + (v) => + v.name === "AGENTPLAT_NPM_DIRECT_RELEASE_ENABLED" && + v.value === "true", + ) + ) + findings.push("npm_direct_release_not_enabled"); + } else { + if ( + environment.deployment_branch_policy?.protected_branches !== true || + environment.deployment_branch_policy?.custom_branch_policies !== false + ) + findings.push("npm_environment_protected_branch_policy_missing"); + if (variable("AGENTPLAT_NPM_STAGE_ONLY_CONFIRMED") !== "true") + findings.push("npm_stage_only_confirmation_variable_missing"); } } @@ -142,13 +164,31 @@ export function analyzeNpmReleaseGovernance({ } export function verifyNpmReleaseGovernance() { - const environment = ghApi(`repos/${REPOSITORY}/environments/npm-production`, { - allow404: true, - }); + const releaseMode = process.env.AGENTPLAT_NPM_RELEASE_MODE ?? "staged"; + assert.ok(["staged", "direct"].includes(releaseMode)); + const environmentName = + releaseMode === "direct" ? "npm-release" : "npm-production"; + const environment = ghApi( + `repos/${REPOSITORY}/environments/${environmentName}`, + { + allow404: true, + }, + ); const environmentVariables = ghApi( - `repos/${REPOSITORY}/environments/npm-production/variables`, + `repos/${REPOSITORY}/environments/${environmentName}/variables`, { allow404: true }, ); + const repositoryVariables = + releaseMode === "direct" + ? ghApi(`repos/${REPOSITORY}/actions/variables`) + : undefined; + const deploymentBranchPolicies = + releaseMode === "direct" + ? ghApi( + `repos/${REPOSITORY}/environments/${environmentName}/deployment-branch-policies`, + { allow404: true }, + ) + : undefined; const repositorySecrets = ghApi(`repos/${REPOSITORY}/actions/secrets`); const actionsPermissions = ghApi(`repos/${REPOSITORY}/actions/permissions`); const mainBranchRules = ghApi(`repos/${REPOSITORY}/rules/branches/main`); @@ -167,6 +207,9 @@ export function verifyNpmReleaseGovernance() { ]; }); const report = analyzeNpmReleaseGovernance({ + releaseMode, + repositoryVariables, + deploymentBranchPolicies, environment, environmentVariables, repositorySecrets, diff --git a/scripts/verify-npm-release-provenance.mjs b/scripts/verify-npm-release-provenance.mjs index de8ce554..fe3d3c1a 100644 --- a/scripts/verify-npm-release-provenance.mjs +++ b/scripts/verify-npm-release-provenance.mjs @@ -1,4 +1,8 @@ import assert from "node:assert/strict"; +import { + verifyRegistryArtifact, + publicRegistryUrl, +} from "./npm-registry-artifact.mjs"; import { readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -36,6 +40,12 @@ export async function verifyNpmReleaseProvenance({ expectedPackageNames, }); + const keyResponse = await fetchImplementation( + "https://registry.npmjs.org/-/npm/v1/keys", + { redirect: "error" }, + ); + assert.ok(keyResponse.ok, "Unable to read npm registry signing keys"); + const keys = await keyResponse.json(); for (const artifact of manifest.artifacts) { const packumentResponse = await fetchImplementation( `https://registry.npmjs.org/${encodeURIComponent(artifact.name)}`, @@ -46,13 +56,20 @@ export async function verifyNpmReleaseProvenance({ const version = packument.versions?.[artifact.version]; assert.ok(version, `${artifact.name}@${artifact.version} is not published`); const attestationResponse = await fetchImplementation( - version.dist?.attestations?.url, + publicRegistryUrl(version.dist?.attestations?.url), + { redirect: "error" }, ); assert.equal( attestationResponse.ok, true, `Unable to read ${artifact.name} attestations`, ); + await verifyRegistryArtifact({ + artifact, + dist: version.dist, + keys, + fetchImplementation, + }); validateRegistryPackageEvidence({ artifact, attestations: await attestationResponse.json(), @@ -60,6 +77,7 @@ export async function verifyNpmReleaseProvenance({ distributionTags: packument["dist-tags"], expectedCommit: manifest.sourceCommit, expectedDistTag: manifest.distTag, + expectedWorkflowPath: environment.AGENTPLAT_RELEASE_WORKFLOW_PATH, }); } console.log( @@ -74,7 +92,15 @@ export function validateRegistryPackageEvidence({ distributionTags, expectedCommit, expectedDistTag, + expectedWorkflowPath = ".github/workflows/release.yml", }) { + assert.ok( + [ + ".github/workflows/release.yml", + ".github/workflows/release-direct.yml", + ].includes(expectedWorkflowPath), + "Unapproved release workflow", + ); assert.equal( dist.integrity, artifact.integrity, @@ -124,7 +150,7 @@ export function validateRegistryPackageEvidence({ assert.deepEqual(buildDefinition?.externalParameters?.workflow, { ref: "refs/heads/main", repository: "https://github.com/Agentplat/agentplat", - path: ".github/workflows/release.yml", + path: expectedWorkflowPath, }); assert.ok( buildDefinition?.resolvedDependencies?.some( diff --git a/tests/approve-owner-release-profile.test.mjs b/tests/approve-owner-release-profile.test.mjs new file mode 100644 index 00000000..8398d83f --- /dev/null +++ b/tests/approve-owner-release-profile.test.mjs @@ -0,0 +1,114 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { approveOwnerNpmRelease } from "../scripts/approve-owner-npm-release.mjs"; +function fixture(mode) { + const owner = { login: "douglas-grishen", id: 207043696 }; + const direct = mode === "direct"; + const r = "repos/Agentplat/agentplat", + name = direct ? "npm-release" : "npm-production"; + const table = { + [`${r}/actions/runs/123`]: { + actor: owner, + triggering_actor: owner, + head_branch: "main", + path: direct + ? ".github/workflows/release-direct.yml" + : ".github/workflows/release.yml", + event: "workflow_dispatch", + }, + user: owner, + [`${r}/actions/runs/123/jobs?per_page=100`]: { + jobs: [{ name: "prepare", status: "completed", conclusion: "success" }], + }, + [`${r}/environments/${name}`]: { + can_admins_bypass: false, + deployment_branch_policy: { + protected_branches: !direct, + custom_branch_policies: direct, + }, + protection_rules: [ + { + type: "required_reviewers", + prevent_self_review: false, + reviewers: [{ type: "User", reviewer: owner }], + }, + ], + }, + [`${r}/actions/runs/123/pending_deployments`]: [ + { environment: { id: 9, name }, current_user_can_approve: true }, + ], + [`${r}/actions/variables/AGENTPLAT_NPM_DIRECT_RELEASE_ENABLED`]: { + value: "true", + }, + [`${r}/environments/${name}/variables/AGENTPLAT_NPM_DIRECT_PUBLISH_CONFIRMED`]: + { value: "true" }, + [`${r}/environments/${name}/deployment-branch-policies`]: { + branch_policies: [{ name: "main", type: "branch" }], + }, + }; + const writes = []; + const api = (key, body) => { + if (body) { + writes.push({ key, body }); + return; + } + assert(key in table, key); + return table[key]; + }; + return { table, writes, api, r, name }; +} +for (const mode of ["staged", "direct"]) + test(`${mode} approval binds the intended gate after successful preparation`, async () => { + const f = fixture(mode); + assert.equal( + (await approveOwnerNpmRelease({ runId: "123", mode, api: f.api })) + .approved, + true, + ); + assert.equal(f.writes.length, 1); + assert.deepEqual(f.writes[0].body.environment_ids, [9]); + }); +for (const fault of [ + "actor", + "rerun", + "branch", + "workflow", + "prepare", + "reviewer", + "gate", + "flag", + "branch-policy", +]) + test(`direct approval rejects ${fault} before mutation`, async () => { + const f = fixture("direct"), + run = f.table[`${f.r}/actions/runs/123`], + env = f.table[`${f.r}/environments/${f.name}`]; + if (fault === "actor") run.actor = { login: "other", id: 1 }; + if (fault === "rerun") run.triggering_actor = { login: "other", id: 1 }; + if (fault === "branch") run.head_branch = "topic"; + if (fault === "workflow") run.path = ".github/workflows/release.yml"; + if (fault === "prepare") + f.table[`${f.r}/actions/runs/123/jobs?per_page=100`].jobs[0].conclusion = + "failure"; + if (fault === "reviewer") + env.protection_rules[0].reviewers.push({ + type: "User", + reviewer: { id: 2 }, + }); + if (fault === "gate") + f.table[ + `${f.r}/actions/runs/123/pending_deployments` + ][0].environment.name = "npm-production"; + if (fault === "flag") + f.table[ + `${f.r}/actions/variables/AGENTPLAT_NPM_DIRECT_RELEASE_ENABLED` + ].value = "false"; + if (fault === "branch-policy") + f.table[ + `${f.r}/environments/${f.name}/deployment-branch-policies` + ].branch_policies = [{ name: "*", type: "branch" }]; + await assert.rejects( + approveOwnerNpmRelease({ runId: "123", mode: "direct", api: f.api }), + ); + assert.equal(f.writes.length, 0); + }); diff --git a/tests/direct-npm-release.test.mjs b/tests/direct-npm-release.test.mjs new file mode 100644 index 00000000..833aa84e --- /dev/null +++ b/tests/direct-npm-release.test.mjs @@ -0,0 +1,449 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createHash, generateKeyPairSync, sign } from "node:crypto"; +import { + mkdtemp, + mkdir, + writeFile, + readFile, + rm, + symlink, +} from "node:fs/promises"; +import { execFileSync } from "node:child_process"; +import os from "node:os"; +import path from "node:path"; +import { + assertDirectReleaseContext, + publishDirectNpmRelease, + publicationOrder, + DIRECT_WORKFLOW, +} from "../scripts/publish-direct-npm-release.mjs"; +import { validateRegistryPackageEvidence } from "../scripts/verify-npm-release-provenance.mjs"; +const canonical = (x) => + x === null || typeof x !== "object" + ? JSON.stringify(x) + : Array.isArray(x) + ? "[" + x.map(canonical).join(",") + "]" + : "{" + + Object.keys(x) + .sort() + .map((k) => JSON.stringify(k) + ":" + canonical(x[k])) + .join(",") + + "}"; +const sha = (b) => "sha512-" + createHash("sha512").update(b).digest("base64"); +const environment = () => ({ + AGENTPLAT_NPM_DIRECT_RELEASE_ENABLED: "true", + AGENTPLAT_NPM_DIRECT_PUBLISH_CONFIRMED: "true", + GITHUB_EVENT_NAME: "workflow_dispatch", + GITHUB_REF: "refs/heads/main", + GITHUB_REF_PROTECTED: "true", + GITHUB_REPOSITORY: "Agentplat/agentplat", + RUNNER_ENVIRONMENT: "github-hosted", + GITHUB_WORKFLOW_REF: `Agentplat/agentplat/${DIRECT_WORKFLOW}@refs/heads/main`, + GITHUB_ACTOR: "douglas-grishen", + GITHUB_ACTOR_ID: "207043696", + GITHUB_TRIGGERING_ACTOR: "douglas-grishen", + GITHUB_SHA: "a".repeat(40), + ACTIONS_ID_TOKEN_REQUEST_URL: "https://fixture.invalid/oidc", + ACTIONS_ID_TOKEN_REQUEST_TOKEN: "test-only", + NPM_DIST_TAG: "next", +}); +async function fixture(t, { hooks = false } = {}) { + const root = await mkdtemp(path.join(os.tmpdir(), "direct-release-test-")); + t.after(() => rm(root, { recursive: true, force: true })); + const dir = path.join(root, "release-artifacts"); + await mkdir(dir); + await mkdir(path.join(root, "config")); + await writeFile( + path.join(root, "package.json"), + JSON.stringify({ version: "1.2.3-beta.1" }), + ); + const packages = ["alpha", "zeta"].map((n) => ({ + name: "@agentplat/" + n, + directory: "packages/" + n, + layer: "runtime", + publish: true, + packSmoke: true, + providerNeutral: true, + browserEntrypoints: [], + })); + await writeFile( + path.join(root, "config/public-packages.json"), + JSON.stringify({ schemaVersion: 2, packages }), + ); + const artifacts = [], + contents = new Map(); + for (const p of packages) { + const temp = path.join(root, p.name.split("/")[1]); + await mkdir(path.join(temp, "package"), { recursive: true }); + await writeFile( + path.join(temp, "package/package.json"), + JSON.stringify({ + name: p.name, + version: "1.2.3-beta.1", + ...(p.name.endsWith("alpha") + ? { dependencies: { "@agentplat/zeta": "1.2.3-beta.1" } } + : {}), + ...(hooks && p.name.endsWith("zeta") + ? { scripts: { prepublishOnly: "do-not-run" } } + : {}), + }), + ); + const filename = + p.name.replace("@", "").replace("/", "-") + "-1.2.3-beta.1.tgz"; + execFileSync("tar", [ + "-czf", + path.join(dir, filename), + "-C", + temp, + "package", + ]); + const bytes = await readFile(path.join(dir, filename)); + contents.set(p.name, bytes); + artifacts.push({ + name: p.name, + filename, + version: "1.2.3-beta.1", + size: bytes.length, + integrity: sha(bytes), + }); + } + const body = { + schemaVersion: 1, + kind: "agentplat-npm-release-artifacts-v1", + sourceCommit: "a".repeat(40), + releaseVersion: "1.2.3-beta.1", + scope: "all", + distTag: "next", + artifacts, + }; + const manifest = { + ...body, + manifestDigest: + "sha256-" + createHash("sha256").update(canonical(body)).digest("base64"), + }; + await writeFile( + path.join(dir, "npm-release-artifacts-v1.json"), + JSON.stringify(manifest), + ); + return { root, dir, manifest, contents }; +} +const freshRegistry = async (url) => ({ + ok: true, + status: 200, + json: async () => ({ + name: decodeURIComponent(new URL(url).pathname.slice(1)), + versions: {}, + "dist-tags": { next: "1.2.2" }, + }), +}); +function executor(calls, { fail = false } = {}) { + return (command, args, opts) => { + assert.equal(command, "npm"); + if (args[0] === "--version") return { stdout: "11.19.0\n" }; + calls.push({ args, opts }); + if (fail) throw Error("simulated registry failure"); + return { stdout: "" }; + }; +} + +test("direct publishing is opt-in and scoped to the owner, main, exact workflow and OIDC", () => { + assert.doesNotThrow(() => assertDirectReleaseContext(environment())); + for (const [key, value] of Object.entries({ + AGENTPLAT_NPM_DIRECT_RELEASE_ENABLED: undefined, + AGENTPLAT_NPM_DIRECT_PUBLISH_CONFIRMED: "false", + GITHUB_REF: "refs/heads/topic", + GITHUB_REF_PROTECTED: "false", + GITHUB_EVENT_NAME: "pull_request", + GITHUB_ACTOR_ID: "1", + GITHUB_ACTOR: "other", + GITHUB_TRIGGERING_ACTOR: "other", + GITHUB_REPOSITORY: "other/repo", + RUNNER_ENVIRONMENT: "self-hosted", + GITHUB_WORKFLOW_REF: + "Agentplat/agentplat/.github/workflows/release.yml@refs/heads/main", + GITHUB_SHA: "invalid", + NODE_AUTH_TOKEN: "forbidden", + NPM_TOKEN: "forbidden", + ACTIONS_ID_TOKEN_REQUEST_TOKEN: undefined, + NPM_DIST_TAG: "surprise", + })) + assert.throws( + () => assertDirectReleaseContext({ ...environment(), [key]: value }), + key, + ); +}); + +test("publishes only exact tarballs, dependencies first, from a credential-free working directory", async (t) => { + const f = await fixture(t), + calls = []; + const result = await publishDirectNpmRelease({ + ...f, + environment: environment(), + fetchImplementation: freshRegistry, + execute: executor(calls), + }); + assert.equal(result.packages.length, 2); + assert(calls[0].args[1].includes("agentplat-zeta-")); + assert(calls[1].args[1].includes("agentplat-alpha-")); + for (const c of calls) { + assert.equal(c.args[0], "publish"); + assert(c.args.includes("--ignore-scripts")); + assert(c.args.includes("--provenance")); + assert(c.args.includes("--registry=https://registry.npmjs.org/")); + assert.equal(c.opts.environment.NPM_CONFIG_USERCONFIG, "/dev/null"); + assert.equal(c.opts.environment.NPM_CONFIG_GLOBALCONFIG, "/dev/null"); + assert.notEqual(c.opts.cwd, f.root); + assert.notEqual(c.opts.cwd, f.dir); + } +}); + +for (const fault of [ + "tamper", + "missing", + "symlink", + "extra", + "wrong-commit", + "wrong-version", + "hooks", +]) + test(`the entire cohort is checked before publishing: ${fault}`, async (t) => { + const f = await fixture(t, { hooks: fault === "hooks" }), + calls = []; + const file = path.join(f.dir, f.manifest.artifacts.at(-1).filename); + if (fault === "tamper") await writeFile(file, "changed"); + if (fault === "missing") await rm(file); + if (fault === "symlink") { + const original = await readFile(file); + await rm(file); + const other = path.join(f.root, "target"); + await writeFile(other, original); + await symlink(other, file); + } + if (fault === "extra") + await writeFile(path.join(f.dir, "unexpected"), "extra"); + if (fault === "wrong-version") + await writeFile(path.join(f.root, "package.json"), '{"version":"9.9.9"}'); + const e = environment(); + if (fault === "wrong-commit") e.GITHUB_SHA = "b".repeat(40); + await assert.rejects( + publishDirectNpmRelease({ + ...f, + environment: e, + fetchImplementation: freshRegistry, + execute: executor(calls), + }), + ); + assert.equal(calls.length, 0); + }); + +test("registry uncertainty stops the release before any mutation", async (t) => { + const f = await fixture(t), + calls = []; + await assert.rejects( + publishDirectNpmRelease({ + ...f, + environment: environment(), + fetchImplementation: async () => ({ ok: false, status: 503 }), + execute: executor(calls), + }), + ); + assert.equal(calls.length, 0); +}); + +test("a publish failure stops before dependent packages", async (t) => { + const f = await fixture(t), + calls = []; + await assert.rejects( + publishDirectNpmRelease({ + ...f, + environment: environment(), + fetchImplementation: freshRegistry, + execute: executor(calls, { fail: true }), + }), + /simulated registry failure/, + ); + assert.equal(calls.length, 1); + assert(calls[0].args[1].includes("zeta")); +}); + +test("cycles and omitted internal dependencies fail closed", () => { + assert.throws( + () => + publicationOrder([ + { name: "@agentplat/a", dependencies: { "@agentplat/b": "1" } }, + { name: "@agentplat/b", dependencies: { "@agentplat/a": "1" } }, + ]), + /cycle/, + ); + assert.throws( + () => + publicationOrder([ + { name: "@agentplat/a", dependencies: { "@agentplat/missing": "1" } }, + ]), + /unpublished/, + ); +}); + +function registryEvidence(a, commit, bytes) { + const pair = generateKeyPairSync("ec", { namedCurve: "prime256v1" }); + const key = pair.publicKey + .export({ format: "der", type: "spki" }) + .toString("base64"); + const keys = { + keys: [ + { + keyid: "fixture", + keytype: "ecdsa-sha2-nistp256", + scheme: "ecdsa-sha2-nistp256", + key, + expires: null, + }, + ], + }; + const sig = sign( + "sha256", + Buffer.from(`${a.name}@${a.version}:${a.integrity}`), + pair.privateKey, + ).toString("base64"); + const envelope = (payload) => ({ + bundle: { + dsseEnvelope: { + payload: Buffer.from(JSON.stringify(payload)).toString("base64"), + }, + }, + }); + const attestations = { + attestations: [ + envelope({ + predicateType: + "https://github.com/npm/attestation/tree/main/specs/publish/v0.1", + }), + envelope({ + predicateType: "https://slsa.dev/provenance/v1", + subject: [ + { + name: `pkg:npm/${a.name.replace("@", "%40")}@${a.version}`, + digest: { + sha512: Buffer.from(a.integrity.slice(7), "base64").toString( + "hex", + ), + }, + }, + ], + predicate: { + buildDefinition: { + buildType: + "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", + externalParameters: { + workflow: { + ref: "refs/heads/main", + repository: "https://github.com/Agentplat/agentplat", + path: DIRECT_WORKFLOW, + }, + }, + resolvedDependencies: [{ digest: { gitCommit: commit } }], + }, + runDetails: { + builder: { id: "https://github.com/actions/runner/github-hosted" }, + }, + }, + }), + ], + }; + return { + keys, + attestations, + packument: { + name: a.name, + versions: { + [a.version]: { + dist: { + integrity: a.integrity, + signatures: [{ keyid: "fixture", sig }], + attestations: { url: "https://registry.npmjs.org/attestation" }, + tarball: "https://registry.npmjs.org/archive.tgz", + }, + }, + }, + "dist-tags": { next: a.version }, + }, + bytes, + }; +} +for (const corrupt of [false, "bytes", "commit", "tag", "origin", "signature"]) + test(`resume validates immutable registry evidence (${corrupt || "valid"})`, async (t) => { + const f = await fixture(t), + calls = []; + const a = f.manifest.artifacts.find((a) => a.name.endsWith("zeta")); + const e = registryEvidence( + a, + corrupt === "commit" ? "b".repeat(40) : f.manifest.sourceCommit, + f.contents.get(a.name), + ); + if (corrupt === "signature") + e.packument.versions[a.version].dist.signatures[0].sig = + Buffer.alloc(64).toString("base64"); + if (corrupt === "tag") e.packument["dist-tags"].next = "9.9.9"; + if (corrupt === "origin") + e.packument.versions[a.version].dist.attestations.url = + "https://untrusted.invalid/evidence"; + const fetchImplementation = async (url) => { + if (url === "https://registry.npmjs.org/-/npm/v1/keys") + return { ok: true, json: async () => e.keys }; + if (url === "https://registry.npmjs.org/attestation") + return { ok: true, json: async () => e.attestations }; + if (url === "https://registry.npmjs.org/archive.tgz") + return { + ok: true, + arrayBuffer: async () => + corrupt === "bytes" ? Buffer.from("bad") : e.bytes, + }; + if (decodeURIComponent(new URL(url).pathname.slice(1)) === a.name) + return { ok: true, json: async () => e.packument }; + return freshRegistry(url); + }; + const action = () => + publishDirectNpmRelease({ + ...f, + environment: environment(), + fetchImplementation, + execute: executor(calls), + }); + if (corrupt) { + await assert.rejects(action); + assert.equal(calls.length, 0); + } else { + const r = await action(); + assert.equal(calls.length, 1); + assert(calls[0].args[1].includes("alpha")); + assert.equal(r.packages[0].status, "verified-existing"); + } + }); + +test("the workflow keeps preparation unprivileged, publication gated and registry consumers mandatory", async () => { + const text = await readFile( + new URL("../.github/workflows/release-direct.yml", import.meta.url), + "utf8", + ); + const prepare = text.split("\n prepare:")[1].split("\n publish:")[0]; + const publish = text.split("\n publish:")[1].split("\n verify:")[0]; + const verify = text.split("\n verify:")[1]; + assert.match(text, /dry_run:[\s\S]*?default: true/); + assert.match( + prepare, + /inputs\.dry_run \|\| vars\.AGENTPLAT_NPM_DIRECT_RELEASE_ENABLED == 'true'/, + ); + assert.doesNotMatch(prepare, /id-token: write/); + assert.match(prepare, /pnpm run check/); + assert.match(prepare, /pnpm run verify:pack/); + assert.match(publish, /needs: prepare/); + assert.match(publish, /environment: npm-release/); + assert.match(publish, /AGENTPLAT_NPM_DIRECT_PUBLISH_CONFIRMED/); + assert.doesNotMatch(publish, /pnpm install|npm install|pnpm run build/); + assert.match(verify, /needs: publish/); + assert.doesNotMatch(verify, /id-token: write/); + assert.match(verify, /verify-npm-release-provenance/); + assert.match(verify, /AGENTPLAT_REGISTRY_CONSUMER_PROFILE: postgres/); + assert.match(verify, /node-version: 22\.22\.0/); +}); diff --git a/tests/verify-npm-release-governance.test.mjs b/tests/verify-npm-release-governance.test.mjs index 5d2f2564..7d4ea81e 100644 --- a/tests/verify-npm-release-governance.test.mjs +++ b/tests/verify-npm-release-governance.test.mjs @@ -104,29 +104,40 @@ test("owner self-review is limited to Beta 8 and the authorized single reviewer" ); }); - test("standing owner PR review exception is independent of the npm release version", () => { const state = secureState(); state.mainBranchRules.push({ - type: "ruleset_bypass_actor", ruleset_id: 20820479, - bypass_mode: "pull_request", actor_type: "User", actor_id: 207043696, + type: "ruleset_bypass_actor", + ruleset_id: 20820479, + bypass_mode: "pull_request", + actor_type: "User", + actor_id: 207043696, }); for (const releaseVersion of ["0.3.0-beta.8", "0.3.0-beta.9"]) { state.releaseVersion = releaseVersion; assert.equal(analyzeNpmReleaseGovernance(state).status, "passed"); } state.environment.protection_rules[0].prevent_self_review = false; - assert.ok(analyzeNpmReleaseGovernance(state).findings.includes("npm_environment_independent_review_missing")); + assert.ok( + analyzeNpmReleaseGovernance(state).findings.includes( + "npm_environment_independent_review_missing", + ), + ); }); test("owner PR exception rejects other actors, rulesets and always bypass", () => { const allowed = { - type: "ruleset_bypass_actor", ruleset_id: 20820479, - bypass_mode: "pull_request", actor_type: "User", actor_id: 207043696, + type: "ruleset_bypass_actor", + ruleset_id: 20820479, + bypass_mode: "pull_request", + actor_type: "User", + actor_id: 207043696, }; for (const change of [ - { actor_id: 1 }, { actor_type: "RepositoryRole" }, - { ruleset_id: 20819947 }, { ruleset_id: undefined }, + { actor_id: 1 }, + { actor_type: "RepositoryRole" }, + { ruleset_id: 20819947 }, + { ruleset_id: undefined }, { bypass_mode: "always" }, ]) { const state = secureState(); @@ -135,14 +146,73 @@ test("owner PR exception rejects other actors, rulesets and always bypass", () = } }); -test('standing npm owner exception preserves the sole-owner reviewer boundary',()=>{ - const s=secureState();s.releaseVersion='0.3.0-beta.9'; - s.environment.protection_rules[0]={type:'required_reviewers',prevent_self_review:false,reviewers:[{type:'User',reviewer:{login:'douglas-grishen',id:207043696}}]}; - s.environmentVariables.variables.push({name:'AGENTPLAT_NPM_OWNER_REVIEW_VERSION',value:'owner-initiated'},{name:'AGENTPLAT_NPM_OWNER_REVIEW_LOGIN',value:'douglas-grishen'}); - assert.equal(analyzeNpmReleaseGovernance(s).status,'passed'); - s.environment.protection_rules[0].reviewers[0].reviewer.id=1; - assert.equal(analyzeNpmReleaseGovernance(s).status,'failed'); - s.environment.protection_rules[0].reviewers[0].reviewer.id=207043696; - s.environment.protection_rules[0].reviewers.push({type:'User',reviewer:{login:'other'}}); - assert.equal(analyzeNpmReleaseGovernance(s).status,'failed'); +test("standing npm owner exception preserves the sole-owner reviewer boundary", () => { + const s = secureState(); + s.releaseVersion = "0.3.0-beta.9"; + s.environment.protection_rules[0] = { + type: "required_reviewers", + prevent_self_review: false, + reviewers: [ + { type: "User", reviewer: { login: "douglas-grishen", id: 207043696 } }, + ], + }; + s.environmentVariables.variables.push( + { name: "AGENTPLAT_NPM_OWNER_REVIEW_VERSION", value: "owner-initiated" }, + { name: "AGENTPLAT_NPM_OWNER_REVIEW_LOGIN", value: "douglas-grishen" }, + ); + assert.equal(analyzeNpmReleaseGovernance(s).status, "passed"); + s.environment.protection_rules[0].reviewers[0].reviewer.id = 1; + assert.equal(analyzeNpmReleaseGovernance(s).status, "failed"); + s.environment.protection_rules[0].reviewers[0].reviewer.id = 207043696; + s.environment.protection_rules[0].reviewers.push({ + type: "User", + reviewer: { login: "other" }, + }); + assert.equal(analyzeNpmReleaseGovernance(s).status, "failed"); +}); + +test("direct release requires explicit enablement, immutable owner and main-only deployment selection", () => { + const s = secureState(); + s.releaseMode = "direct"; + s.environment.name = "npm-release"; + s.environment.protection_rules[0] = { + type: "required_reviewers", + prevent_self_review: false, + reviewers: [ + { type: "User", reviewer: { login: "douglas-grishen", id: 207043696 } }, + ], + }; + s.environment.deployment_branch_policy = { + protected_branches: false, + custom_branch_policies: true, + }; + s.deploymentBranchPolicies = { + branch_policies: [{ name: "main", type: "branch" }], + }; + s.environmentVariables.variables = [ + { name: "AGENTPLAT_NPM_DIRECT_PUBLISH_CONFIRMED", value: "true" }, + ]; + s.repositoryVariables = { + variables: [ + { name: "AGENTPLAT_NPM_DIRECT_RELEASE_ENABLED", value: "true" }, + ], + }; + assert.equal(analyzeNpmReleaseGovernance(s).status, "passed"); + for (const mutate of [ + (x) => (x.environment.protection_rules[0].reviewers[0].reviewer.id = 1), + (x) => (x.environment.protection_rules[0].prevent_self_review = true), + (x) => (x.environment.can_admins_bypass = true), + (x) => + x.deploymentBranchPolicies.branch_policies.push({ + name: "*", + type: "branch", + }), + (x) => (x.deploymentBranchPolicies.branch_policies[0].type = "tag"), + (x) => (x.repositoryVariables.variables = []), + (x) => (x.environmentVariables.variables = []), + ]) { + const x = structuredClone(s); + mutate(x); + assert.equal(analyzeNpmReleaseGovernance(x).status, "failed"); + } }); From d75ccbc934b30b0f8f4732bf345ae5a832f33cf0 Mon Sep 17 00:00:00 2001 From: grishencorp Date: Mon, 21 Sep 2026 22:28:18 -0300 Subject: [PATCH 2/2] Use explicit scoped-name encoding in release test fixtures --- tests/direct-npm-release.test.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/direct-npm-release.test.mjs b/tests/direct-npm-release.test.mjs index 833aa84e..a63aca5e 100644 --- a/tests/direct-npm-release.test.mjs +++ b/tests/direct-npm-release.test.mjs @@ -90,7 +90,7 @@ async function fixture(t, { hooks = false } = {}) { }), ); const filename = - p.name.replace("@", "").replace("/", "-") + "-1.2.3-beta.1.tgz"; + p.name.replace(/^@/u, "").replaceAll("/", "-") + "-1.2.3-beta.1.tgz"; execFileSync("tar", [ "-czf", path.join(dir, filename), @@ -323,7 +323,7 @@ function registryEvidence(a, commit, bytes) { predicateType: "https://slsa.dev/provenance/v1", subject: [ { - name: `pkg:npm/${a.name.replace("@", "%40")}@${a.version}`, + name: `pkg:npm/${a.name.replace(/^@/u, "%40")}@${a.version}`, digest: { sha512: Buffer.from(a.integrity.slice(7), "base64").toString( "hex",