|
| 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