From b02e5cdb147c4defd662bcfcdc94772655e6aa16 Mon Sep 17 00:00:00 2001 From: Slate Rehm Date: Sat, 29 Aug 2026 17:14:57 -0500 Subject: [PATCH 1/2] ci(release): adopt trusted publishing Publish only the immutable Blacksmith artifact from a minimal GitHub-hosted job. Verify package bytes and npm provenance before signed release finalization. --- .github/workflows/ci.yml | 2 - .github/workflows/release.yml | 394 +++++++++++++++++++++------ AGENTS.md | 8 +- apps/api/package.json | 2 +- apps/cli/package.json | 2 +- docs/OPERATIONS.md | 66 ++++- package.json | 2 +- packages/client/README.md | 2 +- packages/client/package.json | 2 +- packages/crypto/package.json | 2 +- packages/protocol/package.json | 2 +- scripts/check-bootstrap-boundary.mjs | 29 +- 12 files changed, 407 insertions(+), 106 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 89b1a11..d02f4a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,8 +2,6 @@ name: CI on: pull_request: - push: - branches: [master] permissions: contents: read diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cda1881..1be89d6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,7 @@ name: Release on: - workflow_run: - workflows: [CI] - types: [completed] + push: branches: [master] permissions: @@ -15,22 +13,21 @@ concurrency: jobs: prepare: - if: >- - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'push' && - github.event.workflow_run.head_branch == 'master' && - github.event.workflow_run.head_repository.full_name == github.repository runs-on: blacksmith-4vcpu-ubuntu-2404 timeout-minutes: 20 env: - DEPLOY_SHA: ${{ github.event.workflow_run.head_sha }} + DEPLOY_SHA: ${{ github.sha }} outputs: + artifact_digest: ${{ steps.upload.outputs.artifact-digest }} + artifact_id: ${{ steps.upload.outputs.artifact-id }} + npm_integrity: ${{ steps.artifacts.outputs.npm_integrity }} + npm_package_file: ${{ steps.artifacts.outputs.npm_package_file }} published: ${{ steps.release.outputs.published }} - tag: ${{ steps.release.outputs.tag }} + tag: ${{ steps.artifacts.outputs.tag }} tagged: ${{ steps.release.outputs.tagged }} - version: ${{ steps.release.outputs.version }} + version: ${{ steps.artifacts.outputs.version }} steps: - - name: Check out the successful commit + - name: Check out the release commit uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 with: fetch-depth: 0 @@ -45,23 +42,31 @@ jobs: with: node-version: 24 cache: pnpm - registry-url: https://registry.npmjs.org - name: Install dependencies run: pnpm install --frozen-lockfile - name: Check and build run: | - test "$DEPLOY_SHA" = "$(git rev-parse HEAD)" || { echo "The checkout does not match the successful CI commit." >&2; exit 1; } + test "$DEPLOY_SHA" = "$GITHUB_SHA" || { echo "The release event does not match the target commit." >&2; exit 1; } + test "$DEPLOY_SHA" = "$(git rev-parse HEAD)" || { echo "The checkout does not match the release commit." >&2; exit 1; } + pnpm semark:check pnpm check pnpm build + pnpm exec wrangler deploy --dry-run --config apps/api/wrangler.jsonc + pnpm audit --prod --audit-level high - - name: Check the canonical release version - id: release - if: github.repository == 'paperkeel/secret-effects' + - name: Build and test release artifacts + id: artifacts + env: + ARTIFACT_DIR: ${{ runner.temp }}/release-artifacts run: | + set -o pipefail version="$(node -p 'require("./package.json").version')" tag="v$version" + npm_package_file="paperkeel-secret-effects-client-$version.tgz" + cli_archive_file="secreteffects-$version-node.tar.gz" + for manifest in \ apps/api/package.json \ apps/cli/package.json \ @@ -72,103 +77,343 @@ jobs: test "$(node -p "require('./$manifest').version")" = "$version" || { echo "$manifest must use version $version." >&2; exit 1; } done - published="$(PACKAGE_VERSION="$version" node --input-type=module <<'NODE' + mkdir -p "$ARTIFACT_DIR" + ( + cd packages/client + pnpm pack --pack-destination "$ARTIFACT_DIR" + ) + test -f "$ARTIFACT_DIR/$npm_package_file" || { echo "The npm package artifact is missing." >&2; exit 1; } + + consumer="$RUNNER_TEMP/client-consumer" + mkdir -p "$consumer" + pnpm --dir "$consumer" init + pnpm --dir "$consumer" add "$ARTIFACT_DIR/$npm_package_file" + pnpm --dir "$consumer" exec node --input-type=module -e 'const client = await import("@paperkeel/secret-effects-client"); if (typeof client.loadEnv !== "function" || typeof client.defineEnv !== "function" || typeof client.z?.string !== "function") process.exit(1);' + + source_epoch="$(git show -s --format=%ct "$DEPLOY_SHA")" + tar \ + --sort=name \ + --mtime="@$source_epoch" \ + --owner=0 \ + --group=0 \ + --numeric-owner \ + -cf - \ + -C apps/cli/dist \ + secreteffects.js \ + | gzip -n > "$ARTIFACT_DIR/$cli_archive_file" + + npm_integrity="$(PACKAGE_PATH="$ARTIFACT_DIR/$npm_package_file" node --input-type=module <<'NODE' + import { createHash } from "node:crypto"; + import { readFile } from "node:fs/promises"; + + const bytes = await readFile(process.env.PACKAGE_PATH); + process.stdout.write(`sha512-${createHash("sha512").update(bytes).digest("base64")}`); + NODE + )" + + NPM_INTEGRITY="$npm_integrity" \ + NPM_PACKAGE_FILE="$npm_package_file" \ + CLI_ARCHIVE_FILE="$cli_archive_file" \ + RELEASE_TAG="$tag" \ + RELEASE_VERSION="$version" \ + node --input-type=module <<'NODE' > "$ARTIFACT_DIR/release-manifest.json" + const manifest = { + cliArchive: process.env.CLI_ARCHIVE_FILE, + npmIntegrity: process.env.NPM_INTEGRITY, + npmPackage: "@paperkeel/secret-effects-client", + npmPackageFile: process.env.NPM_PACKAGE_FILE, + sourceSha: process.env.DEPLOY_SHA, + tag: process.env.RELEASE_TAG, + version: process.env.RELEASE_VERSION, + }; + process.stdout.write(`${JSON.stringify(manifest, null, 2)}\n`); + NODE + + ( + cd "$ARTIFACT_DIR" + sha256sum \ + "$npm_package_file" \ + "$cli_archive_file" \ + release-manifest.json \ + > SHA256SUMS + sha256sum "$cli_archive_file" > "$cli_archive_file.sha256" + ) + + echo "npm_integrity=$npm_integrity" >> "$GITHUB_OUTPUT" + echo "npm_package_file=$npm_package_file" >> "$GITHUB_OUTPUT" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Check the canonical release state + id: release + if: github.repository == 'paperkeel/secret-effects' + env: + NPM_INTEGRITY: ${{ steps.artifacts.outputs.npm_integrity }} + RELEASE_TAG: ${{ steps.artifacts.outputs.tag }} + RELEASE_VERSION: ${{ steps.artifacts.outputs.version }} + run: | + published="$(node --input-type=module <<'NODE' const name = "@paperkeel/secret-effects-client"; - const version = process.env.PACKAGE_VERSION; const response = await fetch( - `https://registry.npmjs.org/${encodeURIComponent(name)}/${encodeURIComponent(version)}`, + `https://registry.npmjs.org/${encodeURIComponent(name)}/${encodeURIComponent(process.env.RELEASE_VERSION)}`, { signal: AbortSignal.timeout(15_000) }, ); if (response.status === 404) { process.stdout.write("false"); - } else if (!response.ok) { + process.exit(0); + } + if (!response.ok) { throw new Error(`The npm registry returned HTTP ${response.status}.`); - } else { - const metadata = await response.json(); - if (metadata.gitHead !== process.env.DEPLOY_SHA) { - throw new Error(`${name}@${version} already belongs to another commit.`); - } - process.stdout.write("true"); } + + const metadata = await response.json(); + if (metadata.dist?.integrity !== process.env.NPM_INTEGRITY) { + throw new Error(`${name}@${process.env.RELEASE_VERSION} has different package bytes.`); + } + const attestationsUrl = metadata.dist?.attestations?.url; + if (typeof attestationsUrl !== "string") { + throw new Error(`${name}@${process.env.RELEASE_VERSION} has no npm provenance.`); + } + const attestationsResponse = await fetch(attestationsUrl, { + signal: AbortSignal.timeout(15_000), + }); + if (!attestationsResponse.ok) { + throw new Error(`The npm attestation registry returned HTTP ${attestationsResponse.status}.`); + } + const attestations = await attestationsResponse.json(); + const provenance = attestations.attestations?.find( + (item) => item.predicateType === "https://slsa.dev/provenance/v1", + ); + if (!provenance?.bundle?.dsseEnvelope?.payload) { + throw new Error(`${name}@${process.env.RELEASE_VERSION} has no SLSA provenance.`); + } + const statement = JSON.parse( + Buffer.from(provenance.bundle.dsseEnvelope.payload, "base64").toString("utf8"), + ); + const expectedDigest = Buffer.from( + process.env.NPM_INTEGRITY.slice("sha512-".length), + "base64", + ).toString("hex"); + const subject = statement.subject?.find( + (item) => item.digest?.sha512 === expectedDigest, + ); + const workflow = statement.predicate?.buildDefinition?.externalParameters?.workflow; + const source = statement.predicate?.buildDefinition?.resolvedDependencies?.find( + (dependency) => dependency.digest?.gitCommit === process.env.DEPLOY_SHA, + ); + const builder = statement.predicate?.runDetails?.builder?.id; + if ( + workflow?.repository !== "https://github.com/paperkeel/secret-effects" || + workflow?.path !== ".github/workflows/release.yml" || + workflow?.ref !== "refs/heads/master" || + !subject || + !source || + builder !== "https://github.com/actions/runner/github-hosted" + ) { + throw new Error(`${name}@${process.env.RELEASE_VERSION} has different source provenance.`); + } + process.stdout.write("true"); NODE )" tagged=false - if git rev-parse --verify --quiet "refs/tags/$tag" >/dev/null; then - test "$(git rev-parse "$tag^{commit}")" = "$DEPLOY_SHA" || { echo "$tag already belongs to another commit." >&2; exit 1; } - git -c gpg.ssh.allowedSignersFile="$GITHUB_WORKSPACE/.github/signing_allowed_signers" verify-tag "$tag" + if git rev-parse --verify --quiet "refs/tags/$RELEASE_TAG" >/dev/null; then + test "$(git rev-parse "$RELEASE_TAG^{commit}")" = "$DEPLOY_SHA" || { echo "$RELEASE_TAG already belongs to another commit." >&2; exit 1; } + git -c gpg.ssh.allowedSignersFile="$GITHUB_WORKSPACE/.github/signing_allowed_signers" verify-tag "$RELEASE_TAG" tagged=true fi echo "published=$published" >> "$GITHUB_OUTPUT" - echo "tag=$tag" >> "$GITHUB_OUTPUT" echo "tagged=$tagged" >> "$GITHUB_OUTPUT" - echo "version=$version" >> "$GITHUB_OUTPUT" - - name: Test the client package - run: | - version="$(node -p 'require("./packages/client/package.json").version')" - package="$RUNNER_TEMP/paperkeel-secret-effects-client-$version.tgz" - consumer="$RUNNER_TEMP/client-consumer" - pnpm --dir packages/client pack --pack-destination "$RUNNER_TEMP" - mkdir -p "$consumer" - pnpm --dir "$consumer" init - pnpm --dir "$consumer" add "$package" - pnpm --dir "$consumer" exec node --input-type=module -e 'const client = await import("@paperkeel/secret-effects-client"); if (typeof client.loadEnv !== "function" || typeof client.defineEnv !== "function" || typeof client.z?.string !== "function") process.exit(1);' + - name: Upload the release artifacts + id: upload + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: secret-effects-${{ env.DEPLOY_SHA }}-attempt-${{ github.run_attempt }} + path: ${{ runner.temp }}/release-artifacts + if-no-files-found: error + retention-days: 7 + compression-level: 0 production: needs: prepare uses: ./.github/workflows/deploy.yml with: - deploy_sha: ${{ github.event.workflow_run.head_sha }} + deploy_sha: ${{ github.sha }} secrets: inherit - publish: + publish-npm: if: github.repository == 'paperkeel/secret-effects' needs: [prepare, production] + runs-on: ubuntu-24.04 + timeout-minutes: 10 + environment: release + permissions: + contents: read + id-token: write + env: + ARTIFACT_DIR: ${{ github.workspace }}/release-artifacts + DEPLOY_SHA: ${{ github.sha }} + NPM_INTEGRITY: ${{ needs.prepare.outputs.npm_integrity }} + RELEASE_VERSION: ${{ needs.prepare.outputs.version }} + steps: + - name: Install Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 24 + package-manager-cache: false + + - name: Install the trusted npm client + run: | + npm install --global npm@12.0.2 --ignore-scripts + test "$(npm --version)" = "12.0.2" || { echo "The npm client version is not trusted." >&2; exit 1; } + + - name: Download the Blacksmith artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + artifact-ids: ${{ needs.prepare.outputs.artifact_id }} + path: ${{ runner.temp }}/release-artifacts + digest-mismatch: error + + - name: Verify the Blacksmith artifact + id: artifact + env: + NPM_PACKAGE_FILE: ${{ needs.prepare.outputs.npm_package_file }} + run: | + ( + cd "$ARTIFACT_DIR" + sha256sum --check SHA256SUMS + ) + PACKAGE_PATH="$ARTIFACT_DIR/$NPM_PACKAGE_FILE" node --input-type=module <<'NODE' + import { createHash } from "node:crypto"; + import { readFile } from "node:fs/promises"; + + const manifest = JSON.parse( + await readFile(`${process.env.ARTIFACT_DIR}/release-manifest.json`, "utf8"), + ); + const expectedFile = `paperkeel-secret-effects-client-${process.env.RELEASE_VERSION}.tgz`; + if ( + manifest.npmPackage !== "@paperkeel/secret-effects-client" || + manifest.npmPackageFile !== expectedFile || + manifest.npmPackageFile !== process.env.NPM_PACKAGE_FILE || + manifest.npmIntegrity !== process.env.NPM_INTEGRITY || + manifest.sourceSha !== process.env.DEPLOY_SHA || + manifest.version !== process.env.RELEASE_VERSION + ) { + throw new Error("The release manifest does not match the workflow inputs."); + } + const bytes = await readFile(process.env.PACKAGE_PATH); + const integrity = `sha512-${createHash("sha512").update(bytes).digest("base64")}`; + if (integrity !== process.env.NPM_INTEGRITY) { + throw new Error("The npm package integrity does not match the Blacksmith build."); + } + NODE + echo "package_path=$ARTIFACT_DIR/$NPM_PACKAGE_FILE" >> "$GITHUB_OUTPUT" + + - name: Publish with npm Trusted Publishing + if: needs.prepare.outputs.published != 'true' + run: npm publish "${{ steps.artifact.outputs.package_path }}" --ignore-scripts --access public + + - name: Verify the npm publication + run: | + node --input-type=module <<'NODE' + const name = "@paperkeel/secret-effects-client"; + const url = `https://registry.npmjs.org/${encodeURIComponent(name)}/${encodeURIComponent(process.env.RELEASE_VERSION)}`; + let metadata; + for (let attempt = 1; attempt <= 12; attempt++) { + const response = await fetch(url, { signal: AbortSignal.timeout(15_000) }); + if (response.ok) { + metadata = await response.json(); + if (metadata.dist?.attestations?.url) break; + } else if (response.status !== 404) { + throw new Error(`The npm registry returned HTTP ${response.status}.`); + } + if (attempt === 12) throw new Error("The npm publication did not become available."); + await new Promise((resolve) => setTimeout(resolve, 5_000)); + } + if (metadata.dist?.integrity !== process.env.NPM_INTEGRITY) { + throw new Error("The published npm package has different bytes."); + } + const attestationsResponse = await fetch(metadata.dist.attestations.url, { + signal: AbortSignal.timeout(15_000), + }); + if (!attestationsResponse.ok) { + throw new Error(`The npm attestation registry returned HTTP ${attestationsResponse.status}.`); + } + const attestations = await attestationsResponse.json(); + const provenance = attestations.attestations?.find( + (item) => item.predicateType === "https://slsa.dev/provenance/v1", + ); + if (!provenance?.bundle?.dsseEnvelope?.payload) { + throw new Error("The npm package has no SLSA provenance."); + } + const statement = JSON.parse( + Buffer.from(provenance.bundle.dsseEnvelope.payload, "base64").toString("utf8"), + ); + const expectedDigest = Buffer.from( + process.env.NPM_INTEGRITY.slice("sha512-".length), + "base64", + ).toString("hex"); + const subject = statement.subject?.find( + (item) => item.digest?.sha512 === expectedDigest, + ); + const workflow = statement.predicate?.buildDefinition?.externalParameters?.workflow; + const source = statement.predicate?.buildDefinition?.resolvedDependencies?.find( + (dependency) => dependency.digest?.gitCommit === process.env.DEPLOY_SHA, + ); + const builder = statement.predicate?.runDetails?.builder?.id; + if ( + workflow?.repository !== "https://github.com/paperkeel/secret-effects" || + workflow?.path !== ".github/workflows/release.yml" || + workflow?.ref !== "refs/heads/master" || + !subject || + !source || + builder !== "https://github.com/actions/runner/github-hosted" + ) { + throw new Error("The npm package has different source provenance."); + } + NODE + + finalize: + if: github.repository == 'paperkeel/secret-effects' + needs: [prepare, production, publish-npm] runs-on: blacksmith-4vcpu-ubuntu-2404 - timeout-minutes: 20 + timeout-minutes: 10 environment: release permissions: attestations: write contents: write id-token: write env: - DEPLOY_SHA: ${{ github.event.workflow_run.head_sha }} + ARTIFACT_DIR: ${{ github.workspace }}/release-artifacts + DEPLOY_SHA: ${{ github.sha }} TAG: ${{ needs.prepare.outputs.tag }} VERSION: ${{ needs.prepare.outputs.version }} steps: - - name: Check out the deployed commit + - name: Check out the released commit uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 with: fetch-depth: 0 ref: ${{ env.DEPLOY_SHA }} persist-credentials: false - - name: Install pnpm - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - - - name: Install Node.js - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + - name: Download the Blacksmith artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - node-version: 24 - cache: pnpm - registry-url: https://registry.npmjs.org - - - name: Install dependencies - run: pnpm install --frozen-lockfile + artifact-ids: ${{ needs.prepare.outputs.artifact_id }} + path: ${{ runner.temp }}/release-artifacts + digest-mismatch: error - - name: Build release artifacts + - name: Verify the release artifact run: | - test "$DEPLOY_SHA" = "$(git rev-parse HEAD)" || { echo "The checkout does not match the deployed commit." >&2; exit 1; } + test "$DEPLOY_SHA" = "$(git rev-parse HEAD)" || { echo "The checkout does not match the released commit." >&2; exit 1; } test -n "$TAG" || { echo "The release tag is missing." >&2; exit 1; } test -n "$VERSION" || { echo "The release version is missing." >&2; exit 1; } - pnpm build - mkdir -p dist - archive="dist/secreteffects-$VERSION-node.tar.gz" - tar -czf "$archive" -C apps/cli/dist secreteffects.js - archive_name="$(basename "$archive")" - (cd dist && sha256sum "$archive_name" > "$archive_name.sha256") + ( + cd "$ARTIFACT_DIR" + sha256sum --check SHA256SUMS + ) - name: Create the signed release tag if: needs.prepare.outputs.tagged != 'true' @@ -178,6 +423,7 @@ jobs: run: | test -n "$RELEASE_SIGNING_PRIVATE_KEY" || { echo "Set RELEASE_SIGNING_PRIVATE_KEY in the release environment." >&2; exit 1; } signing_key="$RUNNER_TEMP/paperkeel-release-signing-key" + trap 'rm -f "$signing_key"' EXIT printf '%s\n' "$RELEASE_SIGNING_PRIVATE_KEY" > "$signing_key" chmod 600 "$signing_key" git config user.name "Paperkeel Release" @@ -188,21 +434,11 @@ jobs: git tag --sign "$TAG" "$DEPLOY_SHA" --message "Release $TAG" gh auth setup-git git push origin "refs/tags/$TAG" - rm -f "$signing_key" - - - name: Publish the canonical client package - if: github.repository == 'paperkeel/secret-effects' && needs.prepare.outputs.published != 'true' - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: | - test -n "$NODE_AUTH_TOKEN" || { echo "Set NPM_TOKEN in the release environment." >&2; exit 1; } - cd packages/client - pnpm publish --no-git-checks --access public - name: Attest the release archive uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 with: - subject-path: dist/secreteffects-${{ needs.prepare.outputs.version }}-node.tar.gz + subject-path: ${{ runner.temp }}/release-artifacts/secreteffects-${{ needs.prepare.outputs.version }}-node.tar.gz - name: Create the GitHub Release env: @@ -212,7 +448,7 @@ jobs: exit 0 fi gh release create "$TAG" \ - "dist/secreteffects-$VERSION-node.tar.gz" \ - "dist/secreteffects-$VERSION-node.tar.gz.sha256" \ + "$ARTIFACT_DIR/secreteffects-$VERSION-node.tar.gz" \ + "$ARTIFACT_DIR/secreteffects-$VERSION-node.tar.gz.sha256" \ --generate-notes \ --verify-tag diff --git a/AGENTS.md b/AGENTS.md index 4938086..6c5f633 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,8 +18,7 @@ - Never use Secret Effects to supply this repository's deployment or release secrets. - Configure bootstrap secrets in GitHub and inject Worker secrets through Alchemy. - Publish the npm client only from the canonical `paperkeel/secret-effects` repository. -- Treat each successful canonical `master` CI run as a complete production - release. +- Treat each canonical `master` push as a complete production release. - Increment every workspace package version before a canonical merge to `master`. - Cache only encrypted bundles. @@ -27,7 +26,10 @@ - Use Effect v4 for application services and typed errors. - Use Zod and `@t3-oss/env-core` for repository environment schemas. - Use Alchemy v2 for all Cloudflare resources. -- Use Blacksmith runners for all GitHub Actions jobs. +- Use Blacksmith runners for all build, test, deploy, and release finalization + jobs. +- Use a GitHub-hosted runner only for the npm Trusted Publishing job. That job + publishes the package artifact that Blacksmith built and tested. - Use `master` as the default branch. - Run `pnpm check` before each commit. diff --git a/apps/api/package.json b/apps/api/package.json index 267a920..431db8b 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,6 +1,6 @@ { "name": "@secret-effects/api", - "version": "0.2.1", + "version": "0.2.2", "private": true, "type": "module", "dependencies": { diff --git a/apps/cli/package.json b/apps/cli/package.json index 4c5baa8..c8858a2 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@secret-effects/cli", - "version": "0.2.1", + "version": "0.2.2", "private": true, "type": "module", "bin": { diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 7cfefe5..9448147 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -49,15 +49,60 @@ pnpm run deploy Alchemy owns D1, R2, Queue, Worker, cache, and Durable Object bindings. Production resources use retain protection and adopt existing named resources. -In the canonical repository, each successful `master` CI run is a complete -release. The release workflow checks and builds the exact commit. It deploys the -commit, checks the commit from `/health`, publishes the matching npm package, -creates a signed version tag, and creates the GitHub release. +In the canonical repository, each `master` push starts a complete release. The +release workflow checks and builds the exact commit on Blacksmith. It deploys +the commit and checks the commit from `/health`. + +Blacksmith packs and tests the npm package. A GitHub-hosted runner publishes +that package with npm Trusted Publishing. Blacksmith then creates the signed +version tag, attests the command archive, and creates the GitHub release. Increment the root, API, command interface, client, cryptography, and protocol -versions before each canonical merge. The workflow rejects a version that -belongs to a different commit. GitHub queues up to 100 release runs and does not -replace an earlier pending `master` release. +versions before each canonical merge. The workflow rejects a package with +different bytes or source provenance. GitHub queues up to 100 release runs. It +does not replace an earlier pending `master` release. + +## npm Trusted Publishing + +npm requires a package to exist before it can have a trusted publisher. Publish +`@paperkeel/secret-effects-client@0.2.1` once from the signed `v0.2.1` tag. Use +an interactive npm session with two-factor authentication. Verify the tag and +package bytes before you publish. This first publication is the only release +without Trusted Publishing provenance. + +Configure one npm trusted publisher for +`@paperkeel/secret-effects-client`: + +| Name | Value | +| ------------ | ---------------- | +| Provider | GitHub Actions | +| Organization | `paperkeel` | +| Repository | `secret-effects` | +| Workflow | `release.yml` | +| Environment | `release` | +| Permission | `npm publish` | + +Configure the publisher with npm 11.15.0 or later: + +```sh +npm trust github @paperkeel/secret-effects-client \ + --repo paperkeel/secret-effects \ + --file release.yml \ + --env release \ + --allow-publish +``` + +The npm publish job uses a GitHub-hosted runner because npm does not accept +OIDC claims from self-hosted runners. The job receives the immutable package +artifact from Blacksmith. It does not receive an npm token. + +The job checks the artifact digest, file checksums, package integrity, source +commit, workflow path, repository, and hosted-runner identity. npm adds the +SLSA provenance during publication. + +After the first trusted publication, configure npm to require two-factor +authentication and reject traditional publishing tokens. Remove `NPM_TOKEN` +from the GitHub release environment. ## Bootstrap @@ -157,7 +202,6 @@ these values manually in GitHub: - `SECRET_EFFECTS_ISSUER_PRIVATE_KEY` - `SECRET_EFFECTS_GLOBAL_ADMIN_TOKEN` - `SENTRY_DSN`, when Sentry is active -- `NPM_TOKEN`, only in the canonical Paperkeel release environment - `RELEASE_SIGNING_PRIVATE_KEY`, only in the canonical Paperkeel release environment @@ -167,9 +211,9 @@ bindings into Cloudflare. Do not configure `SECRET_EFFECTS_KEY` for this repository. Forks and deployment copies do not publish the client package or create -Paperkeel release tags. Their release workflow deploys the successful `master` -commit and skips the canonical publication job. Applications install the -canonical public package from Paperkeel. +Paperkeel release tags. Their release workflow deploys their `master` commit +and skips the canonical publication job. Applications install the canonical +public package from Paperkeel. ## Runtime use diff --git a/package.json b/package.json index 9ed1618..c42ba89 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "secret-effects", - "version": "0.2.1", + "version": "0.2.2", "private": true, "type": "module", "license": "MIT", diff --git a/packages/client/README.md b/packages/client/README.md index 841a948..baad824 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -13,7 +13,7 @@ Effects. It combines these functions: Install only this package: ```sh -pnpm add @paperkeel/secret-effects-client@0.2.1 +pnpm add @paperkeel/secret-effects-client@0.2.2 ``` Paperkeel publishes this package from the canonical Secret Effects repository. diff --git a/packages/client/package.json b/packages/client/package.json index 81f51ac..6a261d9 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@paperkeel/secret-effects-client", - "version": "0.2.1", + "version": "0.2.2", "description": "Type-safe Secret Effects environment loading for Node.js and Cloudflare Workers", "type": "module", "license": "MIT", diff --git a/packages/crypto/package.json b/packages/crypto/package.json index edc9079..5ab9741 100644 --- a/packages/crypto/package.json +++ b/packages/crypto/package.json @@ -1,6 +1,6 @@ { "name": "@secret-effects/crypto", - "version": "0.2.1", + "version": "0.2.2", "type": "module", "license": "MIT", "exports": { diff --git a/packages/protocol/package.json b/packages/protocol/package.json index fb5e6e3..e511d20 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@secret-effects/protocol", - "version": "0.2.1", + "version": "0.2.2", "type": "module", "license": "MIT", "exports": { diff --git a/scripts/check-bootstrap-boundary.mjs b/scripts/check-bootstrap-boundary.mjs index 3d33a12..7805652 100644 --- a/scripts/check-bootstrap-boundary.mjs +++ b/scripts/check-bootstrap-boundary.mjs @@ -55,12 +55,19 @@ const releaseWorkflow = readFileSync( ); const releaseDocument = parse(releaseWorkflow); const releaseTriggers = releaseDocument?.on; -const workflowRunBranches = releaseTriggers?.workflow_run?.branches; +const releaseBranches = releaseTriggers?.push?.branches; +const publishJob = releaseDocument?.jobs?.["publish-npm"]; if (releaseWorkflow.includes("SECRET_EFFECTS_KEY")) { throw new Error( "The release workflow must use only manual bootstrap credentials.", ); } +if ( + releaseWorkflow.includes("NPM_TOKEN") || + releaseWorkflow.includes("NODE_AUTH_TOKEN") +) { + throw new Error("The release workflow must use npm Trusted Publishing."); +} if ( !releaseWorkflow.includes( "if: github.repository == 'paperkeel/secret-effects'", @@ -69,14 +76,28 @@ if ( throw new Error("Only the canonical repository can publish the npm client."); } if ( - !Array.isArray(workflowRunBranches) || - !workflowRunBranches.includes("master") || + !Array.isArray(releaseBranches) || + !releaseBranches.includes("master") || releaseTriggers?.push?.tags !== undefined +) { + throw new Error("The release workflow must release each master push."); +} +if ( + publishJob?.["runs-on"] !== "ubuntu-24.04" || + publishJob?.environment !== "release" || + publishJob?.permissions?.["id-token"] !== "write" ) { throw new Error( - "The release workflow must promote successful master CI runs.", + "The npm publish job must use GitHub-hosted Trusted Publishing.", ); } +for (const jobName of ["prepare", "finalize"]) { + if ( + !releaseDocument?.jobs?.[jobName]?.["runs-on"]?.startsWith("blacksmith-") + ) { + throw new Error(`${jobName} must use a Blacksmith runner.`); + } +} process.stdout.write("Bootstrap boundary validation passed.\n"); From 5495513464b02b7d37352e9e939522938658ed1e Mon Sep 17 00:00:00 2001 From: Slate Rehm Date: Sat, 29 Aug 2026 19:14:59 -0500 Subject: [PATCH 2/2] fix(release): secure artifact handoff Use one verified artifact directory across publish and finalize jobs. Pass the npm package path through the environment before shell use. --- .github/workflows/release.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1be89d6..53a7e19 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -273,7 +273,7 @@ jobs: uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: artifact-ids: ${{ needs.prepare.outputs.artifact_id }} - path: ${{ runner.temp }}/release-artifacts + path: ${{ env.ARTIFACT_DIR }} digest-mismatch: error - name: Verify the Blacksmith artifact @@ -313,7 +313,9 @@ jobs: - name: Publish with npm Trusted Publishing if: needs.prepare.outputs.published != 'true' - run: npm publish "${{ steps.artifact.outputs.package_path }}" --ignore-scripts --access public + env: + PACKAGE_PATH: ${{ steps.artifact.outputs.package_path }} + run: npm publish "$PACKAGE_PATH" --ignore-scripts --access public - name: Verify the npm publication run: | @@ -402,7 +404,7 @@ jobs: uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: artifact-ids: ${{ needs.prepare.outputs.artifact_id }} - path: ${{ runner.temp }}/release-artifacts + path: ${{ env.ARTIFACT_DIR }} digest-mismatch: error - name: Verify the release artifact @@ -438,7 +440,7 @@ jobs: - name: Attest the release archive uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 with: - subject-path: ${{ runner.temp }}/release-artifacts/secreteffects-${{ needs.prepare.outputs.version }}-node.tar.gz + subject-path: ${{ env.ARTIFACT_DIR }}/secreteffects-${{ needs.prepare.outputs.version }}-node.tar.gz - name: Create the GitHub Release env: