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
84 changes: 84 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -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
137 changes: 116 additions & 21 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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 <<EOF
Automated release version bump opened by \`release.yml\`.

- Planned tag: \`$TAG\`
- After this PR merges, \`release.yml\` tags the resulting \`main\` commit
and dispatches \`publish.yml\` through npm Trusted Publishing.

Tests ran before this version-only commit.
EOF
)
pr_url=$(gh pr create \
--base main \
--head "$BRANCH" \
--title "$TITLE" \
--body "$body")
gh pr merge "$pr_url" --rebase --delete-branch
merged_sha="$(gh pr view "$pr_url" --json mergeCommit --jq '.mergeCommit.oid')"
if [[ ! "$merged_sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "Could not resolve the merged release commit for $pr_url" >&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"
19 changes: 9 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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

Expand Down
46 changes: 46 additions & 0 deletions src/release-workflows.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading