Skip to content

Commit c14465c

Browse files
MajorTalclaude
andcommitted
ci: publish run402-mcp + run402 + @run402/sdk via Trusted Publisher OIDC
Mirrors the publish-astro.yml pattern for the three lockstep packages. First-time setup requires Trusted Publisher to be configured for each package on npmjs.com (org kychee-com, repo run402, workflow publish.yml) before the workflow's first run succeeds. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f98359d commit c14465c

1 file changed

Lines changed: 254 additions & 0 deletions

File tree

.github/workflows/publish.yml

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
# Publish run402-mcp + run402 + @run402/sdk to npm via Trusted Publisher OIDC.
2+
#
3+
# Triggered manually via `gh workflow run publish.yml -f bump=<patch|minor|major>`.
4+
# This is the canonical publish path for the three repo packages as of v2.4.0+
5+
# (matching the same OIDC model used by .github/workflows/publish-astro.yml).
6+
#
7+
# WHY OIDC INSTEAD OF STORED TOKENS:
8+
# No npm token stored as a GitHub Secret. The job exchanges its short-lived
9+
# GitHub OIDC token for an even-shorter-lived npm publish credential at the
10+
# moment of publish. npm verifies the exchange against the Trusted Publisher
11+
# configuration on each package's npm settings (org `kychee-com`, repo
12+
# `run402`, this workflow file). If any of those match-claims diverge, the
13+
# exchange fails. Forking the repo, copying the workflow into a different
14+
# repo, or running an unauthorized branch all fail the npm-side check.
15+
#
16+
# WHY id-token: write:
17+
# Required for GitHub to mint the OIDC token. WITHOUT this permission the
18+
# token isn't issued and `npm publish` falls back to looking for a stored
19+
# auth token — which doesn't exist here, so the publish would 401.
20+
#
21+
# WHY contents: write:
22+
# Used to commit the version bump back to main + push the v<x.y.z> tag.
23+
# The default GITHUB_TOKEN has this permission when the workflow declares it.
24+
#
25+
# LOCKSTEP MODEL:
26+
# The three packages ship at the same version. The workflow reads the root
27+
# package.json version, applies the bump kind, and mirrors that exact target
28+
# to cli/package.json + sdk/package.json. No per-package bump option — for a
29+
# subset release, run `npm publish` manually with --otp until we add a
30+
# selective workflow input.
31+
32+
name: Publish run402-mcp + run402 + @run402/sdk
33+
34+
on:
35+
workflow_dispatch:
36+
inputs:
37+
bump:
38+
description: 'Version bump kind (lockstep across all three packages)'
39+
required: true
40+
default: 'patch'
41+
type: choice
42+
options:
43+
- patch
44+
- minor
45+
- major
46+
dry_run:
47+
description: 'Dry run (build + smoke test only; no publish, no commit, no tag)'
48+
type: boolean
49+
default: false
50+
51+
# Only one publish at a time. A second invocation queues behind any in-flight
52+
# publish so the version-bump commits stay ordered.
53+
concurrency:
54+
group: publish
55+
cancel-in-progress: false
56+
57+
jobs:
58+
publish:
59+
name: Bump, smoke, publish, tag
60+
runs-on: ubuntu-latest
61+
# Restrict to main. Workflow_dispatch can target any branch in the UI,
62+
# but we want the canonical published commit to come from main.
63+
if: github.ref == 'refs/heads/main'
64+
65+
permissions:
66+
contents: write
67+
id-token: write
68+
69+
steps:
70+
- name: Checkout
71+
uses: actions/checkout@v5
72+
with:
73+
# Need history depth for `npm version` to look sane and for the
74+
# commit-then-push step to operate against the current HEAD.
75+
fetch-depth: 0
76+
# Use the default GITHUB_TOKEN for the push back. Repo settings
77+
# must allow Actions to push to main (Settings → Actions → General
78+
# → Workflow permissions → Read and write).
79+
token: ${{ secrets.GITHUB_TOKEN }}
80+
81+
- name: Setup Node
82+
uses: actions/setup-node@v5
83+
with:
84+
# Node 24 ships npm 11.x. OIDC publish (the trusted-publisher
85+
# token exchange) requires npm 11.5.1+; npm 10.x (bundled with
86+
# Node 22) can still sign provenance attestations but doesn't
87+
# know how to exchange the OIDC token for an npm publish
88+
# credential — the publish then falls through to a missing
89+
# token auth and 404s. Pinning to Node 24 is the cleanest way
90+
# to guarantee the right npm.
91+
node-version: '24'
92+
# registry-url tells setup-node to write a project-level .npmrc
93+
# that points at npmjs.org. Required for OIDC token exchange.
94+
# Without this, `npm publish` may resolve to a different default
95+
# registry on the runner.
96+
registry-url: 'https://registry.npmjs.org'
97+
98+
- name: Verify npm has OIDC publish support
99+
run: |
100+
NPM_VER=$(npm --version)
101+
echo "npm version: $NPM_VER"
102+
# node -e exits non-zero (and the step fails) if npm <11.5.1.
103+
node -e "const [a,b,c] = process.argv[1].split('.').map(Number); if (a < 11 || (a === 11 && b < 5) || (a === 11 && b === 5 && c < 1)) { console.error('npm '+process.argv[1]+' lacks OIDC trusted-publisher exchange (needs 11.5.1+)'); process.exit(1); }" "$NPM_VER"
104+
105+
- name: Configure git for commit + push
106+
run: |
107+
git config user.name 'github-actions[bot]'
108+
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
109+
110+
- name: Install dependencies
111+
run: npm ci
112+
113+
- name: Run full test suite
114+
run: npm test
115+
116+
- name: Build all packages
117+
# `npm run build` runs build:core + build:sdk + tsc (mcp) and stages
118+
# the per-package core-dist / sdk/dist mirrors that prepack uses.
119+
run: npm run build
120+
121+
- name: Bump version (lockstep)
122+
id: bump
123+
run: |
124+
set -euo pipefail
125+
CUR=$(node -p "require('./package.json').version")
126+
# `npm version <kind> --no-git-tag-version` on the root modifies
127+
# the root package.json. We then mirror the exact NEW version to
128+
# cli/package.json and sdk/package.json via `node -e` (not `npm
129+
# version` from inside those dirs — that would bump from the
130+
# subpackage's current version, which can diverge after subset
131+
# releases).
132+
npm version "${{ inputs.bump }}" --no-git-tag-version >/dev/null
133+
NEW=$(node -p "require('./package.json').version")
134+
node -e "
135+
const fs = require('fs');
136+
for (const path of ['cli/package.json', 'sdk/package.json']) {
137+
const pkg = JSON.parse(fs.readFileSync(path, 'utf8'));
138+
pkg.version = '$NEW';
139+
fs.writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n');
140+
console.log('Bumped ' + path + ' to ' + pkg.version);
141+
}
142+
"
143+
echo "Lockstep bump: $CUR → $NEW"
144+
echo "old=$CUR" >> "$GITHUB_OUTPUT"
145+
echo "new=$NEW" >> "$GITHUB_OUTPUT"
146+
147+
- name: Sync lockfile to reflect new versions
148+
run: npm install --package-lock-only
149+
150+
- name: Rebuild after bump
151+
# The bump updated package.json files but didn't touch dist/ — the
152+
# version string is read from package.json at runtime by the CLI's
153+
# --version flag, so we re-run build to refresh any embedded
154+
# version metadata in declaration files.
155+
run: npm run build
156+
157+
- name: Tarball smoke test
158+
run: |
159+
set -euo pipefail
160+
NEW="${{ steps.bump.outputs.new }}"
161+
SMOKE=/tmp/smoke-$NEW
162+
rm -rf "$SMOKE" && mkdir "$SMOKE"
163+
164+
# Pack all three tarballs into the same scratch dir.
165+
npm pack --pack-destination "$SMOKE" # run402-mcp
166+
(cd cli && npm pack --pack-destination "$SMOKE") # run402
167+
(cd sdk && npm pack --pack-destination "$SMOKE") # @run402/sdk
168+
ls -la "$SMOKE"
169+
170+
# CLI: extract, install, --version must report the new version.
171+
mkdir "$SMOKE/cli" && tar xzf "$SMOKE/run402-$NEW.tgz" -C "$SMOKE/cli"
172+
(cd "$SMOKE/cli/package" && npm install --omit=dev)
173+
ACTUAL=$(node "$SMOKE/cli/package/cli.mjs" --version)
174+
if [ "$ACTUAL" != "$NEW" ]; then
175+
echo "CLI --version mismatch: expected $NEW, got $ACTUAL"
176+
exit 1
177+
fi
178+
echo "CLI smoke OK: $ACTUAL"
179+
180+
# MCP: extract, install, verify getSdk resolves (don't boot the
181+
# stdio server — it doesn't exit).
182+
mkdir "$SMOKE/mcp" && tar xzf "$SMOKE/run402-mcp-$NEW.tgz" -C "$SMOKE/mcp"
183+
(cd "$SMOKE/mcp/package" && npm install --omit=dev)
184+
(cd "$SMOKE/mcp/package" && node -e "import('./dist/sdk.js').then(m => { if (typeof m.getSdk !== 'function') { console.error('getSdk is not a function'); process.exit(1); } console.log('MCP smoke OK'); }).catch(e => { console.error('Import failed:', e.message); process.exit(1); })")
185+
186+
# SDK: extract, install, verify both entry points.
187+
mkdir "$SMOKE/sdk" && tar xzf "$SMOKE/run402-sdk-$NEW.tgz" -C "$SMOKE/sdk"
188+
(cd "$SMOKE/sdk/package" && npm install --omit=dev)
189+
(cd "$SMOKE/sdk/package" && node -e "import('./dist/node/index.js').then(m => { if (typeof m.run402 !== 'function') { console.error('node run402 export is not a function'); process.exit(1); } console.log('SDK /node smoke OK'); }).catch(e => { console.error('Import failed:', e.message); process.exit(1); })")
190+
(cd "$SMOKE/sdk/package" && node -e "import('./dist/index.js').then(m => { if (typeof m.Run402 !== 'function') { console.error('iso Run402 export is not a function'); process.exit(1); } console.log('SDK iso smoke OK'); }).catch(e => { console.error('Import failed:', e.message); process.exit(1); })")
191+
192+
- name: Publish run402-mcp to npm
193+
if: ${{ !inputs.dry_run }}
194+
# --provenance is optional under OIDC (provenance is generated
195+
# implicitly), but the flag makes the intent explicit and would
196+
# cause the publish to fail loudly if OIDC isn't actually wired
197+
# — which is exactly what we want during the first few CI runs.
198+
# --access public is redundant for these public packages but
199+
# harmless and self-documenting.
200+
run: npm publish --access public --provenance
201+
202+
- name: Publish run402 to npm
203+
if: ${{ !inputs.dry_run }}
204+
run: cd cli && npm publish --access public --provenance
205+
206+
- name: Publish @run402/sdk to npm
207+
if: ${{ !inputs.dry_run }}
208+
run: cd sdk && npm publish --access public --provenance
209+
210+
- name: Commit version bump
211+
if: ${{ !inputs.dry_run }}
212+
run: |
213+
set -euo pipefail
214+
git add package.json cli/package.json sdk/package.json package-lock.json
215+
git commit -m "chore: bump version to v${{ steps.bump.outputs.new }}"
216+
git push origin main
217+
218+
- name: Tag and push
219+
if: ${{ !inputs.dry_run }}
220+
run: |
221+
set -euo pipefail
222+
git tag "v${{ steps.bump.outputs.new }}"
223+
git push origin "v${{ steps.bump.outputs.new }}"
224+
225+
- name: Create GitHub release
226+
if: ${{ !inputs.dry_run }}
227+
uses: softprops/action-gh-release@v2
228+
with:
229+
tag_name: v${{ steps.bump.outputs.new }}
230+
name: 'v${{ steps.bump.outputs.new }}'
231+
generate_release_notes: true
232+
target_commitish: main
233+
234+
- name: Summary
235+
run: |
236+
NEW="${{ steps.bump.outputs.new }}"
237+
OLD="${{ steps.bump.outputs.old }}"
238+
DRY="${{ inputs.dry_run }}"
239+
{
240+
echo "## run402 lockstep publish summary"
241+
echo ""
242+
echo "- **Bump:** \`${{ inputs.bump }}\` ($OLD → $NEW)"
243+
echo "- **Dry run:** $DRY"
244+
if [ "$DRY" = "true" ]; then
245+
echo ""
246+
echo "_Dry run — no publish, no commit, no tag was made._"
247+
else
248+
echo "- **run402-mcp:** https://www.npmjs.com/package/run402-mcp/v/$NEW"
249+
echo "- **run402:** https://www.npmjs.com/package/run402/v/$NEW"
250+
echo "- **@run402/sdk:** https://www.npmjs.com/package/@run402/sdk/v/$NEW"
251+
echo "- **Tag:** \`v$NEW\`"
252+
echo "- **Provenance attestation:** auto-generated via OIDC"
253+
fi
254+
} >> "$GITHUB_STEP_SUMMARY"

0 commit comments

Comments
 (0)