Publish MCP Package #13
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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/loopover-mcp/package.json's | |
| # version, matching the old "push a tag to publish" flow just invoked differently. | |
| on: | |
| 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 | |
| concurrency: | |
| group: npm-publish-${{ github.ref_name }} | |
| cancel-in-progress: false | |
| jobs: | |
| # Unprivileged: resolves the version, runs the MCP package's own build/test/pack, 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 | |
| outputs: | |
| version: ${{ steps.version.outputs.version }} | |
| tag: ${{ steps.version.outputs.tag }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 | |
| with: | |
| fetch-depth: 0 | |
| persist-credentials: false | |
| - name: Verify release commit is on main | |
| env: | |
| RELEASE_SHA: ${{ github.sha }} | |
| run: | | |
| set -euo pipefail | |
| git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main | |
| if ! git merge-base --is-ancestor "$RELEASE_SHA" refs/remotes/origin/main; then | |
| echo "::error::MCP package releases must be cut from a commit reachable from main." | |
| exit 1 | |
| fi | |
| - name: Setup Node | |
| uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 | |
| with: | |
| node-version: 24.18.0 | |
| # 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 | |
| VERSION="$(node -p "require('./packages/loopover-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/loopover-mcp's CLI test suite (test/unit/mcp-cli-*.test.ts) spawns the real binary, | |
| # which imports @loopover/engine. That package's dist/ is gitignored (see ci.yml's own | |
| # build --workspace @loopover/engine step for the same reason) -- without building it first, | |
| # the CLI test suite below fails with "Failed to resolve entry for package @loopover/engine". | |
| - name: Build loopover-engine | |
| run: npm run build --workspace @loopover/engine | |
| # engine/miner/ui-kit's own publish workflows only validate their OWN package (a workspace | |
| # test run + pack/smoke-test) -- mcp previously ran the full npm run test:ci (every workspace, | |
| # every drift-check, ui:lint/build, migrations, etc.), coupling an MCP release to the health of | |
| # the entire monorepo. Confirmed harmful live: an unrelated packages/loopover-miner env-reference | |
| # doc going stale blocked MCP's publish outright, even though MCP's own package code was | |
| # completely unaffected -- and main's required CI already re-verifies whole-repo health on | |
| # every PR, including release-please's own, so re-running all of it here is redundant with what | |
| # already gated the merge. Scoped down to match its siblings instead: MCP's own syntax check | |
| # (node --check, same as build:mcp) plus its own CLI test suite -- test/unit/mcp-cli-*.test.ts | |
| # specifically (imports packages/loopover-mcp and spawns the built binary), NOT the sibling | |
| # test/unit/mcp-*.test.ts files that exercise the Worker's separate remote MCP server | |
| # (src/mcp/server.ts, a different deployable entirely). | |
| - name: MCP package syntax validation | |
| run: npm run build:mcp | |
| - name: MCP CLI test suite | |
| run: npx vitest run mcp-cli- | |
| # 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 @loopover/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")" | |
| 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_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 "$TARBALL_PATH" >/dev/null | |
| "$TMP/node_modules/.bin/loopover-mcp" --help >/dev/null | |
| - name: Upload package tarball | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: loopover-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: Verify release commit is on main | |
| env: | |
| RELEASE_SHA: ${{ github.sha }} | |
| run: | | |
| set -euo pipefail | |
| git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main | |
| if ! git merge-base --is-ancestor "$RELEASE_SHA" refs/remotes/origin/main; then | |
| echo "::error::MCP package releases must be cut from a commit reachable from main." | |
| exit 1 | |
| fi | |
| - 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 "@loopover/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: loopover-mcp-tarball | |
| path: ${{ runner.temp }}/loopover-mcp-package | |
| - name: Publish to npm (OIDC trusted publishing) | |
| env: | |
| NPM_CONFIG_PROVENANCE: "true" | |
| run: | | |
| set -euo pipefail | |
| count=$(find "$RUNNER_TEMP/loopover-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/loopover-mcp-package" -maxdepth 1 -type f -name "*.tgz" -print >&2 | |
| exit 1 | |
| fi | |
| tarball=$(find "$RUNNER_TEMP/loopover-mcp-package" -maxdepth 1 -type f -name "*.tgz" -print -quit) | |
| npx -y npm@11.15.0 publish "$tarball" --access public --provenance | |
| # apps/loopover-ui/src/lib/mcp-package.ts's MCP_PACKAGE_KNOWN_LATEST_VERSION (ui:version-audit, | |
| # already CI-enforced) tracks the latest PUBLISHED release -- its own header comment explains why | |
| # it can't be pre-synced in the Release PR itself: it must never claim a version ahead of npm, so | |
| # the earliest correct moment to update it is right here, after publish actually succeeds. Left as | |
| # a manual "did anyone notice CI red and run the sync command" step before, this blocked completely | |
| # unrelated PRs' CI with zero connection to what they actually changed (confirmed live: an | |
| # auto-generated release PR for a DIFFERENT package went red on this exact gap, twice in one | |
| # session). Opens a small PR instead of pushing straight to main: this repo's branch protection | |
| # requires PRs (required_pull_request_reviews), so a direct push would just be rejected. | |
| sync-ui-version-copy: | |
| runs-on: ubuntu-latest | |
| needs: [validate, publish] | |
| if: ${{ !cancelled() && needs.publish.result == 'success' }} | |
| timeout-minutes: 10 | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 | |
| with: | |
| persist-credentials: false | |
| - name: Setup Node | |
| uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 | |
| with: | |
| node-version: 24.18.0 | |
| - name: Sync and open a PR if the known-latest copy is now stale | |
| env: | |
| # A real collaborator token, not github.token -- mirrors mcp-release-please.yml's own | |
| # rationale: PRs/commits pushed as github-actions[bot] repeatedly get re-flagged as needing | |
| # manual workflow-run approval, which a real account sidesteps. | |
| GH_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| node scripts/check-ui-mcp-version-copy.mjs --write | |
| if git diff --quiet apps/loopover-ui/src/lib/mcp-package.ts; then | |
| echo "Already in sync, nothing to do." | |
| exit 0 | |
| fi | |
| git config user.name "github-actions[bot]" | |
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
| branch="chore/sync-mcp-version-copy-${{ needs.validate.outputs.version }}" | |
| git checkout -B "$branch" | |
| git add apps/loopover-ui/src/lib/mcp-package.ts | |
| git commit -m "chore(ui): sync MCP_PACKAGE_KNOWN_LATEST_VERSION with npm dist-tags.latest" | |
| git remote set-url origin "https://github.com/${GITHUB_REPOSITORY}.git" | |
| gh auth setup-git | |
| git push origin "HEAD:$branch" | |
| gh pr create --repo "$GITHUB_REPOSITORY" --base main --head "$branch" \ | |
| --title "chore(ui): sync MCP_PACKAGE_KNOWN_LATEST_VERSION with npm dist-tags.latest" \ | |
| --body "Automated: @loopover/mcp v${{ needs.validate.outputs.version }} just published to npm; keeps apps/loopover-ui/src/lib/mcp-package.ts's known-latest-version copy in sync (ui:version-audit)." | |
| github-release: | |
| runs-on: ubuntu-latest | |
| 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 | |
| steps: | |
| - name: Create GitHub release | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| RELEASE_TAG: ${{ needs.validate.outputs.tag }} | |
| RELEASE_VERSION: ${{ needs.validate.outputs.version }} | |
| run: | | |
| set -euo pipefail | |
| NOTES_FILE="$(mktemp)" | |
| cat > "$NOTES_FILE" <<EOF | |
| Published [@loopover/mcp v${RELEASE_VERSION}](https://www.npmjs.com/package/@loopover/mcp/v/${RELEASE_VERSION}) to npm with provenance. | |
| Install: | |
| \`\`\`sh | |
| npm install -g @loopover/mcp@${RELEASE_VERSION} | |
| \`\`\` | |
| EOF | |
| if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then | |
| gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --title "@loopover/mcp v${RELEASE_VERSION}" --notes-file "$NOTES_FILE" | |
| else | |
| gh release create "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --title "@loopover/mcp v${RELEASE_VERSION}" --notes-file "$NOTES_FILE" --verify-tag | |
| fi |