mcp-release.yml

In .github/workflows/

Preview

Code · 7 KiB
name: "Release: MCP"

# Release-gated corpus updates: the MCP indexes a release tag's tree, so the
# served corpus always matches the published crates — `main` content never
# bleeds into a released version label. Versions are immutable once seeded
# (skip-if-indexed); re-runs and GitHub's occasional duplicate tag events are
# harmless. The escape hatch re-seeds a version FROM ITS OWN TAG (`force`),
# e.g. after an indexer bugfix — same source, better representation.
#
# No `paths:` filter on purpose: paths semantics are unreliable for tag pushes
# (no stable diff base), which is also why this is a separate workflow from
# the PR-validation mcp.yml.

on:
  push:
    tags:
      - 'v*.*.*'
  # Manual escape hatch (mirrors release.yml's): re-run for an existing tag,
  # or force a re-seed of an already-indexed version after an indexer fix.
  workflow_dispatch:
    inputs:
      tag:
        description: 'Existing tag to (re)index (e.g. v0.2.0). Must be pushed to origin.'
        required: true
        type: string
      force:
        description: 'Re-seed even if this version is already indexed'
        type: boolean
        default: false

permissions:
  contents: read

# Serialize racing tags / re-seed dispatches against each other.
concurrency:
  group: mcp-deploy
  cancel-in-progress: false

jobs:
  release:
    name: Seed corpus from tag
    runs-on: ubuntu-latest
    environment: mcp
    env:
      CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
      CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
      RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
    steps:
      - name: Validate tag input shape (dispatch only)
        if: ${{ github.event_name == 'workflow_dispatch' }}
        run: |
          if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
            echo "::error::tag '$RELEASE_TAG' is not a vX.Y.Z release tag"
            exit 1
          fi

      # Check out the TAG, not a branch — the indexed tree IS the released
      # source by construction.
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # v7.0.1
        with:
          ref: ${{ inputs.tag || github.ref }}

      # workflow_dispatch always runs THIS workflow file (from the ref you
      # dispatch from, normally main) against the checked-out TAG's tree —
      # the two can disagree on layout. The MCP app lived at top-level `mcp/`
      # before the apps/ reorg (PR #444/#504, merged 2026-06-23) and at
      # `apps/mcp/` after. Detect which one the checked-out tree actually has
      # instead of hardcoding a path that only matches post-reorg tags.
      - name: Locate MCP app directory in checked-out tree
        id: locate-mcp
        run: |
          if [ -d apps/mcp ]; then
            echo "dir=apps/mcp" >> "$GITHUB_OUTPUT"
          elif [ -d mcp ]; then
            echo "dir=mcp" >> "$GITHUB_OUTPUT"
          else
            echo "::error::could not locate the MCP app directory (checked apps/mcp and mcp) in the checked-out tree for ${{ inputs.tag || github.ref }}"
            exit 1
          fi

      - name: Setup Node.js
        uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1  # v7.1.0
        with:
          node-version: '22'

      - name: Install MCP dependencies
        working-directory: ${{ steps.locate-mcp.outputs.dir }}
        run: npm ci

      - name: Typecheck + test (never seed from a tree whose indexer fails)
        working-directory: ${{ steps.locate-mcp.outputs.dir }}
        run: npm run ci

      - name: Build corpus index from the tag's tree
        working-directory: ${{ steps.locate-mcp.outputs.dir }}
        run: npm run index

      # Guard: the indexer derives the version from rust/Cargo.toml. If the tag
      # was pushed before the release-plz version-bump merged, the label would
      # lie about the content — fail loudly instead.
      - name: Guard — tag matches workspace version
        working-directory: ${{ steps.locate-mcp.outputs.dir }}
        run: |
          INDEXED="$(jq -r .version dist/manifest.json)"
          if [[ "$INDEXED" != "$RELEASE_TAG" ]]; then
            echo "::error::workspace version '$INDEXED' (rust/Cargo.toml at this tag) != tag '$RELEASE_TAG' — refusing to seed a mislabeled corpus"
            exit 1
          fi
          echo "version check ok: $INDEXED"

      - name: Check whether this version is already indexed
        id: indexed
        working-directory: ${{ steps.locate-mcp.outputs.dir }}
        shell: bash  # explicit: gets `-o pipefail`, so a wrangler failure fails the step
        env:
          FORCE: ${{ inputs.force }}
        run: |
          set -o pipefail
          # Run wrangler separately so its exit code and output are visible in
          # the log (a command-substitution failure under `set -e` otherwise dies
          # before any diagnostic prints). Capture stdout; keep stderr in the log.
          set +e
          RAW="$(npx --yes wrangler d1 execute mkit-mcp-search --remote --json \
              --command "SELECT version FROM versions WHERE version = '$RELEASE_TAG'" 2>wrangler.err)"
          rc=$?
          set -e
          echo "--- wrangler stdout (rc=$rc) ---"
          printf '%s\n' "$RAW"
          echo "--- wrangler stderr ---"
          cat wrangler.err || true
          echo "------------------------------"
          if [ "$rc" -ne 0 ]; then
            echo "::error::wrangler d1 execute failed (rc=$rc) for $RELEASE_TAG"
            exit 1
          fi
          echo "--- wrangler --json stdout ---"
          printf '%s\n' "$RAW"
          echo "------------------------------"
          # wrangler's `d1 execute --json` shape drifts across versions (array-
          # wrapped `[{results:[...]}]` vs bare object `{results:[...]}`, and it
          # may print a leading banner). Recursively collect any `results` arrays
          # and count rows — tolerant of all of these.
          COUNT="$(printf '%s' "$RAW" | jq -s '[.. | objects | .results? // empty] | add // [] | length')"
          echo "indexed-row count for $RELEASE_TAG: '$COUNT'"
          # A non-numeric COUNT means the query/parse failed — fail the run, never
          # masquerade as "already indexed".
          if [[ ! "$COUNT" =~ ^[0-9]+$ ]]; then
            echo "::error::could not determine indexed state for $RELEASE_TAG (got: '$COUNT')"
            exit 1
          fi
          if [[ "$COUNT" != "0" && "$FORCE" != "true" ]]; then
            echo "seed=false" >> "$GITHUB_OUTPUT"
            echo "$RELEASE_TAG already indexed — corpus untouched (immutable); pass force=true to re-seed from this tag."
          else
            echo "seed=true" >> "$GITHUB_OUTPUT"
          fi

      - name: Apply D1 migrations (idempotent)
        if: ${{ steps.indexed.outputs.seed == 'true' }}
        working-directory: ${{ steps.locate-mcp.outputs.dir }}
        run: npm run db:migrate

      - name: Seed corpus into remote D1
        if: ${{ steps.indexed.outputs.seed == 'true' }}
        working-directory: ${{ steps.locate-mcp.outputs.dir }}
        run: npm run db:seed

Files