From 01754d352c328f0537056747d55a20d03d0b228e Mon Sep 17 00:00:00 2001 From: calvin-archastro Date: Wed, 19 Aug 2026 12:15:00 -0700 Subject: [PATCH] Add semantic npm release automation --- .github/workflows/publish.yml | 84 +++++++++++++++++++++ .github/workflows/release.yml | 137 ++++++++++++++++++++++++++++------ README.md | 19 +++-- src/release-workflows.test.ts | 46 ++++++++++++ 4 files changed, 255 insertions(+), 31 deletions(-) create mode 100644 .github/workflows/publish.yml create mode 100644 src/release-workflows.test.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..1f4bb74 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,84 @@ +name: publish + +# Triggered by the vX.Y.Z tags created by release.yml. npm authentication uses +# Trusted Publishing (OIDC), so this workflow does not need an NPM_TOKEN. +# +# One-time npm configuration: +# package: @archastro/intern-mcp +# repository: ArchAstro/intern-mcp +# workflow: publish.yml +# environment: npm-release + +on: + push: + tags: + - "v*" + # release.yml dispatches this workflow because tags pushed by GITHUB_TOKEN + # do not themselves trigger workflow runs. + workflow_dispatch: + +concurrency: + group: publish-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: write + id-token: write + +jobs: + publish: + name: Verify and publish npm package + if: startsWith(github.ref, 'refs/tags/v') + environment: npm-release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + # Node 24 ships npm with Trusted Publishing support. + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24 + cache: npm + registry-url: https://registry.npmjs.org + scope: "@archastro" + + - run: npm ci --no-audit --no-fund + - run: npm run check + + - name: Verify package version matches tag + env: + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + version="${TAG#v}" + actual="$(node -p "require('./package.json').version")" + if [[ "$actual" != "$version" ]]; then + echo "Version mismatch: tag says $version, package.json says $actual" >&2 + exit 1 + fi + + - name: Refuse an existing version + run: | + version="$(node -p "require('./package.json').version")" + if npm view "@archastro/intern-mcp@${version}" version >/dev/null 2>&1; then + echo "@archastro/intern-mcp@${version} already exists" >&2 + exit 1 + fi + + # npm Trusted Publishing supports private source repositories, but npm + # provenance attestations do not. Add --provenance if this repo becomes + # public. + - name: Publish package through npm OIDC + run: npm publish --access public + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + version="${TAG#v}" + gh release create "$TAG" \ + --title "@archastro/intern-mcp v$version" \ + --notes "Published to npm: https://www.npmjs.com/package/@archastro/intern-mcp/v/$version" \ + --generate-notes diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fc04a14..dd6a105 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,46 +1,141 @@ name: release +# Manually trigger from the Actions tab to: +# 1. Bump the package version on a release branch +# 2. Rebase-merge the version PR so main records the release +# 3. Tag that main commit and dispatch publish.yml +# +# Publishing uses npm Trusted Publishing through publish.yml. The protected +# npm-release environment remains the approval boundary for npm publication. + on: - workflow_dispatch: {} + workflow_dispatch: + inputs: + bump: + description: Version bump + required: true + type: choice + options: + - patch + - minor + - major concurrency: group: release cancel-in-progress: false permissions: - contents: read - id-token: write + contents: write + pull-requests: write + actions: write jobs: - publish: - name: Verify and publish npm package + release: + name: Bump, branch, and tag if: github.ref == 'refs/heads/main' - environment: npm-release runs-on: ubuntu-latest steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: "24" - registry-url: https://registry.npmjs.org - scope: "@archastro" + node-version: 22 + cache: npm - run: npm ci --no-audit --no-fund - run: npm run check - - name: Refuse an existing version - shell: bash + - name: Configure git identity + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Bump version + id: bump + env: + BUMP: ${{ inputs.bump }} + run: | + set -euo pipefail + version="$(npm version "$BUMP" --no-git-tag-version)" + version="${version#v}" + tag="v${version}" + { + echo "version=$version" + echo "tag=$tag" + echo "branch=release/$tag" + echo "title=release: @archastro/intern-mcp v$version" + } >> "$GITHUB_OUTPUT" + + - name: Commit and push release branch + env: + BRANCH: ${{ steps.bump.outputs.branch }} + TITLE: ${{ steps.bump.outputs.title }} run: | - version="$(node -p "require('./package.json').version")" - if npm view "@archastro/intern-mcp@${version}" version >/dev/null 2>&1; then - echo "@archastro/intern-mcp@${version} already exists" >&2 + set -euo pipefail + git checkout -b "$BRANCH" + git add package.json package-lock.json + git commit -m "$TITLE" + git push origin "$BRANCH" + + - name: Open and merge release PR + id: merge + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BRANCH: ${{ steps.bump.outputs.branch }} + TITLE: ${{ steps.bump.outputs.title }} + TAG: ${{ steps.bump.outputs.tag }} + run: | + set -euo pipefail + body=$(cat <&2 + exit 1 + fi + echo "merged_sha=$merged_sha" >> "$GITHUB_OUTPUT" + echo "Merged $pr_url" + + - name: Tag the merged main commit + env: + TAG: ${{ steps.bump.outputs.tag }} + VERSION: ${{ steps.bump.outputs.version }} + MERGED_SHA: ${{ steps.merge.outputs.merged_sha }} + run: | + set -euo pipefail + git fetch origin main + git merge-base --is-ancestor "$MERGED_SHA" origin/main || { + echo "Merged release commit $MERGED_SHA is not on origin/main" >&2 + exit 1 + } + merged_version="$(git show "$MERGED_SHA:package.json" | node -e "let input=''; process.stdin.on('data', chunk => input += chunk).on('end', () => process.stdout.write(JSON.parse(input).version))")" + if [[ "$merged_version" != "$VERSION" ]]; then + echo "Merged release version mismatch: expected $VERSION, found $merged_version" >&2 exit 1 fi + git tag -a "$TAG" "$MERGED_SHA" -m "$TAG" + git push origin "$TAG" - - name: Publish package - run: npm publish --access public + # Tags pushed by GITHUB_TOKEN do not trigger another workflow, so the + # release workflow explicitly dispatches publish.yml at the tag. + - name: Dispatch publish workflow env: - # NPM_TOKEN bootstraps 0.1.0. Afterward npm trusted publishing for - # this workflow uses GitHub OIDC and the secret can be removed. - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.bump.outputs.tag }} + run: gh workflow run publish.yml --ref "$TAG" diff --git a/README.md b/README.md index 6b7e50e..071b9e2 100644 --- a/README.md +++ b/README.md @@ -52,8 +52,7 @@ version replacing `0.1.0`. The installer updates only the user-level `intern` registration. The host never executes a newly published package version merely because it restarted. -The repository is private and the npm package has not been published yet. The -commands above become available after the first package release. +The repository is private; the package is public on npm. If a developer machine maps the `@archastro` scope to another registry, override that local mapping for this public package: @@ -63,14 +62,14 @@ npx --yes --@archastro:registry=https://registry.npmjs.org \ @archastro/intern-mcp@0.1.0 setup --host codex ``` -Maintainers run the manual **release** workflow to publish the version in -`package.json`. The first release uses a short-lived `NPM_TOKEN` repository -secret in the protected `npm-release` environment because npm cannot attach a -trusted publisher to a package that does not exist. That environment accepts -only protected branches; `main` itself requires CI and approving review. After -`0.1.0`, configure `ArchAstro/intern-mcp`, `release.yml`, and environment -`npm-release` as the npm trusted publisher, remove the secret, and later runs -authenticate with GitHub OIDC. +Maintainers run the manual **release** workflow from `main` and choose a patch, +minor, or major bump. It verifies the package, commits the version change on a +release branch, rebase-merges the version-only PR, tags that exact merged commit +as `vX.Y.Z`, and dispatches `publish.yml`. The publish workflow verifies that +the tag and `package.json` agree, publishes through npm Trusted Publishing, and +creates the GitHub Release. npm must configure `ArchAstro/intern-mcp`, +`publish.yml`, and environment `npm-release` as the trusted publisher; no +`NPM_TOKEN` is used. ## Configure the server diff --git a/src/release-workflows.test.ts b/src/release-workflows.test.ts new file mode 100644 index 0000000..cd7ece0 --- /dev/null +++ b/src/release-workflows.test.ts @@ -0,0 +1,46 @@ +import fs from "node:fs/promises"; +import { describe, expect, test } from "vitest"; + +describe("Intern MCP release automation", () => { + test("a manual semantic version bump reaches the tag-bound OIDC publisher", async () => { + // The release entrypoint must expose the operator's version decision and + // record that decision in package metadata before creating the tag. + const release = await fs.readFile(".github/workflows/release.yml", "utf8"); + expect(release).toMatch(/bump:\n[\s\S]*type: choice/); + expect(release).toMatch(/- patch\n\s+- minor\n\s+- major/); + expect(release).toContain('npm version "$BUMP" --no-git-tag-version'); + expect(release).toContain('tag="v${version}"'); + expect(release).toContain('gh workflow run publish.yml --ref "$TAG"'); + expect(release).toContain("actions: write"); + expect(release).toContain('gh pr merge "$pr_url" --rebase --delete-branch'); + expect(release).not.toContain('gh pr merge "$pr_url" --squash'); + expect(release).toContain( + "gh pr view \"$pr_url\" --json mergeCommit --jq '.mergeCommit.oid'", + ); + expect(release).toContain('git tag -a "$TAG" "$MERGED_SHA"'); + expect(release).not.toContain('git tag -a "$TAG" origin/main'); + expect(release).toContain( + "Automated release version bump opened by \\`release.yml\\`.", + ); + expect(release).toContain("- Planned tag: \\`$TAG\\`"); + const mergeIndex = release.indexOf('gh pr merge "$pr_url"'); + const tagIndex = release.indexOf('git tag -a "$TAG" "$MERGED_SHA"'); + const publishIndex = release.indexOf('gh workflow run publish.yml --ref "$TAG"'); + expect(mergeIndex).toBeGreaterThan(-1); + expect(tagIndex).toBeGreaterThan(mergeIndex); + expect(publishIndex).toBeGreaterThan(tagIndex); + + // The publication boundary must be the immutable tag, with the protected + // npm environment and OIDC authority rather than a long-lived npm token. + const publish = await fs.readFile(".github/workflows/publish.yml", "utf8"); + expect(publish).toMatch(/tags:\n\s+- ["']v\*["']/); + expect(publish).toContain("if: startsWith(github.ref, 'refs/tags/v')"); + expect(publish).toContain("environment: npm-release"); + expect(publish).toContain("id-token: write"); + expect(publish).toContain('version="${TAG#v}"'); + expect(publish).toContain("npm publish --access public"); + expect(publish).not.toContain("npm publish --access public --provenance"); + expect(publish).toContain('gh release create "$TAG"'); + expect(publish).not.toContain("secrets.NPM_TOKEN"); + }); +});