From d310da69fcbd9be35ee75908820cf5c741961543 Mon Sep 17 00:00:00 2001 From: spacebear Date: Tue, 11 Aug 2026 21:05:33 +0000 Subject: [PATCH 1/4] Extract nix setup into a composite action --- .github/actions/setup-nix/action.yml | 14 ++++++++++++ .github/workflows/crates-release.yml | 32 +++++++--------------------- .github/workflows/csharp.yml | 8 ++----- .github/workflows/dart.yml | 8 ++----- .github/workflows/format.yml | 8 ++----- .github/workflows/javascript.yml | 8 ++----- .github/workflows/python.yml | 8 ++----- 7 files changed, 32 insertions(+), 54 deletions(-) create mode 100644 .github/actions/setup-nix/action.yml 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/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..1e97694ae 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 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 From 9929a1491d6d5502972d91a3359f89501bf80329 Mon Sep 17 00:00:00 2001 From: spacebear Date: Tue, 11 Aug 2026 21:06:17 +0000 Subject: [PATCH 2/4] Extract tag-version check into a composite action Every registry publish job must refuse to push an artifact whose version differs from what the pushed tag calls for. The check was written inline in the NuGet publish job; the upcoming npm, pub.dev, and PyPI publish jobs would each duplicate it. Move the prefix-strip-and-compare into a verify-tag-version composite action taking the tag prefix and the actual packed version. The NuGet job keeps its locate step (exactly one .nupkg, version parsed from the filename) and hands the result to the action. Local actions resolve from the workspace, so the job gains a checkout step it previously avoided; its contents: read permission already covers it. --- .github/actions/verify-tag-version/action.yml | 37 +++++++++++++++++++ .github/workflows/csharp.yml | 32 +++++++++------- 2 files changed, 55 insertions(+), 14 deletions(-) create mode 100644 .github/actions/verify-tag-version/action.yml 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/csharp.yml b/.github/workflows/csharp.yml index 1e97694ae..3b88ad94c 100644 --- a/.github/workflows/csharp.yml +++ b/.github/workflows/csharp.yml @@ -272,8 +272,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: @@ -285,27 +288,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 @@ -316,7 +320,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 @@ -335,7 +339,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 \ From 001d4f0d5ac53a9f81ef5a48a8b296eb8e94d301 Mon Sep 17 00:00:00 2001 From: spacebear Date: Tue, 18 Aug 2026 14:09:52 -0400 Subject: [PATCH 3/4] Extract tag hygiene checks into a shared script The annotated, trusted-signature, and ancestor-of-master checks in verify-tag.sh apply to any release tag, not only crate release tags. Move them into verify-tag-hygiene.sh so tag-triggered publishing workflows for language bindings can enforce the same authenticity gate without taking on the crate-specific checks (manifest version, release invariants, sibling crates.io dependencies), which stay in verify-tag.sh. Pure code move; verify-tag.sh behavior is unchanged. --- .github/workflows/csharp.yml | 14 +++++++-- .github/workflows/verify-tag-hygiene.yml | 31 ++++++++++++++++++ contrib/release/verify-tag-hygiene.sh | 40 ++++++++++++++++++++++++ contrib/release/verify-tag.sh | 26 +++------------ 4 files changed, 87 insertions(+), 24 deletions(-) create mode 100644 .github/workflows/verify-tag-hygiene.yml create mode 100755 contrib/release/verify-tag-hygiene.sh diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml index 3b88ad94c..aa5cb86ea 100644 --- a/.github/workflows/csharp.yml +++ b/.github/workflows/csharp.yml @@ -256,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 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/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)" From ea9d982a8c401a3d4df360d2dc437c255e068cbd Mon Sep 17 00:00:00 2001 From: spacebear Date: Tue, 11 Aug 2026 21:07:18 +0000 Subject: [PATCH 4/4] Extract GitHub release job into a reusable workflow Publishing ends the same way for every binding: download the packed artifact, generate SHA256SUMS, and attach both to the GitHub release for the pushed tag. Move the job to release-assets.yml as a workflow_call and point the C# github-release job at it. The artifact-pattern input downloads and merges multiple artifacts for workflows that build one per platform. Two behavior notes: the job now runs on ubuntu-26.04 like the rest of the repo instead of ubuntu-latest, and pre-release detection derives from a SemVer hyphen in the tag's version (ignoring + build metadata) instead of matching only -preview and -rc, which classifies all existing tags identically. --- .github/workflows/csharp.yml | 43 ++---------- .github/workflows/release-assets.yml | 91 ++++++++++++++++++++++++++ contrib/release/generate-sha256sums.sh | 51 +++++++++++++++ 3 files changed, 148 insertions(+), 37 deletions(-) create mode 100644 .github/workflows/release-assets.yml create mode 100755 contrib/release/generate-sha256sums.sh diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml index aa5cb86ea..c08ce696d 100644 --- a/.github/workflows/csharp.yml +++ b/.github/workflows/csharp.yml @@ -355,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/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/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