Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .github/workflows/distribution-checks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
name: Distribution checks

# Spec 121: fast PR guards for the install surfaces (registry entry, scoop and
# winget manifest generators, the agent install prompt). No secrets, no release.
on:
pull_request:
paths:
- 'server.json'
- 'Dockerfile'
- 'README.md'
- 'docs/getting-started/**'
- 'scripts/stamp-server-json*'
- 'scripts/registry-preflight.sh'
- 'scripts/gen-scoop-manifest*'
- 'scripts/gen-winget-manifest*'
- 'scripts/sync-agent-prompt*'
- 'scripts/testdata/**'
- '.github/workflows/distribution-checks.yml'
- '.github/workflows/release.yml'
- '.github/workflows/retry-sign-release.yml'
- '.github/workflows/publish-windows-packages.yml'
workflow_dispatch:

permissions:
contents: read

jobs:
scripts:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Generator and stamping tests
run: |
scripts/stamp-server-json.test.sh
scripts/gen-scoop-manifest.test.sh
scripts/gen-winget-manifest.test.sh
scripts/sync-agent-prompt.test.sh

- name: Agent install prompt is in sync (README <-> docs)
run: scripts/sync-agent-prompt.sh --check

- name: Install mcp-publisher
run: |
curl -fsSL "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_linux_amd64.tar.gz" | tar xz mcp-publisher

# Schema + semantic validation of the stamped entry, so an invalid
# `packages` block fails the PR instead of silently skipping a release's
# registry publish (that job is continue-on-error by design).
- name: Validate stamped server.json
run: |
scripts/stamp-server-json.sh v0.0.1 server.json > "${RUNNER_TEMP}/server.json"
./mcp-publisher validate "${RUNNER_TEMP}/server.json"

- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod

- name: Workflow and installer invariants
run: go test ./cmd/release-gate/ -run 'Registry|Windows|Installer|PublishJobsGated' -count=1
94 changes: 94 additions & 0 deletions .github/workflows/installer-channel-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
name: Windows installer channel marker

# Spec 121 FR-014: the Inno installer must (a) write the winget channel marker
# only for /CHANNEL=winget, (b) remove a stale marker on any other run and on
# uninstall, and (c) never block on a dialog in silent mode (winget runs it
# silently). Builds a throwaway installer around dummy binaries and exercises
# the real silent install / reinstall / uninstall lifecycle on a disposable
# runner (it modifies the machine PATH registry value, which is why this must
# never be pointed at a developer machine).
on:
pull_request:
paths:
- 'scripts/installer.iss'
- '.github/workflows/installer-channel-test.yml'
workflow_dispatch:

permissions:
contents: read

jobs:
lifecycle:
runs-on: windows-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Ensure Inno Setup
shell: pwsh
run: |
if (-not (Test-Path "C:\Program Files (x86)\Inno Setup 6\ISCC.exe")) {
choco install innosetup -y --no-progress
}

- name: Build a throwaway installer
shell: pwsh
run: |
New-Item -ItemType Directory -Force dist\windows-amd64 | Out-Null
Copy-Item "$env:WINDIR\System32\whoami.exe" dist\windows-amd64\mcpproxy.exe
Copy-Item "$env:WINDIR\System32\whoami.exe" dist\windows-amd64\mcpproxy-tray.exe
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" /DVersion=0.0.1 /DArch=amd64 "/DBinPath=..\dist\windows-amd64" scripts\installer.iss
if ($LASTEXITCODE -ne 0) { throw "ISCC failed" }
if (-not (Test-Path dist\mcpproxy-setup-0.0.1-amd64.exe)) { throw "installer not produced" }

- name: Exercise channel marker lifecycle
shell: pwsh
run: |
$setup = (Resolve-Path dist\mcpproxy-setup-0.0.1-amd64.exe).Path
$dir = "C:\mcpproxy-e7-test"
$marker = Join-Path $dir "install-channel"

function Run-Silent([string]$exe, [string[]]$extra) {
$a = @("/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART") + $extra
$p = Start-Process -FilePath $exe -ArgumentList $a -PassThru
if (-not $p.WaitForExit(180000)) { $p.Kill(); throw "$exe blocked (>180s): a dialog or prompt is waiting in silent mode" }
if ($p.ExitCode -ne 0) { throw "$exe exited $($p.ExitCode)" }
}

# 1. winget install writes exactly the token
Run-Silent $setup @("/DIR=$dir", "/CHANNEL=winget")
if (-not (Test-Path $marker)) { throw "marker missing after /CHANNEL=winget" }
if ((Get-Content $marker -Raw).Trim() -ne "winget") { throw "marker content wrong: $(Get-Content $marker -Raw)" }

# 2. direct reinstall (no switch) removes the stale marker
Run-Silent $setup @("/DIR=$dir")
if (Test-Path $marker) { throw "stale marker survived a direct reinstall" }

# 3. an unknown channel value is never written
Run-Silent $setup @("/DIR=$dir", "/CHANNEL=C:\Users\secret")
if (Test-Path $marker) { throw "an arbitrary /CHANNEL value was written" }

# 4. uninstall removes the marker with the app
Run-Silent $setup @("/DIR=$dir", "/CHANNEL=winget")
if (-not (Test-Path $marker)) { throw "marker missing before uninstall" }
Run-Silent (Join-Path $dir "unins000.exe") @()
if (Test-Path $marker) { throw "marker survived uninstall" }

- name: Silent install must not block on an over-long PATH
shell: pwsh
run: |
# Disposable runner only: push the machine PATH past the 2047-char limit.
$orig = [Environment]::GetEnvironmentVariable("Path", "Machine")
$pad = ";C:\e7-pad\" + ("x" * 200)
$long = $orig
while ($long.Length -lt 2200) { $long += $pad }
[Environment]::SetEnvironmentVariable("Path", $long, "Machine")
try {
$setup = (Resolve-Path dist\mcpproxy-setup-0.0.1-amd64.exe).Path
$p = Start-Process -FilePath $setup -ArgumentList @("/VERYSILENT","/SUPPRESSMSGBOXES","/NORESTART","/DIR=C:\mcpproxy-e7-path") -PassThru
if (-not $p.WaitForExit(180000)) { $p.Kill(); throw "installer blocked on the PATH-length dialog in silent mode" }
if ($p.ExitCode -ne 0) { throw "installer exited $($p.ExitCode)" }
if (Test-Path C:\mcpproxy-e7-path\unins000.exe) { Start-Process C:\mcpproxy-e7-path\unins000.exe -ArgumentList "/VERYSILENT","/SUPPRESSMSGBOXES","/NORESTART" -Wait }
} finally {
[Environment]::SetEnvironmentVariable("Path", $orig, "Machine")
}
168 changes: 168 additions & 0 deletions .github/workflows/publish-windows-packages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
name: Publish Windows packages

# Spec 121 (E7): keep the scoop bucket and the winget community repository in
# step with every STABLE release, with no human in the loop after the one-time
# setup in docs/release-runbook.md#windows-package-managers-one-time-setup.
#
# Reusable: release.yml and retry-sign-release.yml call it after the GitHub
# Release exists. Also manually dispatchable with a tag to republish a release
# (idempotent: an unchanged scoop manifest is not recommitted; an existing
# winget version or open submission is reported and skipped).
#
# Both jobs SKIP (notice, success) when their credentials / target repositories
# are not configured, so an unconfigured fork or a first release never fails.
on:
workflow_call:
inputs:
tag:
description: Release tag (vX.Y.Z)
required: true
type: string
secrets:
SCOOP_BUCKET_TOKEN:
required: false
WINGET_TOKEN:
required: false
workflow_dispatch:
inputs:
tag:
description: Release tag to publish (vX.Y.Z)
required: true
type: string

permissions:
contents: read

jobs:
scoop:
name: scoop bucket
runs-on: ubuntu-latest
# Optional surface: a failure here must never turn a published release red.
continue-on-error: true
# Stable tags only: an RC must never reach a package manager's default channel.
if: ${{ !contains(inputs.tag, '-') }}
env:
TAG: ${{ inputs.tag }}
BUCKET: smart-mcp-proxy/scoop-bucket
BUCKET_TOKEN: ${{ secrets.SCOOP_BUCKET_TOKEN }}
GH_TOKEN: ${{ github.token }}
steps:
- name: Check configuration
id: cfg
run: |
if [ -z "${BUCKET_TOKEN}" ]; then
echo "::notice::SCOOP_BUCKET_TOKEN is not set; skipping scoop publish (see docs/release-runbook.md#windows-package-managers-one-time-setup)"
echo "run=false" >> "$GITHUB_OUTPUT"
else
echo "run=true" >> "$GITHUB_OUTPUT"
fi

- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
if: steps.cfg.outputs.run == 'true'

- name: Fetch release checksums
if: steps.cfg.outputs.run == 'true'
run: gh release download "${TAG}" --repo "${{ github.repository }}" --pattern checksums.txt --dir .

- name: Generate and push scoop manifest
if: steps.cfg.outputs.run == 'true'
run: |
set -euo pipefail
if ! git clone --depth 1 "https://x-access-token:${BUCKET_TOKEN}@github.com/${BUCKET}.git" bucket 2>/dev/null; then
echo "::notice::${BUCKET} is not reachable with SCOOP_BUCKET_TOKEN; skipping scoop publish"
exit 0
fi
mkdir -p bucket/bucket
# Never move the bucket backwards: re-running an older release (recovery,
# manual dispatch) must not make new installs get an older version.
CUR="$(jq -r '.version // empty' bucket/bucket/mcpproxy.json 2>/dev/null || true)"
NEW="${TAG#v}"
if [ -n "${CUR}" ] && [ "${CUR}" != "${NEW}" ] && [ "$(printf '%s\n%s\n' "${CUR}" "${NEW}" | sort -V | tail -1)" = "${CUR}" ]; then
echo "::notice::scoop bucket already at ${CUR}, newer than ${NEW}; not downgrading"
exit 0
fi
scripts/gen-scoop-manifest.sh "${TAG}" checksums.txt > bucket/bucket/mcpproxy.json
cd bucket
if git diff --quiet -- bucket/mcpproxy.json && [ -z "$(git ls-files --others --exclude-standard bucket/mcpproxy.json)" ]; then
echo "scoop manifest for ${TAG} already published; nothing to do"
exit 0
fi
git config user.name "mcpproxy-release[bot]"
git config user.email "release-bot@mcpproxy.app"
git add bucket/mcpproxy.json
git commit -m "mcpproxy: update to ${TAG#v}"
git push origin HEAD

winget:
name: winget community repository
runs-on: ubuntu-latest
continue-on-error: true
if: ${{ !contains(inputs.tag, '-') }}
env:
TAG: ${{ inputs.tag }}
PKG_ID: smart-mcp-proxy.mcpproxy
WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }}
GH_TOKEN: ${{ github.token }}
steps:
- name: Check configuration
id: cfg
run: |
if [ -z "${WINGET_TOKEN}" ]; then
echo "::notice::WINGET_TOKEN is not set; skipping winget publish (see docs/release-runbook.md#windows-package-managers-one-time-setup)"
echo "run=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# The first submission of a new package id is manual (moderated by
# Microsoft); this job only updates a package that already exists.
if ! GH_TOKEN="${WINGET_TOKEN}" gh api "repos/microsoft/winget-pkgs/contents/manifests/s/smart-mcp-proxy/mcpproxy" >/dev/null 2>&1; then
echo "::notice::${PKG_ID} does not exist in microsoft/winget-pkgs yet; submit it once by hand first (docs/release-runbook.md#windows-package-managers-one-time-setup)"
echo "run=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "run=true" >> "$GITHUB_OUTPUT"

- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
if: steps.cfg.outputs.run == 'true'

- name: Fetch release checksums
if: steps.cfg.outputs.run == 'true'
run: gh release download "${TAG}" --repo "${{ github.repository }}" --pattern checksums.txt --dir .

- name: Submit manifest pull request
if: steps.cfg.outputs.run == 'true'
env:
GH_TOKEN: ${{ secrets.WINGET_TOKEN }}
run: |
set -euo pipefail
VER="${TAG#v}"
DIR="manifests/s/smart-mcp-proxy/mcpproxy/${VER}"
FORK_OWNER="$(gh api user --jq .login)"

# Idempotence: skip when the version is already merged or a PR is open.
if gh api "repos/microsoft/winget-pkgs/contents/${DIR}" >/dev/null 2>&1; then
echo "winget ${VER} already exists upstream; nothing to do"; exit 0
fi
OPEN="$(gh pr list --repo microsoft/winget-pkgs --state open --search "${PKG_ID} version ${VER} in:title" --json number --jq 'length')"
if [ "${OPEN}" != "0" ]; then
echo "an open winget-pkgs PR for ${PKG_ID} ${VER} already exists; nothing to do"; exit 0
fi

scripts/gen-winget-manifest.sh "${TAG}" checksums.txt out

# Work on the fork (created once by the maintainer), synced to upstream master.
gh repo sync "${FORK_OWNER}/winget-pkgs" --branch master --force >/dev/null
git clone --depth 1 --filter=blob:none --sparse "https://x-access-token:${WINGET_TOKEN}@github.com/${FORK_OWNER}/winget-pkgs.git" fork
cd fork
BRANCH="mcpproxy-${VER}"
git checkout -b "${BRANCH}"
mkdir -p "${DIR}"
cp ../out/*.yaml "${DIR}/"
git config user.name "mcpproxy-release[bot]"
git config user.email "release-bot@mcpproxy.app"
git add --sparse "${DIR}"
git commit -m "New version: ${PKG_ID} version ${VER}"
git push --force origin "${BRANCH}"
gh pr create --repo microsoft/winget-pkgs --base master \
--head "${FORK_OWNER}:${BRANCH}" \
--title "New version: ${PKG_ID} version ${VER}" \
--body "Automated update from https://github.com/${{ github.repository }}/releases/tag/${TAG} (manifest generated by scripts/gen-winget-manifest.sh; installer hashes taken from the release's checksums.txt)."
34 changes: 28 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1340,6 +1340,7 @@ jobs:
org.opencontainers.image.licenses=MIT
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.version=${{ github.ref_name }}
io.modelcontextprotocol.server.name=io.github.smart-mcp-proxy/mcpproxy-go

# Spec 081: release qualification gate. Runs the server-type matrix +
# invariants + assembled suites and produces one pass/fail verdict. The
Expand Down Expand Up @@ -2231,6 +2232,20 @@ jobs:
event-type: update-version
client-payload: '{"version": "${{ github.ref_name }}"}'

# Spec 121: scoop bucket + winget community repository. Reusable workflow so a
# recovered release can republish by manual dispatch with a tag. Stable only;
# each inner job skips (notice) when its credentials are not configured.
publish-windows-packages:
name: Publish Windows packages
needs: release
if: startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-') && github.repository == 'smart-mcp-proxy/mcpproxy-go'
permissions:
contents: read
uses: ./.github/workflows/publish-windows-packages.yml
with:
tag: ${{ github.ref_name }}
secrets: inherit

mcp-registry:
name: Publish to MCP Registry
needs: release
Expand All @@ -2241,20 +2256,27 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Extract version from tag
id: version
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"

- name: Install mcp-publisher
run: |
curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_linux_amd64.tar.gz" | tar xz mcp-publisher

- name: Authenticate to MCP Registry
run: ./mcp-publisher login github-oidc

- name: Update version in server.json
# Spec 121: stamp version AND the OCI package identifier from the tag
# (shared with retry-sign-release.yml), then prove the image the entry
# points at exists, is anonymously pullable, carries the registry
# ownership label and answers an MCP initialize. The registry is
# immutable per version, so a bad publish cannot be corrected later.
- name: Stamp server.json for the tag
run: |
jq --arg v "${{ steps.version.outputs.VERSION }}" '.version = $v' server.json > server.tmp && mv server.tmp server.json
scripts/stamp-server-json.sh "${GITHUB_REF_NAME}" server.json > server.tmp && mv server.tmp server.json

- name: Pre-publish checks (image, label, MCP smoke test)
run: scripts/registry-preflight.sh "${GITHUB_REF_NAME}"

- name: Validate server.json
run: ./mcp-publisher validate server.json

- name: Publish to MCP Registry
run: ./mcp-publisher publish
Loading
Loading