# Release Please — accumulative release PR workflow # # On every push to main: # 1. Creates/updates a release PR with version bump + CHANGELOG # 2. When the release PR is merged → creates GitHub release + tag # 3. Post-release: runs prepare-release.sh, publishes npm, Docker, MCPB, MCP Registry name: Release permissions: contents: read on: push: branches: - main env: GHCR_REGISTRY: ghcr.io IMAGE_NAME: structured-world/gitlab-mcp jobs: # Job 1: Release Please — create/update release PR or create release release-please: name: Release Please runs-on: ubuntu-latest permissions: contents: write pull-requests: write outputs: # No root (".") component, so there are no bare release_created/version/tag # outputs. Trigger on releases_created (any component) and take the # version/tag from the core gitlab-mcp package (the two packages are # version-locked, so its tag is the release reference for checkout + assets). release-created: ${{ steps.release.outputs.releases_created }} release-version: ${{ steps.release.outputs['packages/gitlab-mcp--version'] }} release-tag: ${{ steps.release.outputs['packages/gitlab-mcp--tag_name'] }} release-sha: ${{ steps.release.outputs['packages/gitlab-mcp--sha'] }} pr-number: ${{ steps.relpr.outputs.number }} pr-branch: ${{ steps.relpr.outputs.branch }} steps: - name: Generate release token uses: actions/create-github-app-token@v3 id: app-token with: app-id: ${{ secrets.RELEASER_APP_ID }} private-key: ${{ secrets.RELEASER_APP_PRIVATE_KEY }} owner: ${{ github.repository_owner }} repositories: ${{ github.event.repository.name }} - uses: googleapis/release-please-action@v5 id: release with: token: ${{ steps.app-token.outputs.token }} # The packages are version-locked and the core release already carries both # MCPB bundles, so the db GitHub release only duplicates it. Its tag must stay: # release-please locates the previous db release by that tag (skip-github-release # would not create one, leaving the next release without a base), so delete the # release object alone and mark the core release as latest. - name: Drop the duplicate db GitHub release if: steps.release.outputs['packages/gitlab-mcp-db--tag_name'] != '' env: GH_TOKEN: ${{ steps.app-token.outputs.token }} GH_REPO: ${{ github.repository }} DB_TAG: ${{ steps.release.outputs['packages/gitlab-mcp-db--tag_name'] }} CORE_TAG: ${{ steps.release.outputs['packages/gitlab-mcp--tag_name'] }} run: | gh release delete "$DB_TAG" --yes if [ -n "$CORE_TAG" ]; then gh release edit "$CORE_TAG" --latest fi # Without a root (".") component, release-please-action does not populate the # aggregate `pr` output -- only per-package ones -- so the release PR must be # resolved by its deterministic branch instead. The branch is empty when no # release PR is open (e.g. right after a release merges, or a commit that # produces no release), which downstream steps treat as "nothing to do". - name: Resolve the open release PR id: relpr env: GH_TOKEN: ${{ steps.app-token.outputs.token }} # This job has no checkout; without GH_REPO, gh falls back to local git # detection and fails with "not a git repository". GH_REPO: ${{ github.repository }} run: | BRANCH="release-please--branches--${{ github.ref_name }}" NUMBER="$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number // empty')" if [ -n "$NUMBER" ]; then echo "number=$NUMBER" >> "$GITHUB_OUTPUT" echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" else echo "number=" >> "$GITHUB_OUTPUT" echo "branch=" >> "$GITHUB_OUTPUT" fi # release-please cannot inject the version into a combined multi-package PR # title without a root "." component, and that component breaks lockstep on # root-only changes. Set the title from the gitlab-mcp version instead -- the # packages are version-locked, so it is the single release version. - name: Put the release version in the combined PR title if: steps.relpr.outputs.number != '' env: GH_TOKEN: ${{ steps.app-token.outputs.token }} # Same as above: no checkout in this job, so gh needs the repo explicitly. GH_REPO: ${{ github.repository }} PR_NUMBER: ${{ steps.relpr.outputs.number }} PR_BRANCH: ${{ steps.relpr.outputs.branch }} run: | VERSION="$(gh api "repos/${{ github.repository }}/contents/packages/gitlab-mcp/package.json?ref=${PR_BRANCH}" --jq '.content' | base64 -d | jq -r .version)" gh pr edit "$PR_NUMBER" --title "chore: release ${VERSION}" # Job 2: Post-release — update artifacts, publish everywhere post-release: name: Post-Release Publish runs-on: ubuntu-latest needs: release-please environment: release # Required for npm OIDC trusted publishing # Note: GitHub Actions supports hyphens in job IDs and output names with dot notation. # Bracket notation (needs['release-please']) is NOT required here. if: needs.release-please.outputs.release-created == 'true' permissions: contents: write packages: write id-token: write # npm provenance + MCP Registry OIDC steps: - name: Generate release token uses: actions/create-github-app-token@v3 id: app-token with: app-id: ${{ secrets.RELEASER_APP_ID }} private-key: ${{ secrets.RELEASER_APP_PRIVATE_KEY }} owner: ${{ github.repository_owner }} repositories: ${{ github.event.repository.name }} - name: Checkout release tag uses: actions/checkout@v7 with: ref: ${{ needs.release-please.outputs.release-tag }} fetch-depth: 0 token: ${{ steps.app-token.outputs.token }} persist-credentials: false - name: Setup Node.js uses: actions/setup-node@v7 with: node-version: '24' # NO registry-url — it creates .npmrc with NODE_AUTH_TOKEN placeholder # which overrides OIDC trusted publishing with an invalid token - name: Enable Corepack run: corepack enable - name: Install Yarn run: corepack prepare yarn@4.12.0 --activate - name: Install dependencies run: yarn install --frozen-lockfile - name: Build all packages run: yarn build # Update dynamic artifacts (tool counts in server.json, README.md, package.json manifest) - name: Run prepare-release script run: ./scripts/prepare-release.sh "${{ needs.release-please.outputs.release-version }}" working-directory: packages/gitlab-mcp - name: Build configurable Codex marketplace working-directory: packages/gitlab-mcp run: node scripts/build-codex-plugin.mjs codex-release - name: Verify the exact Codex release archive working-directory: packages/gitlab-mcp run: node scripts/qualify-codex-plugin.mjs --archive codex-release/gitlab-mcp-codex.zip # Note: server.json version is updated by release-please extra-files. # Dynamic counts (README.md, package.json manifest) are updated by prepare-release.sh # locally for the build but NOT committed back — they'll be current in the next release PR. # Publish to npm via OIDC trusted publishing (no static token needed). # Auth: npm trusted publishing via OIDC (id-token: write + environment: release) # Configure at npmjs.com → Package Settings → Publishing Access → Trusted Publishers: # Organization: structured-world, Repository: gitlab-mcp # Workflow: release-please.yml, Environment: release # Uses npm CLI (not yarn npm) — Yarn 4 doesn't support OIDC provenance. - name: Publish core to npm working-directory: packages/gitlab-mcp run: | VERSION="$(jq -r .version package.json)" if npm view "@structured-world/gitlab-mcp@${VERSION}" version >/dev/null 2>&1; then echo "Core ${VERSION} already published to npm; skipping." exit 0 fi npm publish --provenance --access public - name: Publish db package to npm working-directory: packages/gitlab-mcp-db run: | VERSION="$(jq -r .version package.json)" if npm view "@structured-world/gitlab-mcp-db@${VERSION}" version >/dev/null 2>&1; then echo "Db ${VERSION} already published to npm; skipping." exit 0 fi # Resolve any Yarn workspace: specifier to the real (lockstep) version. # Plain `npm publish` (used for OIDC provenance) does not understand the # workspace: protocol, so it would otherwise ship an invalid range. # Restore package.json afterwards so the resolved range does not leak into # later steps (the Docker build runs `yarn install --immutable` over the # same tree, and a workspace:^ -> ^x.y.z drift fails the immutable check). cp package.json package.json.orig jq --arg v "^${VERSION}" ' def resolve_ws: with_entries( if (.value | type == "string") and (.value | startswith("workspace:")) then .value = $v else . end); (if (.dependencies | type) == "object" then .dependencies |= resolve_ws else . end) | (if (.devDependencies | type) == "object" then .devDependencies |= resolve_ws else . end) | (if (.peerDependencies | type) == "object" then .peerDependencies |= resolve_ws else . end) ' package.json > package.json.tmp && mv package.json.tmp package.json npm publish --provenance --access public mv package.json.orig package.json # Build and upload both MCPB bundles (lightweight core + full with db) - name: Build MCPB bundles working-directory: packages/gitlab-mcp run: | VERSION="$(jq -r .version package.json)" ./scripts/build-mcpb.sh "$VERSION" MCPB_WITH_DB=true ./scripts/build-mcpb.sh "$VERSION" - name: Upload MCPB bundles to GitHub Release uses: softprops/action-gh-release@v3 with: tag_name: ${{ needs.release-please.outputs.release-tag }} files: | packages/gitlab-mcp/gitlab-mcp-[0-9]*.mcpb packages/gitlab-mcp/gitlab-mcp-db-*.mcpb env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload Codex marketplace to GitHub Release uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3 with: tag_name: ${{ needs.release-please.outputs.release-tag }} fail_on_unmatched_files: true files: | packages/gitlab-mcp/codex-release/gitlab-mcp-codex.zip packages/gitlab-mcp/codex-release/gitlab-mcp-codex.zip.sha256 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Publish Docker images - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - name: Log in to GitHub Container Registry uses: docker/login-action@v4 with: registry: ${{ env.GHCR_REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} # Per-package versions (monorepo versions are independent). - name: Read package versions id: versions run: | echo "core=$(jq -r .version packages/gitlab-mcp/package.json)" >> "$GITHUB_OUTPUT" echo "db=$(jq -r .version packages/gitlab-mcp-db/package.json)" >> "$GITHUB_OUTPUT" - name: Extract core Docker metadata id: meta uses: docker/metadata-action@v6 with: images: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }} tags: | type=raw,value=${{ steps.versions.outputs.core }} type=raw,value=latest type=semver,pattern={{major}}.{{minor}},value=${{ steps.versions.outputs.core }} type=semver,pattern={{major}},value=${{ steps.versions.outputs.core }} - name: Build and push core Docker image uses: docker/build-push-action@v7 with: context: . file: ./packages/gitlab-mcp/Dockerfile platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max build-args: | VERSION=${{ steps.versions.outputs.core }} - name: Extract db Docker metadata id: meta-db uses: docker/metadata-action@v6 with: images: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}-db tags: | type=raw,value=${{ steps.versions.outputs.db }} type=raw,value=latest type=semver,pattern={{major}}.{{minor}},value=${{ steps.versions.outputs.db }} type=semver,pattern={{major}},value=${{ steps.versions.outputs.db }} # Layered on the core image just pushed; reuses its cached layers. - name: Build and push db Docker image uses: docker/build-push-action@v7 with: context: . file: ./packages/gitlab-mcp-db/Dockerfile platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta-db.outputs.tags }} labels: ${{ steps.meta-db.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max build-args: | CORE_IMAGE=${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.versions.outputs.core }} # Publish to MCP Registry - name: Install cosign uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 with: cosign-release: v3.1.4 - name: Install mcp-publisher # Trust only the pinned upstream release workflow's signed checksum document. env: GH_TOKEN: ${{ github.token }} MCP_PUBLISHER_VERSION: v1.7.9 run: | set -euo pipefail destination="$PWD" download_dir="$(mktemp -d "$RUNNER_TEMP/mcp-publisher.XXXXXX")" trap 'rm -rf "$download_dir"' EXIT cd "$download_dir" asset="mcp-publisher_linux_amd64.tar.gz" checksums="registry_${MCP_PUBLISHER_VERSION#v}_checksums.txt" gh release download "$MCP_PUBLISHER_VERSION" \ --repo modelcontextprotocol/registry \ --pattern "$asset" \ --pattern "$checksums" \ --pattern "$checksums.sigstore.json" \ --dir . cosign verify-blob "$checksums" \ --bundle "$checksums.sigstore.json" \ --certificate-identity "https://github.com/modelcontextprotocol/registry/.github/workflows/release.yml@refs/tags/${MCP_PUBLISHER_VERSION}" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" awk -v asset="$asset" ' $2 == asset { entry = $0; count++ } END { if (count != 1) exit 1; print entry } ' "$checksums" | sha256sum -c - tar xzf "$asset" -C "$destination" mcp-publisher test -x "$destination/mcp-publisher" || { echo "Error: mcp-publisher not found after extraction"; exit 1; } # MCPB file is already on disk from "Build MCPB bundle" step above. # Note: .packages append is safe for reruns — server.json is checked out # fresh from the release tag each time, so no duplicate entries accumulate. - name: Add MCPB entry to server.json working-directory: packages/gitlab-mcp run: | VERSION="$(jq -r .version package.json)" MCPB_FILE="gitlab-mcp-${VERSION}.mcpb" MCPB_URL="https://github.com/${{ github.repository }}/releases/download/${{ needs.release-please.outputs.release-tag }}/${MCPB_FILE}" SHA256=$(openssl dgst -sha256 "${MCPB_FILE}" | awk '{print $NF}') echo "MCPB SHA256: ${SHA256}" jq --arg url "$MCPB_URL" --arg sha "$SHA256" \ '.packages += [{"registryType": "mcpb", "identifier": $url, "fileSha256": $sha, "transport": {"type": "stdio"}}]' \ server.json > server.tmp jq . server.tmp > /dev/null || { echo "Error: invalid JSON produced by jq"; exit 1; } mv server.tmp server.json echo "Final server.json:" cat server.json # mcp-publisher reads server.json from the working directory; the binary # was extracted to the workspace root. - name: Authenticate to MCP Registry (OIDC) working-directory: packages/gitlab-mcp run: '"$GITHUB_WORKSPACE/mcp-publisher" login github-oidc' - name: Publish to MCP Registry working-directory: packages/gitlab-mcp run: '"$GITHUB_WORKSPACE/mcp-publisher" publish' notify-plugin-marketplace: name: Refresh the shared plugin marketplace runs-on: ubuntu-latest needs: [release-please, post-release] if: needs.release-please.outputs.release-created == 'true' && needs.post-release.result == 'success' permissions: {} steps: - name: Generate marketplace dispatch token uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3 id: marketplace-token with: app-id: ${{ secrets.RELEASER_APP_ID }} private-key: ${{ secrets.RELEASER_APP_PRIVATE_KEY }} owner: ${{ github.repository_owner }} repositories: repo permission-contents: write - name: Request publication of the released plugin env: GH_TOKEN: ${{ steps.marketplace-token.outputs.token }} RELEASE_TAG: ${{ needs.release-please.outputs.release-tag }} run: | jq -n --arg tag "$RELEASE_TAG" '{event_type: "refresh-gitlab-mcp-plugin", client_payload: {release_tag: $tag}}' \ | gh api --method POST repos/structured-world/repo/dispatches --input - # Sync generated metadata into the open release PR. After its version bump, # regenerate package manifests, README counts and the registry description. # This pushes to the release PR branch while the workflow triggers on main, # so it does not create a publication loop. If release-please rebuilds that # branch on the next main push, this job reapplies the generated metadata. sync-release-pr: name: Sync generated metadata into release PR runs-on: ubuntu-latest needs: release-please if: needs.release-please.outputs.pr-number != '' && needs.release-please.outputs.release-created != 'true' permissions: contents: write steps: - name: Generate release token uses: actions/create-github-app-token@v3 id: app-token with: app-id: ${{ secrets.RELEASER_APP_ID }} private-key: ${{ secrets.RELEASER_APP_PRIVATE_KEY }} owner: ${{ github.repository_owner }} repositories: ${{ github.event.repository.name }} - name: Checkout release PR branch uses: actions/checkout@v7 with: ref: ${{ needs.release-please.outputs.pr-branch }} token: ${{ steps.app-token.outputs.token }} - name: Setup Node.js uses: actions/setup-node@v7 with: node-version: '24' - name: Enable Corepack run: corepack enable - name: Install Yarn run: corepack prepare yarn@4.12.0 --activate - name: Install dependencies run: yarn install --frozen-lockfile - name: Build all packages run: yarn build # release-please already bumped package.json version on the PR branch; # use it so README/server.json counts carry the pending release version. - name: Regenerate dynamic metadata run: ./scripts/prepare-release.sh "$(jq -r .version package.json)" working-directory: packages/gitlab-mcp # release-please does not manage the monorepo root package.json (the root # "." component was removed in #519 because it broke lockstep, #518), so its # version field would otherwise drift behind the packages. Mirror the locked # release version into it here, on the same PR branch, so the merged root # version always matches the published packages. - name: Sync root package.json version run: | VERSION="$(jq -r .version packages/gitlab-mcp/package.json)" # jq -r prints the literal "null" (exit 0) when .version is absent, which # set -e does not catch -- guard so a missing version fails loud instead # of committing "version": "null" into the root package.json. if [ -z "$VERSION" ] || [ "$VERSION" = "null" ]; then echo "Error: no version in packages/gitlab-mcp/package.json" >&2 exit 1 fi jq --arg v "$VERSION" '.version = $v' package.json > package.json.tmp mv package.json.tmp package.json - name: Commit regenerated metadata to the release PR working-directory: packages/gitlab-mcp run: | git config user.name "sw-release-bot[bot]" git config user.email "255865126+sw-release-bot[bot]@users.noreply.github.com" # Only the committed dynamic artifacts. .semantic-release-version is a # release-time-only file and must not be committed. The root # package.json (../../) carries the mirrored release version. # prepare-release.sh renders the README to all three shipping locations # (core npm page, GitHub repo landing, db npm page) -- stage every one so # the public counts/version never drift between them. Missing the root and # db READMEs here previously left them stuck a release behind the core page. git add package.json README.md server.json ../../README.md ../../package.json ../gitlab-mcp-db/README.md ../../.agents ../../plugins if git diff --cached --quiet; then echo "No metadata drift; nothing to commit." else git commit -m "chore: sync generated tool metadata" git push origin "HEAD:${{ needs.release-please.outputs.pr-branch }}" fi # Job 4: Summary summary: name: Release Summary runs-on: ubuntu-latest permissions: {} needs: [release-please, post-release] if: always() steps: - name: Summary run: | { echo "## Release Summary" echo "" if [[ "${{ needs.release-please.outputs.release-created }}" == "true" ]]; then echo "🚀 New version released: v${{ needs.release-please.outputs.release-version }}" if [[ "${{ needs.post-release.result }}" == "success" ]]; then echo "✅ All publish steps completed" echo "📦 npm: @structured-world/gitlab-mcp@${{ needs.release-please.outputs.release-version }}" echo "🐳 Docker: ghcr.io/${{ env.IMAGE_NAME }}:${{ needs.release-please.outputs.release-version }}" echo "📦 MCPB: gitlab-mcp-${{ needs.release-please.outputs.release-version }}.mcpb" echo "📦 Codex: gitlab-mcp-codex.zip and SHA-256 checksum" echo "🌐 MCP Registry: io.github.structured-world/gitlab-mcp" else echo "❌ Post-release publish failed" fi elif [[ -n "${{ needs.release-please.outputs.pr-number }}" ]]; then echo "📋 Release PR updated: #${{ needs.release-please.outputs.pr-number }}" else echo "ℹ️ No release-worthy changes detected" fi } >> "$GITHUB_STEP_SUMMARY"