Skip to content

Release

Release #98

Workflow file for this run

name: Release
on:
push:
tags:
- "v*.*.*"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
preflight:
# Fail within seconds if WINGET_GITHUB_TOKEN is expired or revoked,
# instead of 14 minutes into GoReleaser — after it has already created
# the draft release and opened the Homebrew/Scoop PRs — which strands
# the release in a half-published state (see the v0.18.2 run). The
# winget publisher is the only one on a long-lived PAT: the release-bot
# app token cannot open PRs on microsoft/winget-pkgs, where the app is
# not installed.
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check winget token validity and expiry
env:
WINGET_GITHUB_TOKEN: ${{ secrets.WINGET_GITHUB_TOKEN }}
run: |
set -euo pipefail
headers_file="$(mktemp)"
status="$(curl -sS -o /dev/null -D "$headers_file" -w '%{http_code}' \
-H "Authorization: Bearer ${WINGET_GITHUB_TOKEN}" \
https://api.github.com/repos/bomly-dev/winget-pkgs)"
if [ "$status" != "200" ]; then
echo "::error::WINGET_GITHUB_TOKEN cannot read bomly-dev/winget-pkgs (HTTP ${status}). The PAT has likely expired or been revoked — mint a new one (public_repo scope, it must be able to open PRs on microsoft/winget-pkgs) and update the secret: gh secret set WINGET_GITHUB_TOKEN -R bomly-dev/bomly-cli"
exit 1
fi
expiry="$(grep -i '^github-authentication-token-expiration:' "$headers_file" | cut -d' ' -f2- | tr -d '\r' || true)"
if [ -n "$expiry" ]; then
expiry_epoch="$(date -u -d "$expiry" +%s)"
days_left=$(( (expiry_epoch - $(date -u +%s)) / 86400 ))
echo "WINGET_GITHUB_TOKEN expires ${expiry} UTC (${days_left} days from now)."
if [ "$days_left" -lt 14 ]; then
echo "::warning::WINGET_GITHUB_TOKEN expires in ${days_left} days (${expiry} UTC). Rotate it soon: gh secret set WINGET_GITHUB_TOKEN -R bomly-dev/bomly-cli"
fi
else
echo "WINGET_GITHUB_TOKEN has no expiration date."
fi
validate:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
cache: true
cache-dependency-path: |
go.sum
- name: Run test suite
run: make test
- name: Run go vet
run: go vet ./...
release:
needs: [preflight, validate]
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: write
packages: write
id-token: write # required for cosign keyless signing
outputs:
hashes: ${{ steps.hash.outputs.hashes }}
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
cache: true
cache-dependency-path: |
go.sum
- name: Mint package-manager token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
id: package-token
with:
client-id: ${{ vars.RELEASE_BOT_CLIENT_ID }}
private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}
owner: bomly-dev
repositories: |
homebrew-tap
scoop-bucket
permission-contents: write
permission-pull-requests: write
- name: Mint release token
# Used as GoReleaser's GITHUB_TOKEN to create the draft release on this
# repo and upload its assets (archives, packages, checksums, signature).
# The release stays a draft here (see .goreleaser.yaml) — actual
# publishing happens in the `publish` job below, after the SLSA
# provenance file is also attached.
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
id: release-token
with:
client-id: ${{ vars.RELEASE_BOT_CLIENT_ID }}
private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}
owner: bomly-dev
repositories: bomly-cli
permission-contents: write
- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3
with:
distribution: goreleaser
version: v2.16.0
args: release --clean
env:
GITHUB_TOKEN: ${{ steps.release-token.outputs.token }}
TAP_GITHUB_TOKEN: ${{ steps.package-token.outputs.token }}
SCOOP_GITHUB_TOKEN: ${{ steps.package-token.outputs.token }}
WINGET_GITHUB_TOKEN: ${{ secrets.WINGET_GITHUB_TOKEN }}
- name: Generate artifact hashes for SLSA provenance
id: hash
run: echo "hashes=$(base64 -w0 < dist/SHA256SUMS)" >> "$GITHUB_OUTPUT"
provenance:
needs: release
permissions:
actions: read # detect the workflow that triggered this run
id-token: write # sign the provenance
contents: write # upload the provenance to the release
# The SLSA generator MUST be pinned by a full vX.Y.Z tag, not a commit SHA or
# shorter tag — slsa-verifier resolves the builder identity from this exact ref,
# and SHA-pinning breaks that resolution. See .github/pinact.yaml, which excludes
# this line from automated re-pinning.
#
# upload-assets is deliberately false: its underlying action
# (softprops/action-gh-release) finds a release by tag, and GitHub's
# "get release by tag" API does not return drafts. Against our still-draft
# release, it can't find one — so it creates a *second*, non-draft release
# for the same tag instead, which immediately and permanently marks that
# tag as immutable (even if the bad release is later deleted). The
# `publish` job uploads this provenance file itself, by release ID, to
# avoid that entirely.
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.1.0
with:
base64-subjects: ${{ needs.release.outputs.hashes }}
upload-assets: false
publish:
needs: [release, provenance]
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Mint release token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
id: release-token
with:
client-id: ${{ vars.RELEASE_BOT_CLIENT_ID }}
private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}
owner: bomly-dev
repositories: bomly-cli
permission-contents: write
- name: Download SLSA provenance
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ${{ needs.provenance.outputs.provenance-name }}
- name: Attach provenance and publish release
# Uploads the provenance file directly to the draft release by ID
# (not by tag — see the `provenance` job comment for why), then
# finalizes the draft into a published release now that every asset
# is attached. Must run last: immutable releases reject new assets
# once published.
#
# Uses the release-bot app token, not the default GITHUB_TOKEN: GitHub
# does not start new workflow runs from events triggered by the
# default token, so a release published with it would never fire the
# `release: published` trigger on "Release lifecycle sync"
# (notify-landing-yank.yml) — and the landing-page docs sync would
# silently never run. An app token is attributed to the app, so the
# publish event cascades.
env:
GH_TOKEN: ${{ steps.release-token.outputs.token }}
RELEASE_TAG: ${{ github.ref_name }}
PROVENANCE_FILE: ${{ needs.provenance.outputs.provenance-name }}
run: |
set -euo pipefail
release_id="$(gh api "repos/bomly-dev/bomly-cli/releases?per_page=100" | \
jq -r --arg tag "$RELEASE_TAG" '.[] | select(.tag_name == $tag and .draft == true) | .id' | head -n1)"
if [ -z "$release_id" ]; then
echo "::error::No draft release found for tag ${RELEASE_TAG}"
exit 1
fi
curl -fsSL \
-X POST \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${PROVENANCE_FILE}" \
"https://uploads.github.com/repos/bomly-dev/bomly-cli/releases/${release_id}/assets?name=${PROVENANCE_FILE}" \
> /dev/null
gh release edit "$RELEASE_TAG" --draft=false --repo bomly-dev/bomly-cli
publish-npm:
# Publishes the `bomly-mcp` npm wrapper, which is the package the official
# MCP Registry entry points at.
#
# Authentication is npm Trusted Publishing (OIDC) — there is deliberately
# no npm token here, and none should ever be added. npm's own token screen
# warns against long-lived automation tokens for exactly this case.
#
# Must run after `publish`: the wrapper's postinstall step downloads the
# release archive and its checksum file from this tag, and draft-release
# assets are not publicly downloadable — so publishing npm any earlier
# ships a package that cannot install.
#
# Split from `publish-mcp-registry` so a failure in either half can be
# re-run on its own. npm versions are immutable, so this job also treats
# "this exact version is already published" as success rather than as an
# error that would block the registry step behind it.
#
# Inert until Ahmed turns it on. Setup is one-time and documented in
# the short version:
# 1. publish bomly-mcp once by hand (trusted publishing cannot create a
# package that does not exist yet)
# 2. set the trusted publisher at
# npmjs.com/package/bomly-mcp/access -> this repo, workflow release.yml
# 3. gh variable set PUBLISH_MCP_REGISTRY -R bomly-dev/bomly-cli --body true
needs: publish
if: startsWith(github.ref, 'refs/tags/') && vars.PUBLISH_MCP_REGISTRY == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
# Named in npm's trusted-publisher config, which checks it as an OIDC
# claim: publishing fails unless the two match. Restrict this environment
# to the tag pattern `v*` so a push to any branch cannot reach npm.
#
# Deliberately NOT the existing `release` environment — that one is scoped
# to the `main` branch, and this job runs on a tag ref, so it would be
# rejected outright.
environment: npm-publish
permissions:
contents: read
id-token: write # mints the OIDC token npm exchanges for publish rights
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Ensure npm supports trusted publishing
# Trusted publishing needs npm >= 11.5.1, which the runner image has
# not always bundled. Upgrade only when the shipped npm is too old, so
# this becomes a no-op once the image catches up.
#
# `sudo` is required: npm's global prefix on the runner is /usr/local,
# which the `runner` user cannot write to. Without it this fails with
# EACCES on /usr/local/share/man — which is exactly how the v0.21.2
# release lost its npm and registry publish.
run: |
set -euo pipefail
required=11.5.1
current="$(npm --version)"
if printf '%s\n%s\n' "$required" "$current" | sort -V -C; then
echo "npm ${current} already supports trusted publishing."
else
echo "npm ${current} is older than ${required}; upgrading."
sudo npm install -g "npm@^${required}"
fi
npm --version
- name: Stamp the release version into npm/package.json
# The tag is authoritative. Auto Version keeps the committed files in
# sync too, but deriving here means a missed bump cannot publish a
# wrapper that points at a release that does not exist.
env:
RELEASE_TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
version="${RELEASE_TAG#v}"
jq --arg v "$version" '.version = $v' npm/package.json > npm/package.json.tmp
mv npm/package.json.tmp npm/package.json
- name: Publish bomly-mcp to npm
# No `env:` and no npmrc: npm reads the OIDC token from the Actions
# runtime itself. --provenance is passed explicitly even though trusted
# publishing is documented to imply it, because the implicit path has
# been reported to no-op.
env:
RELEASE_TAG: ${{ github.ref_name }}
working-directory: npm
run: |
set -euo pipefail
version="${RELEASE_TAG#v}"
# Idempotent: a re-run after a partial failure must not die here on
# an immutable version that is already live.
if curl -fsSL "https://registry.npmjs.org/bomly-mcp/${version}" > /dev/null 2>&1; then
echo "bomly-mcp@${version} is already published; skipping npm publish."
exit 0
fi
npm publish --provenance --access public
publish-mcp-registry:
# Publishes the server entry in the official MCP Registry
# (https://registry.modelcontextprotocol.io).
#
# Registry auth is GitHub OIDC, so it needs no secret of its own; the
# `io.github.bomly-dev/*` namespace is granted from this repo's identity.
#
# Kept separate from publish-npm so either half can be re-run alone: npm
# versions are immutable, so a combined job that failed here could not be
# retried without tripping over the already-published version. (It was also
# the boundary that kept an npm token away from the downloaded
# mcp-publisher binary; with trusted publishing there is no token left to
# isolate, but the retry argument stands on its own.)
needs: publish-npm
if: startsWith(github.ref, 'refs/tags/') && vars.PUBLISH_MCP_REGISTRY == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
id-token: write # required for mcp-publisher's OIDC login
env:
# Pinned deliberately. `releases/latest` would download and execute a
# moving target inside a job that holds an OIDC identity able to publish
# under our registry namespace. Bump this together with the digest and
# the signer identity below, after reading the release diff.
MCP_PUBLISHER_VERSION: v1.8.0
MCP_PUBLISHER_SHA256: 1370446bbe74d562608e8005a6ccce02d146a661fbd78674e11cc70b9618d6cf
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Stamp the release version into server.json
env:
RELEASE_TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
version="${RELEASE_TAG#v}"
jq --arg v "$version" '.version = $v | .packages[0].version = $v' server.json > server.json.tmp
mv server.json.tmp server.json
- name: Wait for the npm package to be resolvable
# The registry verifies package ownership by fetching the package, so
# publishing the server entry before npm's CDN has the new version
# fails validation.
env:
RELEASE_TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
version="${RELEASE_TAG#v}"
for attempt in $(seq 1 30); do
if curl -fsSL "https://registry.npmjs.org/bomly-mcp/${version}" > /dev/null 2>&1; then
echo "bomly-mcp@${version} is resolvable."
exit 0
fi
echo "Attempt ${attempt}: bomly-mcp@${version} not visible yet, waiting."
sleep 10
done
echo "::error::bomly-mcp@${version} did not become resolvable on npm within 5 minutes"
exit 1
- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Download and verify mcp-publisher
# Two independent checks before anything is extracted or executed:
# the pinned SHA-256, and the Sigstore bundle proving the archive was
# built by the registry's own tagged release workflow.
run: |
set -euo pipefail
base="https://github.com/modelcontextprotocol/registry/releases/download/${MCP_PUBLISHER_VERSION}"
archive="mcp-publisher_linux_amd64.tar.gz"
curl -fsSL -o "${archive}" "${base}/${archive}"
curl -fsSL -o "${archive}.sigstore.json" "${base}/${archive}.sigstore.json"
echo "${MCP_PUBLISHER_SHA256} ${archive}" | sha256sum --check --strict
cosign verify-blob "${archive}" \
--bundle "${archive}.sigstore.json" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
--certificate-identity "https://github.com/modelcontextprotocol/registry/.github/workflows/release.yml@refs/tags/${MCP_PUBLISHER_VERSION}"
tar xzf "${archive}" mcp-publisher
- name: Publish the server entry to the MCP Registry
run: |
set -euo pipefail
./mcp-publisher login github-oidc
./mcp-publisher publish