docs/superpowers/plans/2026-05-29-ws8b-release-workflows.md
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Draft two workflow_dispatch-only release workflows (release-sys.yml, release-safe.yml) plus the shared scripts/check-release-sync.sh helper. Workflows are authored to never publish automatically (dry_run: bool input, default true); the secret token is consumed only when dry_run: false.
Architecture: Two near-identical YAML files (one per crate, since two-call CI is simpler than one reusable workflow with matrix). One bash helper enforces version/CHANGELOG consistency; --require-date is the safety gate for real publish. The workflows are validated end-to-end in WS8c via act; WS8b only verifies the sync-check helper directly.
Tech Stack: GitHub Actions YAML, bash, cargo metadata, jq.
Spec reference: docs/superpowers/specs/2026-05-29-ws8-release-prep-checkpoint-design.md §4 WS8b.
scripts/check-release-sync.shFiles:
Create: scripts/check-release-sync.sh
Step 1: Confirm scripts/ directory state
Run: ls scripts/ 2>&1 || echo "missing"
Expected: either lists existing files or prints missing. If missing, the file creation below will fail until mkdir -p scripts runs.
Run: mkdir -p scripts
Expected: silent success or "directory exists".
Create scripts/check-release-sync.sh with this exact content:
#!/usr/bin/env bash
# Usage: check-release-sync.sh <crate-name> [--require-date]
# Verifies:
# 1. Cargo.toml version matches CHANGELOG.md latest heading version.
# 2. If --require-date: CHANGELOG heading must include an ISO date (rejects "(unreleased)").
# 3. For raylib: the raylib-sys dep version-req matches the actual raylib-sys version.
set -euo pipefail
crate="${1:-}"
require_date="${2:-}"
if [ -z "$crate" ]; then
echo "Usage: $0 <crate-name> [--require-date]" >&2
exit 2
fi
manifest_version=$(cargo metadata --no-deps --format-version 1 \
| jq -r ".packages[] | select(.name == \"$crate\") | .version")
if [ -z "$manifest_version" ] || [ "$manifest_version" = "null" ]; then
echo "ERROR: could not read version for crate '$crate' from cargo metadata" >&2
exit 1
fi
changelog_version=$(grep -oE '^## [0-9]+\.[0-9]+\.[0-9]+' CHANGELOG.md | head -1 | awk '{print $2}')
if [ -z "$changelog_version" ]; then
echo "ERROR: could not parse latest version from CHANGELOG.md heading" >&2
exit 1
fi
if [ "$manifest_version" != "$changelog_version" ]; then
echo "ERROR: version mismatch for $crate" >&2
echo " Cargo.toml: $manifest_version" >&2
echo " CHANGELOG.md latest: $changelog_version" >&2
exit 1
fi
if [ "$require_date" = "--require-date" ]; then
if ! grep -qE '^## [0-9.]+ — [0-9]{4}-[0-9]{2}-[0-9]{2}' CHANGELOG.md; then
echo "ERROR: CHANGELOG.md heading still says (unreleased) — flip to '## X.Y.Z — YYYY-MM-DD' before real publish" >&2
exit 1
fi
fi
if [ "$crate" = "raylib" ]; then
sys_req=$(grep -E '^raylib-sys = .*version = "[0-9.]+"' raylib/Cargo.toml \
| grep -oE '"[0-9.]+"' | head -1 | tr -d '"')
if [ -z "$sys_req" ]; then
echo "ERROR: could not parse raylib-sys dep pin from raylib/Cargo.toml" >&2
exit 1
fi
if [ "$sys_req" != "$manifest_version" ]; then
echo "ERROR: raylib-sys dep pin $sys_req != raylib version $manifest_version" >&2
exit 1
fi
fi
echo "OK: $crate @ $manifest_version, CHANGELOG @ $changelog_version${require_date:+, date-required passed}"
Run: chmod +x scripts/check-release-sync.sh
Expected: silent success.
Run: ls -la scripts/check-release-sync.sh
Expected: -rwxr-xr-x or similar (executable bit set).
--require-dateFiles: none (verification only)
Run: bash scripts/check-release-sync.sh raylib-sys
Expected: exits 0 with OK: raylib-sys @ 6.0.0, CHANGELOG @ 6.0.0. (Requires WS8a's version bump to have landed first.)
Run: bash scripts/check-release-sync.sh raylib-sys; echo "exit=$?"
Expected: last line exit=0.
--require-dateFiles: none (verification only)
Run: bash scripts/check-release-sync.sh raylib
Expected: exits 0 with OK: raylib @ 6.0.0, CHANGELOG @ 6.0.0.
--require-date (proves the gate works)Files: none (verification only)
Run: bash scripts/check-release-sync.sh raylib-sys --require-date; echo "exit=$?"
Expected: ERROR: CHANGELOG.md heading still says (unreleased) ... on stderr; last line exit=1.
Run: bash scripts/check-release-sync.sh raylib --require-date; echo "exit=$?"
Expected: same failure shape; exit=1.
This is exactly the behavior we want — the workflows will only pass --require-date when dry_run: false, so act validation in WS8c (which always uses dry_run: true) sees the soft path, and the real publish path is gated.
.github/workflows/release-sys.ymlFiles:
Create: .github/workflows/release-sys.yml
Step 1: Confirm .github/workflows/ exists
Run: ls .github/workflows/
Expected: lists book.yml, check.yml, sanitizers.yml, test.yml, web.yml.
Create .github/workflows/release-sys.yml with this exact content:
name: Release raylib-sys
on:
workflow_dispatch:
inputs:
dry_run:
description: "Dry-run only (no cargo publish). Default: true. Set to false for real publish."
type: boolean
default: true
jobs:
publish-sys:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
with:
submodules: recursive
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Install jq (sync-check dep)
run: sudo apt-get update && sudo apt-get install -y jq
- name: Version + CHANGELOG sync check
run: |
if [ "${{ inputs.dry_run }}" = "false" ]; then
bash scripts/check-release-sync.sh raylib-sys --require-date
else
bash scripts/check-release-sync.sh raylib-sys
fi
- name: cargo publish (dry-run, always)
run: cargo publish -p raylib-sys --dry-run
- name: cargo publish (real)
if: ${{ inputs.dry_run == false }}
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_TOKEN }}
run: cargo publish -p raylib-sys
Run: head -5 .github/workflows/release-sys.yml
Expected: starts with name: Release raylib-sys.
.github/workflows/release-safe.ymlFiles:
Create: .github/workflows/release-safe.yml
Step 1: Write the workflow
Create .github/workflows/release-safe.yml with this exact content:
name: Release raylib
on:
workflow_dispatch:
inputs:
dry_run:
description: "Dry-run only (no cargo publish). Default: true. Set to false for real publish. NOTE: cargo publish --dry-run for raylib requires raylib-sys 6.0.0 to exist on crates.io; the dry-run step will fail at registry resolution otherwise."
type: boolean
default: true
jobs:
publish-safe:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
with:
submodules: recursive
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Install jq (sync-check dep)
run: sudo apt-get update && sudo apt-get install -y jq
- name: Version + CHANGELOG sync check
run: |
if [ "${{ inputs.dry_run }}" = "false" ]; then
bash scripts/check-release-sync.sh raylib --require-date
else
bash scripts/check-release-sync.sh raylib
fi
- name: cargo publish (dry-run, always)
run: cargo publish -p raylib --dry-run
- name: cargo publish (real)
if: ${{ inputs.dry_run == false }}
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_TOKEN }}
run: cargo publish -p raylib
Run: head -5 .github/workflows/release-safe.yml
Expected: starts with name: Release raylib.
Files: none (verification only)
python -c yaml.safe_load as a lightweight parser checkRun:
python -c "import yaml; yaml.safe_load(open('.github/workflows/release-sys.yml'))" && echo "release-sys OK"
python -c "import yaml; yaml.safe_load(open('.github/workflows/release-safe.yml'))" && echo "release-safe OK"
Expected: both print OK. If Python isn't on PATH, fall back to:
gh workflow view release-sys --repo Dacode45/ms-raylib-rs 2>&1 | head -5
(but gh workflow view requires the workflow to be on a pushed branch; if it isn't yet, skip and let WS8c's act run be the YAML validation).
Run: grep -n 'inputs.dry_run' .github/workflows/release-sys.yml .github/workflows/release-safe.yml
Expected: each file shows the if: ${{ inputs.dry_run == false }} gate on the real publish step + the conditional --require-date invocation in the sync-check step.
Files:
Create: scripts/check-release-sync.sh
Create: .github/workflows/release-sys.yml
Create: .github/workflows/release-safe.yml
Step 1: Confirm working-tree contents
Run: git status --short
Expected: three new untracked files listed. Verify TODO.md, prompt.md, next-session-prompt.md are still untracked and NOT in any staging list.
git add -A)git add scripts/check-release-sync.sh .github/workflows/release-sys.yml .github/workflows/release-safe.yml
Run: git diff --cached --stat
Expected: three new files, exactly the ones above.
git commit -m "$(cat <<'EOF'
feat(ws8b): draft release-sys.yml + release-safe.yml + sync-check helper
Two workflow_dispatch-only release workflows, each with a `dry_run: bool`
input (default `true`). Real publish is gated by
`if: ${{ inputs.dry_run == false }}` so the CARGO_REGISTRY_TOKEN secret
is only consumed during an intentional real publish.
`scripts/check-release-sync.sh` enforces:
- Cargo.toml version == CHANGELOG.md latest heading version
- (with --require-date) CHANGELOG heading has an ISO date, not "(unreleased)"
- For raylib only: the raylib-sys dep version-req matches the
just-bumped raylib-sys version
The workflows pass --require-date to the sync-check only when
`dry_run: false`, so act-based validation in WS8c passes with
the CHANGELOG still saying "(unreleased)".
The workflows do NOT run automatically; no `on: push` or `on: tags`
triggers, only `workflow_dispatch`.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
EOF
)"
Run: git log -1 --stat
Expected: shows the commit with the three new files and the WS8b-shaped message.
scripts/check-release-sync.sh exists, is executable, and passes for both crates without --require-date.--require-date (CHANGELOG still says (unreleased))..github/workflows/release-sys.yml and .github/workflows/release-safe.yml exist.workflow_dispatch-only (no on: push or on: tags).if: ${{ inputs.dry_run == false }}.gh workflow view or act in WS8c — at minimum one syntax check passed).Next: WS8c (act-based validation).