From fbfa31c28c8a2bcc07a9910c8da9961850e160f5 Mon Sep 17 00:00:00 2001 From: spacebear Date: Mon, 27 Jul 2026 18:10:02 +0000 Subject: [PATCH 1/8] Align test-utils requirement on mailroom with its version payjoin-mailroom's manifest is at 0.1.2, but payjoin-test-utils still declared a dependency requirement of 0.1.1 on it. Align the two so the forthcoming release invariant check, which requires every workspace member's requirement on a release crate to equal that crate's manifest version, holds on master. --- payjoin-test-utils/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/payjoin-test-utils/Cargo.toml b/payjoin-test-utils/Cargo.toml index e980f9eda..dd9b42b2b 100644 --- a/payjoin-test-utils/Cargo.toml +++ b/payjoin-test-utils/Cargo.toml @@ -33,7 +33,7 @@ http = { version = "1.3.1", optional = true } ohttp = { package = "bitcoin-ohttp", version = "0.6.0", optional = true } once_cell = "1.21.3" payjoin = { version = "1.0.0-rc.6", default-features = false } -payjoin-mailroom = { version = "0.1.1", features = ["_manual-tls"] } +payjoin-mailroom = { version = "0.1.2", features = ["_manual-tls"] } tar = "0.4.20" # Pin to 0.14.7 (last version to support our MSRV 1.85) rcgen = { version = "=0.14.7", optional = true } From 3e2ed49166bf7864b1f7878591159941a3bfd402 Mon Sep 17 00:00:00 2001 From: spacebear Date: Mon, 27 Jul 2026 18:14:12 +0000 Subject: [PATCH 2/8] Add release script library and invariant checks Start the crates.io release tooling with a sourced helper library and an offline invariant checker, mirroring how contrib/lockfile.sh is sourced by contrib/test.sh. contrib/release/crates.sh defines the release crate set, reads versions from `cargo metadata` parsed with jq, maps a `-v` tag back to its crate, and provides shared helpers for sibling dependencies and crates.io lookups. contrib/release/check-invariants.sh turns the checklist's consistency steps into machine checks: every workspace member's requirement on a release crate equals its manifest version, both tracked lock files record that version, and the crate's CHANGELOG.md carries a section for it. Add a `release` devshell pinning the tools these scripts need (a Rust toolchain, jq, gnupg, curl, git, and GNU grep/sed/awk/coreutils) so they run identically under `nix develop .#release -c` locally and in CI. --- contrib/release/check-invariants.sh | 47 ++++++++++++++++++++ contrib/release/crates.sh | 66 +++++++++++++++++++++++++++++ flake.nix | 18 ++++++++ 3 files changed, 131 insertions(+) create mode 100755 contrib/release/check-invariants.sh create mode 100644 contrib/release/crates.sh diff --git a/contrib/release/check-invariants.sh b/contrib/release/check-invariants.sh new file mode 100755 index 000000000..0ef8b0b8e --- /dev/null +++ b/contrib/release/check-invariants.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# +# Check that each release crate is internally consistent: every dependent's +# version requirement on it matches its manifest version, both tracked lock +# files record that version, and its CHANGELOG.md has a section for it. +# Offline, a few seconds. Checks all release crates, or the ones named. +set -euo pipefail +# shellcheck source=contrib/release/crates.sh +source "$(dirname "${BASH_SOURCE[0]}")/crates.sh" + +crates="${*:-$RELEASE_CRATES}" +RELEASE_METADATA="$(cargo_metadata)" +status=0 +problem() { + echo "$*" >&2 + status=1 +} + +# Print a crate's version as recorded in a Cargo lock file. +lockfile_version() { + grep -A1 "^name = \"$2\"\$" "$1" | sed -n 's/^version = "\(.*\)"/\1/p' +} + +echo "Checking version requirements on: $crates" +# Version requirements that do not match the depended-on crate's version. +mismatches="$(cargo_metadata | jq -r --argjson t "$(printf '%s' "$crates" | jq -R 'split(" ")')" ' + (.packages | map({key: .name, value: .version}) | from_entries) as $ver + | .packages[] as $p + | $p.dependencies[] + | select(.path == null and (.name | IN($t[]))) + | select((.req | ltrimstr("^") | ltrimstr("=")) != $ver[.name]) + | "\($p.name) requires \(.name) \(.req), expected \($ver[.name])" +')" +[ -z "$mismatches" ] || problem "$mismatches" + +for crate in $crates; do + version="$(manifest_version "$crate")" + echo "Checking $crate $version: lock files and CHANGELOG.md" + for lock in Cargo-minimal.lock Cargo-recent.lock; do + recorded="$(lockfile_version "$REPO_ROOT/$lock" "$crate")" + [ "$recorded" = "$version" ] || problem "$lock records $crate ${recorded:-}, expected $version" + done + grep -qxF "## $version" "$REPO_ROOT/$crate/CHANGELOG.md" || problem "$crate/CHANGELOG.md has no ## $version section" +done + +[ "$status" -eq 0 ] && echo "Release invariants hold for: $crates" +exit "$status" diff --git a/contrib/release/crates.sh b/contrib/release/crates.sh new file mode 100644 index 000000000..7c0a8b07d --- /dev/null +++ b/contrib/release/crates.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# +# Shared helpers for the release scripts, sourced like contrib/lockfile.sh. +# Versions come from `cargo metadata` parsed with jq, not from grepping +# manifests. + +RELEASE_CRATES="payjoin payjoin-cli payjoin-mailroom" + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +# `cargo metadata` for the workspace. Command substitutions run in a subshell +# that cannot write the cache back, so a caller that queries repeatedly sets +# it once in its own shell: RELEASE_METADATA="$(cargo_metadata)". +cargo_metadata() { + if [ -n "${RELEASE_METADATA:-}" ]; then + printf '%s' "$RELEASE_METADATA" + else + cargo metadata --no-deps --format-version 1 --manifest-path "$REPO_ROOT/Cargo.toml" + fi +} + +# Print a crate's manifest version. +manifest_version() { + cargo_metadata | jq -r --arg c "$1" '.packages[] | select(.name == $c) | .version' +} + +# Print the release crate a `-` tag belongs to, or fail. +crate_from_tag() { + local crate="${1%-[0-9]*}" + case " $RELEASE_CRATES " in + *" $crate "*) printf '%s' "$crate" ;; + *) return 1 ;; + esac +} + +# Print the version in a `-` tag. +version_from_tag() { + local crate + crate="$(crate_from_tag "$1")" || return 1 + printf '%s' "${1#"$crate"-}" +} + +# Succeed if the version has a semver pre-release suffix. +is_prerelease() { + case "$1" in + *-*) return 0 ;; + *) return 1 ;; + esac +} + +# Print "\t" for each sibling release-crate dependency of a crate. +sibling_deps() { + local rel + rel="$(printf '%s' "$RELEASE_CRATES" | jq -R 'split(" ")')" + cargo_metadata | jq -r --arg self "$1" --argjson rel "$rel" ' + .packages[] | select(.name == $self) | .dependencies[] + | select(.path == null and .name != $self and (.name | IN($rel[]))) + | "\(.name)\t\(.req)" + ' +} + +# Succeed if exists on crates.io. +crate_published() { + curl -sfL -o /dev/null -H "User-Agent: rust-payjoin release tooling" \ + "https://crates.io/api/v1/crates/$1/$2" +} diff --git a/flake.nix b/flake.nix index 4e8cc538d..8a33ea980 100644 --- a/flake.nix +++ b/flake.nix @@ -452,6 +452,23 @@ inherit buildInputs; src = dummySrc; }; + + # Pinned tooling for the contrib/release scripts, so they run + # identically under `nix develop .#release -c` locally and in CI. + releaseDevShell = pkgs.mkShell { + name = "release"; + packages = with pkgs; [ + rustToolchains.stable + jq + gnupg + curl + git + gnugrep + gnused + gawk + coreutils + ]; + }; in { packages = @@ -462,6 +479,7 @@ }; devShells = devShells // { default = devShells.nightly; + release = releaseDevShell; python = pythonDevShell; javascript = javascriptDevShell; csharp = csharpDevShell; From a7f09a319a47be378205aeeb974fe455fdaf3f0a Mon Sep 17 00:00:00 2001 From: spacebear Date: Mon, 27 Jul 2026 18:30:21 +0000 Subject: [PATCH 3/8] Add changelog extraction script extract-changelog.sh prints a crate's CHANGELOG.md section for a given version, the text between its `## ` heading and the next `## ` heading with surrounding blank lines trimmed. The release workflow uses it to fill the GitHub release body from the hand-written changelog. --- contrib/release/extract-changelog.sh | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100755 contrib/release/extract-changelog.sh diff --git a/contrib/release/extract-changelog.sh b/contrib/release/extract-changelog.sh new file mode 100755 index 000000000..8bd7549be --- /dev/null +++ b/contrib/release/extract-changelog.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# +# Print a crate's CHANGELOG.md section for a version: the lines between the +# `## ` heading and the next `## `, with blank edges trimmed. Used +# to fill the GitHub release body. +set -euo pipefail +# shellcheck source=contrib/release/crates.sh +source "$(dirname "${BASH_SOURCE[0]}")/crates.sh" + +[ "$#" -eq 2 ] || { + echo "usage: extract-changelog.sh " >&2 + exit 1 +} + +# awk matches the heading exactly (a version has regex-special dots); sed +# drops leading blank lines and the command substitution drops trailing ones. +section="$(awk -v h="## $2" ' + $0 == h { inside = 1; next } + inside && /^## / { exit } + inside +' "$REPO_ROOT/$1/CHANGELOG.md" | sed '/./,$!d')" + +[ -n "$section" ] || { + echo "extract-changelog: no ## $2 section in $1/CHANGELOG.md" >&2 + exit 1 +} +printf '%s\n' "$section" From c5a7110733fbecc9cdd79366c514a49b8a5ad10f Mon Sep 17 00:00:00 2001 From: spacebear Date: Mon, 27 Jul 2026 18:30:43 +0000 Subject: [PATCH 4/8] Add tag and publication verification scripts verify-tag.sh is the release gate. It confirms the tag is annotated, its GPG signature verifies against a throwaway keyring seeded only with contrib/release/keys/*.asc, the tagged commit is an ancestor of origin/master, the tag version matches the manifest, the release invariants hold, and every sibling release-crate dependency is already on crates.io. It reads the working tree, so it runs at the tagged commit, which the workflow guarantees. check-bump.sh runs on pull requests. For each release crate whose version changed relative to the PR base it checks the bump is consistent (check-invariants) and still publishes (cargo publish --dry-run), and no-ops when no release version changed. A sibling release crate not yet on crates.io is skipped, not failed, since a PR may bump two crates at once. verify-published.sh runs after upload. It polls crates.io and fails if the published checksum does not match the sha256 of the attested .crate, then polls docs.rs until it reports the docs built. --- contrib/release/check-bump.sh | 58 ++++++++++++++++++++++++++ contrib/release/verify-published.sh | 52 ++++++++++++++++++++++++ contrib/release/verify-tag.sh | 63 +++++++++++++++++++++++++++++ 3 files changed, 173 insertions(+) create mode 100755 contrib/release/check-bump.sh create mode 100755 contrib/release/verify-published.sh create mode 100755 contrib/release/verify-tag.sh diff --git a/contrib/release/check-bump.sh b/contrib/release/check-bump.sh new file mode 100755 index 000000000..7be846c98 --- /dev/null +++ b/contrib/release/check-bump.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# +# Pull request check. For each release crate whose version changed relative +# to the base commit, confirm the bump is consistent (check-invariants) and +# the crate still publishes (cargo publish --dry-run). No-ops when no release +# version changed. A sibling release crate not yet on crates.io is skipped, +# not failed, since a PR may bump two crates at once. +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=contrib/release/crates.sh +source "$DIR/crates.sh" +cd "$REPO_ROOT" +# shellcheck source=contrib/lockfile.sh +source contrib/lockfile.sh + +[ "$#" -eq 1 ] || { + echo "usage: check-bump.sh " >&2 + exit 1 +} +base="$1" + +# A crate's [package] version at a git ref (the only line-anchored `version`). +version_at() { + git show "$1:$2/Cargo.toml" 2>/dev/null | sed -n 's/^version = "\(.*\)"/\1/p' | head -1 +} + +RELEASE_METADATA="$(cargo_metadata)" +echo "Comparing release crate versions against $base" +changed=() +for crate in $RELEASE_CRATES; do + [ "$(version_at "$base" "$crate")" = "$(manifest_version "$crate")" ] || changed+=("$crate") +done + +if [ "${#changed[@]}" -eq 0 ]; then + echo "No release crate version changed" + exit 0 +fi + +echo "Version changed for: ${changed[*]}" +"$DIR/check-invariants.sh" "${changed[@]}" + +use_lockfile Cargo-recent.lock +for crate in "${changed[@]}"; do + unpublished="" + while IFS=$'\t' read -r dep req; do + [ -n "$dep" ] || continue + v="${req#^}" + v="${v#=}" + crate_published "$dep" "$v" || unpublished="$unpublished $dep $v" + done < <(sibling_deps "$crate") + if [ -n "$unpublished" ]; then + echo "Skipping $crate dry-run; sibling not on crates.io yet:$unpublished" + continue + fi + echo "Dry-run publishing $crate" + cargo publish --dry-run --locked -q -p "$crate" + echo "$crate packages and publishes cleanly" +done diff --git a/contrib/release/verify-published.sh b/contrib/release/verify-published.sh new file mode 100755 index 000000000..9abc85ac9 --- /dev/null +++ b/contrib/release/verify-published.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# +# After publishing, confirm crates.io reports a checksum matching the +# attested .crate and docs.rs built the docs. Runs after the upload, so a +# mismatch is a loud alert, not a gate. Poll counts and interval are +# overridable via the environment; the defaults suit CI. +set -euo pipefail + +[ "$#" -eq 3 ] || { + echo "usage: verify-published.sh " >&2 + exit 1 +} +crate="$1" +version="$2" +file="$3" +ua="rust-payjoin release verify-published" +interval="${POLL_INTERVAL:-10}" +die() { + echo "verify-published: $*" >&2 + exit 1 +} + +# Poll a URL until its jq filter yields non-empty output; print it, or fail. +poll() { + local attempts="$1" url="$2" filter="$3" out i + for ((i = 0; i < attempts; i++)); do + out="$(curl -sfL -H "User-Agent: $ua" "$url" 2>/dev/null | jq -r "$filter" 2>/dev/null || true)" + [ -n "$out" ] && { + printf '%s' "$out" + return 0 + } + sleep "$interval" + done + return 1 +} + +[ -f "$file" ] || die "no such file: $file" +local_sha="$(sha256sum "$file" | cut -d' ' -f1)" + +echo "Waiting for $crate $version on crates.io (${CRATES_IO_ATTEMPTS:-30} checks, ${interval}s apart)" +published_sha="$(poll "${CRATES_IO_ATTEMPTS:-30}" \ + "https://crates.io/api/v1/crates/$crate/$version" '.version.checksum // empty')" || + die "$crate $version never appeared on crates.io" +[ "$published_sha" = "$local_sha" ] || + die "checksum mismatch: crates.io $published_sha vs local $local_sha" +echo "crates.io checksum matches the attested .crate ($local_sha)" + +echo "Waiting for docs.rs to build $crate $version (${DOCS_RS_ATTEMPTS:-60} checks, ${interval}s apart)" +poll "${DOCS_RS_ATTEMPTS:-60}" \ + "https://docs.rs/crate/$crate/$version/status.json" 'select(.doc_status == true) | "built"' >/dev/null || + die "docs.rs did not build $crate $version" +echo "docs.rs built $crate $version" diff --git a/contrib/release/verify-tag.sh b/contrib/release/verify-tag.sh new file mode 100755 index 000000000..511805422 --- /dev/null +++ b/contrib/release/verify-tag.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# +# Release gate. Confirms a `-` tag is annotated, signed by a +# key in contrib/release/keys/, an ancestor of origin/master, matches the +# crate's manifest version and the release invariants, and that its sibling +# release-crate dependencies are already on crates.io. +# +# Checks against the working tree, so run it at the tagged commit, which the +# release workflow does. +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=contrib/release/crates.sh +source "$DIR/crates.sh" + +[ "$#" -eq 1 ] || { + echo "usage: verify-tag.sh " >&2 + exit 1 +} +tag="$1" +die() { + echo "verify-tag: $*" >&2 + exit 1 +} + +crate="$(crate_from_tag "$tag")" || die "$tag is not a - release tag" +version="$(version_from_tag "$tag")" + +echo "Verifying $tag as a release of $crate $version" + +echo "Checking the tag is annotated" +[ "$(git -C "$REPO_ROOT" cat-file -t "$tag" 2>/dev/null)" = tag ] || + die "$tag is not an annotated tag" + +echo "Checking the tag is signed by a key in contrib/release/keys/" +# The throwaway keyring holds only trusted keys, so a successful +# verification against it proves the signer is trusted. +home="$(mktemp -d)" +trap 'rm -rf "$home"' EXIT +gpg --homedir "$home" --batch --quiet --import "$REPO_ROOT"/contrib/release/keys/*.asc 2>/dev/null || + die "no importable keys in contrib/release/keys/" +GNUPGHOME="$home" git -C "$REPO_ROOT" verify-tag "$tag" >/dev/null 2>&1 || + die "$tag is not signed by a trusted key" + +echo "Checking the tag is an ancestor of origin/master" +git -C "$REPO_ROOT" merge-base --is-ancestor "$tag" origin/master 2>/dev/null || + die "$tag is not an ancestor of origin/master" + +echo "Checking the $crate manifest version is $version" +RELEASE_METADATA="$(cargo_metadata)" +manifest="$(manifest_version "$crate")" +[ "$manifest" = "$version" ] || die "$crate manifest version is $manifest, tag says $version" + +"$DIR/check-invariants.sh" "$crate" || die "release invariants fail for $crate" + +echo "Checking sibling release-crate dependencies are on crates.io" +while IFS=$'\t' read -r dep req; do + [ -n "$dep" ] || continue + v="${req#^}" + v="${v#=}" + crate_published "$dep" "$v" || die "$dep $v is not on crates.io yet; publish it before $crate" +done < <(sibling_deps "$crate") + +echo "$tag is cleared for release" From cbcef72619bca293c6158f3f30176737a976d1a6 Mon Sep 17 00:00:00 2001 From: spacebear Date: Mon, 27 Jul 2026 18:39:51 +0000 Subject: [PATCH 5/8] Add the crates.io release workflow Wire the release scripts into CI as thin callers running in the pinned `release` devshell. A pull request that bumps a release crate's version runs check-bump.sh; the paths filter keeps it off unrelated PRs and the script no-ops when no version changed. On a pushed `-v` tag it verifies the tag, waits for the test, lint, and format checks at the tagged commit, then publishes. Publishing runs in the `crates-release` environment so it pauses for a required reviewer, packages the crate, attests build provenance on the .crate, mints a short-lived crates.io token via keyless OIDC, and publishes. A follow-up job cuts the GitHub release with the changelog section plus auto-generated notes and the attested .crate, and a final job confirms crates.io reports a matching checksum and docs.rs built. wait-for-ci scopes its check wait by regexp rather than the whole commit, so it does not deadlock on this workflow's own downstream publish jobs. --- .github/workflows/crates-release.yml | 207 +++++++++++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 .github/workflows/crates-release.yml diff --git a/.github/workflows/crates-release.yml b/.github/workflows/crates-release.yml new file mode 100644 index 000000000..a6bf2310c --- /dev/null +++ b/.github/workflows/crates-release.yml @@ -0,0 +1,207 @@ +name: Release + +# A pull request that bumps a release crate's version is checked for +# consistency and publishability. Pushing a `-` tag verifies +# the tag, waits for CI at that commit, then publishes to crates.io through +# the `crates-release` environment (required reviewer) with keyless OIDC, +# cuts the GitHub release, and confirms the upload. + +on: + pull_request: + paths: + - payjoin/Cargo.toml + - payjoin-cli/Cargo.toml + - payjoin-mailroom/Cargo.toml + push: + tags: + - payjoin-[0-9]* + - payjoin-cli-[0-9]* + - payjoin-mailroom-[0-9]* + +permissions: + contents: read + +jobs: + check-bump: + name: Check release version bump + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Install nix + uses: DeterminateSystems/determinate-nix-action@main + - name: Use nix cache + uses: DeterminateSystems/magic-nix-cache-action@main + - name: Check the bump is consistent and publishable + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: nix --quiet develop .#release -c ./contrib/release/check-bump.sh "$BASE_SHA" + + verify-tag: + name: Verify release tag + if: github.event_name == 'push' + runs-on: ubuntu-latest + outputs: + crate: ${{ steps.meta.outputs.crate }} + version: ${{ steps.meta.outputs.version }} + prerelease: ${{ steps.meta.outputs.prerelease }} + steps: + - name: Checkout repo + uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Fetch master + run: git fetch --no-tags origin +refs/heads/master:refs/remotes/origin/master + - name: Install nix + uses: DeterminateSystems/determinate-nix-action@main + - name: Use nix cache + uses: DeterminateSystems/magic-nix-cache-action@main + - name: Compute tag metadata + id: meta + env: + TAG: ${{ github.ref_name }} + run: | + source contrib/release/crates.sh + version="$(version_from_tag "$TAG")" + { + echo "crate=$(crate_from_tag "$TAG")" + echo "version=$version" + is_prerelease "$version" && echo "prerelease=true" || echo "prerelease=false" + } >>"$GITHUB_OUTPUT" + - name: Verify tag + env: + TAG: ${{ github.ref_name }} + run: nix --quiet develop .#release -c ./contrib/release/verify-tag.sh "$TAG" + + wait-for-ci: + name: Wait for CI + if: github.event_name == 'push' + needs: verify-tag + runs-on: ubuntu-latest + permissions: + checks: read + contents: read + steps: + # Wait only for the correctness checks (rust.yml, format.yml) at the + # tagged commit. Scoping by regexp avoids waiting on this workflow's + # own downstream publish jobs, which would deadlock. + - name: Wait for test, lint, and format checks + uses: lewagon/wait-on-check-action@v1.9.0 + with: + ref: ${{ github.sha }} + check-regexp: ^(Test|Lint|Format) + repo-token: ${{ secrets.GITHUB_TOKEN }} + wait-interval: 30 + + publish: + name: Publish to crates.io + if: github.event_name == 'push' + needs: [verify-tag, wait-for-ci] + runs-on: ubuntu-latest + environment: crates-release + permissions: + id-token: write + attestations: write + contents: read + env: + RUSTUP_TOOLCHAIN: stable + CRATE: ${{ needs.verify-tag.outputs.crate }} + VERSION: ${{ needs.verify-tag.outputs.version }} + steps: + - name: Checkout repo + uses: actions/checkout@v6 + - name: Install toolchain + uses: dtolnay/rust-toolchain@stable + - name: Use cache + uses: Swatinem/rust-cache@v2 + - name: Prepare lockfile + run: cp Cargo-recent.lock Cargo.lock + - name: Package the crate + run: cargo package --locked -p "$CRATE" + - name: Attest build provenance + uses: actions/attest-build-provenance@v4 + with: + subject-path: target/package/${{ needs.verify-tag.outputs.crate }}-${{ needs.verify-tag.outputs.version }}.crate + - name: Authenticate to crates.io + id: auth + uses: rust-lang/crates-io-auth-action@v1 + - name: Publish to crates.io + env: + CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} + run: cargo publish --locked -p "$CRATE" + - name: Upload packaged crate + uses: actions/upload-artifact@v4 + with: + name: crate + path: target/package/${{ needs.verify-tag.outputs.crate }}-${{ needs.verify-tag.outputs.version }}.crate + + github-release: + name: Create GitHub release + if: github.event_name == 'push' + needs: [verify-tag, publish] + runs-on: ubuntu-latest + permissions: + contents: write + env: + CRATE: ${{ needs.verify-tag.outputs.crate }} + VERSION: ${{ needs.verify-tag.outputs.version }} + steps: + - name: Checkout repo + uses: actions/checkout@v6 + - name: Install nix + uses: DeterminateSystems/determinate-nix-action@main + - name: Use nix cache + uses: DeterminateSystems/magic-nix-cache-action@main + - name: Download packaged crate + uses: actions/download-artifact@v4 + with: + name: crate + path: dist + - name: Generate SHA256SUMS + run: (cd dist && sha256sum ./*.crate >SHA256SUMS) + - name: Extract changelog section + id: changelog + run: | + echo "Extracting the ## $VERSION section of $CRATE/CHANGELOG.md" + { + echo "body<>"$GITHUB_OUTPUT" + - name: Create release + uses: softprops/action-gh-release@v3 + with: + tag_name: ${{ github.ref_name }} + name: Release ${{ github.ref_name }} + body: ${{ steps.changelog.outputs.body }} + generate_release_notes: true + prerelease: ${{ needs.verify-tag.outputs.prerelease }} + files: | + dist/*.crate + dist/SHA256SUMS + + verify-published: + name: Verify publication + if: github.event_name == 'push' + needs: [verify-tag, publish] + runs-on: ubuntu-latest + env: + CRATE: ${{ needs.verify-tag.outputs.crate }} + VERSION: ${{ needs.verify-tag.outputs.version }} + steps: + - name: Checkout repo + uses: actions/checkout@v6 + - name: Install nix + uses: DeterminateSystems/determinate-nix-action@main + - name: Use nix cache + uses: DeterminateSystems/magic-nix-cache-action@main + - name: Download packaged crate + uses: actions/download-artifact@v4 + with: + name: crate + path: dist + - name: Verify crates.io and docs.rs + run: nix --quiet develop .#release -c ./contrib/release/verify-published.sh "$CRATE" "$VERSION" "dist/$CRATE-$VERSION.crate" From 0a6c218f22a42505e4b962fdeff19b529c2f0cc9 Mon Sep 17 00:00:00 2001 From: spacebear Date: Mon, 27 Jul 2026 18:40:11 +0000 Subject: [PATCH 6/8] Narrow the mailroom image tag filter The Docker image workflow triggered on `payjoin-mailroom-**`, which also matched any future subtag. Narrow it to `payjoin-mailroom-[0-9]*` to align with the release tag scheme and to keep it from firing on unrelated tags. --- .github/workflows/release-image.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-image.yml b/.github/workflows/release-image.yml index b60f6f750..64a46c340 100644 --- a/.github/workflows/release-image.yml +++ b/.github/workflows/release-image.yml @@ -6,7 +6,7 @@ on: - payjoin-mailroom/** push: tags: - - payjoin-mailroom-** + - payjoin-mailroom-[0-9]* workflow_dispatch: jobs: From 38f6adeb85d970f8d60f9a69ac0d7c1f50b2fbce Mon Sep 17 00:00:00 2001 From: spacebear Date: Mon, 27 Jul 2026 18:43:11 +0000 Subject: [PATCH 7/8] Rewrite the minor release checklist Update the release template for the new tooling and tag scheme. Tags now follow `-v`, and the bump touches one crate plus the workspace requirements on it rather than every crate at once. The crates-release workflow checks the bump PR, verifies the tag, waits for CI, publishes to crates.io, cuts the GitHub release, and confirms the upload, so those steps leave the checklist. What remains is what a human must do: write the changelog, sign and push the tag, approve the crates-release environment, and announce. --- .github/ISSUE_TEMPLATE/minor-release.md | 29 +++++++++---------------- 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/minor-release.md b/.github/ISSUE_TEMPLATE/minor-release.md index 9e40ddf97..a812f1b72 100644 --- a/.github/ISSUE_TEMPLATE/minor-release.md +++ b/.github/ISSUE_TEMPLATE/minor-release.md @@ -23,7 +23,8 @@ assignees: "" ### Checklist Release numbering must follow [Semantic Versioning]. These steps assume the current `master` -branch **development** version is _MAJOR.MINOR.0_. +branch **development** version is _MAJOR.MINOR.0_. Release-managed crates are `payjoin`, +`payjoin-cli`, and `payjoin-mailroom`; tags use the `-` scheme. #### On the day of the feature freeze @@ -32,9 +33,11 @@ Change the `master` branch to the next MINOR+1 version: - [ ] Switch to the `master` branch. - [ ] Create a new PR branch called `bump-CRATE-MAJOR-MINOR+1`, eg. `bump-CRATE-0-22`. - [ ] Bump the `bump-CRATE-MAJOR-MINOR+1` branch to the next development MINOR+1 version. - - Change the `Cargo.toml` version value to `MAJOR.MINOR+1.0` for all crates in the workspace. + - Change the `Cargo.toml` version value to `MAJOR.MINOR+1.0` for the crate being released, + and update every workspace member's version requirement on it to match. - Run `contrib/update-lock-files.sh` to apply upgrades to the Cargo lock files. - - Update the `CHANGELOG.md` file. + - Update the crate's `CHANGELOG.md` file, adding a `## MAJOR.MINOR+1.0` section that + summarizes the PRs merged since the last release tag. - The commit message should be "Bump CRATE version to MAJOR.MINOR+1.0". - [ ] Create PR for the `bump-CRATE-MAJOR-MINOR+1` branch to `master`. - Title PR "Bump CRATE version to MAJOR.MINOR+1.0". @@ -48,25 +51,13 @@ If any issues need to be fixed before the _MAJOR.MINOR+1.0_ version is released: #### On the day of the release -Tag and publish new release: - -- [ ] Check that the crate is publishable with `cargo publish --dry-run` from that crate's directory. -- [ ] Add a tag to the `HEAD` commit in the `master` branch. - - The tag name should be `CRATE-MAJOR.MINOR+1.0` +- [ ] Create a signed annotated tag on the `HEAD` commit in the `master` branch. + - The tag name should be `CRATE-MAJOR.MINOR+1.0`, eg. `payjoin-1.0.0`. - The first line of the tag message should be "Release CRATE-MAJOR.MINOR+1.0". - In the body of the tag message put a copy of the **Summary** and **Changelog** for the release. - - Make sure the tag is signed, for extra safety use the explicit `--sign` flag. -- [ ] Wait for the CI to finish one last time. -- [ ] Build the docs locally to ensure they are building correctly. + - Sign with a key committed under `contrib/release/keys/`, using the explicit `--sign` flag. - [ ] Push the new tag to the `payjoin/rust-payjoin` repo. -- [ ] Publish the crate in question to crates.io. -- [ ] Create the release on GitHub. - - Go to "tags", click on the dots on the right and select "Create Release". - - Set the title to `Release CRATE-MAJOR.MINOR+1.0`. - - In the release notes body put the **Summary** and **Changelog**. - - Use the "+ Auto-generate release notes" button to add details from included PRs. - - Until we reach a `1.0.0` release check the "Pre-release" box. -- [ ] Make sure the new release shows up on [crates.io] and that the docs are built correctly on [docs.rs]. +- [ ] Approve the `crates-release` environment when it requests a reviewer. - [ ] Announce the release, using the **Summary**, on Discord, Twitter, Nostr, and stacker.news. - [ ] Celebrate 🎉 From 99a1aa7bc61fb227a2e69965bc6f9ffaed3028cf Mon Sep 17 00:00:00 2001 From: spacebear Date: Mon, 27 Jul 2026 20:46:33 +0000 Subject: [PATCH 8/8] Add spacebear release signing key --- contrib/release/keys/spacebear.asc | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 contrib/release/keys/spacebear.asc diff --git a/contrib/release/keys/spacebear.asc b/contrib/release/keys/spacebear.asc new file mode 100644 index 000000000..fc996a254 --- /dev/null +++ b/contrib/release/keys/spacebear.asc @@ -0,0 +1,13 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mDMEZPisAhYJKwYBBAHaRw8BAQdAG+NC2tjYBYU912vi6XP/OK7Jss3VP0FRzYjl +YXHhocW0HXNwYWNlYmVhciA8Z2l0QHNwYWNlYmVhci5kZXY+iJMEExYKADsWIQRy +SsuPzcTOAjP6wu/IDD31KdZ/FgUCZoRsqgIbAwULCQgHAgIiAgYVCgkICwIEFgID +AQIeBwIXgAAKCRDIDD31KdZ/FskkAP9v3xm+deKTOLyx/NeFS1wGoUGZkQ1s6f5a +DO6Nk+yWVAEA/DO6qOB3qbWrpjKRrxbt2uhLY4V54BsbaoHLvHpvJgK4OARk+KwC +EgorBgEEAZdVAQUBAQdAfNAOveqsjpUOZTA+4gx79bktSTfx8qaUiq9pE5TfXxED +AQgHiHgEGBYKACAWIQRySsuPzcTOAjP6wu/IDD31KdZ/FgUCZPisAgIbDAAKCRDI +DD31KdZ/Fn3VAP9AyiR+iokV5+AAPVUA1eEgfaX0cFNaKTuSX4uxKTDimwD/TLcn +NNJS5ZtPELqjXDIS94U+Le2pX124FeyW/VFGbQk= +=vJCs +-----END PGP PUBLIC KEY BLOCK-----