From 3ead96a79f1c8e0d5f8cfc74dd04ee2cb0099ed2 Mon Sep 17 00:00:00 2001 From: DanGould Date: Fri, 2 Oct 2026 15:59:37 +0800 Subject: [PATCH 1/4] Add tag.sh to sign release tags Release tags were created by hand from the issue checklist, one per crate and one per language package, with the changelog pasted into the tag message. The script reads each version from the manifest that owns it, skips anything already tagged, runs check-invariants on the crates it is about to tag, and creates signed annotated tags named -. The message is the one line "Release "; the GitHub release body already comes from CHANGELOG.md. It refuses a tree with uncommitted changes or a HEAD that is not on master, which is what verify-tag-hygiene would reject after the push. Master is fetched from github.com/payjoin/rust-payjoin by URL at tag time, because a maintainer's origin is often a fork and a local remote-tracking ref can be stale; verify-tag-hygiene takes that commit through RELEASE_MASTER and still defaults to origin/master in CI. It prints the push command, naming whichever remote points at payjoin/rust-payjoin, and never pushes. With no names it sweeps the release crates and reports the ones that are not bumped. With --bindings it also covers each payjoin-ffi package that ships a contrib/release-version.sh. That script prints the version the package's next tag names, so adding a language means adding one script rather than editing tag.sh, and a package without one is never tagged. The python tag carries +payjoin- like the others; pyproject.toml cannot hold it, so its script appends the payjoin version tag.sh passes in. --- contrib/release/tag.sh | 151 ++++++++++++++++++ contrib/release/verify-tag-hygiene.sh | 11 +- payjoin-ffi/csharp/RELEASING.md | 19 +-- payjoin-ffi/csharp/contrib/release-version.sh | 9 ++ payjoin-ffi/dart/CONTRIBUTING.md | 15 +- payjoin-ffi/dart/contrib/release-version.sh | 9 ++ payjoin-ffi/javascript/RELEASING.md | 17 +- .../javascript/contrib/release-version.sh | 8 + payjoin-ffi/python/RELEASING.md | 17 +- payjoin-ffi/python/contrib/release-version.sh | 15 ++ 10 files changed, 235 insertions(+), 36 deletions(-) create mode 100755 contrib/release/tag.sh create mode 100755 payjoin-ffi/csharp/contrib/release-version.sh create mode 100755 payjoin-ffi/dart/contrib/release-version.sh create mode 100755 payjoin-ffi/javascript/contrib/release-version.sh create mode 100755 payjoin-ffi/python/contrib/release-version.sh diff --git a/contrib/release/tag.sh b/contrib/release/tag.sh new file mode 100755 index 000000000..52fc50ba8 --- /dev/null +++ b/contrib/release/tag.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +# +# Sign the release tags for HEAD. For each release crate (and, with +# --bindings, each language package) whose manifest version has no tag yet, +# confirm the release invariants hold and HEAD is on payjoin/rust-payjoin's +# master, then create a signed annotated tag named - with the +# message "Release -". Prints the push command and never +# pushes. +# +# usage: tag.sh [--dry-run] [--bindings] [--key ] [name...] +# name limit to these crates or packages: payjoin, payjoin-cli, +# payjoin-mailroom, or any payjoin-ffi/ with a +# contrib/release-version.sh +# +# Run it in the release devShell, which pins jq, gpg and cargo: +# nix develop .#release -c contrib/release/tag.sh [...] +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=contrib/release/crates.sh +source "$DIR/crates.sh" +cd "$REPO_ROOT" + +dry_run=0 +bindings=0 +key="" +names=() +while [ "$#" -gt 0 ]; do + case "$1" in + --dry-run) dry_run=1 ;; + --bindings) bindings=1 ;; + --key) + key="$2" + shift + ;; + -h | --help) + sed -n '2,16p' "$0" + exit 0 + ;; + *) names+=("$1") ;; + esac + shift +done +die() { + echo "tag: $*" >&2 + exit 1 +} + +# Master is fetched from the canonical repository by URL rather than read +# from a remote-tracking ref: a maintainer's origin is often a fork, and any +# local ref can be stale. +RELEASE_REPO="payjoin/rust-payjoin" + +# Binding packages are the payjoin-ffi/ directories that ship a +# contrib/release-version.sh. It prints the version the next tag names, +# with +payjoin-X build metadata naming the wrapped payjoin version, and +# takes the payjoin version as its argument for packages whose manifest +# cannot hold that metadata. +binding_reader() { + printf '%s/payjoin-ffi/%s/contrib/release-version.sh' "$REPO_ROOT" "$1" +} +binding_names() { + local reader + for reader in "$REPO_ROOT"/payjoin-ffi/*/contrib/release-version.sh; do + [ -x "$reader" ] || continue + reader="${reader%/contrib/release-version.sh}" + printf '%s\n' "${reader##*/}" + done +} + +[ -z "$(git status --porcelain --untracked-files=no)" ] || + die "working tree has uncommitted changes; tags apply to HEAD" +git fetch --quiet "https://github.com/$RELEASE_REPO.git" master || + die "could not fetch master from github.com/$RELEASE_REPO" +master="$(git rev-parse FETCH_HEAD)" +git merge-base --is-ancestor HEAD "$master" || + die "HEAD is not on $RELEASE_REPO master, so verify-tag-hygiene would reject the tag; check out the merged commit" + +RELEASE_METADATA="$(cargo_metadata)" +explicit=0 +if [ "${#names[@]}" -gt 0 ]; then + explicit=1 + want=("${names[@]}") +else + read -r -a want <<<"$RELEASE_CRATES" + [ "$bindings" -eq 0 ] || mapfile -t -O "${#want[@]}" want < <(binding_names) +fi + +tags=() +crates=() +for name in "${want[@]}"; do + case " $RELEASE_CRATES " in + *" $name "*) + version="$(manifest_version "$name")" + prefix="$name" + ;; + *) + reader="$(binding_reader "$name")" + [ -x "$reader" ] || die "unknown name $name: not a release crate, and no $reader" + version="$("$reader" "$(manifest_version payjoin)")" || die "$reader failed" + prefix="payjoin-$name" + ;; + esac + [ -n "$version" ] || die "could not read a version for $name" + tag="$prefix-$version" + if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then + at="$(git rev-list -n1 "$tag")" + if [ "$at" = "$(git rev-parse HEAD)" ]; then + echo "$tag already tagged at HEAD, skipping" + elif [ "$explicit" -eq 1 ]; then + die "$tag already exists at ${at:0:9}, not HEAD; bump the version first" + else + echo "$name is at $version, released at ${at:0:9}; not bumped, skipping" + fi + continue + fi + tags+=("$tag") + case " $RELEASE_CRATES " in + *" $name "*) crates+=("$name") ;; + esac +done +[ "${#tags[@]}" -gt 0 ] || { + echo "Nothing to tag: every manifest version is already tagged" + exit 0 +} + +[ "${#crates[@]}" -eq 0 ] || "$DIR/check-invariants.sh" "${crates[@]}" + +sign=(--sign) +[ -z "$key" ] || sign+=(--local-user "$key") +for tag in "${tags[@]}"; do + if [ "$dry_run" -eq 1 ]; then + echo "would run: git tag ${sign[*]} --annotate $tag -m 'Release $tag' HEAD" + continue + fi + git tag "${sign[@]}" --annotate "$tag" -m "Release $tag" HEAD + RELEASE_MASTER="$master" "$DIR/verify-tag-hygiene.sh" "$tag" >/dev/null || + die "$tag failed hygiene after creation; delete it with: git tag -d $tag" + echo "created $tag" +done + +[ "$dry_run" -eq 0 ] || exit 0 +# Name the remote that points at the canonical repository, whatever it is +# called locally, or fall back to its URL. +remote="$(git remote -v | awk -v repo="$RELEASE_REPO" \ + '$3 == "(push)" && $2 ~ ("[:/]" repo "(\\.git)?/?$") { print $1; exit }')" +[ -n "$remote" ] || remote="https://github.com/$RELEASE_REPO.git" +echo +echo "Push when ready; each tag starts its publish workflow and the release environment prompt:" +printf ' git push %s' "$remote" +printf ' %s' "${tags[@]}" +echo diff --git a/contrib/release/verify-tag-hygiene.sh b/contrib/release/verify-tag-hygiene.sh index 4ffacd5b7..3f7577a7a 100755 --- a/contrib/release/verify-tag-hygiene.sh +++ b/contrib/release/verify-tag-hygiene.sh @@ -2,7 +2,9 @@ # # Generic release-tag gate shared by the crate and language-binding release # workflows. Confirms a tag is annotated, signed by a key in -# contrib/release/keys/, and an ancestor of origin/master. +# contrib/release/keys/, and an ancestor of master: origin/master as CI +# checks it out, or the commit in RELEASE_MASTER when a caller has fetched +# master some other way. # # Checks the tag object and repo history only, so it carries no assumption # about what is being released; ecosystem-specific checks (manifest versions, @@ -35,6 +37,7 @@ gpg --homedir "$home" --batch --quiet --import "$REPO_ROOT"/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" +master="${RELEASE_MASTER:-origin/master}" +echo "Checking the tag is an ancestor of $master" +git -C "$REPO_ROOT" merge-base --is-ancestor "$tag" "$master" 2>/dev/null || + die "$tag is not an ancestor of $master" diff --git a/payjoin-ffi/csharp/RELEASING.md b/payjoin-ffi/csharp/RELEASING.md index a5995ccaa..436f1ffe1 100644 --- a/payjoin-ffi/csharp/RELEASING.md +++ b/payjoin-ffi/csharp/RELEASING.md @@ -116,17 +116,18 @@ is ever stored. The workflow is 1. Work through the release readiness checklist above on the release commit in `master`; confirm every `Build and Test CSharp` job is green. -2. Tag that commit `payjoin-csharp-`, where `` is the - `Payjoin.csproj` `` exactly, build metadata included. NuGet - strips the metadata from the `Payjoin..nupkg` filename, so - the publish job strips it from the tag as well before comparing. The - tag must be annotated and signed by a maintainer key in - `contrib/release/keys/`, and the tagged commit must be on `master`; - `verify-tag` refuses to publish otherwise. +2. Tag that commit with `contrib/release/tag.sh` and push the tag it + names. The tag is `payjoin-csharp-`, where `` is the + `Payjoin.csproj` `` exactly, build metadata included, as + [`contrib/release-version.sh`](contrib/release-version.sh) prints it. + NuGet strips the metadata from the `Payjoin..nupkg` filename, + so the publish job strips it from the tag as well before comparing. + tag.sh signs it with your key, which must be in `contrib/release/keys/` for + `verify-tag` to accept it, refuses a commit that is not on `master`, + and prints the push command. ```shell - git tag -s payjoin-csharp-0.1.0+payjoin-1.0.0 -m payjoin-csharp-0.1.0+payjoin-1.0.0 - git push upstream payjoin-csharp-0.1.0+payjoin-1.0.0 + nix develop .#release -c contrib/release/tag.sh csharp ``` The tag reruns the full build/pack/smoke graph at the tagged commit, then diff --git a/payjoin-ffi/csharp/contrib/release-version.sh b/payjoin-ffi/csharp/contrib/release-version.sh new file mode 100755 index 000000000..fc2b1f6e5 --- /dev/null +++ b/payjoin-ffi/csharp/contrib/release-version.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# +# Print the version the next payjoin-csharp release tag names: the +# of Payjoin.csproj. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." +version="$(sed -n 's/.*\(.*\)<\/Version>.*/\1/p' Payjoin.csproj | head -1)" +[ -n "$version" ] || exit 1 +printf '%s\n' "$version" diff --git a/payjoin-ffi/dart/CONTRIBUTING.md b/payjoin-ffi/dart/CONTRIBUTING.md index 47fedc818..e70533173 100644 --- a/payjoin-ffi/dart/CONTRIBUTING.md +++ b/payjoin-ffi/dart/CONTRIBUTING.md @@ -58,15 +58,16 @@ validates the archive with a publish dry run changes under a matching heading in `CHANGELOG.md`. 3. Confirm every `Build and Test Dart` job is green on the release commit in `master`. -4. Tag that commit `payjoin-dart-`, where `` is the - `pubspec.yaml` version exactly (including the `+` build metadata). The - tag must be annotated and signed by a maintainer key in - `contrib/release/keys/`, and the tagged commit must be on `master`; - `verify-tag` refuses to publish otherwise. +4. Tag that commit with `contrib/release/tag.sh` and push the tag it + names. The tag is `payjoin-dart-`, where `` is the + `pubspec.yaml` version exactly (including the `+` build metadata), as + [`contrib/release-version.sh`](contrib/release-version.sh) prints it. + tag.sh signs it with your key, which must be in `contrib/release/keys/` for + `verify-tag` to accept it, refuses a commit that is not on `master`, + and prints the push command. ```shell - git tag -s 'payjoin-dart-0.2.1+payjoin-1.0.0-rc.8' -m 'payjoin-dart-0.2.1+payjoin-1.0.0-rc.8' - git push upstream 'payjoin-dart-0.2.1+payjoin-1.0.0-rc.8' + nix develop .#release -c contrib/release/tag.sh dart ``` The tag reruns the tests and the archive verification at the tagged diff --git a/payjoin-ffi/dart/contrib/release-version.sh b/payjoin-ffi/dart/contrib/release-version.sh new file mode 100755 index 000000000..74fa4e79f --- /dev/null +++ b/payjoin-ffi/dart/contrib/release-version.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# +# Print the version the next payjoin-dart release tag names: the +# `version` of pubspec.yaml. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." +version="$(sed -n 's/^version: *\(.*\)$/\1/p' pubspec.yaml | head -1)" +[ -n "$version" ] || exit 1 +printf '%s\n' "$version" diff --git a/payjoin-ffi/javascript/RELEASING.md b/payjoin-ffi/javascript/RELEASING.md index 84cb87491..67059c4c5 100644 --- a/payjoin-ffi/javascript/RELEASING.md +++ b/payjoin-ffi/javascript/RELEASING.md @@ -32,16 +32,17 @@ TypeScript), which is platform-independent. 1. Confirm every `Build and Test JavaScript` job is green on the release commit in `master`. -2. Tag that commit `payjoin-javascript-`, where `` is the - `package.json` `releaseTag` value exactly; the publish job strips the - build metadata before comparing the tag against the packed tarball. - The tag must be annotated and signed by a maintainer key in - `contrib/release/keys/`, and the tagged commit must be on `master`; - `verify-tag` refuses to publish otherwise. +2. Tag that commit with `contrib/release/tag.sh` and push the tag it + names. The tag is `payjoin-javascript-`, where `` is + the `package.json` `releaseTag` value exactly, as + [`contrib/release-version.sh`](contrib/release-version.sh) prints it; + the publish job strips the build metadata before comparing the tag + against the packed tarball. tag.sh signs it with your key, which must be in `contrib/release/keys/` for + `verify-tag` to accept it, refuses a commit that is not on `master`, + and prints the push command. ```shell - git tag -s payjoin-javascript-0.2.0+payjoin-1.0.0 -m payjoin-javascript-0.2.0+payjoin-1.0.0 - git push upstream payjoin-javascript-0.2.0+payjoin-1.0.0 + nix develop .#release -c contrib/release/tag.sh javascript ``` The tag reruns the full build/pack/smoke graph at the tagged commit, diff --git a/payjoin-ffi/javascript/contrib/release-version.sh b/payjoin-ffi/javascript/contrib/release-version.sh new file mode 100755 index 000000000..94c86f2e4 --- /dev/null +++ b/payjoin-ffi/javascript/contrib/release-version.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# +# Print the version the next payjoin-javascript release tag names: the +# `releaseTag` field of package.json. npm rejects build metadata in +# `version`, so the full version lives in `releaseTag`. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." +jq -er '.releaseTag' package.json diff --git a/payjoin-ffi/python/RELEASING.md b/payjoin-ffi/python/RELEASING.md index 6c56ca1da..c172aaa47 100644 --- a/payjoin-ffi/python/RELEASING.md +++ b/payjoin-ffi/python/RELEASING.md @@ -43,17 +43,18 @@ CPython satisfying `requires-python` can install them. 1. Confirm every `Build and Test Python` job is green on the release commit in `master`, including the per-platform smoke tests. -2. Tag that commit `payjoin-python-`, where `` is the +2. Tag that commit with `contrib/release/tag.sh` and push the tag it + names. The tag is `payjoin-python-`, where `` is the `pyproject.toml` version plus `+payjoin-` build metadata - naming the wrapped payjoin core release; the publish job strips the - metadata before comparing the tag against the built wheels. The tag - must be annotated and signed by a maintainer key in - `contrib/release/keys/`, and the tagged commit must be on `master`; - `verify-tag` refuses to publish otherwise. + naming the wrapped payjoin core release, as + [`contrib/release-version.sh`](contrib/release-version.sh) prints it; + the publish job strips the metadata before comparing the tag against + the built wheels. tag.sh signs it with your key, which must be in `contrib/release/keys/` for + `verify-tag` to accept it, refuses a commit that is not on `master`, + and prints the push command. ```shell - git tag -s payjoin-python-0.2.0+payjoin-1.0.0 -m payjoin-python-0.2.0+payjoin-1.0.0 - git push upstream payjoin-python-0.2.0+payjoin-1.0.0 + nix develop .#release -c contrib/release/tag.sh python ``` The tag reruns the full build/wheel/smoke graph at the tagged commit, diff --git a/payjoin-ffi/python/contrib/release-version.sh b/payjoin-ffi/python/contrib/release-version.sh new file mode 100755 index 000000000..9af3c7796 --- /dev/null +++ b/payjoin-ffi/python/contrib/release-version.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# +# Print the version the next payjoin-python release tag names: +# +payjoin-. PyPI rejects local +# versions, so pyproject.toml holds only the bare version and the build +# metadata is added here; the publish workflow strips it again. +# +# usage: release-version.sh +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." +[ -n "${1:-}" ] || exit 1 +version="$(awk '/^\[/ { in_project = ($0 == "[project]") } + in_project && /^version *=/ { gsub(/^version *= *"|"$/, ""); print; exit }' pyproject.toml)" +[ -n "$version" ] || exit 1 +printf '%s+payjoin-%s\n' "$version" "$1" From 8c2aac96af59d9d2aa8312821baa8b507f2d11ad Mon Sep 17 00:00:00 2001 From: DanGould Date: Fri, 2 Oct 2026 15:59:38 +0800 Subject: [PATCH 2/4] Record changelog lines in the changing PR Changelog sections were written at release time, in the bump PR. Any merge to master under an open bump PR then meant editing that PR and restacking everything above it, and the 1.1.0 bindings release was lost to that churn. Each release crate's CHANGELOG.md now keeps an Unreleased section at the top. A pull request that changes what a user of the crate sees adds its line there in the same commit. The bump PR renames the heading and nothing else, so it no longer has to change when something lands beneath it. A workflow fails a pull request that touches a release crate's src/ without touching its CHANGELOG.md, unless the PR carries the "changelog: none" label for changes with no user-visible effect. The rule is documented in CONTRIBUTING.md. Only triage access can apply a label, so an outside contributor says in the PR description that the change has no user-visible effect and a maintainer labels it. --- .github/CONTRIBUTING.md | 6 ++++ .github/workflows/changelog-check.yml | 41 +++++++++++++++++++++++++++ payjoin-cli/CHANGELOG.md | 2 ++ payjoin/CHANGELOG.md | 2 ++ 4 files changed, 51 insertions(+) create mode 100644 .github/workflows/changelog-check.yml diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index a9eca440c..ed247213b 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -109,6 +109,12 @@ fi echo "✓ Pre-commit hook passed" ``` +### Changelog + +Each release crate (`payjoin`, `payjoin-cli`, `payjoin-mailroom`) keeps a `CHANGELOG.md` with an `## Unreleased` section at the top. If your pull request changes what a user of that crate sees, add a line there in the same commit. Write it for the person reading the release notes, not for the reviewer. The release PR renames the heading to the version number and nothing else. + +If the change has no user-visible effect (refactoring, tests, CI, comments), say so in the pull request description and a maintainer will apply the `changelog: none` label. CI fails a pull request that touches a release crate's `src/` without either a changelog line or that label. + ## AI Assistance Notice > [!IMPORTANT] diff --git a/.github/workflows/changelog-check.yml b/.github/workflows/changelog-check.yml new file mode 100644 index 000000000..7e7066d72 --- /dev/null +++ b/.github/workflows/changelog-check.yml @@ -0,0 +1,41 @@ +# .github/workflows/changelog-check.yml +# +# A pull request that changes a release crate's source must add a line to +# that crate's CHANGELOG.md under "## Unreleased", or carry the +# "changelog: none" label. The bump PR then only renames the heading, so a +# merge under an open bump PR never requires editing it. +name: Changelog + +on: + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] + +permissions: + contents: read + +jobs: + changelog: + name: Changelog entry + if: "${{ !contains(github.event.pull_request.labels.*.name, 'changelog: none') }}" + runs-on: ubuntu-26.04 + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Require a changelog line for each changed release crate + env: + BASE: ${{ github.event.pull_request.base.sha }} + HEAD: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + source contrib/release/crates.sh + changed="$(git diff --name-only "$BASE" "$HEAD")" + status=0 + for crate in $RELEASE_CRATES; do + grep -q "^$crate/src/" <<<"$changed" || continue + if ! grep -q "^$crate/CHANGELOG.md$" <<<"$changed"; then + echo "::error::$crate/src changed but $crate/CHANGELOG.md did not; add a line under '## Unreleased', or say in the PR description that there is no user-visible change so a maintainer can label it 'changelog: none'" + status=1 + fi + done + exit "$status" diff --git a/payjoin-cli/CHANGELOG.md b/payjoin-cli/CHANGELOG.md index ddf662a35..f4111e156 100644 --- a/payjoin-cli/CHANGELOG.md +++ b/payjoin-cli/CHANGELOG.md @@ -1,5 +1,7 @@ # payjoin-cli Changelog +## Unreleased + ## 1.0.0-rc.2 Track payjoin 1.0.0-rc.8, whose `check_inputs_not_owned` callback now diff --git a/payjoin/CHANGELOG.md b/payjoin/CHANGELOG.md index d4e484a19..055cbfffe 100644 --- a/payjoin/CHANGELOG.md +++ b/payjoin/CHANGELOG.md @@ -1,5 +1,7 @@ # Payjoin Changelog +## Unreleased + ## 1.2.0 This release adds a non-blocking interface to the receiver state machine. From 39ca74ebed877ddd3fe24e8615f11251be6819d9 Mon Sep 17 00:00:00 2001 From: DanGould Date: Fri, 2 Oct 2026 15:59:42 +0800 Subject: [PATCH 3/4] Rewrite minor release checklist for tag.sh The checklist still asked for a changelog compiled in the issue body and pasted into the tag message, and it stopped at the crate without saying what happens to the bindings or the dependent crates. The changelog is now the crate's Unreleased section, the tag is made by contrib/release/tag.sh, fixes merge to master with their own lines instead of being cherry-picked into the bump branch, and a payjoin release is followed by the bindings bump (Dart pins the release tag commit, never a branch commit) and by the dependent crates on their own schedule. --- .github/ISSUE_TEMPLATE/minor-release.md | 86 ++++++++++++------------- 1 file changed, 42 insertions(+), 44 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/minor-release.md b/.github/ISSUE_TEMPLATE/minor-release.md index 206e6efe0..4a6f42d5b 100644 --- a/.github/ISSUE_TEMPLATE/minor-release.md +++ b/.github/ISSUE_TEMPLATE/minor-release.md @@ -1,65 +1,63 @@ --- name: Minor Release -about: Checklist for releasing a new minor version bump -title: Release MAJOR.MINOR+1.0 +about: Checklist for releasing a new minor version of a release crate +title: Release CRATE-MAJOR.MINOR+1.0 labels: "" assignees: "" --- -## Create a new minor release +## Release CRATE MAJOR.MINOR+1.0 -### Summary - - - -### Commit +Release-managed crates are `payjoin`, `payjoin-cli` and `payjoin-mailroom`. Versions follow +[Semantic Versioning]. Tags are `-`. The changelog for this release is the +`## Unreleased` section of `CRATE/CHANGELOG.md`; it was written by the pull requests that +landed since the last release, so there is nothing to compile here. - - -### Changelog +### Summary - + ### Checklist -Release numbering must follow [Semantic Versioning]. These steps assume the current `master` -branch **development** version is _MAJOR.MINOR.0_. Release-managed crates are `payjoin`, -`payjoin-cli`, and `payjoin-mailroom`; tags use the `-` scheme. +#### Bump + +- [ ] Branch `bump-CRATE-MAJOR-MINOR+1` from `master`. +- [ ] Set `version` in `CRATE/Cargo.toml` to `MAJOR.MINOR+1.0` and update every workspace + member's version requirement on it to match. +- [ ] Rename `## Unreleased` in `CRATE/CHANGELOG.md` to `## MAJOR.MINOR+1.0` and add a fresh + empty `## Unreleased` above it. Read the section once; fix wording, do not add history. +- [ ] Run `contrib/update-lock-files.sh`. +- [ ] One commit, "Bump CRATE version to MAJOR.MINOR+1.0". Open the PR against `master`. + The `Check release version bump` job runs check-invariants, a publish dry run and, + for `payjoin`, cargo-semver-checks. +- [ ] Merge. -#### On the day of the feature freeze +If a fix must land before the tag, merge it to `master` as usual; it carries its own +changelog line. The bump PR is not edited for it. -Change the `master` branch to the next MINOR+1 version: +#### Tag and publish -- [ ] 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 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 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". -- [ ] Merge the `bump-CRATE-MAJOR-MINOR+1` branch to `master`. +- [ ] Check out the merged `master` commit. Run + `nix develop .#release -c contrib/release/tag.sh CRATE`. It checks the tree is clean + and on payjoin/rust-payjoin's `master` (fetched by URL, so any remote layout works), + runs check-invariants, and creates the signed tag `CRATE-MAJOR.MINOR+1.0` with a key + from `contrib/release/keys/`. +- [ ] Push the tag with the command the script prints. +- [ ] Approve the `release` environment when the workflow asks. Publication, the GitHub + release and the crates.io/docs.rs verification run from there. -If any issues need to be fixed before the _MAJOR.MINOR+1.0_ version is released: +#### After a `payjoin` release -- [ ] Merge fix PRs to the `master` branch. -- [ ] Git cherry-pick fix commits to the `bump-CRATE-MAJOR.MINOR+1` branch. -- [ ] Verify fixes in `bump-CRATE-MAJOR.MINOR+1` branch. +- [ ] Open the bindings bump PR (python, javascript, csharp, dart manifests and changelogs; + Dart's `native/Cargo.toml` pins the `payjoin-MAJOR.MINOR+1.0` tag commit, never a + branch commit). Merge, then run + `nix develop .#release -c contrib/release/tag.sh --bindings` and push the tags. +- [ ] Bump dependent release crates (`payjoin-cli`, `payjoin-mailroom`) on their own + schedule, each with this checklist. Their tags verify that `payjoin` is already on + crates.io. -#### On the day of the release +#### Announce -- [ ] 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. - - Sign with a key committed under `contrib/release/keys/`, using the explicit `--sign` flag. -- [ ] Push the new tag to the `payjoin/rust-payjoin` repo. -- [ ] Approve the `release` environment when it requests a reviewer. -- [ ] Announce the release, using the **Summary**, on Discord, Twitter, Nostr, and stacker.news. -- [ ] Celebrate 🎉 +- [ ] Discord, Twitter, Nostr, stacker.news, using the Summary. [Semantic Versioning]: https://semver.org/ -["keep a changelog"]: https://keepachangelog.com/en/1.0.0/ From c6d23fae53d23ef03bee895674a9c4037c939e89 Mon Sep 17 00:00:00 2001 From: DanGould Date: Fri, 2 Oct 2026 16:32:38 +0800 Subject: [PATCH 4/4] Add bump.sh to prepare a release bump commit The bump commit was assembled by hand: edit the crate's version, find every workspace member that requires it and edit those too, retitle the changelog section, run update-lock-files.sh, and hope check-invariants agrees. The script does those steps in that order and stops at the first one that fails, leaving the tree uncommitted so the changelog section can be read before the commit. --no-lock skips the lock file regeneration for a quick look at the edits. The minor release checklist now calls it instead of listing the steps. --- .github/ISSUE_TEMPLATE/minor-release.md | 13 +++-- contrib/release/bump.sh | 78 +++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 5 deletions(-) create mode 100755 contrib/release/bump.sh diff --git a/.github/ISSUE_TEMPLATE/minor-release.md b/.github/ISSUE_TEMPLATE/minor-release.md index 4a6f42d5b..d77f7000f 100644 --- a/.github/ISSUE_TEMPLATE/minor-release.md +++ b/.github/ISSUE_TEMPLATE/minor-release.md @@ -22,11 +22,14 @@ landed since the last release, so there is nothing to compile here. #### Bump - [ ] Branch `bump-CRATE-MAJOR-MINOR+1` from `master`. -- [ ] Set `version` in `CRATE/Cargo.toml` to `MAJOR.MINOR+1.0` and update every workspace - member's version requirement on it to match. -- [ ] Rename `## Unreleased` in `CRATE/CHANGELOG.md` to `## MAJOR.MINOR+1.0` and add a fresh - empty `## Unreleased` above it. Read the section once; fix wording, do not add history. -- [ ] Run `contrib/update-lock-files.sh`. +- [ ] Run `contrib/release/bump.sh CRATE MAJOR.MINOR+1.0`. It sets the version, rewrites + every workspace member's requirement on the crate, renames `## Unreleased` to + `## MAJOR.MINOR+1.0` with a fresh empty `## Unreleased` above it, regenerates both + lock files and runs check-invariants. It needs jq, and the lock file step calls + `cargo +nightly`, so run it where rustup has a nightly toolchain. `nix develop .#release` + has jq but no rustup; there, pass `--no-lock`, then run `contrib/update-lock-files.sh` + from a rustup shell and `contrib/release/check-invariants.sh CRATE` again. +- [ ] Read the new changelog section once; fix wording, do not add history. - [ ] One commit, "Bump CRATE version to MAJOR.MINOR+1.0". Open the PR against `master`. The `Check release version bump` job runs check-invariants, a publish dry run and, for `payjoin`, cargo-semver-checks. diff --git a/contrib/release/bump.sh b/contrib/release/bump.sh new file mode 100755 index 000000000..f6f05d31d --- /dev/null +++ b/contrib/release/bump.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# +# Prepare a release crate's bump commit. Sets the crate's version, rewrites +# every workspace member's version requirement on it, renames the +# "## Unreleased" changelog section to the new version and opens a fresh +# empty one above it, regenerates both lock files, then runs +# check-invariants. Leaves the changes uncommitted for review. +# +# usage: bump.sh [--no-lock] +# --no-lock skip contrib/update-lock-files.sh (then check-invariants +# reports the stale lock files, as it should) +# +# Needs jq, and without --no-lock a rustup nightly toolchain, which the +# lock file script calls as `cargo +nightly`. +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=contrib/release/crates.sh +source "$DIR/crates.sh" +cd "$REPO_ROOT" + +lock=1 +if [ "${1:-}" = "--no-lock" ]; then + lock=0 + shift +fi +[ "$#" -eq 2 ] || { + sed -n '2,14p' "$0" >&2 + exit 1 +} +crate="$1" +new="$2" +die() { + echo "bump: $*" >&2 + exit 1 +} + +case " $RELEASE_CRATES " in + *" $crate "*) ;; + *) die "$crate is not a release crate ($RELEASE_CRATES)" ;; +esac +[[ $new =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]] || die "$new is not a semver version" +[ -z "$(git status --porcelain --untracked-files=no)" ] || die "commit or discard your changes first" + +RELEASE_METADATA="$(cargo_metadata)" +old="$(manifest_version "$crate")" +[ "$old" != "$new" ] || die "$crate is already $new" +echo "Bumping $crate $old -> $new" + +# The crate's own version: the first line-anchored `version =` in its manifest. +sed -i "0,/^version = \"$old\"/s//version = \"$new\"/" "$crate/Cargo.toml" +[ "$(sed -n 's/^version = "\(.*\)"/\1/p' "$crate/Cargo.toml" | head -1)" = "$new" ] || + die "could not set version in $crate/Cargo.toml" + +# Every workspace member that requires it by version. Members use the +# single-line table form `crate = { version = "x", ... }`. +while IFS= read -r manifest; do + [ -f "$manifest" ] || continue + if grep -q "^$crate = { version = \"$old\"" "$manifest"; then + sed -i "s/^\($crate = { version = \"\)$old\"/\1$new\"/" "$manifest" + echo " $manifest: requirement on $crate -> $new" + fi +done < <(cargo_metadata | jq -r '.packages[].manifest_path') + +changelog="$crate/CHANGELOG.md" +grep -qx '## Unreleased' "$changelog" || + die "$changelog has no '## Unreleased' section; add the lines for this release under one first" +sed -i "0,/^## Unreleased$/s//## Unreleased\n\n## $new/" "$changelog" +echo " $changelog: '## Unreleased' -> '## $new', fresh Unreleased section added" + +if [ "$lock" -eq 1 ]; then + echo "Regenerating lock files (contrib/update-lock-files.sh)" + ./contrib/update-lock-files.sh +fi + +"$DIR/check-invariants.sh" "$crate" +echo +echo "Review the '## $new' section of $changelog, then commit as:" +echo " git commit -am 'Bump $crate version to $new'"