Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
b7f90ff
spec(092): one-click auto-updater (macOS) + channel-aware mcpproxy up…
Dumbris Aug 7, 2026
8bf3b21
spec(092): codex round 1 — durable core ownership, unknown-channel gu…
Dumbris Aug 7, 2026
a91c844
spec(092): codex round 2 — align P3 test/scenario with FR-020/FR-022 …
Dumbris Aug 7, 2026
e186f57
feat(core): report durable launch provenance in /api/v1/info
Dumbris Aug 8, 2026
4b466bd
feat(updatecheck): make the tarball install channel positively detect…
Dumbris Aug 8, 2026
00e2b5c
feat(cli): add channel-aware `mcpproxy update`
Dumbris Aug 8, 2026
a20f7fa
fix(ci): make the tarball stamp self-check pipefail-safe
Dumbris Aug 8, 2026
fae973c
feat(core): report the core pid in /api/v1/info
Dumbris Aug 8, 2026
118e19e
fix(tray): compare versions by SemVer 2.0 precedence
Dumbris Aug 8, 2026
20d186b
feat(tray): supersede a stale core after an upgrade
Dumbris Aug 8, 2026
387d15e
feat(tray): offer a relaunch when the app bundle is replaced
Dumbris Aug 8, 2026
f65df4c
fix(packaging): quit the running app before the installer launches th…
Dumbris Aug 8, 2026
df36c8e
fix(tray): refresh the legacy staged core copy when it is provably stale
Dumbris Aug 8, 2026
3ef2e46
fix(tray): keep the connection when a stale core cannot be stopped
Dumbris Aug 8, 2026
1b2015a
feat(core): report the effective update policy in /api/v1/info
Dumbris Aug 8, 2026
b994e8b
feat(tray): wire the Sparkle one-click updater
Dumbris Aug 8, 2026
09cc72c
feat(tray): request the architecture-specific Sparkle feed
Dumbris Aug 8, 2026
f0bc891
ci(release): publish a signed Sparkle enclosure and appcast
Dumbris Aug 8, 2026
ba5b1c1
docs: document the auto-updater end to end
Dumbris Aug 8, 2026
3a82463
docs: list Auto-Update in the sidebar and the second env-var surface
Dumbris Aug 8, 2026
d5c98c9
fix(ci): give the sparkle-appcast jobs a repo for gh to upload to
Dumbris Aug 8, 2026
04b357a
feat(cli): warn when --self asserts ownership of an unknown install
Dumbris Aug 8, 2026
6a20948
docs(update): correct the tarball channel comments and the dev-bundle…
Dumbris Aug 8, 2026
20caffa
fix(cli): make the self-update swap crash-recoverable and its version…
Dumbris Aug 8, 2026
75fa63d
fix(tray): honour SemVer §9 and compare unbounded numeric identifiers
Dumbris Aug 8, 2026
95809cf
fix(tray): let exactly one source own the update menu item
Dumbris Aug 8, 2026
6914bb6
fix(tray): make the update pipeline refuse to act on what it has not …
Dumbris Aug 8, 2026
1daaa37
fix(packaging): quit only the installing user's tray, not everybody's
Dumbris Aug 8, 2026
b78949c
ci(prerelease): publish signed checksums for RC artifacts
Dumbris Aug 8, 2026
1ee15ae
docs(update): spell out what still has to happen before one-click works
Dumbris Aug 8, 2026
fdfc402
fix(cli): tell an unverified swap apart from a finished one
Dumbris Aug 8, 2026
7c581e0
fix(tray): never arm the install-on-quit the core stop cannot veto
Dumbris Aug 8, 2026
026f268
docs(update): link Prerelease Builds via GitHub — the doc is not on t…
Dumbris Aug 8, 2026
bf2f6e3
fix(cli): serialize self-update per target with an OS-level advisory …
Dumbris Aug 8, 2026
6d615e3
fix(cli): key the update lock on the binary, not on the name we resolved
Dumbris Aug 8, 2026
ada3edd
ci(release): auto-publish the Sparkle feeds to mcpproxy.app
Dumbris Aug 8, 2026
663d52c
docs(092): rollout & first-test plan — failure modes, RC dress rehear…
Dumbris Aug 8, 2026
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
261 changes: 261 additions & 0 deletions .github/workflows/prerelease.yml
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,13 @@ jobs:
# Defensive CGO flags to ensure proper deployment target
CGO_CFLAGS: "-mmacosx-version-min=13.0"
CGO_LDFLAGS: "-mmacosx-version-min=13.0"
# Spec 092 FR-011/FR-014: pin the Sparkle EdDSA PUBLIC key so RC builds
# can consume the beta feed. Same secret as the stable pipeline — one
# keypair, two channels; the CHANNEL is what separates them, not the key.
# No secret ⇒ scripts/build-swift-app.sh leaves the placeholder and the
# updater stays off.
SPARKLE_PUBLIC_ED_KEY: ${{ secrets.SPARKLE_ED_PUBLIC_KEY }}
SPARKLE_FEED_URL: ${{ vars.SPARKLE_FEED_URL }}
run: |
# For prerelease, determine version differently
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
Expand All @@ -217,6 +224,15 @@ jobs:
fi

# Create clean core binary for archive
#
# NOTE (Spec 092 FR-014/FR-020): unlike release.yml, the RC archive is
# deliberately NOT stamped with updatecheck.buildChannel=tarball. The
# verification half of that prerequisite is now met — the release job
# below publishes checksums.txt and its cosign bundle, so an RC IS
# installable by a stable tarball user who opted into prereleases —
# but stamping the RC archive itself is a separate decision about
# whether a binary extracted from a prerelease should self-update, and
# it is not made here.
go build -ldflags "${LDFLAGS}" -o ${CLEAN_BINARY} ./cmd/mcpproxy

# Build tray binary for macOS
Expand Down Expand Up @@ -690,6 +706,73 @@ jobs:

echo "✅ Notarization and stapling complete"

# Spec 092 FR-014 — RC builds get the same enclosure the stable pipeline
# produces. See the identical step in release.yml for why `ditto -c -k
# --sequesterRsrc --keepParent` and why the .app is notarized and stapled
# separately from the PKG. Signing step order is untouched.
#
# Only TAGGED RCs produce an enclosure: this workflow also runs on pushes to
# `next`, and a branch build has no release to attach a feed to.
- name: Build Sparkle enclosure (macOS)
if: matrix.goos == 'darwin' && startsWith(github.ref, 'refs/tags/')
env:
SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }}
run: |
set -euo pipefail

mkdir -p sparkle-enclosure

if [ -z "${SPARKLE_ED_PRIVATE_KEY:-}" ]; then
echo "ℹ️ SPARKLE_ED_PRIVATE_KEY is not configured — skipping the Sparkle enclosure."
exit 0
fi

if [ -z "${SWIFT_APP_PATH:-}" ] || [ ! -d "${SWIFT_APP_PATH}" ]; then
echo "❌ SWIFT_APP_PATH is not a bundle: '${SWIFT_APP_PATH:-<unset>}'"
exit 1
fi

VERSION=${GITHUB_REF#refs/tags/}
ENCLOSURE="mcpproxy-${VERSION#v}-darwin-${{ matrix.goarch }}.app.zip"
NOTARIZE_ZIP="sparkle-notarize-${{ matrix.goarch }}.zip"

ditto -c -k --sequesterRsrc --keepParent "${SWIFT_APP_PATH}" "${NOTARIZE_ZIP}"

SUBMISSION_OUTPUT=$(xcrun notarytool submit "${NOTARIZE_ZIP}" \
--apple-id "${{ secrets.APPLE_ID_USERNAME }}" \
--password "${{ secrets.APPLE_ID_APP_PASSWORD }}" \
--team-id "${{ secrets.APPLE_TEAM_ID }}" \
--wait \
--output-format json)
STATUS=$(echo "${SUBMISSION_OUTPUT}" | jq -r '.status // empty')
if [ "${STATUS}" != "Accepted" ]; then
echo "❌ App-bundle notarization did not succeed"
echo "Response: ${SUBMISSION_OUTPUT}"
exit 1
fi

xcrun stapler staple "${SWIFT_APP_PATH}"
xcrun stapler validate "${SWIFT_APP_PATH}"

ditto -c -k --sequesterRsrc --keepParent "${SWIFT_APP_PATH}" "sparkle-enclosure/${ENCLOSURE}"
rm -f "${NOTARIZE_ZIP}"

VERIFY_DIR=$(mktemp -d)
ditto -x -k "sparkle-enclosure/${ENCLOSURE}" "${VERIFY_DIR}"
codesign --verify --deep --strict --verbose=2 "${VERIFY_DIR}/MCPProxy.app"
xcrun stapler validate "${VERIFY_DIR}/MCPProxy.app"
rm -rf "${VERIFY_DIR}"

echo "✅ Sparkle enclosure ready: ${ENCLOSURE}"

- name: Upload Sparkle enclosure artifact
if: matrix.goos == 'darwin' && startsWith(github.ref, 'refs/tags/')
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: sparkle-enclosure-${{ matrix.goarch }}
path: sparkle-enclosure/*
if-no-files-found: ignore

- name: Cleanup isolated keychain (macOS)
if: matrix.goos == 'darwin' && always()
run: |
Expand Down Expand Up @@ -772,6 +855,12 @@ jobs:
needs: [build, qa-gate]
runs-on: ubuntu-latest
environment: staging
permissions:
contents: write
# Spec 092 FR-014: cosign keyless signing takes its identity from the
# GitHub Actions OIDC token. release.yml grants this at the workflow
# level; here only this job needs it.
id-token: write
# Only create releases for tag pushes, not branch pushes
if: startsWith(github.ref, 'refs/tags/v') && (contains(github.ref, '-rc.') || contains(github.ref, '-next.'))

Expand Down Expand Up @@ -850,6 +939,20 @@ jobs:
fi
done

# Spec 092 FR-014: RC artifacts must be covered by a signed checksum
# manifest, exactly as stable ones are. Without it `mcpproxy update`
# refuses every RC — it will not install an artifact it cannot verify —
# so a user who opted into the prerelease channel had no update path at
# all. Same shape as release.yml's step, including the self-reference
# guard so a re-run cannot make checksums.txt list itself.
- name: Generate checksums
run: |
cd release-files
find . -maxdepth 1 -type f ! -name 'checksums.txt' ! -name 'checksums.txt.cosign.bundle' -printf '%P\n' \
| sort | xargs -r sha256sum > checksums.txt
echo "Generated checksums:"
cat checksums.txt

- name: List files for upload
run: |
echo "Files to upload:"
Expand Down Expand Up @@ -931,6 +1034,40 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2

- name: Sign checksums (keyless)
# Same recipe as release.yml, with the identity naming THIS workflow.
# `mcpproxy update` pins both (cmd/mcpproxy/update_cmd.go:
# cosignIdentityRegexp) and pins the ref to a tag, so a branch run can
# never sign something the CLI would install.
#
# To verify after download:
# cosign verify-blob \
# --bundle checksums.txt.cosign.bundle \
# --certificate-identity-regexp "^https://github.com/smart-mcp-proxy/mcpproxy-go/.github/workflows/prerelease.yml@refs/tags/v" \
# --certificate-oidc-issuer https://token.actions.githubusercontent.com \
# checksums.txt
run: |
set -euo pipefail
# Guarded like the other new jobs: a fork or a run without OIDC has no
# identity to sign with, and an unsigned RC is better than a failed
# one — `mcpproxy update` refuses it and says why.
if [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then
echo "::notice::No OIDC token available — RC checksums are published unsigned."
exit 0
fi
cosign sign-blob \
--yes \
--bundle release-files/checksums.txt.cosign.bundle \
release-files/checksums.txt
gh release upload "${{ github.ref_name }}" \
release-files/checksums.txt.cosign.bundle \
--clobber
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Upload pending notarizations
if: hashFiles('pending-notarizations/*.pending') != ''
run: |
Expand All @@ -943,3 +1080,127 @@ jobs:
done
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

# Spec 092 FR-014 — the RC (beta) update feed.
#
# Same shape as release.yml's `sparkle-appcast`, with one difference that IS
# the requirement: `--channel beta`. Sparkle only offers a channel-tagged item
# to a client that lists that channel in `allowedChannelsForUpdater:`, and the
# tray lists "beta" only when the core reports update_policy.channel == "rc"
# (see UpdateChannel.allowedSparkleChannels). That is what keeps stable users
# from ever being offered an RC.
#
# TODO(maintainer decision #1 / #8): the beta feed needs its own stable URL —
# the GitHub `releases/latest/download/...` shortcut resolves to the newest
# NON-prerelease release, so it cannot serve this feed. Until the website repo
# hosts appcast-beta-<arch>.xml, the files are attached to the RC release and
# exported as the `sparkle-appcast-beta` artifact.
#
# FR-014's other half — signed checksums for RC artifacts — is handled in the
# `release` job above (Generate checksums / Sign checksums).
sparkle-appcast:
needs: [release]
runs-on: macos-15
permissions:
contents: write
steps:
- name: Download Sparkle enclosures
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: sparkle-enclosure-*
path: enclosures

- name: Generate and publish the beta appcast
id: appcast
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# No actions/checkout in this job, so `gh` cannot infer the repository
# from a git remote — and it does not read GITHUB_REPOSITORY. See the
# same note in release.yml's sparkle-appcast job.
GH_REPO: ${{ github.repository }}
SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }}
# Keep in step with Package.resolved (Sparkle 2.9.3).
SPARKLE_VERSION: "2.9.3"
run: |
set -euo pipefail

mkdir -p appcast-out

if [ -z "${SPARKLE_ED_PRIVATE_KEY:-}" ]; then
echo "::notice::SPARKLE_ED_PRIVATE_KEY is not configured — no beta appcast generated."
exit 0
fi
if [ -z "$(find enclosures -name '*.app.zip' -print -quit 2>/dev/null)" ]; then
echo "::notice::No Sparkle enclosures were produced — no beta appcast generated."
exit 0
fi

curl -fsSL -o sparkle-tools.tar.xz \
"https://github.com/sparkle-project/Sparkle/releases/download/${SPARKLE_VERSION}/Sparkle-${SPARKLE_VERSION}.tar.xz"
mkdir -p sparkle-tools
tar xf sparkle-tools.tar.xz -C sparkle-tools
GENERATE_APPCAST=$(find sparkle-tools -name generate_appcast -type f -perm +111 | head -1)
if [ -z "${GENERATE_APPCAST}" ]; then
echo "❌ generate_appcast not found in the Sparkle ${SPARKLE_VERSION} tarball"
exit 1
fi

PREFIX="https://github.com/${GITHUB_REPOSITORY}/releases/download/${GITHUB_REF_NAME}/"

for ARCH in arm64 amd64; do
SRC=$(find enclosures -name "*-darwin-${ARCH}.app.zip" | head -1)
if [ -z "${SRC}" ]; then
echo "::warning::No ${ARCH} enclosure — skipping its beta feed."
continue
fi
WORK="appcast-work-${ARCH}"
rm -rf "${WORK}"
mkdir -p "${WORK}"
cp "${SRC}" "${WORK}/"

echo "=== Generating appcast-beta-${ARCH}.xml from $(basename "${SRC}") ==="
echo "${SPARKLE_ED_PRIVATE_KEY}" | "${GENERATE_APPCAST}" \
--ed-key-file - \
--channel beta \
--download-url-prefix "${PREFIX}" \
--link "https://github.com/${GITHUB_REPOSITORY}/releases/tag/${GITHUB_REF_NAME}" \
-o "appcast-out/appcast-beta-${ARCH}.xml" \
"${WORK}"
done

if [ -z "$(ls -A appcast-out 2>/dev/null)" ]; then
echo "❌ generate_appcast produced no feeds"
exit 1
fi

for f in appcast-out/*.xml; do
echo "--- ${f} ---"
cat "${f}"
grep -q 'sparkle:edSignature' "${f}" || { echo "❌ ${f} has no EdDSA signature"; exit 1; }
# The channel tag is the whole point of this job: without it every
# stable user would be offered an RC (FR-014).
grep -q '<sparkle:channel>beta</sparkle:channel>' "${f}" \
|| { echo "❌ ${f} is not tagged as the beta channel"; exit 1; }
done

gh release upload "${GITHUB_REF_NAME}" appcast-out/*.xml --clobber
echo "generated=true" >> "$GITHUB_OUTPUT"

- name: Upload beta appcast artifact for the website repo
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: sparkle-appcast-beta
path: appcast-out/*
if-no-files-found: ignore

# Same site hand-off as release.yml, beta channel. Non-blocking; the
# site workflow is manually re-runnable (workflow_dispatch).
- name: Publish beta feeds to mcpproxy.app
if: steps.appcast.outputs.generated == 'true'
continue-on-error: true
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
with:
token: ${{ secrets.MARKETING_SITE_DISPATCH_TOKEN }}
repository: smart-mcp-proxy/mcpproxy.app-website
event-type: publish-appcast
client-payload: '{"version": "${{ github.ref_name }}", "channel": "beta"}'
Loading
Loading