Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
100 changes: 45 additions & 55 deletions .claude/skills/release/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,74 +1,64 @@
---
name: release
description: Cut a txcript release — preflight the workspace, bump versions, tag, watch the publish-crates and publish-npm workflows, and verify on crates.io and npm. Use when asked to release, publish, or ship a new version of txcript.
description: Cut a txcript release — dispatch the prepare-release workflow, approve the plan, watch the tag publish to crates.io, npm, and GitHub Releases, and verify. Use when asked to release, publish, or ship a new version of txcript.
argument-hint: [version]
---

# Release txcript

Publishes the `txcript` library to crates.io and the WASM package to npm via
the tag-triggered `publish-crates` and `publish-npm` workflows. Three releases
(v0.1.0–v0.3.0) established this procedure; follow it in order. A crates.io
version is **permanent** — it can be yanked but never deleted or reused — so
every gate runs before the tag exists.
Releases are cut by two workflows. `prepare-release` computes the version
from the conventional-commit PR titles merged since the last tag, generates
the `CHANGELOG.md` section, bumps every manifest, and lands one commit plus
its tag on main. The tag runs `release`, which publishes crates.io, npm, and
CLI binaries, then creates the GitHub Release from the changelog section.

Nothing runs locally. Main is releasable by construction: `ci` proves the
crate packages, the npm bundle builds, and the CLI builds in release mode on
every PR, and the ruleset requires PRs to be up to date before merge.

## Inputs

`$ARGUMENTS`: optional target version. If absent, propose one from the diff
since the last tag: breaking API change on 0.x → minor bump, otherwise patch.
Confirm the version with the user before tagging — this is the one
irreversible decision.
`$ARGUMENTS`: optional version. Without one, the workflow derives it: a
`feat` PR since the last tag is a minor bump, otherwise patch, and a breaking
change stays a minor bump until 1.0. Preview locally with
`git-cliff --bumped-version` and `git-cliff --unreleased --tag vX.Y.Z --strip all`.

## Preflight (before any version edit)
## Procedure

1. Working tree clean, on `main`, up to date with origin. Uncommitted files
fail `cargo publish --locked`.
2. `cargo test --workspace` — bare `cargo test` skips the CLI member.
3. `cargo clippy --workspace --all-targets` — pedantic baseline,
`unwrap/expect/panic` denied in `src/`.
4. `cargo fmt --all --check` — CI gates on this and the other preflight
commands do not, so unformatted code passes every check here and fails the
run you're waiting on before tagging (cost a round trip at v0.5.0).
5. `cargo test --no-default-features` — the featureless build is a supported
surface and has broken independently of the default build before.
6. `cargo publish --dry-run --locked -p txcript` — catches packaging errors
(missing metadata, dirty files) that the workflow would only surface after
the tag is pushed.
1. Confirm main's latest CI run is green: `gh run list --branch main -L 3`.
2. Dispatch: `gh workflow run prepare-release.yml` (add `-f version=X.Y.Z`
to override). Then `gh run watch` the run.
3. The Plan job writes the version and changelog section to the run summary
and the Tag job waits on the `release` environment. Read the summary,
then approve in the Actions UI. This is the one irreversible decision: a
crates.io version can be yanked but never reused.
4. The Tag job pushes `chore(release): vX.Y.Z` and the tag atomically. If
main moved mid-run it rebuilds the commit on the new head; if the derived
version changed because of what landed, it fails and asks for a rerun.
5. The tag starts `release`. Watch it: `gh run list --workflow release.yml -L 1`
then `gh run watch <id>`. Jobs: verify, crates.io, npm, binaries for five
targets, GitHub Release.

## Bump and tag
## Verify

1. Set the new version in **all four** places: `Cargo.toml` (root `txcript`),
`cli/Cargo.toml` (`txcript-cli` — `publish = false`, but its version tracks
the library), the `txcript = { version = … }` pin inside `cli/Cargo.toml`,
and `package.json` (the npm/WASM package). The pin only *has* to move on a
minor bump — `^0.4.0` admits 0.4.3 but not 0.5.0 — which is exactly when
it's easiest to forget. `package.json` had drifted three releases behind by
v0.5.0; the npm workflow guards tag-vs-`package.json`, so drift there is a
dispatch-time failure, not a tag-time one. `cargo check` once so
`Cargo.lock` picks up the bump; commit the lockfile with the manifests.
2. Commit the bump, push, and confirm CI is green on that commit before
tagging.
3. Annotated tag matching the manifest exactly:
`git tag -a v<X.Y.Z> -m "v<X.Y.Z>" && git push origin v<X.Y.Z>`.
The workflow's first step compares `${GITHUB_REF_NAME#v}` against the
manifest and hard-fails on mismatch.
- crates.io: `cargo search txcript` or `https://crates.io/api/v1/crates/txcript`.
- npm: `npm view txcript version`.
- Release: `gh release view vX.Y.Z` shows the changelog notes, the package
links, and ten assets (five archives, five checksums).
- `cargo binstall --git https://github.com/skillsynchq/txcript txcript-cli`
installs the new binary.

## Watch and verify
## When something fails

1. The tag push fires **two** workflows: `publish-crates` (cargo, with
`cargo publish --locked -p txcript` — only the library ships) and
`publish-npm` (builds the WASM bundle and publishes via OIDC trusted
publishing — no token, npm trusts this repo + workflow filename as of
v0.5.0). Watch both runs (`gh run watch` in the background).
2. Verify crates.io: `cargo search txcript` or fetch
`https://crates.io/api/v1/crates/txcript` and check `max_version`.
3. Verify npm: `npm view txcript version`. If the npm run failed on its
version guard, `package.json` missed the bump (step 1 of Bump and tag);
fix the manifest, then re-dispatch on the tag is not possible — the tag
must carry the right `package.json`, so a failed guard means cutting a
patch release with the manifest fixed.
- `verify` fails on a manifest mismatch: the tag was pushed by hand without
a bump. Delete nothing; run `prepare-release` for the next patch.
- A publish job fails after the tag exists: fix forward. The crates.io
version may already be taken, so the next run needs a new version.
- `release` can be rerun on an existing tag with
`gh workflow run release.yml --ref vX.Y.Z`; the GitHub Release step fails
if the release already exists, which is the intended guard.

## Report

State the published version, both workflow run URLs, and the crates.io and
npm verification results.
State the version, the prepare and release run URLs, the release URL, and
the crates.io and npm verification results.
21 changes: 21 additions & 0 deletions .github/scripts/bump-version.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/usr/bin/env sh
# Set the release version everywhere it lives, then refresh the lockfile.
# Usage: bump-version.sh 0.13.0
# Four locations: the library manifest, the CLI manifest, the CLI's pin on
# the library, and package.json. All must agree or the release guards fail.
set -eu
ver="$1"
case "$ver" in
[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "::error::not a version: $ver" >&2; exit 1 ;;
esac
sed -i.bak -E "s/^version = \"[^\"]+\"/version = \"$ver\"/" Cargo.toml cli/Cargo.toml
sed -i.bak -E "s/^(txcript = \{ version = )\"[^\"]+\"/\1\"$ver\"/" cli/Cargo.toml
sed -i.bak -E "s/^( \"version\": )\"[^\"]+\"/\1\"$ver\"/" package.json
rm -f Cargo.toml.bak cli/Cargo.toml.bak package.json.bak
cargo update --workspace --quiet
for f in Cargo.toml cli/Cargo.toml package.json; do
grep -q "\"$ver\"" "$f" || { echo "::error::$f did not take version $ver" >&2; exit 1; }
done
[ "$(grep -c "\"$ver\"" cli/Cargo.toml)" = 2 ] || { echo "::error::cli/Cargo.toml needs both its version and the txcript pin at $ver" >&2; exit 1; }
echo "version $ver set in Cargo.toml, cli/Cargo.toml, package.json, Cargo.lock"
11 changes: 11 additions & 0 deletions .github/scripts/changelog-insert.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#!/usr/bin/env sh
# Insert a generated release section into CHANGELOG.md, directly above the
# newest existing section. Usage: changelog-insert.sh section.md
set -eu
section="$1"
awk -v f="$section" '
!done && /^## \[/ { while ((getline line < f) > 0) print line; print ""; done = 1 }
{ print }
END { if (!done) { while ((getline line < f) > 0) print line } }
' CHANGELOG.md > CHANGELOG.md.new
mv CHANGELOG.md.new CHANGELOG.md
26 changes: 26 additions & 0 deletions .github/scripts/release-notes.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#!/usr/bin/env sh
# Print the CHANGELOG.md section for one version, followed by links to the
# published packages. Usage: release-notes.sh 0.12.1 > notes.md
# Exits 1 when the version has no section, so a tag without changelog
# coverage fails the release job instead of shipping empty notes.
set -eu
ver="$1"
section="$(awk -v ver="$ver" '
/^## \[/ { inside = ($0 ~ "^## \\[" ver "\\]") ; next }
inside && /^\[/ { next }
inside { print }
' CHANGELOG.md)"
# Trim leading/trailing blank lines.
section="$(printf '%s\n' "$section" | sed -e '/./,$!d' | sed -e :a -e '/^\n*$/{$d;N;ba' -e '}')"
if [ -z "$section" ]; then
echo "::error::CHANGELOG.md has no section for $ver" >&2
exit 1
fi
printf '%s\n\n' "$section"
cat <<NOTES
---

- crates.io: https://crates.io/crates/txcript/$ver
- npm: https://www.npmjs.com/package/txcript/v/$ver
- docs.rs: https://docs.rs/txcript/$ver
NOTES
78 changes: 78 additions & 0 deletions .github/workflows/build-binaries.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
name: build-binaries

# Builds the CLI for every supported target and uploads one archive per
# target as a workflow artifact. release.yml calls it and attaches the
# archives to the GitHub Release; the matrix also runs on PRs that touch
# this file so a broken target is caught before a tag depends on it.
on:
workflow_call:
workflow_dispatch:
pull_request:
paths:
- .github/workflows/build-binaries.yml

permissions:
contents: read

env:
RUSTFLAGS: -D warnings

jobs:
build:
name: ${{ matrix.target }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
# Linux links glibc; the 22.04 runners set the floor at glibc 2.35.
# musl is out: the live harnesses build BoringSSL, which needs a
# C++ toolchain the musl tools do not provide.
- target: x86_64-unknown-linux-gnu
os: ubuntu-22.04
- target: aarch64-unknown-linux-gnu
os: ubuntu-22.04-arm
- target: x86_64-apple-darwin
os: macos-15-intel
- target: aarch64-apple-darwin
os: macos-latest
- target: x86_64-pc-windows-msvc
os: windows-latest
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.target }}
- run: cargo build --release --locked -p txcript-cli --target ${{ matrix.target }}
- name: Package (unix)
if: runner.os != 'Windows'
run: |
set -eu
name="txcript-${{ matrix.target }}"
mkdir -p dist
cp "target/${{ matrix.target }}/release/txcript" LICENSE dist/
tar -czf "$name.tar.gz" -C dist txcript LICENSE
shasum -a 256 "$name.tar.gz" > "$name.tar.gz.sha256"
./dist/txcript --version
- name: Package (windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
$name = "txcript-${{ matrix.target }}"
New-Item -ItemType Directory -Force dist | Out-Null
Copy-Item "target/${{ matrix.target }}/release/txcript.exe", LICENSE dist/
Compress-Archive -Path dist/txcript.exe, dist/LICENSE -DestinationPath "$name.zip"
(Get-FileHash "$name.zip" -Algorithm SHA256).Hash.ToLower() + " $name.zip" | Out-File -Encoding ascii "$name.zip.sha256"
& ./dist/txcript.exe --version
- uses: actions/upload-artifact@v4
with:
name: txcript-${{ matrix.target }}
path: |
txcript-${{ matrix.target }}.tar.gz
txcript-${{ matrix.target }}.tar.gz.sha256
txcript-${{ matrix.target }}.zip
txcript-${{ matrix.target }}.zip.sha256
if-no-files-found: error
49 changes: 48 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ name: ci

# The quality gate: every push and PR runs the same checks the publish tags
# assume. Formatting, the full clippy deny set, tests across all features,
# the wasm32 build, and the declared MSRV.
# the wasm32 build, the declared MSRV, and the packaging steps the release
# performs: crate dry-run, npm bundle, and a release build of the CLI.
on:
push:
branches: [main]
Expand Down Expand Up @@ -69,3 +70,49 @@ jobs:
with:
cache-on-failure: true
- run: cargo check --workspace --all-features

# Publish readiness. Every merged head must be releasable as-is, so the
# packaging steps the release runs are proven here, on the PR.
crate-package:
name: crates.io package (dry-run)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
cache-on-failure: true
- run: cargo publish --dry-run --locked -p txcript

npm-package:
name: npm package (dry-run)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
with:
cache-on-failure: true
- uses: taiki-e/install-action@v2
with:
tool: wasm-bindgen-cli@0.2.126
- uses: oven-sh/setup-bun@v2
- uses: actions/setup-node@v4
with:
node-version: 24
- run: bun run build
- run: npm pack --dry-run --ignore-scripts

cli-release-build:
name: CLI release build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
cache-on-failure: true
- run: cargo build --release --locked -p txcript-cli
- run: ./target/release/txcript --version
34 changes: 34 additions & 0 deletions .github/workflows/pr-title.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: pr-title

# Every PR title must be a conventional commit. Squash merges make the title
# the commit on main, and the changelog and version bump are computed from
# those commits, so an off-format title means a missing changelog line or a
# wrong version.
on:
pull_request:
types: [opened, edited, synchronize, reopened]

permissions:
pull-requests: read

jobs:
check:
name: PR title
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@v6
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
types: |
feat
fix
perf
refactor
revert
docs
test
build
ci
chore
style
Loading
Loading