From 17ef55ad708568e8b5182de3c3739709e346334a Mon Sep 17 00:00:00 2001 From: ProgramComputer <22284856+ProgramComputer@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:31:22 -0500 Subject: [PATCH] Stage releases through npm trusted publishing and verify them after owner approval --- .github/workflows/release.yml | 101 ++++++++---------------- .github/workflows/verify-release.yml | 110 +++++++++++++++++++++++++++ RELEASING.md | 26 +++++-- 3 files changed, 159 insertions(+), 78 deletions(-) create mode 100644 .github/workflows/verify-release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0a65057..0f033c9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,13 +1,13 @@ name: Release -# The only workflow that publishes to npm. Run it manually from main with an +# The only workflow that can publish to npm. Run it manually from main with an # existing, immutable release tag. It rebuilds and tests that tag, packs one -# tarball, runs the package gate on it, publishes exactly that tarball under a -# non-latest dist-tag via npm trusted publishing (OIDC, with provenance), and -# then verifies the published artifact from the registry. +# tarball, runs the package gate on it, and STAGES exactly that tarball via npm +# trusted publishing (OIDC, with provenance) under a non-latest dist-tag. # -# Promotion to "latest" is a separate owner step: npm OIDC does not authorize -# `npm dist-tag`. See RELEASING.md. +# Staged versions are not public until a package owner approves them with 2FA +# (`npm stage approve`). After approval, run the "Verify release" workflow. +# Promotion to "latest" is a separate owner step; see RELEASING.md. on: workflow_dispatch: @@ -119,8 +119,8 @@ jobs: if-no-files-found: error retention-days: 90 - publish: - name: publish to npm (${{ inputs.dist_tag }}) + stage: + name: stage on npm (${{ inputs.dist_tag }}) needs: build runs-on: ubuntu-latest timeout-minutes: 15 @@ -145,81 +145,42 @@ jobs: VERSION: ${{ needs.build.outputs.version }} run: | set -euo pipefail - node -e "const [a,b,c]=process.versions.node.split('.').map(Number);if(a<22||(a===22&&b<14))process.exit(1)" + node -e "const [a,b]=process.versions.node.split('.').map(Number);if(a<22||(a===22&&b<14))process.exit(1)" npm_version=$(npm --version) node -e "const [a,b,c]='$npm_version'.split('.').map(Number);if(a<11||(a===11&&(b<5||(b===5&&c<1))))process.exit(1)" || { echo "::error::npm >= 11.5.1 is required for trusted publishing (have $npm_version)"; exit 1; } + npm stage --help >/dev/null 2>&1 || { echo "::error::npm $npm_version has no 'npm stage' command"; exit 1; } actual="sha512-$(openssl dgst -sha512 -binary "$TARBALL" | base64 -w0)" - [[ "$actual" == "$INTEGRITY" ]] || { echo "::error::tarball integrity changed between build and publish"; exit 1; } + [[ "$actual" == "$INTEGRITY" ]] || { echo "::error::tarball integrity changed between build and stage"; exit 1; } if npm view "$PACKAGE@$VERSION" version >/dev/null 2>&1; then echo "::error::$PACKAGE@$VERSION already exists; not republishing"; exit 1 fi - - name: Publish the tested tarball (trusted publishing, provenance) + - name: Stage the tested tarball (trusted publishing, provenance) env: TARBALL: ${{ needs.build.outputs.tarball }} DIST_TAG: ${{ inputs.dist_tag }} - run: npm publish "./$TARBALL" --tag "$DIST_TAG" --access public --provenance - - verify: - name: verify published package (${{ matrix.os }}) - needs: [build, publish] - runs-on: ${{ matrix.os }} - timeout-minutes: 20 - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest] - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ needs.build.outputs.sha }} - persist-credentials: false - - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: '24.x' - - - run: npm ci - - run: npm run build && npm run build:test + run: npm stage publish "./$TARBALL" --tag "$DIST_TAG" --access public --provenance 2>&1 | tee stage-output.txt - - name: Wait for the registry and check dist-tag and integrity - shell: bash + - name: Next steps env: VERSION: ${{ needs.build.outputs.version }} INTEGRITY: ${{ needs.build.outputs.integrity }} + SHA: ${{ needs.build.outputs.sha }} + TAG: ${{ inputs.tag }} DIST_TAG: ${{ inputs.dist_tag }} run: | - set -euo pipefail - for i in $(seq 1 30); do - published=$(npm view "$PACKAGE@$VERSION" dist.integrity --prefer-online 2>/dev/null || true) - [[ -n "$published" ]] && break - sleep 10 - done - [[ "$published" == "$INTEGRITY" ]] || { echo "::error::registry integrity '$published' != tested '$INTEGRITY'"; exit 1; } - [[ "$(npm view "$PACKAGE" "dist-tags.$DIST_TAG" --prefer-online)" == "$VERSION" ]] || { echo "::error::dist-tag $DIST_TAG does not point at $VERSION"; exit 1; } - echo "latest is still: $(npm view "$PACKAGE" dist-tags.latest --prefer-online)" - - - name: Fresh install from the registry + MCP tests against the installed bin - shell: bash - env: - VERSION: ${{ needs.build.outputs.version }} - INTEGRITY: ${{ needs.build.outputs.integrity }} - run: node scripts/package-smoke.mjs --from-registry "$PACKAGE@$VERSION" --expect-integrity "$INTEGRITY" --report registry-report-${{ matrix.os }}.json - - - name: Provenance and registry signatures - if: runner.os == 'Linux' - shell: bash - env: - VERSION: ${{ needs.build.outputs.version }} - run: | - set -euo pipefail - npm view "$PACKAGE@$VERSION" dist.attestations --json | tee attestations.json - node -e "const a=require('./attestations.json');if(!a||!a.provenance)process.exit(1)" || { echo "::error::no provenance attestation"; exit 1; } - dir=$(mktemp -d) && cd "$dir" && npm init -y >/dev/null && npm install "$PACKAGE@$VERSION" --omit=dev --no-fund >/dev/null && npm audit signatures - - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - if: always() - with: - name: registry-verification-${{ matrix.os }} - path: registry-report-*.json - if-no-files-found: ignore + { + echo "## $PACKAGE@$VERSION staged (not public yet)" + echo + echo "- Release tag: \`$TAG\` (commit \`$SHA\`)" + echo "- Tested tarball integrity: \`$INTEGRITY\`" + echo "- dist-tag applied on approval: \`$DIST_TAG\`" + echo + echo '```' + cat stage-output.txt + echo '```' + echo + echo "1. Owner: \`npx -y npm@11 stage list $PACKAGE\`, then \`npx -y npm@11 stage approve \` (2FA)." + echo "2. Run **Verify release** with tag \`$TAG\` and integrity \`$INTEGRITY\`." + echo "3. Owner, after verification: \`npm dist-tag add $PACKAGE@$VERSION latest\`." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/verify-release.yml b/.github/workflows/verify-release.yml new file mode 100644 index 0000000..1df271b --- /dev/null +++ b/.github/workflows/verify-release.yml @@ -0,0 +1,110 @@ +name: Verify release + +# Run after an owner has approved a staged version. Checks that the public +# registry serves exactly the tarball the Release workflow tested, installs it +# fresh on Linux and Windows, reruns the MCP transport tests against the +# installed bin, and checks provenance and registry signatures. It has no +# publishing permissions. + +on: + workflow_dispatch: + inputs: + tag: + description: Release tag that was staged (for example v1.1.0) + required: true + type: string + integrity: + description: Tested tarball integrity from the Release run summary (sha512-...) + required: true + type: string + dist_tag: + description: dist-tag the version was staged under + required: true + default: next + type: choice + options: [next] + +permissions: + contents: read + +env: + PACKAGE: '@programcomputer/nasa-mcp-server' + +jobs: + verify: + name: verify published package (${{ matrix.os }}) + if: github.repository == 'ProgramComputer/NASA-MCP-server' + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + steps: + - name: Validate inputs + shell: bash + env: + TAG: ${{ inputs.tag }} + INTEGRITY: ${{ inputs.integrity }} + run: | + [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || { echo "::error::tag must look like v1.2.3"; exit 1; } + [[ "$INTEGRITY" =~ ^sha512-[A-Za-z0-9+/]{86}==$ ]] || { echo "::error::integrity must be a sha512-... value"; exit 1; } + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: refs/tags/${{ inputs.tag }} + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24.x' + + - run: npm ci + - run: npm run build && npm run build:test + + - name: Registry serves the tested artifact under the expected dist-tag + shell: bash + env: + TAG: ${{ inputs.tag }} + INTEGRITY: ${{ inputs.integrity }} + DIST_TAG: ${{ inputs.dist_tag }} + run: | + set -euo pipefail + VERSION="${TAG#v}" + [[ "$(node -p "require('./package.json').version")" == "$VERSION" ]] || { echo "::error::tag and package.json disagree"; exit 1; } + published="" + for i in $(seq 1 30); do + published=$(npm view "$PACKAGE@$VERSION" dist.integrity --prefer-online 2>/dev/null || true) + [[ -n "$published" ]] && break + sleep 10 + done + [[ "$published" == "$INTEGRITY" ]] || { echo "::error::registry integrity '$published' != tested '$INTEGRITY'"; exit 1; } + [[ "$(npm view "$PACKAGE" "dist-tags.$DIST_TAG" --prefer-online)" == "$VERSION" ]] || { echo "::error::dist-tag $DIST_TAG does not point at $VERSION"; exit 1; } + echo "latest currently: $(npm view "$PACKAGE" dist-tags.latest --prefer-online)" + + - name: Fresh install from the registry + MCP tests against the installed bin + shell: bash + env: + TAG: ${{ inputs.tag }} + INTEGRITY: ${{ inputs.integrity }} + REPORT: registry-report-${{ matrix.os }}.json + run: node scripts/package-smoke.mjs --from-registry "$PACKAGE@${TAG#v}" --expect-integrity "$INTEGRITY" --report "$REPORT" + + - name: Provenance and registry signatures + if: runner.os == 'Linux' + shell: bash + env: + TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + VERSION="${TAG#v}" + npm view "$PACKAGE@$VERSION" dist.attestations --json | tee attestations.json + node -e "const a=require('./attestations.json');if(!a||!a.provenance)process.exit(1)" || { echo "::error::no provenance attestation"; exit 1; } + dir=$(mktemp -d) && cd "$dir" && npm init -y >/dev/null && npm install "$PACKAGE@$VERSION" --omit=dev --no-fund >/dev/null && npm audit signatures + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: always() + with: + name: registry-verification-${{ matrix.os }} + path: registry-report-*.json + if-no-files-found: ignore diff --git a/RELEASING.md b/RELEASING.md index accea20..340456a 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,6 +1,6 @@ # Releasing -Releases are published from GitHub Actions with npm **trusted publishing** (OIDC, automatic provenance) by `.github/workflows/release.yml`. That is the only publishing workflow; it never publishes to `latest`. +Releases are **staged** from GitHub Actions with npm **trusted publishing** (OIDC, automatic provenance) by `.github/workflows/release.yml`. That is the only workflow that can publish; it never targets `latest`. A staged version is not public until a package owner approves it with 2FA. ## One-time setup (package owner) @@ -9,10 +9,11 @@ Releases are published from GitHub Actions with npm **trusted publishing** (OIDC - Repository: `NASA-MCP-server` - Workflow filename: `release.yml` - Environment: `npm-release` + - Allowed actions: leave **Allow npm publish** unchecked, so only `npm stage publish` is allowed (npm's recommended setting). 2. Recommended afterwards: set publishing access to **Require two-factor authentication and disallow tokens**. Trusted publishing keeps working, and stale tokens (such as the unused `NPM_TOKEN` repository secret) can no longer publish. 3. The `npm-release` GitHub environment only allows deployments from `main`. -npm's OIDC authorizes `npm publish` only, not `npm dist-tag`. Promoting a release to `latest` therefore needs an interactive owner login. +npm's OIDC authorizes `npm stage publish` and `npm publish` only. Approving a staged version and promoting a release to `latest` therefore need an interactive owner login with 2FA. ## Release steps @@ -25,21 +26,30 @@ npm's OIDC authorizes `npm publish` only, not `npm dist-tag`. Promoting a releas 3. Draft the GitHub Release for the tag (keep it a draft until verification is complete). 4. Run the **Release** workflow from `main`: `gh workflow run release.yml -f tag=vX.Y.Z -f dist_tag=next`. - **build** checks the tag is annotated and on `main`, that the tag, `package.json`, lockfile and changelog versions match, and that the version is not on npm. It then runs clean install, lint, typecheck, clean build, the docs check and all tests, packs **one** tarball and runs the package gate on it (install outside the repo with runtime dependencies only, then MCP stdio/HTTP tests against the installed bin). - - **publish** re-checks the tarball's sha512 and publishes that exact file with `--tag next` and provenance. - - **verify** (Linux and Windows) waits for the registry, requires the published integrity to equal the tested one and `next` to point at the version, installs from the registry with a fresh cache, reruns the MCP tests against the installed bin, and checks the provenance attestation and `npm audit signatures`. -5. Optionally run the live checks against real services: `NASA_MCP_LIVE=1 npm run test:live`. -6. Promote the same version (owner, after verification passes): + - **stage** re-checks the tarball's sha512 and runs `npm stage publish` on that exact file with `--tag next` and provenance. The run summary lists the tested integrity and the next commands. +5. Approve the staged version (owner, 2FA). The `next` tag from step 4 is applied on approval: + ```bash + npx -y npm@11 stage list @programcomputer/nasa-mcp-server + npx -y npm@11 stage approve + ``` +6. Run **Verify release**: `gh workflow run verify-release.yml -f tag=vX.Y.Z -f integrity=`. On Linux and Windows it: + - requires the registry's integrity to equal the tested one and `next` to point at the version; + - installs from the registry with a fresh cache and reruns the MCP tests against the installed bin; + - checks the provenance attestation and `npm audit signatures`. + + Optionally also run the live checks against real services: `NASA_MCP_LIVE=1 npm run test:live`. +7. Promote the same version (owner, after verification passes): ```bash npm view @programcomputer/nasa-mcp-server dist-tags # record the current latest first npm login npm dist-tag add @programcomputer/nasa-mcp-server@X.Y.Z latest ``` -7. Confirm `npm view @programcomputer/nasa-mcp-server dist-tags.latest` is `X.Y.Z` and that `npx -y @programcomputer/nasa-mcp-server@latest --version` prints it from a fresh cache. Then publish the GitHub Release with the version, commit, notes and the workflow run link. +8. Confirm `npm view @programcomputer/nasa-mcp-server dist-tags.latest` is `X.Y.Z` and that `npx -y @programcomputer/nasa-mcp-server@latest --version` prints it from a fresh cache. Then publish the GitHub Release with the version, commit, notes and the workflow run link. ## If something fails - **Before publish:** nothing reached npm. Fix it, release a new commit, and use a new tag if the tagged commit changes. -- **Publish failed or timed out:** check `npm view @programcomputer/nasa-mcp-server@X.Y.Z dist.integrity` before retrying. If the version exists and its integrity matches the build job's tarball, only re-run the **verify** job. Never republish, and never try to replace an existing version. +- **Staging failed or timed out:** check `npx -y npm@11 stage list @programcomputer/nasa-mcp-server` and `npm view @programcomputer/nasa-mcp-server@X.Y.Z version` before retrying. If a stage already exists for the version, approve or reject that one instead of staging again. If the version is already published and its integrity matches the Release summary, only run **Verify release**. Never republish, and never try to replace an existing version. - **Verification failed:** leave `latest` alone, investigate, and fix forward with a new version. - **A promoted release is broken:** move `latest` back to the recorded previous version (`npm dist-tag add @programcomputer/nasa-mcp-server@ latest`), announce it, and fix forward with a new version. Do not unpublish. - **Another release moved `latest` in the meantime:** inspect it before promoting; do not overwrite it blindly.