diff --git a/.github/actions/setup-nix/action.yml b/.github/actions/setup-nix/action.yml new file mode 100644 index 000000000..d705a5b69 --- /dev/null +++ b/.github/actions/setup-nix/action.yml @@ -0,0 +1,14 @@ +name: Set up nix +description: > + Install nix with cache. + Must run after actions/checkout so the cache can key off the repository + contents. +runs: + using: composite + steps: + - name: Install nix + uses: DeterminateSystems/determinate-nix-action@main + - name: Use nix cache + uses: DeterminateSystems/magic-nix-cache-action@main + with: + use-flakehub: false diff --git a/.github/actions/verify-tag-version/action.yml b/.github/actions/verify-tag-version/action.yml new file mode 100644 index 000000000..a21e155f0 --- /dev/null +++ b/.github/actions/verify-tag-version/action.yml @@ -0,0 +1,37 @@ +name: Verify tag version +description: > + Check that the pushed tag is the expected prefix followed by exactly the + version about to be published, refusing to publish on any mismatch. +inputs: + tag-prefix: + description: Expected tag prefix, e.g. payjoin-csharp- + required: true + version: + description: Version of the artifact or package about to be published + required: true +outputs: + version: + description: The version the tag calls for + value: ${{ steps.check.outputs.version }} +runs: + using: composite + steps: + - name: Compare tag against version + id: check + shell: bash + env: + TAG: ${{ github.ref_name }} + PREFIX: ${{ inputs.tag-prefix }} + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + if [[ $TAG != "$PREFIX"* ]]; then + echo "::error::tag $TAG does not start with $PREFIX; refusing to publish" + exit 1 + fi + tag_version="${TAG#"$PREFIX"}" + if [[ $tag_version != "$VERSION" ]]; then + echo "::error::tag $TAG implies version $tag_version but the packed version is $VERSION; refusing to publish" + exit 1 + fi + echo "version=$tag_version" >>"$GITHUB_OUTPUT" diff --git a/.github/workflows/crates-release.yml b/.github/workflows/crates-release.yml index 5bea7e7b3..517e73a74 100644 --- a/.github/workflows/crates-release.yml +++ b/.github/workflows/crates-release.yml @@ -31,12 +31,8 @@ jobs: 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 - with: - use-flakehub: false + - name: Set up nix + uses: ./.github/actions/setup-nix - name: Check the bump is consistent and publishable env: BASE_SHA: ${{ github.event.pull_request.base.sha }} @@ -57,12 +53,8 @@ jobs: 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 - with: - use-flakehub: false + - name: Set up nix + uses: ./.github/actions/setup-nix - name: Compute tag metadata id: meta env: @@ -155,12 +147,8 @@ jobs: 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 - with: - use-flakehub: false + - name: Set up nix + uses: ./.github/actions/setup-nix - name: Download packaged crate uses: actions/download-artifact@v4 with: @@ -200,12 +188,8 @@ jobs: 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 - with: - use-flakehub: false + - name: Set up nix + uses: ./.github/actions/setup-nix - name: Download packaged crate uses: actions/download-artifact@v8 with: diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml index b5476545d..c08ce696d 100644 --- a/.github/workflows/csharp.yml +++ b/.github/workflows/csharp.yml @@ -35,12 +35,8 @@ jobs: uses: actions/checkout@v6 - name: "Use cache" uses: Swatinem/rust-cache@v2 - - name: "Install nix" - uses: DeterminateSystems/determinate-nix-action@main - - name: "Use nix cache" - uses: DeterminateSystems/magic-nix-cache-action@main - with: - use-flakehub: false + - name: Set up nix + uses: ./.github/actions/setup-nix - name: "Build and test" run: nix develop .#csharp -c ./payjoin-ffi/csharp/contrib/test.sh @@ -260,13 +256,21 @@ jobs: # --------------------------------------------------------------------------- # PUBLISH PATH (tag-gated). Runs only for a payjoin-csharp-* tag push, after - # pack + every per-RID smoke job are green. Consumes the already-built, - # already-smoke-tested `payjoin-csharp-nuget-package` artifact — never repacks. + # the tag passes the hygiene checks and pack + every per-RID smoke job are + # green. Consumes the already-built, already-smoke-tested + # `payjoin-csharp-nuget-package` artifact — never repacks. # --------------------------------------------------------------------------- + verify-tag: + name: "Verify release tag" + if: startsWith(github.ref, 'refs/tags/payjoin-csharp-') + permissions: + contents: read + uses: ./.github/workflows/verify-tag-hygiene.yml + publish-nuget: name: "Publish to nuget.org (trusted publishing / OIDC)" runs-on: ubuntu-latest - needs: [pack-nuget, smoke-nuget] + needs: [pack-nuget, smoke-nuget, verify-tag] if: startsWith(github.ref, 'refs/tags/payjoin-csharp-') # Optional but recommended: a GitHub Actions environment lets you bind the # nuget.org policy to `release` AND add required reviewers, turning @@ -276,8 +280,11 @@ jobs: permissions: id-token: write # OIDC: used by BOTH NuGet/login and attest-build-provenance attestations: write # actions/attest-build-provenance writes the attestation - contents: read # checkout of the (already-packed) repo is not needed; read is the floor + contents: read # needed only to check out the in-repo verify-tag-version action steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Download packed NuGet package uses: actions/download-artifact@v4 with: @@ -289,27 +296,28 @@ jobs: with: dotnet-version: "10.0.x" - - name: Verify tag matches packed artifact version - id: verify + - name: Locate packed artifact + id: locate shell: bash run: | set -euo pipefail - # payjoin-csharp-0.24.0-preview.1 -> 0.24.0-preview.1 - version="${GITHUB_REF_NAME#payjoin-csharp-}" shopt -s nullglob pkgs=(dist/*.nupkg) if [ "${#pkgs[@]}" -ne 1 ]; then echo "::error::expected exactly one .nupkg in dist/, found ${#pkgs[@]}: ${pkgs[*]:-none}" exit 1 fi - expected="Payjoin.${version}.nupkg" - actual="$(basename "${pkgs[0]}")" - if [ "${actual}" != "${expected}" ]; then - echo "::error::tag ${GITHUB_REF_NAME} implies ${expected} but packed artifact is ${actual}; refusing to publish" - exit 1 - fi + # Payjoin.0.24.0-preview.1.nupkg -> 0.24.0-preview.1 + name="$(basename "${pkgs[0]}" .nupkg)" echo "nupkg=${pkgs[0]}" >> "$GITHUB_OUTPUT" - echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "version=${name#Payjoin.}" >> "$GITHUB_OUTPUT" + + - name: Verify tag matches packed artifact version + id: verify + uses: ./.github/actions/verify-tag-version + with: + tag-prefix: payjoin-csharp- + version: ${{ steps.locate.outputs.version }} - name: Attest build provenance (nupkg) # Attesting the .nupkg covers every RID native lib inside it; a consumer @@ -320,7 +328,7 @@ jobs: # published unit.) uses: actions/attest-build-provenance@v4 with: - subject-path: ${{ steps.verify.outputs.nupkg }} + subject-path: ${{ steps.locate.outputs.nupkg }} - name: NuGet login (OIDC -> short-lived API key) id: login @@ -339,7 +347,7 @@ jobs: # the tag would otherwise 409 on the already-published version and # redden the whole run. With it, an already-present version is a # no-op success and the run converges. - dotnet nuget push "${{ steps.verify.outputs.nupkg }}" \ + dotnet nuget push "${{ steps.locate.outputs.nupkg }}" \ --api-key "${{ steps.login.outputs.NUGET_API_KEY }}" \ --source https://api.nuget.org/v3/index.json \ --no-symbols \ @@ -347,44 +355,13 @@ jobs: github-release: name: "Attach nupkg + SHA256SUMS to the GitHub release" - runs-on: ubuntu-latest needs: [publish-nuget] if: startsWith(github.ref, 'refs/tags/payjoin-csharp-') permissions: contents: write # create/update the Release for this tag and upload assets - steps: - - name: Download packed NuGet package - uses: actions/download-artifact@v4 - with: - name: payjoin-csharp-nuget-package - path: dist - - - name: Generate SHA256SUMS (nupkg + each native lib) - working-directory: dist - shell: bash - run: | - set -euo pipefail - # Hash the package itself. - sha256sum *.nupkg > SHA256SUMS - # Also hash each shipped native library extracted from inside the - # package, so a consumer can verify an individual .so/.dylib/.dll - # (paths are runtimes//native/). Matches the tor/hwi pattern. - tmp="$(mktemp -d)" - unzip -q *.nupkg -d "$tmp" 'runtimes/*/native/*' - ( cd "$tmp" && find runtimes -type f -print0 | sort -z | xargs -0 sha256sum ) >> SHA256SUMS - echo "----- SHA256SUMS -----" - cat SHA256SUMS - - - name: Create / update GitHub release - uses: softprops/action-gh-release@v3 - with: - files: | - dist/*.nupkg - dist/SHA256SUMS - fail_on_unmatched_files: true - draft: false - # Mark preview / rc tags as pre-releases on the Releases page. - prerelease: ${{ contains(github.ref_name, '-preview') || contains(github.ref_name, '-rc') }} - # Dan's GPG detached signature over SHA256SUMS is added out-of-band - # (locally, then uploaded as SHA256SUMS.asc) — his private key must not - # live on a runner. See README "GPG signature". + uses: ./.github/workflows/release-assets.yml + with: + artifact-pattern: payjoin-csharp-nuget-package + tag-prefix: payjoin-csharp- + archive-globs: "*.nupkg" + archive-member-globs: runtimes/*/native/* diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml index 317ace255..2e1159598 100644 --- a/.github/workflows/dart.yml +++ b/.github/workflows/dart.yml @@ -25,11 +25,7 @@ jobs: uses: actions/checkout@v6 - name: "Use cache" uses: Swatinem/rust-cache@v2 - - name: "Install nix" - uses: DeterminateSystems/determinate-nix-action@main - - name: "Use nix cache" - uses: DeterminateSystems/magic-nix-cache-action@main - with: - use-flakehub: false + - name: Set up nix + uses: ./.github/actions/setup-nix - name: "Build and test" run: nix develop .#dart -c bash ./payjoin-ffi/dart/contrib/test.sh diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index f1740156b..e99777e93 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -8,12 +8,8 @@ jobs: 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 - with: - use-flakehub: false + - name: Set up nix + uses: ./.github/actions/setup-nix - name: Run treefmt run: | set -eo pipefail diff --git a/.github/workflows/javascript.yml b/.github/workflows/javascript.yml index 0feafb77d..bcefffe5b 100644 --- a/.github/workflows/javascript.yml +++ b/.github/workflows/javascript.yml @@ -25,11 +25,7 @@ jobs: uses: actions/checkout@v6 - name: "Use cache" uses: Swatinem/rust-cache@v2 - - name: "Install nix" - uses: DeterminateSystems/determinate-nix-action@main - - name: "Use nix cache" - uses: DeterminateSystems/magic-nix-cache-action@main - with: - use-flakehub: false + - name: Set up nix + uses: ./.github/actions/setup-nix - name: "Build and test" run: nix develop .#javascript -c ./payjoin-ffi/javascript/contrib/test.sh diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 187ea365b..aebbe9cf2 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -25,11 +25,7 @@ jobs: uses: actions/checkout@v6 - name: "Use cache" uses: Swatinem/rust-cache@v2 - - name: "Install nix" - uses: DeterminateSystems/determinate-nix-action@main - - name: "Use nix cache" - uses: DeterminateSystems/magic-nix-cache-action@main - with: - use-flakehub: false + - name: Set up nix + uses: ./.github/actions/setup-nix - name: "Build and test" run: nix develop .#python --command bash ./payjoin-ffi/python/contrib/test.sh diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml new file mode 100644 index 000000000..3888fe07b --- /dev/null +++ b/.github/workflows/release-assets.yml @@ -0,0 +1,91 @@ +name: Attach release assets + +# Reusable tail of every binding publish workflow: download the packed +# artifact(s), generate SHA256SUMS, and attach everything to the GitHub +# release for the pushed tag. A maintainer's GPG detached signature over +# SHA256SUMS is added out-of-band (locally, then uploaded as +# SHA256SUMS.asc), so no signing key ever reaches a runner. + +on: + workflow_call: + inputs: + artifact-pattern: + description: Name (or glob) of the artifact(s) holding the files to attach + required: true + type: string + tag-prefix: + description: > + Tag prefix before the version, e.g. payjoin-csharp-. Used to + derive the version for pre-release detection. + required: true + type: string + archive-globs: + description: > + Optional whitespace-separated globs naming which downloaded + assets are archives to open for member hashing. Required with + archive-member-globs. + required: false + type: string + default: "" + archive-member-globs: + description: > + Optional whitespace-separated globs of files inside each + matched archive to hash into SHA256SUMS, so files nested + inside a package (e.g. native libraries) can be verified + individually. Required with archive-globs. + required: false + type: string + default: "" + +jobs: + github-release: + name: Create GitHub release + runs-on: ubuntu-26.04 + permissions: + contents: write # create/update the Release for this tag and upload assets + steps: + - name: Checkout repo + uses: actions/checkout@v6 + + - name: Download release artifacts + uses: actions/download-artifact@v4 + with: + pattern: ${{ inputs.artifact-pattern }} + merge-multiple: true + path: dist + + - name: Generate SHA256SUMS + shell: bash + env: + ARCHIVE_GLOBS: ${{ inputs.archive-globs }} + MEMBER_GLOBS: ${{ inputs.archive-member-globs }} + run: | + ./contrib/release/generate-sha256sums.sh dist + echo "----- SHA256SUMS -----" + cat dist/SHA256SUMS + + - name: Derive pre-release status from the tag + id: meta + shell: bash + env: + TAG: ${{ github.ref_name }} + PREFIX: ${{ inputs.tag-prefix }} + run: | + set -euo pipefail + version="${TAG#"$PREFIX"}" + # SemVer: a hyphen in the version proper marks a pre-release; + # build metadata after a + never does. + version="${version%%+*}" + if [[ $version == *-* ]]; then + echo "prerelease=true" >>"$GITHUB_OUTPUT" + else + echo "prerelease=false" >>"$GITHUB_OUTPUT" + fi + + - name: Create / update GitHub release + uses: softprops/action-gh-release@v3 + with: + files: dist/* + fail_on_unmatched_files: true + draft: false + prerelease: ${{ steps.meta.outputs.prerelease }} diff --git a/.github/workflows/verify-tag-hygiene.yml b/.github/workflows/verify-tag-hygiene.yml new file mode 100644 index 000000000..d80900fa6 --- /dev/null +++ b/.github/workflows/verify-tag-hygiene.yml @@ -0,0 +1,31 @@ +name: Verify tag hygiene + +# Reusable head of every binding publish workflow: verify the pushed tag is +# annotated, signed by a maintainer key in contrib/release/keys/, and an +# ancestor of master, so only a maintainer can cut a release. Call it as a +# job the publish job `needs`, so a bad tag fails before the `release` +# environment asks for approval. The tag comes from the triggering ref; the +# checks are prefix-agnostic, so there are no inputs. + +on: + workflow_call: + +jobs: + verify-tag-hygiene: + name: Verify tag hygiene + runs-on: ubuntu-26.04 + permissions: + contents: read + 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: Set up nix + uses: ./.github/actions/setup-nix + - name: Verify tag hygiene + env: + TAG: ${{ github.ref_name }} + run: nix --quiet develop .#release -c ./contrib/release/verify-tag-hygiene.sh "$TAG" diff --git a/contrib/release/generate-sha256sums.sh b/contrib/release/generate-sha256sums.sh new file mode 100755 index 000000000..725fd57e9 --- /dev/null +++ b/contrib/release/generate-sha256sums.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# +# Write SHA256SUMS over every file in a release asset directory. With +# ARCHIVE_GLOBS and MEMBER_GLOBS set, also open each asset matching +# ARCHIVE_GLOBS and append the hash of each member matching MEMBER_GLOBS, +# under its in-archive path, so files nested inside a package can be +# verified individually. Both variables are whitespace-separated glob +# lists and must be set together. Archives must be zip format, which +# every current package with nested libraries is (.nupkg, .whl). +set -euo pipefail + +[ "$#" -eq 1 ] || { + echo "usage: [ARCHIVE_GLOBS=... MEMBER_GLOBS=...] generate-sha256sums.sh " >&2 + exit 1 +} +die() { + echo "generate-sha256sums: $*" >&2 + exit 1 +} + +cd "$1" +# shellcheck disable=SC2094 # find excludes SHA256SUMS, so it is never read +find . -type f ! -name SHA256SUMS -printf '%P\0' | sort -z | + xargs -0 -r sha256sum >SHA256SUMS + +archive_globs="${ARCHIVE_GLOBS:-}" +member_globs="${MEMBER_GLOBS:-}" +if [ -z "$archive_globs" ] && [ -z "$member_globs" ]; then + exit 0 +fi +{ [ -n "$archive_globs" ] && [ -n "$member_globs" ]; } || + die "ARCHIVE_GLOBS and MEMBER_GLOBS must be set together" + +read -ra members <<<"$member_globs" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +shopt -s nullglob +archives=() +# shellcheck disable=SC2086 # unquoted so the shell expands the caller's globs +for archive in $archive_globs; do + archives+=("$archive") +done +[ "${#archives[@]}" -gt 0 ] || die "no files match ARCHIVE_GLOBS ($archive_globs)" + +# unzip matches the member globs itself and fails when an archive +# contains no match, or is not a zip. +for archive in "${archives[@]}"; do + unzip -q "$archive" -d "$tmp" "${members[@]}" +done + +(cd "$tmp" && find . -type f -printf '%P\0' | sort -z | xargs -0 -r sha256sum) >>SHA256SUMS diff --git a/contrib/release/verify-tag-hygiene.sh b/contrib/release/verify-tag-hygiene.sh new file mode 100755 index 000000000..4ffacd5b7 --- /dev/null +++ b/contrib/release/verify-tag-hygiene.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# +# 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. +# +# Checks the tag object and repo history only, so it carries no assumption +# about what is being released; ecosystem-specific checks (manifest versions, +# publishability) belong to the caller. +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$DIR/../.." && pwd)" + +[ "$#" -eq 1 ] || { + echo "usage: verify-tag-hygiene.sh " >&2 + exit 1 +} +tag="$1" +die() { + echo "verify-tag-hygiene: $*" >&2 + exit 1 +} + +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" diff --git a/contrib/release/verify-tag.sh b/contrib/release/verify-tag.sh index 511805422..41e975e8c 100755 --- a/contrib/release/verify-tag.sh +++ b/contrib/release/verify-tag.sh @@ -1,9 +1,9 @@ #!/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. +# Release gate. Confirms a `-` tag passes the shared hygiene +# checks (verify-tag-hygiene.sh), 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. @@ -27,23 +27,7 @@ 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" +"$DIR/verify-tag-hygiene.sh" "$tag" echo "Checking the $crate manifest version is $version" RELEASE_METADATA="$(cargo_metadata)"