From 1c6fc7fcf6b8d1446d4cd4b8c0ee28e36df36ed3 Mon Sep 17 00:00:00 2001 From: Nishant Joshi Date: Thu, 3 Sep 2026 17:59:51 -0700 Subject: [PATCH 1/3] ci: release from main by workflow, with changelog, binaries, and GitHub Releases Main becomes releasable by construction: ci now proves the crate packages, the npm bundle builds, and the CLI builds in release mode on every PR, and PR titles must be conventional commits so version and changelog can be derived from them. prepare-release, run by hand, derives the next version with git-cliff (or takes one), generates the CHANGELOG.md section, bumps every manifest, and lands the release commit and tag on main in one atomic push as the release GitHub App. If main moves during the run it rebuilds on the new head. release replaces publish-crates and publish-npm: on the tag it publishes crates.io and npm, builds CLI binaries for five targets, and creates the GitHub Release with the changelog section and the archives attached. cli/Cargo.toml carries binstall metadata pointing at those archives. Co-Authored-By: Claude Fable 5.1 --- .claude/skills/release/SKILL.md | 100 ++++++-------- .github/scripts/bump-version.sh | 21 +++ .github/scripts/changelog-insert.sh | 11 ++ .github/scripts/release-notes.sh | 26 ++++ .github/workflows/build-binaries.yml | 81 +++++++++++ .github/workflows/ci.yml | 49 ++++++- .github/workflows/pr-title.yml | 34 +++++ .github/workflows/prepare-release.yml | 131 ++++++++++++++++++ .github/workflows/publish-crates.yml | 31 ----- .github/workflows/publish-npm.yml | 47 ------- .github/workflows/release.yml | 122 ++++++++++++++++ CHANGELOG.md | 191 ++++++++++++++++++++++++++ Cargo.toml | 1 + cli/Cargo.toml | 11 ++ cliff.toml | 53 +++++++ package.json | 11 ++ 16 files changed, 786 insertions(+), 134 deletions(-) create mode 100755 .github/scripts/bump-version.sh create mode 100755 .github/scripts/changelog-insert.sh create mode 100755 .github/scripts/release-notes.sh create mode 100644 .github/workflows/build-binaries.yml create mode 100644 .github/workflows/pr-title.yml create mode 100644 .github/workflows/prepare-release.yml delete mode 100644 .github/workflows/publish-crates.yml delete mode 100644 .github/workflows/publish-npm.yml create mode 100644 .github/workflows/release.yml create mode 100644 CHANGELOG.md create mode 100644 cliff.toml diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 3f6a395..476796e 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -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 `. 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 -m "v" && git push origin v`. - 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. diff --git a/.github/scripts/bump-version.sh b/.github/scripts/bump-version.sh new file mode 100755 index 0000000..1b71ff3 --- /dev/null +++ b/.github/scripts/bump-version.sh @@ -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" diff --git a/.github/scripts/changelog-insert.sh b/.github/scripts/changelog-insert.sh new file mode 100755 index 0000000..44eb866 --- /dev/null +++ b/.github/scripts/changelog-insert.sh @@ -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 diff --git a/.github/scripts/release-notes.sh b/.github/scripts/release-notes.sh new file mode 100755 index 0000000..5500d51 --- /dev/null +++ b/.github/scripts/release-notes.sh @@ -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 < "$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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88b035f..5c59057 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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] @@ -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 diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 0000000..502c3bc --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -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_target: + 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 diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 0000000..a99933d --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,131 @@ +name: prepare-release + +# Cuts a release. Computes the next version from the conventional commits +# since the last tag (or takes one as input), generates the changelog +# section, bumps every manifest, and pushes the release commit and its tag +# to main in one atomic push. The tag then runs release.yml. +# +# Main is releasable by construction (ci.yml proves packaging on every PR), +# so this workflow only proves the bump it adds. If main moves while it +# runs, the push is rejected and the release commit is rebuilt on the new +# head; nothing partial ever lands. +# +# Pushes as the release GitHub App, which bypasses the main and tag +# rulesets and whose pushes trigger workflows (GITHUB_TOKEN's do not). +on: + workflow_dispatch: + inputs: + version: + description: "Version to release (X.Y.Z). Leave empty to derive it from the commits since the last tag." + required: false + type: string + +permissions: + contents: read + +# One release at a time. +concurrency: + group: release + cancel-in-progress: false + +jobs: + plan: + name: Plan + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - uses: taiki-e/install-action@v2 + with: + tool: git-cliff@2.14.1 + - name: Resolve version + id: version + run: | + set -eu + input='${{ inputs.version }}' + if [ -n "$input" ]; then + ver="${input#v}" + else + ver="$(git-cliff --bumped-version 2>/dev/null)" + ver="${ver#v}" + fi + echo "$ver" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' || { echo "::error::not a version: $ver"; exit 1; } + current="$(grep -m1 '^version = ' Cargo.toml | sed -E 's/version = "(.*)"/\1/')" + [ "$ver" != "$current" ] || { echo "::error::$ver is already the current version"; exit 1; } + [ "$(printf '%s\n%s\n' "$current" "$ver" | sort -V | tail -1)" = "$ver" ] || { echo "::error::$ver is below current $current"; exit 1; } + ! git rev-parse -q --verify "refs/tags/v$ver" >/dev/null || { echo "::error::tag v$ver already exists"; exit 1; } + echo "version=$ver" >> "$GITHUB_OUTPUT" + - name: Show the plan + run: | + { + echo "## Release v${{ steps.version.outputs.version }}" + echo + echo "Head: \`$(git rev-parse --short HEAD)\` (${{ github.ref_name }})" + echo + git-cliff --unreleased --tag "v${{ steps.version.outputs.version }}" --strip all 2>/dev/null + } >> "$GITHUB_STEP_SUMMARY" + + tag: + name: Tag main + needs: plan + runs-on: ubuntu-latest + # Required reviewer on the environment is the approval step: the plan + # above is in the run summary when the button appears. + environment: release + steps: + - uses: actions/create-github-app-token@v2 + id: app + with: + app-id: ${{ vars.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_KEY }} + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + token: ${{ steps.app.outputs.token }} + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: taiki-e/install-action@v2 + with: + tool: git-cliff@2.14.1 + - name: Commit as the release App + run: | + id="$(gh api "/users/${{ steps.app.outputs.app-slug }}[bot]" --jq .id)" + git config user.name '${{ steps.app.outputs.app-slug }}[bot]' + git config user.email "${id}+${{ steps.app.outputs.app-slug }}[bot]@users.noreply.github.com" + env: + GH_TOKEN: ${{ steps.app.outputs.token }} + - name: Build the release commit and push it with its tag + env: + VERSION: ${{ needs.plan.outputs.version }} + run: | + set -eu + tag="v$VERSION" + for attempt in 1 2 3 4 5; do + git fetch origin main + git reset -q --hard origin/main + git tag -d "$tag" 2>/dev/null || true + # Without an explicit version, the bump must still be what was + # approved; a feat landing mid-run would change it. + if [ -z '${{ inputs.version }}' ]; then + now="$(git-cliff --bumped-version 2>/dev/null)" + [ "${now#v}" = "$VERSION" ] || { echo "::error::main moved and the derived version is now ${now#v}, not $VERSION; rerun"; exit 1; } + fi + git-cliff --unreleased --tag "$tag" --strip all 2>/dev/null > section.md + .github/scripts/changelog-insert.sh section.md + rm section.md + .github/scripts/bump-version.sh "$VERSION" + cargo publish --dry-run --locked -p txcript + git add Cargo.toml Cargo.lock cli/Cargo.toml package.json CHANGELOG.md + git commit -q -m "chore(release): $tag" + git tag -a "$tag" -m "$tag" + if git push --atomic origin HEAD:main "refs/tags/$tag"; then + echo "pushed $tag on $(git rev-parse --short HEAD) (attempt $attempt)" + exit 0 + fi + echo "main moved during attempt $attempt; rebuilding on the new head" + done + echo "::error::could not land the release commit after 5 attempts" + exit 1 diff --git a/.github/workflows/publish-crates.yml b/.github/workflows/publish-crates.yml deleted file mode 100644 index 302b7a3..0000000 --- a/.github/workflows/publish-crates.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: publish-crates - -# Publishes the Rust crate to crates.io on a version tag, e.g. -# `git tag v0.2.0 && git push origin v0.2.0`. -# Guards that the tag matches the manifest version before publishing. -on: - push: - tags: - - "v*" - -permissions: - contents: read - -jobs: - crates-io: - name: Publish to crates.io - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - - name: Tag matches Cargo.toml version - run: | - tag="${GITHUB_REF_NAME#v}" - ver="$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[] | select(.name == "txcript") | .version')" - echo "tag=$tag manifest=$ver" - [ "$tag" = "$ver" ] || { echo "::error::tag $tag != Cargo.toml version $ver"; exit 1; } - # Only the library goes to crates.io; the cli crate is publish = false. - - run: cargo publish --locked -p txcript - env: - CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml deleted file mode 100644 index 129bec6..0000000 --- a/.github/workflows/publish-npm.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: publish-npm - -# Publishes the WASM package to npm via trusted publishing (OIDC) — -# the txcript package on npm trusts this repo + workflow filename, so -# no registry token is involved. Runs on version tags, with manual -# dispatch (on a tag ref) as the fallback. Guards that the tag matches -# package.json before publishing. -on: - push: - tags: ["v*"] - workflow_dispatch: - -permissions: - contents: read - id-token: write - -jobs: - npm: - name: Publish to npm - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - uses: dtolnay/rust-toolchain@stable - with: - targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@v2 - - uses: taiki-e/install-action@v2 - with: - tool: wasm-bindgen-cli@0.2.126 - # The `build`/`prepare` scripts run through Bun; npm still invokes the - # `prepare` lifecycle during publish, so Bun must be on PATH. - - uses: oven-sh/setup-bun@v2 - - uses: actions/setup-node@v4 - with: - node-version: 24 - registry-url: https://registry.npmjs.org - # Trusted publishing needs npm >= 11.5.1; Node 24 can bundle older. - - run: npm install -g npm@latest - - name: Tag matches package.json version - run: | - tag="${GITHUB_REF_NAME#v}" - ver="$(node -p "require('./package.json').version")" - echo "tag=$tag manifest=$ver" - [ "$tag" = "$ver" ] || { echo "::error::tag $tag != package.json version $ver"; exit 1; } - # Build the wasm artifact into pkg/, then publish via OIDC. - - run: bun run build - - run: npm publish --access public --ignore-scripts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4ec41bc --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,122 @@ +name: release + +# Publishes a version tag: the library to crates.io, the WASM package to npm, +# CLI binaries for every target, and the GitHub Release with notes from the +# CHANGELOG.md section. The release is created only after every publish +# succeeded, so a release page never points at a package that isn't there. +# +# Tags come from prepare-release.yml. A hand-pushed `vX.Y.Z` tag works too, +# as long as the manifests and changelog already carry that version. +# +# npm trusted publishing is bound to this filename. crates.io uses the +# CARGO_REGISTRY_TOKEN secret. +on: + push: + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref_name }} + cancel-in-progress: false + +jobs: + verify: + name: Verify tag + runs-on: ubuntu-latest + outputs: + version: ${{ steps.v.outputs.version }} + steps: + - uses: actions/checkout@v5 + - name: Tag matches every manifest + id: v + run: | + set -eu + case "$GITHUB_REF" in refs/tags/v*) ;; *) echo "::error::run this on a version tag, got $GITHUB_REF"; exit 1 ;; esac + tag="${GITHUB_REF_NAME#v}" + cargo="$(grep -m1 '^version = ' Cargo.toml | sed -E 's/version = "(.*)"/\1/')" + cli="$(grep -m1 '^version = ' cli/Cargo.toml | sed -E 's/version = "(.*)"/\1/')" + npm="$(node -p "require('./package.json').version")" + echo "tag=$tag Cargo.toml=$cargo cli/Cargo.toml=$cli package.json=$npm" + for v in "$cargo" "$cli" "$npm"; do + [ "$tag" = "$v" ] || { echo "::error::tag $tag does not match every manifest"; exit 1; } + done + echo "version=$tag" >> "$GITHUB_OUTPUT" + - name: Changelog has this version + run: .github/scripts/release-notes.sh "${GITHUB_REF_NAME#v}" > notes.md && cat notes.md + - uses: actions/upload-artifact@v4 + with: + name: release-notes + path: notes.md + + crates-io: + name: Publish to crates.io + needs: verify + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + # Only the library goes to crates.io; the cli crate is publish = false. + - run: cargo publish --locked -p txcript + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + + npm: + name: Publish to npm + needs: verify + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + - uses: Swatinem/rust-cache@v2 + - uses: taiki-e/install-action@v2 + with: + tool: wasm-bindgen-cli@0.2.126 + # The build script runs through Bun; npm still invokes the prepare + # lifecycle during publish, so Bun must be on PATH. + - uses: oven-sh/setup-bun@v2 + - uses: actions/setup-node@v4 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + # Trusted publishing needs npm >= 11.5.1; Node 24 can bundle older. + - run: npm install -g npm@latest + - run: bun run build + - run: npm publish --access public --ignore-scripts + + binaries: + name: Build CLI binaries + needs: verify + uses: ./.github/workflows/build-binaries.yml + + github-release: + name: Create GitHub Release + needs: [verify, crates-io, npm, binaries] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + path: artifacts + - name: Create release with notes and binaries + env: + GH_TOKEN: ${{ github.token }} + run: | + set -eu + ls -R artifacts + gh release create "$GITHUB_REF_NAME" \ + --repo "$GITHUB_REPOSITORY" \ + --verify-tag \ + --title "$GITHUB_REF_NAME" \ + --notes-file artifacts/release-notes/notes.md \ + artifacts/txcript-*/txcript-* diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5dda52d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,191 @@ +# Changelog + +All notable changes to txcript are recorded here. Entries are generated from +the pull requests merged between releases, following +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and +[Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +Each release is published to [crates.io](https://crates.io/crates/txcript), +[npm](https://www.npmjs.com/package/txcript), and +[GitHub Releases](https://github.com/skillsynchq/txcript/releases). + +## [0.12.1](https://github.com/skillsynchq/txcript/compare/v0.12.0...v0.12.1) - 2026-09-01 + +### Changed + +- Search patterns match literally instead of fuzzily. +- Discovery is cheaper, and the picker's echo stays off the search path. +- Cursor Desktop discovery and loads scale with the session instead of the + whole database. + +## [0.12.0](https://github.com/skillsynchq/txcript/compare/v0.11.0...v0.12.0) - 2026-08-25 + +### Added + +- ChatGPT as a live, pull-only harness: list and continue ChatGPT + conversations in a local harness. + +## [0.11.0](https://github.com/skillsynchq/txcript/compare/v0.10.0...v0.11.0) - 2026-08-24 + +### Added + +- fx (Vercel's coding agent) harness. +- `txcript view` draws images inline on kitty-graphics terminals and ships a + built-in pager with controls over what it shows. +- Claude Chat sessions load directly by UUID. + +### Changed + +- `view` output is tuned for terminals. + +### Fixed + +- Terminal query helpers are gated to unix. + +## [0.10.0](https://github.com/skillsynchq/txcript/compare/v0.9.1...v0.10.0) - 2026-08-21 + +### Added + +- Claude Chat as a live, pull-only harness, off by default and reachable only + through an explicit `--from`. +- `txcript export` for moving sessions between machines. +- `txcript init` installs the ctrl+shift+r session picker into your shell. + +### Changed + +- Launched harnesses receive the real tty. + +### Fixed + +- The wasm bundle is built from the txcript package. + +## [0.9.1](https://github.com/skillsynchq/txcript/compare/v0.9.0...v0.9.1) - 2026-08-20 + +### Added + +- The CLI is also a library (`txcript-cli`), exposing its commands as clap + types and a `run_session` entry point. +- A persistent search cache for `query`. + +## [0.9.0](https://github.com/skillsynchq/txcript/compare/v0.8.1...v0.9.0) - 2026-08-20 + +### Added + +- Cowork, Claude desktop's local agent mode, as a harness. + +## [0.8.1](https://github.com/skillsynchq/txcript/compare/v0.8.0...v0.8.1) - 2026-08-19 + +### Fixed + +- Foreign `tool_result` blocks are flattened when writing claude_code. + +### Changed + +- README translations moved under `docs/translations/`. + +## [0.8.0](https://github.com/skillsynchq/txcript/compare/v0.7.0...v0.8.0) - 2026-08-19 + +### Added + +- Simple, an interchange pseudo-harness for agents without a native store. +- `continue` accepts a Simple document from a file or stdin. + +## [0.7.0](https://github.com/skillsynchq/txcript/compare/v0.6.0...v0.7.0) - 2026-08-18 + +### Added + +- README translations in twelve languages. + +### Changed + +- The harness list is a capability matrix. +- opencode import satisfies the stricter session and message schema. +- CLI quality gaps found against replay-cli are closed. + +## [0.6.0](https://github.com/skillsynchq/txcript/compare/v0.5.0...v0.6.0) - 2026-08-17 + +### Added + +- Cursor desktop harness for the IDE app's `state.vscdb` sessions. +- Documentation of every harness's on-disk transcript format. + +### Changed + +- Stores and the CLI are hardened against hostile session files. +- The Claude Code summary line is anchored to a real leaf. +- npm publishing uses trusted publishing and is triggered by version tags + again. + +## [0.5.0](https://github.com/skillsynchq/txcript/compare/v0.4.3...v0.5.0) - 2026-08-08 + +### Added + +- Claude Code local commands are modelled as `Tool::Command`. +- Experimental `--move` for `txcript continue`. + +## [0.4.3](https://github.com/skillsynchq/txcript/compare/v0.4.2...v0.4.3) - 2026-08-03 + +### Added + +- `Session::updated_at`. + +## [0.4.2](https://github.com/skillsynchq/txcript/compare/v0.4.1...v0.4.2) - 2026-07-30 + +### Fixed + +- Harness session stores resolve correctly on Windows. + +## [0.4.1](https://github.com/skillsynchq/txcript/compare/v0.4.0...v0.4.1) - 2026-07-20 + +### Added + +- `txcript view`, and `#range` refs for `view` and `continue`. + +### Fixed + +- Exported codex rollouts name a real `model_provider`. +- Non-`ses` session ids are re-shaped for opencode export. + +## [0.4.0](https://github.com/skillsynchq/txcript/compare/v0.3.0...v0.4.0) - 2026-07-17 + +### Added + +- amp and antigravity harnesses. +- An MCP server with a token-conscious text projection. +- `Span` for pointing into sessions with zero-copy fragment resolution. +- `completion` subcommand; harness names are advertised to completers. +- `--cwd` scopes `list` and `query` to a folder. + +### Changed + +- `query` indexes in parallel and interactive navigation is responsive. + +## [0.3.0](https://github.com/skillsynchq/txcript/compare/v0.2.0...v0.3.0) - 2026-07-06 + +### Added + +- Grok CLI harness. +- `txcript::search`: fuzzy and substring search with a hot index. +- `txcript::local` and the `query` command with an fzf-style picker. +- `Store::delete` on every harness store. + +### Changed + +- The CLI is a separate workspace crate on clap. +- Literal occurrences rank above every gapped fuzzy alignment. +- MSRV tracks the latest stable Rust. + +## [0.2.0](https://github.com/skillsynchq/txcript/compare/v0.1.0...v0.2.0) - 2026-07-01 + +### Changed + +- The public API is hierarchical. + +## [0.1.0](https://github.com/skillsynchq/txcript/releases/tag/v0.1.0) - 2026-07-01 + +### Added + +- Canonical session model and core traits. +- claude_code, codex, pi, campfire, opencode, and Cursor harnesses. +- `txcript` CLI with `list` and cross-harness `continue`. +- Composable `TextCodec` layer and WASM bindings. diff --git a/Cargo.toml b/Cargo.toml index ac22b00..8d7b66e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ edition = "2024" rust-version = "1.96" description = "Convert coding-agent session transcripts between harness formats." license = "Apache-2.0" +homepage = "https://github.com/skillsynchq/txcript" repository = "https://github.com/skillsynchq/txcript" readme = "README.md" keywords = ["transcript", "claude", "codex", "agent", "wasm"] diff --git a/cli/Cargo.toml b/cli/Cargo.toml index e2afc2e..c13c014 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -62,3 +62,14 @@ tempfile = "3.27.0" [lints] workspace = true + +# Prebuilt binaries are attached to every GitHub Release, one archive per +# target with the executable at the archive root. Install without compiling: +# cargo binstall --git https://github.com/skillsynchq/txcript txcript-cli +[package.metadata.binstall] +pkg-url = "{ repo }/releases/download/v{ version }/txcript-{ target }{ archive-suffix }" +bin-dir = "{ bin }{ binary-ext }" +pkg-fmt = "tgz" + +[package.metadata.binstall.overrides.x86_64-pc-windows-msvc] +pkg-fmt = "zip" diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..c10054a --- /dev/null +++ b/cliff.toml @@ -0,0 +1,53 @@ +# git-cliff configuration: one CHANGELOG.md section per release, generated +# from the conventional-commit PR titles merged since the previous tag. +# The prepare-release workflow runs it; run `git-cliff --unreleased --tag +# vX.Y.Z --strip all` locally to preview the next section. + +[changelog] +trim = true +# Rendered once per release. The heading carries the compare link so the +# file stays self-contained without a reference list at the bottom. +body = """ +## [{{ version | trim_start_matches(pat="v") }}]\ +{% if previous.version %}(https://github.com/skillsynchq/txcript/compare/{{ previous.version }}...{{ version }}){% endif %} \ +- {{ timestamp | date(format="%Y-%m-%d") }} +{% for group, commits in commits | group_by(attribute="group") %} +### {{ group | striptags | trim }} +{% for commit in commits %} +- {% if commit.breaking %}**Breaking:** {% endif %}{{ commit.message | split(pat="\n") | first | upper_first }}\ +{% endfor %} +{% endfor %} +""" +# Squash merges leave "(#N)" on the subject; turn it into a PR link. +postprocessors = [ + { pattern = '\(#([0-9]+)\)', replace = "([#${1}](https://github.com/skillsynchq/txcript/pull/${1}))" }, +] + +[git] +conventional_commits = true +# A subject that escapes the PR-title check still lands under Changed +# instead of vanishing from the changelog. +filter_unconventional = false +split_commits = false +# The hidden numeric prefix fixes the section order; `striptags` removes it. +commit_parsers = [ + { message = "^chore\\(release\\)", skip = true }, + { message = "^feat", group = "Added" }, + { message = "^fix", group = "Fixed" }, + { message = "^perf", group = "Changed" }, + { message = "^refactor", group = "Changed" }, + { message = "^revert", group = "Removed" }, + { message = "^(chore|ci|docs|test|build|style)", skip = true }, + { message = ".*", group = "Changed" }, +] +filter_commits = false +tag_pattern = "v[0-9].*" +topo_order = false +sort_commits = "oldest" + +[bump] +# Pre-1.0 policy, matching the release skill: a feature is a minor bump, a +# breaking change is a minor bump too until 1.0. +features_always_bump_minor = true +breaking_always_bump_major = false +initial_tag = "v0.1.0" diff --git a/package.json b/package.json index 9399085..424067d 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,18 @@ "name": "txcript", "version": "0.12.1", "description": "Convert coding-agent session transcripts between harness formats.", + "keywords": [ + "transcript", + "claude", + "codex", + "agent", + "wasm" + ], "license": "Apache-2.0", + "homepage": "https://github.com/skillsynchq/txcript#readme", + "bugs": { + "url": "https://github.com/skillsynchq/txcript/issues" + }, "repository": { "type": "git", "url": "git+https://github.com/skillsynchq/txcript.git" From 076bee9c8407cb7539bedf4cfcca2833106776b6 Mon Sep 17 00:00:00 2001 From: Nishant Joshi Date: Thu, 3 Sep 2026 18:14:32 -0700 Subject: [PATCH 2/3] ci: build Linux CLI binaries against glibc on Ubuntu 22.04 The live harnesses build BoringSSL, which needs a C++ toolchain the musl tools do not provide. glibc targets on the 22.04 runners give a 2.35 floor and a normal toolchain. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/build-binaries.yml | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index 1f9a8b7..7c08c97 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -25,10 +25,13 @@ jobs: fail-fast: false matrix: include: - - target: x86_64-unknown-linux-musl - os: ubuntu-latest - - target: aarch64-unknown-linux-musl - os: ubuntu-24.04-arm + # 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 @@ -43,13 +46,7 @@ jobs: - uses: Swatinem/rust-cache@v2 with: key: ${{ matrix.target }} - # Static Linux binaries: bundled SQLite compiles with the musl C toolchain. - - if: contains(matrix.target, 'musl') - run: sudo apt-get update -q && sudo apt-get install -y -q musl-tools - run: cargo build --release --locked -p txcript-cli --target ${{ matrix.target }} - env: - CC_x86_64_unknown_linux_musl: musl-gcc - CC_aarch64_unknown_linux_musl: musl-gcc - name: Package (unix) if: runner.os != 'Windows' run: | From 3ed34402e739c3dae0977d892bff92b629014546 Mon Sep 17 00:00:00 2001 From: Nishant Joshi Date: Thu, 3 Sep 2026 18:25:47 -0700 Subject: [PATCH 3/3] ci: run the PR title check from the PR branch Co-Authored-By: Claude Fable 5.1 --- .github/workflows/pr-title.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml index 502c3bc..336a084 100644 --- a/.github/workflows/pr-title.yml +++ b/.github/workflows/pr-title.yml @@ -5,7 +5,7 @@ name: pr-title # those commits, so an off-format title means a missing changelog line or a # wrong version. on: - pull_request_target: + pull_request: types: [opened, edited, synchronize, reopened] permissions: