Skip to content

Update Versions (#59) #231

Update Versions (#59)

Update Versions (#59) #231

Workflow file for this run

name: CI
# One workflow, matching devup-ui and the other org projects: verification,
# changepacks version management, binary builds and release publication all
# live here rather than in a second file that can drift out of step.
#
# Release flow (driven by changepacks/action, not by hand):
# 1. A pull request touching crates/ must carry a changepack. `changepacks`
# comments the detected packs; `changepack-required` makes it a gate.
# 2. On push to main with pending changepacks, the action opens an
# "Update Versions" pull request that runs `changepacks update`.
# 3. Merging that PR leaves no changepacks, so the action cuts tags and
# *draft* releases and reports them in `pending_releases`.
# 4. `build` compiles every MCP binary for all three platforms and uploads
# them onto those drafts.
# 5. `bundle` collects those same three binaries back into one `.mcpb`
# MCP Bundle, so a user on any operating system installs the one file
# rather than picking a binary and wiring up stdio by hand.
# 6. `finalize` publishes the drafts, but only once the uploads succeeded —
# so a release is never visible without its binaries and its bundle.
on:
push:
branches: [main]
pull_request:
permissions:
contents: write
pull-requests: write
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
env:
# Pinned so a release cannot start producing a differently shaped bundle on
# its own. `pack` writes the archive's Unix permission bits from the mode it
# finds on disk in this version, which is why `bundle` chmods first.
MCPB_CLI_VERSION: "2.1.2"
jobs:
# The action comments the changepack status on a pull request but does not
# fail it. A crate change that ships without a changepack never moves the
# version, so it never releases — this turns that silent outcome into a
# red check with the command to fix it.
changepack-required:
name: changepack required
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Require a changepack for crate changes
shell: bash
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
base="$(git merge-base "$BASE_SHA" "$HEAD_SHA")"
changed="$(git diff --name-only "$base" "$HEAD_SHA")"
crate_changes="$(printf '%s\n' "$changed" | grep -E '^crates/' || true)"
if [ -z "$crate_changes" ]; then
echo "No crate sources touched; a changepack is not required."
exit 0
fi
log_changes="$(printf '%s\n' "$changed" \
| grep -E '^\.changepacks/changepack_log_.*\.json$' || true)"
if [ -n "$log_changes" ]; then
echo "Changepack present:"
printf ' %s\n' $log_changes
exit 0
fi
{
echo "This pull request changes crate sources but adds no changepack log."
echo
echo "Without one the workspace version never moves, so the change"
echo "ships to main and is never released."
echo
echo " cargo install changepacks"
echo " changepacks"
echo
echo "Pick the affected crates, choose Major/Minor/Patch, and write"
echo "the release note, then commit the generated"
echo ".changepacks/changepack_log_*.json alongside your change."
echo
echo "--- crate files changed without a changepack ---"
printf ' %s\n' $crate_changes
} >&2
exit 1
verify:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: 24
- uses: dtolnay/rust-toolchain@1.98.0
with:
components: rustfmt, clippy
# Run before rust-cache (which may itself invoke cargo metadata) and
# all builds: otherwise Cargo repairs a stale lock and hides the defect.
- name: Require an up-to-date committed Cargo.lock
shell: bash
run: cargo metadata --locked --format-version 1 > /dev/null
- uses: Swatinem/rust-cache@v2
- run: cargo install cargo-insta --version 1.48.0 --locked
- run: cargo fmt --all -- --check
- run: node --test crates/devup-mcp-figma/tests/explore_script_behavior.mjs
# `plugin/dist/` is committed so a user can import the plugin into Figma
# without running a build. That only stays true if the committed bundle
# is the one this source produces, so rebuild it and refuse a difference.
# The build is reproducible: same input, byte-identical output.
- name: The committed plugin bundle matches its source
if: matrix.os == 'ubuntu-latest'
shell: bash
working-directory: plugin
run: |
npm ci
npm run build
git diff --exit-code -- dist || {
echo "::error::plugin/dist is stale. Run 'npm run build' in plugin/ and commit the result."
exit 1
}
- run: cargo test --locked -p devup-mcp --test stdio_smoke
- run: cargo clippy --locked --workspace --all-targets --all-features -- -D warnings
- run: cargo insta test --workspace --all-features --check
- run: cargo build --locked --workspace --release
- name: Test automatic version and lockfile synchronization
if: matrix.os == 'ubuntu-latest'
run: python3 .github/scripts/test_release_lock.py
# The bundle is only assembled on a release run, so without this the
# first time anyone learns the manifest is malformed is after the tag
# and the draft release already exist. Schema-check it on every pull
# request instead, against the same rendering step `bundle` performs.
- name: Validate the MCPB manifest
if: matrix.os == 'ubuntu-latest'
shell: bash
run: |
set -euo pipefail
version="$(bash packaging/mcpb/workspace-version.sh)"
mkdir -p build
jq --arg version "$version" '.version = $version' \
packaging/mcpb/manifest.json > build/manifest.json
npx --yes "@anthropic-ai/mcpb@$MCPB_CLI_VERSION" validate build/manifest.json
changepacks:
name: changepacks
needs: verify
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
# changepacks diffs HEAD against the previous release commit; the
# default shallow fetch grafts away every parent, so that lookup
# fails and the release never publishes.
fetch-depth: 0
fetch-tags: true
# This job runs after `verify`, which takes about ten minutes, so by the
# time it evaluates, main may already be several commits further on. It
# then judges a state that no longer exists: on 2026-09-08 three merges
# landed inside one such window, each stale run opened a fresh Update
# Versions pull request instead of releasing, all three were merged, and
# the workspace went 0.2.1 -> 0.3.0 -> 0.3.1 while the newest release
# stayed 0.2.1. Nothing was tagged, and re-running could not recover it,
# because the action only cuts a release in the run that actually
# consumes the changepacks - and by then there were none left.
#
# Only the run for the current tip may act. A superseded run stops here,
# and the run for the commit that replaced it does the work.
- name: Stop if a newer commit already replaced this one
id: freshness
shell: bash
run: |
set -euo pipefail
head="$(git ls-remote origin refs/heads/main | cut -f1)"
echo "this run: $GITHUB_SHA"
echo "main tip: $head"
if [ -n "$head" ] && [ "$head" != "$GITHUB_SHA" ]; then
echo "superseded=true" >> "$GITHUB_OUTPUT"
echo "Superseded by $head; leaving the release decision to its run."
else
echo "superseded=false" >> "$GITHUB_OUTPUT"
fi
- uses: dtolnay/rust-toolchain@1.98.0
if: steps.freshness.outputs.superseded == 'false' && github.event_name == 'push' && github.ref == 'refs/heads/main'
# The upstream action stages and commits immediately after its update,
# with --no-verify and no post-update extension point. Scope a Git adapter
# to this action only, running cargo update --workspace BEFORE the real
# version commit. A post-action commit could race PR auto-merge; a
# prepare-commit-msg hook runs after Git has already cached its tree.
# The adapter survives the action's reset/clean in RUNNER_TEMP, forwards
# every other command unchanged, and refuses a stale version-branch push.
- name: Prepare same-commit release lockfile synchronization
id: release_git
if: steps.freshness.outputs.superseded == 'false'
shell: bash
env:
ENABLE_SYNC: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
run: |
set -euo pipefail
echo "real_git=$(command -v git)" >> "$GITHUB_OUTPUT"
if [ "$ENABLE_SYNC" = "true" ]; then
adapter="$RUNNER_TEMP/devup-release-git"
mkdir -p "$adapter"
cp .github/scripts/release_git.py "$adapter/git"
chmod +x "$adapter/git"
echo "path=$adapter:$PATH" >> "$GITHUB_OUTPUT"
else
echo "path=$PATH" >> "$GITHUB_OUTPUT"
fi
- uses: changepacks/action@main
id: changepacks
if: steps.freshness.outputs.superseded == 'false'
env:
PATH: ${{ steps.release_git.outputs.path }}
DEVUP_RELEASE_REAL_GIT: ${{ steps.release_git.outputs.real_git }}
DEVUP_RELEASE_LOCK_SYNC: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
with:
token: ${{ secrets.GITHUB_TOKEN }}
create_release: true
outputs:
changepacks: ${{ steps.changepacks.outputs.changepacks }}
release_assets_urls: ${{ steps.changepacks.outputs.release_assets_urls }}
pending_releases: ${{ steps.changepacks.outputs.pending_releases }}
build:
name: build (${{ matrix.os }})
needs: changepacks
# Only when a draft release is actually waiting for assets. On a pull
# request, or on a push that merely opened the Update Versions PR, there
# is nothing to attach to.
if: >-
needs.changepacks.outputs.pending_releases != ''
&& needs.changepacks.outputs.pending_releases != '{}'
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
targets: x86_64-unknown-linux-gnu
suffix: linux-x86_64
ext: ""
- os: windows-latest
targets: x86_64-pc-windows-msvc
suffix: windows-x86_64
ext: ".exe"
- os: macos-latest
# Fused into one universal binary so a single macOS asset runs on
# both Apple Silicon and Intel.
targets: aarch64-apple-darwin x86_64-apple-darwin
suffix: macos-universal
ext: ""
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@1.98.0
- uses: Swatinem/rust-cache@v2
- name: Build release binaries
shell: bash
env:
TARGETS: ${{ matrix.targets }}
SUFFIX: ${{ matrix.suffix }}
EXT: ${{ matrix.ext }}
OS: ${{ matrix.os }}
# Name the commit outright instead of letting build.rs infer it.
#
# Explicit release identity remains deterministic. R5 synchronizes
# Cargo.lock in the version commit, preventing the old automatic
# Cargo rewrite that made released binaries report <sha>-dirty.
DEVUP_MCP_BUILD_ID: ${{ github.sha }}
run: |
set -euo pipefail
for target in $TARGETS; do
rustup target add "$target"
cargo build --locked --release --target "$target" -p devup-mcp
done
# Only the MCP server ships. devup-mcp-visual is a PNG comparator
# the render harness builds from source with cargo; nothing
# downloads it, so three of the six release assets were binaries no
# consumer had a use for while still costing a build on every
# platform of every release.
mkdir -p dist
out="dist/devup-mcp-${SUFFIX}${EXT}"
if [ "$OS" = "macos-latest" ]; then
lipo -create -output "$out" \
"target/aarch64-apple-darwin/release/devup-mcp" \
"target/x86_64-apple-darwin/release/devup-mcp"
file "$out"
else
set -- $TARGETS
cp "target/$1/release/devup-mcp${EXT}" "$out"
fi
ls -l dist
# `bundle` needs all three platforms' server binaries in one place, and
# each is produced on a different runner.
- name: Hand the server binary to the bundle job
uses: actions/upload-artifact@v7
with:
name: mcpb-binary-${{ matrix.suffix }}
path: dist/devup-mcp-${{ matrix.suffix }}${{ matrix.ext }}
if-no-files-found: error
retention-days: 1
- name: Upload binaries onto the draft release
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ASSET_URLS: ${{ needs.changepacks.outputs.release_assets_urls }}
run: |
set -euo pipefail
# release_assets_urls maps project path -> asset upload URL. The
# binaries belong to the devup-mcp crate; the library crates get
# their own releases with no assets.
upload="$(printf '%s' "$ASSET_URLS" \
| jq -r '.["crates/devup-mcp/Cargo.toml"] // empty')"
if [ -z "$upload" ]; then
echo "no asset upload URL for crates/devup-mcp/Cargo.toml" >&2
printf '%s\n' "$ASSET_URLS" >&2
exit 1
fi
# Drop the RFC 6570 template suffix, e.g. "{?name,label}".
upload="${upload%%\{*}"
for file in dist/*; do
name="$(basename "$file")"
echo "uploading $name"
curl --fail-with-body -sS -X POST \
-H "Authorization: Bearer $GH_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary @"$file" \
"${upload}?name=${name}" >/dev/null
done
# Six loose binaries make a user pick the right file for their machine and
# then wire stdio up by hand. One `.mcpb` — a zip carrying manifest.json and
# all three platform binaries — installs in a click on any of them, because
# the manifest's platform_overrides selects the matching command at launch.
bundle:
name: bundle mcpb
needs: [changepacks, build]
if: >-
needs.changepacks.outputs.pending_releases != ''
&& needs.changepacks.outputs.pending_releases != '{}'
# Must not be windows-latest. mcpb writes the archive's Unix permission
# bits only when packing from a Unix host; from Windows it writes none at
# all, and macOS and Linux then fail to launch the binary with EACCES.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: 24
- uses: actions/download-artifact@v8
with:
pattern: mcpb-binary-*
path: artifacts
merge-multiple: true
- name: Assemble and pack the bundle
shell: bash
run: |
set -euo pipefail
version="$(bash packaging/mcpb/workspace-version.sh)"
echo "bundling devup-mcp $version"
mkdir -p build/server/linux build/server/macos build/server/win
cp artifacts/devup-mcp-linux-x86_64 build/server/linux/devup-mcp
cp artifacts/devup-mcp-macos-universal build/server/macos/devup-mcp
cp artifacts/devup-mcp-windows-x86_64.exe build/server/win/devup-mcp.exe
# The artifact round trip does not carry file modes, and the pinned
# mcpb copies the mode it finds rather than forcing +x on the entry
# point, so the executable bit has to be put back here.
chmod +x build/server/linux/devup-mcp build/server/macos/devup-mcp
jq --arg version "$version" '.version = $version' \
packaging/mcpb/manifest.json > build/manifest.json
# `mcpb validate` only ever looks at server.entry_point, so a typo in
# a platform_overrides path would ship a bundle that is broken on
# exactly one operating system and healthy on the two we can see from
# here. Check every command the manifest is able to select.
missing=0
while read -r referenced; do
[ -n "$referenced" ] || continue
if [ ! -f "build/$referenced" ]; then
echo "manifest.json names a file the bundle does not carry: $referenced" >&2
missing=1
fi
done < <(jq -r '
[ .server.entry_point,
.server.mcp_config.command,
(.server.mcp_config.platform_overrides // {} | .[] | .command // empty) ]
| .[] | ltrimstr("${__dirname}/")' build/manifest.json | sort -u)
[ "$missing" -eq 0 ]
npx --yes "@anthropic-ai/mcpb@$MCPB_CLI_VERSION" pack build "devup-mcp-$version.mcpb"
echo "MCPB_FILE=devup-mcp-$version.mcpb" >> "$GITHUB_ENV"
# A bundle whose binaries lost their executable bit installs cleanly and
# then fails at first launch, which is exactly the kind of defect a
# release should not be able to carry. Read it back out of the archive.
- name: Verify the archive kept the executable bit
shell: bash
run: |
set -euo pipefail
for entry in server/linux/devup-mcp server/macos/devup-mcp; do
mode="$(unzip -Z "$MCPB_FILE" "$entry" \
| awk -v entry="$entry" '$NF == entry { print $1 }')"
echo "$entry: ${mode:-<absent>}"
# mcpb stores the permission bits alone, without the regular-file
# type bits, so zipinfo prints the type column as "?" rather than
# "-" even for a correctly packed archive. Only the owner execute
# position is meaningful, and an absent entry yields an empty
# string, which fails this the same way a non-executable one does.
if [ "${mode:3:1}" != "x" ]; then
echo "$entry is not executable inside $MCPB_FILE" >&2
exit 1
fi
done
- name: Upload the bundle onto the draft release
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ASSET_URLS: ${{ needs.changepacks.outputs.release_assets_urls }}
run: |
set -euo pipefail
upload="$(printf '%s' "$ASSET_URLS" \
| jq -r '.["crates/devup-mcp/Cargo.toml"] // empty')"
if [ -z "$upload" ]; then
echo "no asset upload URL for crates/devup-mcp/Cargo.toml" >&2
printf '%s\n' "$ASSET_URLS" >&2
exit 1
fi
# Drop the RFC 6570 template suffix, e.g. "{?name,label}".
upload="${upload%%\{*}"
echo "uploading $MCPB_FILE"
curl --fail-with-body -sS -X POST \
-H "Authorization: Bearer $GH_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary @"$MCPB_FILE" \
"${upload}?name=${MCPB_FILE}" >/dev/null
finalize:
name: finalize release
needs: [changepacks, build, bundle]
if: >-
needs.changepacks.outputs.pending_releases != ''
&& needs.changepacks.outputs.pending_releases != '{}'
runs-on: ubuntu-latest
steps:
# Finalize-only: the action neither installs changepacks nor touches the
# repository here, so no checkout is needed. Running it after `build`
# is what guarantees a published release always has its binaries.
- uses: changepacks/action@main
with:
token: ${{ secrets.GITHUB_TOKEN }}
finalize_releases: ${{ needs.changepacks.outputs.pending_releases }}