diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 42b7358b5c..64e4e8f601 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -1,9 +1,17 @@ name: Publish MCP Package +# workflow_dispatch-only (no push:tags: trigger): a GITHUB_TOKEN-created tag (e.g. from the release +# automation) does not fire push-triggered workflows, so the release automation must explicitly +# dispatch this workflow after it tags a release. A bare manual dispatch (released_by_release_please +# left false) is the human override path and self-tags HEAD from packages/gittensory-mcp/package.json's +# version, matching the old "push a tag to publish" flow just invoked differently. on: - push: - tags: - - "mcp-v*.*.*" + workflow_dispatch: + inputs: + released_by_release_please: + description: "Internal: set by the release automation's dispatch so this run skips re-creating the GitHub release it already made." + type: boolean + default: false permissions: contents: read @@ -13,12 +21,18 @@ concurrency: cancel-in-progress: false jobs: - publish: + # Unprivileged: resolves the version, runs the full test gate, and packs the tarball -- all with + # contents: read only. npm ci here executes dependency lifecycle scripts; keeping that in a job + # with no write/id-token permission means a compromised build dependency has nothing to abuse + # (Superagent P2 / mirrors metagraphed's publish-client.yml "validate" job, codex #251). + validate: runs-on: ubuntu-latest + timeout-minutes: 20 permissions: contents: read - id-token: write - timeout-minutes: 20 + outputs: + version: ${{ steps.version.outputs.version }} + tag: ${{ steps.version.outputs.tag }} steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 @@ -30,49 +44,153 @@ jobs: uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: 24.18.0 - registry-url: https://registry.npmjs.org - - name: Verify release tag - env: - REF_NAME: ${{ github.ref_name }} + # workflow_dispatch has no tag ref to derive a version from (unlike the old push:tags: trigger), + # so the dispatched commit's package.json is now the single source of truth for VERSION/TAG. Pure + # read here -- if the tag already exists (release automation tagged it before dispatching this + # workflow) it's verified against HEAD; if not, the privileged publish job below creates it, since + # this job has no contents: write. + - name: Resolve release version + id: version run: | set -euo pipefail - node -e 'const tag=process.env.REF_NAME; if (!/^mcp-v[0-9]+\.[0-9]+\.[0-9]+$/.test(tag)) { throw new Error("Invalid MCP release tag: " + tag); }' - export VERSION="${REF_NAME#mcp-v}" - node -e 'const pkg=require("./packages/gittensory-mcp/package.json"); const expected=process.env.VERSION; if (pkg.version !== expected) { throw new Error("package version " + pkg.version + " does not match tag " + expected); }' + VERSION="$(node -p "require('./packages/gittensory-mcp/package.json').version")" + if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Invalid package version: $VERSION" + exit 1 + fi + TAG="mcp-v${VERSION}" + if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then + HEAD_SHA="$(git rev-parse HEAD)" + TAG_SHA="$(git rev-list -n 1 "$TAG")" + if [ "$TAG_SHA" != "$HEAD_SHA" ]; then + echo "::error::Tag $TAG already exists but points at $TAG_SHA, not the dispatched commit $HEAD_SHA" + exit 1 + fi + echo "Tag $TAG already exists and matches HEAD." + else + echo "Tag $TAG does not exist yet; the publish job will create it." + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" - name: Install dependencies run: npm ci + # packages/gittensory-mcp's own test suite pulls in src/mcp/find-opportunities.ts transitively + # (via packages/gittensory-miner/lib/opportunity-fanout.js), which imports @jsonbored/gittensory-engine. + # That package's dist/ is gitignored (see ci.yml's own build --workspace @jsonbored/gittensory-engine + # step for the same reason) -- without building it first, the MCP release validation gate below + # fails with "Failed to resolve entry for package @jsonbored/gittensory-engine" on every run. + - name: Build gittensory-engine + run: npm run build --workspace @jsonbored/gittensory-engine + - name: MCP release validation gate run: npm run test:release:mcp - - name: Packed tarball smoke test + # Build + pack happen in THIS unprivileged job (no id-token). The privileged publish job below + # never runs npm install/build, so a compromised build dependency can't reach the OIDC token. + - name: Pack and smoke-test the tarball run: | set -euo pipefail - PACK_JSON="$(npm pack --workspace @jsonbored/gittensory-mcp --json)" + PACK_JSON="$(npm pack --workspace @jsonbored/gittensory-mcp --pack-destination "$RUNNER_TEMP" --json)" TARBALL="$(node -e 'const fs=require("fs"); const input=fs.readFileSync(0,"utf8"); process.stdout.write(JSON.parse(input)[0].filename)' <<< "$PACK_JSON")" - UNEXPECTED_FILES="$(tar -tzf "$TARBALL" | grep -Ev '^(package/(bin|lib|scripts)/.+|package/(package.json|README.md|CHANGELOG.md|LICENSE))$' || true)" + TARBALL_PATH="$RUNNER_TEMP/$TARBALL" + UNEXPECTED_FILES="$(tar -tzf "$TARBALL_PATH" | grep -Ev '^(package/(bin|lib|scripts)/.+|package/(package.json|README.md|CHANGELOG.md|LICENSE))$' || true)" if [ -n "$UNEXPECTED_FILES" ]; then printf '%s\n' "$UNEXPECTED_FILES" echo "Unexpected file in package tarball" exit 1 fi - if tar -xOf "$TARBALL" | grep -E '(BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY|github_pat_|gh[pousr]_|gts_[0-9a-f]{64}|[A-Z0-9_]*(TOKEN|SECRET|PRIVATE_KEY)=)'; then + if tar -xOf "$TARBALL_PATH" | grep -qE '(BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY|github_pat_|gh[pousr]_|gts_[0-9a-f]{64}|[A-Z0-9_]*(TOKEN|SECRET|PRIVATE_KEY)=)'; then echo "Secret-like content found in package tarball" exit 1 fi TMP="$(mktemp -d)" npm --prefix "$TMP" init -y >/dev/null - npm --prefix "$TMP" install "$PWD/$TARBALL" >/dev/null + npm --prefix "$TMP" install "$TARBALL_PATH" >/dev/null "$TMP/node_modules/.bin/gittensory-mcp" --help >/dev/null - - name: Publish with npm trusted publishing - run: npx -y npm@11.15.0 publish --workspace @jsonbored/gittensory-mcp --access public --provenance + - name: Upload package tarball + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: gittensory-mcp-tarball + path: ${{ runner.temp }}/*.tgz + if-no-files-found: error + retention-days: 7 + + # Privileged: tags + publishes the EXACT tarball the unprivileged job already tested. No npm + # install/build runs here, so nothing with dependency-lifecycle-script access ever sees contents: + # write or the OIDC token (Superagent P2). environment: release requires reviewer approval per repo + # Settings > Environments, same gate release-selfhost.yml already uses (Superagent P1). + publish: + runs-on: ubuntu-latest + needs: validate + environment: release + timeout-minutes: 15 + permissions: + contents: write + id-token: write + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: 24.18.0 + registry-url: https://registry.npmjs.org + + - name: Create or verify release tag + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.validate.outputs.tag }} + VERSION: ${{ needs.validate.outputs.version }} + run: | + set -euo pipefail + HEAD_SHA="$(git rev-parse HEAD)" + if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then + echo "Tag $TAG already exists (verified against HEAD by the validate job)." + else + echo "Creating tag $TAG at HEAD ($HEAD_SHA)." + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "$TAG" -m "@jsonbored/gittensory-mcp v${VERSION}" + git remote set-url origin "https://github.com/${GITHUB_REPOSITORY}.git" + gh auth setup-git + git push origin "$TAG" + fi + + - name: Download package tarball + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: gittensory-mcp-tarball + path: ${{ runner.temp }}/gittensory-mcp-package + + - name: Publish to npm (OIDC trusted publishing) + env: + NPM_CONFIG_PROVENANCE: "true" + run: | + set -euo pipefail + count=$(find "$RUNNER_TEMP/gittensory-mcp-package" -maxdepth 1 -type f -name "*.tgz" | wc -l | tr -d ' ') + if [ "$count" != "1" ]; then + echo "Expected exactly one tarball, found $count" >&2 + find "$RUNNER_TEMP/gittensory-mcp-package" -maxdepth 1 -type f -name "*.tgz" -print >&2 + exit 1 + fi + tarball=$(find "$RUNNER_TEMP/gittensory-mcp-package" -maxdepth 1 -type f -name "*.tgz" -print -quit) + npx -y npm@11.15.0 publish "$tarball" --access public --provenance github-release: runs-on: ubuntu-latest - needs: publish + needs: [validate, publish] + # Skip when the release automation dispatched this run: it already created the GitHub release + # with its own generated changelog notes before dispatching, so running this unconditionally + # would overwrite those richer notes with the generic blurb below. + if: ${{ inputs.released_by_release_please != true }} timeout-minutes: 5 permissions: contents: write @@ -80,22 +198,22 @@ jobs: - name: Create GitHub release env: GH_TOKEN: ${{ github.token }} - REF_NAME: ${{ github.ref_name }} + RELEASE_TAG: ${{ needs.validate.outputs.tag }} + RELEASE_VERSION: ${{ needs.validate.outputs.version }} run: | set -euo pipefail - VERSION="${REF_NAME#mcp-v}" NOTES_FILE="$(mktemp)" cat > "$NOTES_FILE" </dev/null 2>&1; then - gh release edit "$REF_NAME" --repo "$GITHUB_REPOSITORY" --title "@jsonbored/gittensory-mcp v${VERSION}" --notes-file "$NOTES_FILE" + if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --title "@jsonbored/gittensory-mcp v${RELEASE_VERSION}" --notes-file "$NOTES_FILE" else - gh release create "$REF_NAME" --repo "$GITHUB_REPOSITORY" --title "@jsonbored/gittensory-mcp v${VERSION}" --notes-file "$NOTES_FILE" --verify-tag + gh release create "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --title "@jsonbored/gittensory-mcp v${RELEASE_VERSION}" --notes-file "$NOTES_FILE" --verify-tag fi