feat(skills): install the conventions instead of reciting them #234
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }} |