Skip to content

Update Versions

Update Versions #340

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:
# The vendored agent skills go stale when the repositories they were copied
# from move, which is a thing that happens on someone else's schedule and
# that no commit here will ever announce. `skill-drift` below is the only
# job these two triggers exist for.
schedule:
- cron: "0 6 * * 1"
workflow_dispatch:
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
# `cargo test` already holds the manifest to the documents in this tree: a
# hand-edited vendored copy fails `every_origin_is_well_formed` on its digest.
# What nothing checked is the other direction — whether the copy still matches
# the repository it was taken from. That drift is caused by another project
# publishing, so it arrives without a commit here and no pull request notices.
#
# Deliberately NOT a pull request gate. The thing it reports is someone else's
# change, and failing unrelated pull requests over it is a known-bad trade:
# devup-ui pinned a dependency to `latest` next to a committed generated file
# and every pull request against main went red for a staleness none of them
# caused. A weekly run mails the owner and blocks nobody.
skill-drift:
name: refresh vendored skills
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: 24
- name: Re-vendor each skill from its source repository
# Authenticated so the GitHub API rate limit is per-repository rather
# than per-runner-IP, which unauthenticated scheduled runs share.
env:
GITHUB_TOKEN: ${{ github.token }}
run: node scripts/refresh-skills.mjs
# A red scheduled run is a notification nobody acts on. A pull request is
# the same information in a form that can be reviewed and merged, and it
# carries the refreshed bytes so nobody has to reproduce them by hand.
- name: Open a pull request when a document moved
env:
GH_TOKEN: ${{ github.token }}
BRANCH: skills/refresh-vendored-documents
shell: bash
run: |
set -euo pipefail
if git diff --quiet; then
echo "Every vendored document already matches its source."
exit 0
fi
moved="$(git diff --name-only -- crates/devup-mcp/src/server/skills \
| grep -E '/SKILL\.md$' | sed 's#.*/skills/##; s#/SKILL\.md##' | paste -sd', ' -)"
echo "Moved upstream: ${moved:-none}"
# A re-vendored document changes what `devup_skills install` writes,
# so it is a shipped behaviour change and needs a version. Without
# this the `changepack required` gate fails this pull request, and
# the refresh would sit unreleased.
jq -n --arg moved "$moved" --arg date "$(date -u +%Y-%m-%dT%H:%M:%S+00:00)" '{
changes: { "crates/devup-mcp/Cargo.toml": "Patch" },
note: ("Re-vendored the embedded agent skill documents that moved in their source repositories: " + $moved + ". The copies in this binary are what `devup_skills install` writes on a machine with no network, so a copy that has fallen behind installs rules the upstream project no longer states. Opened automatically by the scheduled skill-drift job; the bytes here are exactly what scripts/refresh-skills.mjs produced."),
date: $date
}' > .changepacks/changepack_log_skill_refresh.json
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -B "$BRANCH"
git add crates/devup-mcp/src/server/skills .changepacks
git commit -m "chore(skills): re-vendor the documents that moved upstream" \
-m "Automated by the scheduled skill-drift job. Moved: ${moved:-none}."
# Force is safe and intended here: this branch is owned by this job
# and is rewritten from main on every run, so a stale refresh never
# accumulates on top of a newer one.
git push --force origin "$BRANCH"
if [ -z "$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number // empty')" ]; then
gh pr create --base main --head "$BRANCH" \
--title "chore(skills): re-vendor the documents that moved upstream" \
--body "The embedded copies of these skills fell behind their source repositories: **${moved:-none}**.
Those copies are what \`devup_skills install\` writes on a machine with no network, so a stale one installs rules the upstream project no longer states. \`cargo test\` cannot see this: it checks the manifest against the documents in this tree, which agree with each other while both are behind.
Produced by \`node scripts/refresh-skills.mjs\` in the scheduled \`skill-drift\` job. Review the diff as you would any skill change - it is upstream prose, not generated code."
else
echo "An open pull request already tracks this branch; it now carries the newer bytes."
fi
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/tests/withdraw.test.mjs harness/runtime-audit/test/checks.test.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 }}