Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 31 additions & 70 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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 <stage-id>\` (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"
110 changes: 110 additions & 0 deletions .github/workflows/verify-release.yml
Original file line number Diff line number Diff line change
@@ -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
26 changes: 18 additions & 8 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -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)

Expand All @@ -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

Expand All @@ -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 <stage-id>
```
6. Run **Verify release**: `gh workflow run verify-release.yml -f tag=vX.Y.Z -f integrity=<sha512 from the Release summary>`. 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@<previous> 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.
Loading