9427dd075d
Three fixes from the v0.10.7 release incident (2026-08-18): 1. build (and smoke/soak) required 'not failed' instead of explicit success. failure() does not cover a needed job that TIMED OUT (conclusion 'cancelled'), so lint hitting its 15-min timeout cascaded test into 'skipped' and the pipeline published with the whole test matrix and asan-soak silently skipped. build now requires lint success plus either test success or the sanctioned skip_tests input; smoke/soak require build success explicitly. 2. The tag is inputs.version verbatim: dispatching a bare '0.10.7' published a release the installers can never resolve (they fetch releases/download/v<version>/...), and under immutable releases the mis-named tag cannot be retagged or its name reused. A preflight job now refuses any non-v-prefixed version before anything runs. 3. Lint's 15-min timeout was one slow-runner day away from cancelling a normally-5-min job; raised to 30 so only a genuine hang can hit it. Release-path only (workflow_dispatch); adds one ~5s preflight job; no PR-CI gating, cost, or trigger changes. Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
644 lines
30 KiB
YAML
644 lines
30 KiB
YAML
# Release pipeline: lint → test → build → smoke/soak → draft → verify → publish
|
||
#
|
||
# Security (security-static + codeql-gate) starts immediately and runs in
|
||
# parallel with lint/test/build/smoke/soak. It only blocks the final verify
|
||
# step — everything else proceeds independently.
|
||
name: Release
|
||
|
||
on:
|
||
workflow_dispatch:
|
||
inputs:
|
||
version:
|
||
description: "Release version (e.g. v0.8.0)"
|
||
required: true
|
||
type: string
|
||
release_notes:
|
||
description: "Release notes (optional — auto-generated from commits if empty)"
|
||
required: false
|
||
type: string
|
||
replace:
|
||
description: "Replace existing release if it exists"
|
||
required: false
|
||
type: boolean
|
||
default: false
|
||
soak_level:
|
||
description: 'Soak: full (quick+asan) or quick (10min)'
|
||
type: choice
|
||
options: ['full', 'quick']
|
||
default: 'quick'
|
||
skip_perf:
|
||
description: "Skip performance tests (use when no pipeline logic changed)"
|
||
required: false
|
||
type: boolean
|
||
default: false
|
||
skip_tests:
|
||
description: "Skip the test phase entirely (re-release of an already test-green tree; build/smoke/soak/verify still run)"
|
||
required: false
|
||
type: boolean
|
||
default: false
|
||
|
||
permissions:
|
||
contents: read
|
||
|
||
jobs:
|
||
# ── Security (starts immediately, blocks only verify) ───────────
|
||
security:
|
||
uses: ./.github/workflows/_security.yml
|
||
secrets: inherit
|
||
|
||
# ── 0. Preflight: refuse malformed dispatch inputs ──────────────
|
||
# The tag is inputs.version VERBATIM. A bare "0.10.7" publishes a release the
|
||
# installers can never resolve (they fetch releases/download/v<version>/...),
|
||
# and with immutable releases the mis-named tag cannot be retagged, deleted,
|
||
# or its name reused — the name is burned permanently (2026-08-18 incident).
|
||
preflight:
|
||
runs-on: ubuntu-latest
|
||
timeout-minutes: 5
|
||
steps:
|
||
- name: Enforce v-prefixed semver version input
|
||
env:
|
||
VERSION: ${{ inputs.version }}
|
||
run: |
|
||
if [[ "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.]+)?$ ]]; then
|
||
echo "version OK: $VERSION"
|
||
else
|
||
echo "::error::version must be v-prefixed semver (vX.Y.Z), got '$VERSION'"
|
||
exit 1
|
||
fi
|
||
|
||
# ── 1. Lint (cppcheck + clang-format) ───────────────────────────
|
||
lint:
|
||
needs: [preflight]
|
||
uses: ./.github/workflows/_lint.yml
|
||
|
||
# ── 2. Tests (all platforms, full suite for release) ────────────
|
||
# skip_tests exists for RE-releases of a tree whose tests are already green:
|
||
# when only packaging/gating changed and the previous run proved the suites,
|
||
# re-running ~2h of tests adds no information. It skips ONLY this phase —
|
||
# build, smoke, soak and verify always run against the fresh artifacts.
|
||
test:
|
||
if: ${{ !inputs.skip_tests }}
|
||
needs: [lint]
|
||
uses: ./.github/workflows/_test.yml
|
||
with:
|
||
skip_perf: ${{ inputs.skip_perf }}
|
||
broad_platforms: true
|
||
shard_suites: true
|
||
|
||
# ── 3. Build all platforms ──────────────────────────────────────
|
||
# `test` may be skipped ONLY by the skip_tests input. A skipped `test` is
|
||
# also what a cancelled/timed-out lint produces (needs-cascade), and the
|
||
# bare !cancelled() && !failure() form cannot tell those apart: failure()
|
||
# does not cover a needed job that was CANCELLED, so a lint timeout let the
|
||
# whole pipeline publish with the test matrix silently skipped
|
||
# (v0.10.7 incident, 2026-08-18). Require the explicit results.
|
||
build:
|
||
if: >-
|
||
${{ !cancelled()
|
||
&& needs.lint.result == 'success'
|
||
&& (needs.test.result == 'success'
|
||
|| (inputs.skip_tests && needs.test.result == 'skipped')) }}
|
||
needs: [lint, test]
|
||
permissions:
|
||
contents: read
|
||
id-token: write
|
||
attestations: write
|
||
uses: ./.github/workflows/_build.yml
|
||
with:
|
||
version: ${{ inputs.version }}
|
||
attest: true
|
||
# Release builds have no scan bypass: both candidates in all eight
|
||
# immutable target tuples must complete before smoke or soak starts.
|
||
scan_candidates: true
|
||
secrets: inherit
|
||
|
||
# ── 4. Smoke test every binary ──────────────────────────────────
|
||
# !cancelled() && !failure(): GitHub propagates "skipped" TRANSITIVELY down the
|
||
# needs graph, so with skip_tests=true a skipped `test` skipped smoke and soak
|
||
# too — even though `build` overrode the same condition and succeeded. Nothing
|
||
# failed and nothing said so; the pipeline simply carried on toward publishing
|
||
# artifacts that had never been smoke-tested or soaked. Every job downstream of
|
||
# an optional phase needs this override, or the phase being optional silently
|
||
# makes the phases after it optional as well.
|
||
smoke:
|
||
if: ${{ !cancelled() && needs.build.result == 'success' }}
|
||
needs: [build]
|
||
uses: ./.github/workflows/_smoke.yml
|
||
with:
|
||
broad_platforms: true
|
||
|
||
# ── 5. Soak tests ──────────────────────────────────────────────
|
||
soak:
|
||
if: ${{ !cancelled() && needs.build.result == 'success' }}
|
||
needs: [build]
|
||
uses: ./.github/workflows/_soak.yml
|
||
with:
|
||
duration_minutes: 10
|
||
run_asan: ${{ inputs.soak_level == 'full' }}
|
||
version: ${{ inputs.version }}
|
||
use_release_artifacts: true
|
||
|
||
# ── 6. Create DRAFT release ────────────────────────────────────
|
||
# Requires smoke to have actually SUCCEEDED, not merely "not failed". The bare
|
||
# !cancelled() && !failure() form is fail-OPEN: a skipped smoke is neither
|
||
# cancelled nor failed, so a draft was one condition away from being cut from
|
||
# binaries nobody had run. Selected release bytes must also finish soak; a
|
||
# skipped soak is not an acceptable production-release state.
|
||
release-draft:
|
||
needs: [smoke, soak]
|
||
if: >-
|
||
${{ !cancelled() && !failure()
|
||
&& needs.smoke.result == 'success'
|
||
&& needs.soak.result == 'success' }}
|
||
runs-on: ubuntu-latest
|
||
permissions:
|
||
contents: write
|
||
id-token: write
|
||
attestations: write
|
||
steps:
|
||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||
|
||
- name: Download only selected release containers
|
||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||
with:
|
||
pattern: binaries-*
|
||
merge-multiple: true
|
||
|
||
# Candidate artifacts are deliberately excluded from the container
|
||
# download above. Preserve only the three reviewable manifests that bind
|
||
# candidate bytes, VT results, and the tuple-local decision.
|
||
- name: Download candidate selection evidence separately
|
||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||
with:
|
||
name: release-selection-evidence
|
||
path: selection-evidence
|
||
|
||
- name: Verify and stage the exact evidence set
|
||
run: |
|
||
test "$(find selection-evidence -maxdepth 1 -type f | wc -l | tr -d ' ')" = 3
|
||
for f in release-candidates.tsv virustotal-candidate-results.tsv release-selection.tsv; do
|
||
test -f "selection-evidence/$f"
|
||
cp "selection-evidence/$f" "$f"
|
||
done
|
||
|
||
- name: List selected artifacts and decision evidence
|
||
run: ls -la *.tar.gz *.zip *.mcpb *.tsv
|
||
|
||
- name: Generate checksums
|
||
run: |
|
||
sha256sum \
|
||
*.tar.gz *.zip *.mcpb \
|
||
release-candidates.tsv \
|
||
virustotal-candidate-results.tsv \
|
||
release-selection.tsv \
|
||
> checksums.txt
|
||
|
||
# The legacy ui-* aliases are byte-identical copies published after verify
|
||
# (scripts/ci/publish-legacy-aliases.sh), so they were absent from
|
||
# checksums.txt and 0.9.x updaters refused to install them (#1134). Their
|
||
# digests are added here, BEFORE attestation, so the attested artifact
|
||
# covers both names.
|
||
- name: Cover legacy ui-* aliases in checksums
|
||
run: scripts/ci/append-legacy-alias-checksums.sh checksums.txt
|
||
|
||
- name: Attest checksum provenance
|
||
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
|
||
with:
|
||
subject-path: checksums.txt
|
||
|
||
# publish-mcp-registry reads the *.mcpb sha256 lines from here — a
|
||
# same-run workflow artifact, not a draft-release download, so that
|
||
# job keeps contents: read and no gh dependency.
|
||
- name: Preserve checksums for the registry job
|
||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||
with:
|
||
name: release-checksums
|
||
path: checksums.txt
|
||
|
||
# SBOM content lives in the canonical scripts/ci entry, not inline YAML
|
||
# (venue-parity contract): vendored versions are reviewable there.
|
||
- name: Generate SBOM
|
||
run: python3 scripts/ci/generate-sbom.py "${{ inputs.version }}"
|
||
|
||
- name: Attest SBOM
|
||
uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0
|
||
with:
|
||
subject-path: '*.tar.gz'
|
||
sbom-path: 'sbom.json'
|
||
|
||
- name: Install cosign
|
||
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||
|
||
- name: Sign artifacts
|
||
run: |
|
||
for f in \
|
||
*.tar.gz *.zip *.mcpb \
|
||
release-candidates.tsv \
|
||
virustotal-candidate-results.tsv \
|
||
release-selection.tsv \
|
||
checksums.txt; do
|
||
cosign sign-blob --yes --bundle "${f}.bundle" "$f"
|
||
done
|
||
|
||
- name: Delete existing release
|
||
if: ${{ inputs.replace }}
|
||
env:
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
VERSION: ${{ inputs.version }}
|
||
run: gh release delete "$VERSION" --yes --cleanup-tag || true
|
||
|
||
- name: Create tag
|
||
env:
|
||
VERSION: ${{ inputs.version }}
|
||
# Tag the DISPATCHED sha, not the checkout HEAD: the artifacts were
|
||
# built from github.sha, and a commit pushed to main mid-run must not
|
||
# move the release tag. (A floating HEAD also broke the 0.8.0 release:
|
||
# HEAD had become a commit touching .github/workflows/, and the App
|
||
# token may not create refs pointing at workflow-modifying commits.)
|
||
run: |
|
||
git tag -f "$VERSION" "$GITHUB_SHA"
|
||
git push origin "$VERSION" --force
|
||
|
||
- uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v2
|
||
with:
|
||
tag_name: ${{ inputs.version }}
|
||
draft: true
|
||
# Semver prerelease (v0.9.1-rc.1): never the "latest" release on GitHub.
|
||
prerelease: ${{ contains(inputs.version, '-') }}
|
||
files: |
|
||
*.tar.gz
|
||
*.zip
|
||
*.mcpb
|
||
release-candidates.tsv
|
||
virustotal-candidate-results.tsv
|
||
release-selection.tsv
|
||
checksums.txt
|
||
sbom.json
|
||
*.bundle
|
||
body: ${{ inputs.release_notes || '' }}
|
||
generate_release_notes: ${{ inputs.release_notes == '' }}
|
||
|
||
# ── 7. Verify + Publish (requires security gate) ───────────────
|
||
verify:
|
||
needs: [release-draft, security]
|
||
# Explicit condition: a deliberately skipped test ancestor must not skip
|
||
# the publish chain; the draft itself proves smoke and soak both succeeded.
|
||
if: ${{ !cancelled() && !failure() && needs.security.result == 'success' && needs.release-draft.result == 'success' }}
|
||
runs-on: ubuntu-latest
|
||
permissions:
|
||
contents: write
|
||
steps:
|
||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||
with:
|
||
persist-credentials: false
|
||
|
||
- name: Download and extract release binaries
|
||
env:
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
VERSION: ${{ inputs.version }}
|
||
run: |
|
||
ARCHIVE_DIR="$RUNNER_TEMP/release-archives"
|
||
mkdir -p "$ARCHIVE_DIR" binaries
|
||
gh release download "$VERSION" --dir "$ARCHIVE_DIR" --repo "$GITHUB_REPOSITORY" --pattern '*.tar.gz' --pattern '*.zip' --pattern '*.mcpb'
|
||
gh release download "$VERSION" --repo "$GITHUB_REPOSITORY" --pattern 'release-selection.tsv' --output "$RUNNER_TEMP/release-selection.tsv"
|
||
python3 scripts/ci/verify-release-selection.py \
|
||
--selection "$RUNNER_TEMP/release-selection.tsv" \
|
||
--require-policy virustotal-v2 \
|
||
--archive-dir "$ARCHIVE_DIR"
|
||
# 14 = 8 archives + 6 MCPB bundles; runtime 42 = 8×3 archive
|
||
# sidecars + 6×3 bundle members (manifest.json, LICENSE, notices).
|
||
# The bundle binaries dedupe to the archive objects byte-for-byte.
|
||
scripts/ci/extract-release-archives.sh "$ARCHIVE_DIR" binaries \
|
||
--expect-archives=14 \
|
||
--expect-binaries=14 \
|
||
--expect-runtime-files=42
|
||
|
||
- name: Download the existing candidate VirusTotal evidence
|
||
env:
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
VERSION: ${{ inputs.version }}
|
||
run: |
|
||
gh release download "$VERSION" --repo "$GITHUB_REPOSITORY" --pattern 'release-candidates.tsv' --output "$RUNNER_TEMP/release-candidates.tsv"
|
||
gh release download "$VERSION" --repo "$GITHUB_REPOSITORY" --pattern 'virustotal-candidate-results.tsv' --output "$RUNNER_TEMP/virustotal-candidate-results.tsv"
|
||
|
||
- name: Security audits on all unique extracted release objects
|
||
run: |
|
||
# Audit every distinct extracted member/UI-asset byte sequence. The
|
||
# associations manifest retains each source archive's SHA-256.
|
||
# security-strings.sh detects file type and applies binary-only
|
||
# rules (URL allowlist, dangerous-command detection) only to real
|
||
# binaries; for shell scripts it still runs credential and base64
|
||
# pattern audits.
|
||
for f in binaries/objects/*; do
|
||
[ -f "$f" ] || continue
|
||
echo "--- Auditing: $(basename "$f") ---"
|
||
scripts/security-strings.sh "$f"
|
||
done
|
||
|
||
# Full-surface VirusTotal pass over the SHIPPED containers.
|
||
#
|
||
# The candidate scan upstream covers executables only. Everything else we
|
||
# actually ship — install.sh, install.ps1, LICENSE, THIRD_PARTY_NOTICES.md,
|
||
# the MCPB manifest.json and the unpacked UI assets, 42 runtime files
|
||
# across 14 containers — is extracted here and gets scanned here. Dropping
|
||
# it would have narrowed the promise made in README/SECURITY.md, and
|
||
# install.sh and install.ps1 are the highest-consequence non-executable
|
||
# bytes we publish: users pipe them straight into a shell.
|
||
#
|
||
# The selected executables are NOT re-submitted. They were scanned as
|
||
# candidates, and verify-release-selection.py has just proven every
|
||
# published container carries exactly those bytes — identity is settled by
|
||
# hash, so a second scan adds no assurance.
|
||
#
|
||
# It also is not free, contrary to what this comment used to claim.
|
||
# Measured on v0.10.5: all eight re-submissions produced a NEW analysis
|
||
# (same file-id, timestamp 47 minutes later), and Microsoft's ML engine
|
||
# answered differently for two of them within that hour — in opposite
|
||
# directions. The release notes then cited one scan while the links showed
|
||
# the other. See scripts/ci/exclude-rescanned-selected-objects.sh.
|
||
- name: Withhold already-scanned selected executables
|
||
run: |
|
||
bash scripts/ci/exclude-rescanned-selected-objects.sh \
|
||
binaries/objects "$RUNNER_TEMP/release-selection.tsv" \
|
||
binaries/virustotal-withheld.tsv
|
||
|
||
- name: VirusTotal scan of every extracted release object
|
||
uses: crazy-max/ghaction-virustotal@936d8c5c00afe97d3d9a1af26d017cfdf26800a2 # v5.0.0
|
||
id: virustotal
|
||
with:
|
||
vt_api_key: ${{ secrets.VIRUS_TOTAL_SCANNER_API_KEY }}
|
||
files: binaries/objects/*
|
||
request_rate: 4
|
||
|
||
# Same policy as the candidate gate: a single Microsoft `!ml` is tolerated
|
||
# and disclosed, anything else blocks. Engine count is evidence, not a gate.
|
||
- name: Wait for VirusTotal results
|
||
env:
|
||
VT_API_KEY: ${{ secrets.VIRUS_TOTAL_SCANNER_API_KEY }}
|
||
VT_ANALYSIS: ${{ steps.virustotal.outputs.analysis }}
|
||
VT_EXPECTED_SCAN_SET: binaries/scan-set.tsv
|
||
VT_ASSOCIATIONS: binaries/associations.tsv
|
||
VT_RESULTS_PATH: binaries/vt-results.tsv
|
||
VT_WITHHELD: binaries/virustotal-withheld.tsv
|
||
MIN_ENGINES: 50
|
||
run: scripts/ci/check-virustotal.sh
|
||
|
||
- name: Preserve extracted-object VirusTotal evidence
|
||
if: ${{ always() }}
|
||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||
with:
|
||
name: virustotal-evidence-release-${{ github.run_id }}
|
||
path: |
|
||
binaries/associations.tsv
|
||
binaries/scan-set.tsv
|
||
binaries/vt-results.tsv
|
||
binaries/virustotal-withheld.tsv
|
||
if-no-files-found: warn
|
||
|
||
- name: Publish durable public VirusTotal evidence
|
||
env:
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
VERSION: ${{ inputs.version }}
|
||
VT_EXPECTED_SCAN_SET: binaries/scan-set.tsv
|
||
VT_ASSOCIATIONS: binaries/associations.tsv
|
||
VT_RESULTS_PATH: binaries/vt-results.tsv
|
||
run: bash scripts/ci/publish-vt-evidence.sh
|
||
|
||
# Candidate bytes were scanned before smoke/soak and the verifier above
|
||
# proved every packaged executable has the selected candidate SHA-256.
|
||
# Re-submitting the same executable bytes here would add wait time but no
|
||
# new coverage, so publish the existing per-candidate verdicts instead.
|
||
- name: Append candidate VirusTotal results to release notes
|
||
env:
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
VERSION: ${{ inputs.version }}
|
||
VT_CANDIDATES: ${{ runner.temp }}/release-candidates.tsv
|
||
VT_RESULTS_PATH: ${{ runner.temp }}/virustotal-candidate-results.tsv
|
||
RELEASE_SELECTION: ${{ runner.temp }}/release-selection.tsv
|
||
run: bash scripts/ci/append-vt-notes.sh
|
||
|
||
# ── 8. Publish package wrappers (npm + PyPI) ──────────────────
|
||
# Wrappers in pkg/npm and pkg/pypi download the released binary at
|
||
# install time, so they only need a version bump (already in the repo
|
||
# at this point) — no per-release sha256 substitution.
|
||
#
|
||
# Runs against the still-DRAFT GitHub release. If publish fails, the
|
||
# release stays in draft so we can re-run without a half-shipped state.
|
||
publish-registries:
|
||
needs: [verify]
|
||
if: ${{ !cancelled() && !failure() && needs.verify.result == 'success' }}
|
||
runs-on: ubuntu-latest
|
||
permissions:
|
||
contents: read
|
||
id-token: write # for npm provenance
|
||
steps:
|
||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||
|
||
# The packaged versions MUST match the dispatched release version —
|
||
# the 0.8.0 release failed here because pkg/npm still carried the
|
||
# previous release's hand-pinned version and npm refuses to publish
|
||
# over an existing version. Inject the dispatch input so a forgotten
|
||
# manual bump can never fail the pipeline again. (server.json needs
|
||
# no injection: publish-mcp-registry syncs it from pkg/npm.)
|
||
- name: Sync packaging versions to the release version
|
||
env:
|
||
RELEASE_VERSION: ${{ inputs.version }}
|
||
run: |
|
||
V="${RELEASE_VERSION#v}"
|
||
jq --arg v "$V" '.version = $v' pkg/npm/package.json > pkg/npm/package.tmp
|
||
mv pkg/npm/package.tmp pkg/npm/package.json
|
||
sed -i "s/^version = \".*\"/version = \"$V\"/" pkg/pypi/pyproject.toml
|
||
grep -q "\"version\": \"$V\"" pkg/npm/package.json
|
||
grep -q "^version = \"$V\"" pkg/pypi/pyproject.toml
|
||
echo "packaging synced to $V"
|
||
|
||
# ── npm ──────────────────────────────────────────────────
|
||
- name: Setup Node
|
||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||
with:
|
||
node-version: '22'
|
||
registry-url: 'https://registry.npmjs.org'
|
||
|
||
- name: Publish to npm
|
||
working-directory: pkg/npm
|
||
env:
|
||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||
RELEASE_VERSION: ${{ inputs.version }}
|
||
# A prerelease must never become npm `latest` — plain `npm install`
|
||
# would serve the RC to everyone. Publish prereleases under the
|
||
# `next` dist-tag; testers opt in via `npm i codebase-memory-mcp@next`.
|
||
#
|
||
# Idempotency: npm versions are immutable, so a re-run of this job
|
||
# after a partial success (v0.10.1: npm published, then the twine
|
||
# step died) can never republish — it 403s on its own earlier
|
||
# success and the release wedges in draft forever. If the exact
|
||
# version already exists on the registry, publishing is DONE; skip.
|
||
run: |
|
||
V="${RELEASE_VERSION#v}"
|
||
if npm view "codebase-memory-mcp@$V" version >/dev/null 2>&1; then
|
||
echo "npm already has codebase-memory-mcp@$V — skipping publish (idempotent re-run)"
|
||
elif [[ "$RELEASE_VERSION" == *-* ]]; then
|
||
npm publish --access public --provenance --tag next
|
||
else
|
||
npm publish --access public --provenance
|
||
fi
|
||
|
||
# ── PyPI ─────────────────────────────────────────────────
|
||
- name: Setup Python
|
||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||
with:
|
||
python-version: '3.12'
|
||
|
||
- name: Build PyPI distribution
|
||
working-directory: pkg/pypi
|
||
run: |
|
||
# Every byte of the publish toolchain is bound by hash: version pins
|
||
# alone still let a compromised index serve different content, and a
|
||
# bare `pip install` resolves the whole transitive graph at run time
|
||
# (OSSF Scorecard pinned-dependencies). requirements-publish.txt is
|
||
# generated on a linux/amd64 python:3.12 image — see its header for
|
||
# the exact regeneration command.
|
||
#
|
||
# twine must stay >= 7 and hatchling is pinned in pyproject.toml:
|
||
# `python -m build` resolves its backend fresh in an isolated env, and
|
||
# a hatchling emitting Metadata-Version 2.5 against twine 6.2.0 is
|
||
# what broke the v0.10.1 publish.
|
||
python -m pip install --require-hashes -r requirements-publish.txt
|
||
python -m build
|
||
twine check dist/*
|
||
|
||
- name: Publish to PyPI
|
||
working-directory: pkg/pypi
|
||
env:
|
||
TWINE_USERNAME: __token__
|
||
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
|
||
# --skip-existing: PyPI files are immutable like npm versions; a
|
||
# re-run after partial success must treat already-uploaded files
|
||
# as done, not as a fatal collision.
|
||
run: twine upload --non-interactive --skip-existing dist/*
|
||
|
||
# ── 8b. Publish server.json to the official MCP Registry ──────
|
||
# Runs after npm + PyPI so the registry can verify package ownership
|
||
# (npm: `mcpName` field in package.json; PyPI: `mcp-name:` marker in
|
||
# the package README). Authenticates with GitHub Actions OIDC — no
|
||
# token, no interactive device flow. server.json's version is synced
|
||
# from the just-published npm package so it always matches the release.
|
||
#
|
||
# Intentionally does NOT gate publish-final: the binary release is the
|
||
# product, the registry entry is metadata. A registry-preview outage
|
||
# must never block shipping. Re-run this job alone to retry — it does
|
||
# not touch npm/PyPI, so retries are safe.
|
||
#
|
||
# It DOES depend on publish-final, which is the job that un-drafts the
|
||
# release. Both used to need only publish-registries, so they raced: the
|
||
# registry validates every package URL it is given by fetching it, and a
|
||
# DRAFT release's assets are not publicly readable. In v0.10.3 the registry
|
||
# ran 5s before the un-draft and was told its own .mcpb URL was a 404:
|
||
# MCPB package '...codebase-memory-mcp-darwin-amd64.mcpb' is not publicly
|
||
# accessible (status: 404)
|
||
# The same URL served 200 once the release went public, and the job passed on
|
||
# a plain re-run. Depending on publish-final keeps the documented intent
|
||
# exactly — the registry still cannot block the release, it just runs after
|
||
# the assets it validates actually exist to the outside world.
|
||
publish-mcp-registry:
|
||
needs: [publish-registries, publish-final]
|
||
# Skipped for prereleases: the MCP Registry has no channel concept, so an
|
||
# RC would present itself as the current version to every registry consumer.
|
||
if: ${{ !cancelled() && !failure() && needs.publish-registries.result == 'success' && needs.publish-final.result == 'success' && !contains(inputs.version, '-') }}
|
||
runs-on: ubuntu-latest
|
||
permissions:
|
||
id-token: write # GitHub OIDC auth to the MCP Registry
|
||
contents: read
|
||
steps:
|
||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||
|
||
- name: Sync server.json version to the released package
|
||
env:
|
||
# The dispatch input is the authoritative release version —
|
||
# pkg/npm/package.json can lag behind it in the dispatched commit.
|
||
RELEASE_VERSION: ${{ inputs.version }}
|
||
run: |
|
||
VERSION="${RELEASE_VERSION#v}"
|
||
jq --arg v "$VERSION" '.version = $v | (.packages[].version) = $v' \
|
||
server.json > server.tmp && mv server.tmp server.json
|
||
echo "server.json pinned to $VERSION"
|
||
|
||
# The MCPB entries need each bundle's sha256; checksums.txt comes from
|
||
# the release-draft job as a same-run artifact (not a draft-release
|
||
# download — that would need contents beyond read plus gh).
|
||
- name: Fetch release checksums
|
||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||
with:
|
||
name: release-checksums
|
||
|
||
- name: Append MCPB package entries to server.json
|
||
env:
|
||
RELEASE_VERSION: ${{ inputs.version }}
|
||
run: |
|
||
scripts/ci/gen-mcpb-registry-entries.sh server.json checksums.txt "$RELEASE_VERSION"
|
||
cat server.json
|
||
|
||
# Pinned by version AND content hash: `latest` would let an upstream
|
||
# release (or a compromised one) change what runs in the job that holds
|
||
# the registry publish credential, and piping curl straight into tar
|
||
# executes the payload before anything can verify it.
|
||
#
|
||
# One pin covers every platform. Upstream ships a checksums file for the
|
||
# whole release, so we pin that file's own SHA-256 and verify whichever
|
||
# asset this runner needs against it. Moving the job to a different OS or
|
||
# architecture therefore needs no change here, and an unlisted asset
|
||
# fails closed rather than installing unverified.
|
||
- name: Install mcp-publisher
|
||
env:
|
||
MCP_PUBLISHER_VERSION: v1.8.1
|
||
MCP_PUBLISHER_CHECKSUMS_SHA256: f7937a7908096f63147658e13f0f63a393a1c9fc722a2d10017593940d36e59e
|
||
run: |
|
||
base="https://github.com/modelcontextprotocol/registry/releases/download/$MCP_PUBLISHER_VERSION"
|
||
sums="registry_${MCP_PUBLISHER_VERSION#v}_checksums.txt"
|
||
asset="mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz"
|
||
|
||
curl -fsSLo "$sums" "$base/$sums"
|
||
echo "$MCP_PUBLISHER_CHECKSUMS_SHA256 $sums" | sha256sum -c -
|
||
|
||
curl -fsSLo "$asset" "$base/$asset"
|
||
# Exact filename match: a substring match would also accept the
|
||
# .sbom.json / .sigstore.json lines for the same asset.
|
||
awk -v a="$asset" '$2 == a' "$sums" > expected.sha256
|
||
test -s expected.sha256 || { echo "no checksum listed for $asset" >&2; exit 1; }
|
||
sha256sum -c expected.sha256
|
||
|
||
tar xzf "$asset" mcp-publisher
|
||
|
||
- name: Authenticate to MCP Registry (GitHub OIDC)
|
||
run: ./mcp-publisher login github-oidc
|
||
|
||
- name: Publish server.json to MCP Registry
|
||
run: ./mcp-publisher publish
|
||
|
||
# ── 9. Atomic un-draft (only after npm + PyPI succeed) ────────
|
||
# The GitHub release stays in DRAFT until both registries publish.
|
||
# If anything upstream fails, the draft can be deleted and the run
|
||
# re-tried with replace=true — no half-shipped state visible to users.
|
||
publish-final:
|
||
needs: [verify, publish-registries]
|
||
if: ${{ !cancelled() && !failure() && needs.verify.result == 'success' && needs.publish-registries.result == 'success' }}
|
||
runs-on: ubuntu-latest
|
||
permissions:
|
||
contents: write
|
||
steps:
|
||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||
|
||
# Legacy ui-* aliases so 0.9.x updaters stop 404ing (#1538). Runs after
|
||
# verify: aliases contain the same selected executable bytes already
|
||
# hash-bound to the pre-smoke VirusTotal verdicts.
|
||
- name: Publish legacy ui-* archive aliases
|
||
env:
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
run: scripts/ci/publish-legacy-aliases.sh "${{ inputs.version }}" "$GITHUB_REPOSITORY"
|
||
|
||
- name: Un-draft GitHub release
|
||
env:
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
VERSION: ${{ inputs.version }}
|
||
run: gh release edit "$VERSION" --draft=false --repo "$GITHUB_REPOSITORY"
|