From 1efdfb456a12a6399c498b47083740807bc9b0e6 Mon Sep 17 00:00:00 2001 From: Jonas Lerebours Date: Mon, 26 Oct 2026 16:23:53 +0100 Subject: [PATCH 01/11] ci: set the release version on sync PRs and check tags against Cargo.toml Semver Check runs cargo-semver-checks on monorepo sync PRs against the published crate and picks the smallest version it accepts (in 0.x a breaking change bumps the minor). Version Bump, triggered by workflow_run so it always runs from main, validates that result and commits it to the sync PR through the GitHub API, labels the release level, and disables auto-merge on breaking changes. Merging the sync PR now carries its version, with no separate release PR. publish.yaml gains a check-tag job that fails before the crates-io environment when the tag does not match the Cargo.toml version or is not on main. --- .github/scripts/apply-version-bump.sh | 88 +++++++++++++++++++++++++++ .github/scripts/next-version.sh | 50 +++++++++++++++ .github/scripts/set-version.sh | 38 ++++++++++++ .github/workflows/publish.yaml | 22 +++++++ .github/workflows/semver-check.yaml | 66 ++++++++++++++++++++ .github/workflows/version-bump.yaml | 55 +++++++++++++++++ 6 files changed, 319 insertions(+) create mode 100755 .github/scripts/apply-version-bump.sh create mode 100755 .github/scripts/next-version.sh create mode 100755 .github/scripts/set-version.sh create mode 100644 .github/workflows/semver-check.yaml create mode 100644 .github/workflows/version-bump.yaml diff --git a/.github/scripts/apply-version-bump.sh b/.github/scripts/apply-version-bump.sh new file mode 100755 index 0000000..279e96c --- /dev/null +++ b/.github/scripts/apply-version-bump.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Usage: apply-version-bump.sh +# Runs from the default branch (Version Bump workflow). Everything read from comes from +# a pull_request run and is untrusted: values are validated, and the only change written to the sync PR is +# the version line in Cargo.toml and Cargo.lock, made here. +# Env: GH_TOKEN, REPO (owner/name), RUN_HEAD_SHA and RUN_HEAD_BRANCH (from the workflow_run event). +set -euo pipefail + +dir="$1" +script_dir=$(cd "$(dirname "$0")" && pwd) + +read -r version level breaking < "$dir/result.txt" +pr_number=$(cat "$dir/pr-number.txt") +head_sha=$(cat "$dir/head-sha.txt") +published=$(cat "$dir/published.txt") + +[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "invalid version: $version" >&2; exit 1; } +[[ "$published" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "invalid published version" >&2; exit 1; } +[[ "$level" =~ ^(patch|minor|major)$ ]] || { echo "invalid level: $level" >&2; exit 1; } +[[ "$breaking" =~ ^(true|false)$ ]] || { echo "invalid breaking flag: $breaking" >&2; exit 1; } +[[ "$pr_number" =~ ^[0-9]+$ ]] || { echo "invalid PR number" >&2; exit 1; } +[[ "$head_sha" == "$RUN_HEAD_SHA" ]] || { echo "artifact head SHA does not match the triggering run" >&2; exit 1; } + +# The PR must still be an open, same-repository sync PR into main, at the commit that was checked +pr=$(gh api "repos/$REPO/pulls/$pr_number") +state=$(jq -r .state <<< "$pr") +pr_head_ref=$(jq -r .head.ref <<< "$pr") +pr_head_repo=$(jq -r .head.repo.full_name <<< "$pr") +pr_head_sha=$(jq -r .head.sha <<< "$pr") +pr_base=$(jq -r .base.ref <<< "$pr") +if [ "$state" != "open" ] || [ "$pr_base" != "main" ] || [ "$pr_head_repo" != "$REPO" ] \ + || [ "$pr_head_ref" != "$RUN_HEAD_BRANCH" ] || [[ "$pr_head_ref" != sdk-sync/* ]]; then + echo "PR #$pr_number is not an open sync PR of $REPO into main" >&2 + exit 1 +fi +if [ "$pr_head_sha" != "$head_sha" ]; then + echo "::notice::PR #$pr_number moved since the check ran; the run for the new head will apply the version" + exit 0 +fi + +# Edit the PR head's Cargo.toml and Cargo.lock +work=$(mktemp -d) +for file in Cargo.toml Cargo.lock; do + gh api -H "Accept: application/vnd.github.raw" "repos/$REPO/contents/$file?ref=$head_sha" > "$work/$file" +done +cp "$work/Cargo.toml" "$work/Cargo.toml.orig" +"$script_dir/set-version.sh" "$version" "$work/Cargo.toml" "$work/Cargo.lock" + +if cmp -s "$work/Cargo.toml" "$work/Cargo.toml.orig"; then + echo "Cargo.toml is already at $version" +else + # Committed through the API so GitHub signs it (Verified), and only if the branch is still at head_sha + jq -n \ + --arg repo "$REPO" --arg branch "$pr_head_ref" --arg head "$head_sha" \ + --arg message "chore(release): $version" \ + --arg toml "$(base64 -w0 "$work/Cargo.toml")" --arg lock "$(base64 -w0 "$work/Cargo.lock")" \ + '{query: "mutation($input: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $input) { commit { oid } } }", + variables: {input: { + branch: {repositoryNameWithOwner: $repo, branchName: $branch}, + expectedHeadOid: $head, + message: {headline: $message}, + fileChanges: {additions: [{path: "Cargo.toml", contents: $toml}, {path: "Cargo.lock", contents: $lock}]} + }}}' > "$work/mutation.json" + gh api graphql --input "$work/mutation.json" --jq .data.createCommitOnBranch.commit.oid +fi + +# Label the release level, replacing labels from an earlier run on this PR +for name in release:patch release:minor release:major breaking; do + gh pr edit "$pr_number" --repo "$REPO" --remove-label "$name" > /dev/null 2>&1 || true +done +gh label create "release:$level" --repo "$REPO" --color 0E8A16 --force > /dev/null +gh pr edit "$pr_number" --repo "$REPO" --add-label "release:$level" + +if [ "$breaking" = "true" ]; then + gh label create breaking --repo "$REPO" --color B60205 --force > /dev/null + gh pr edit "$pr_number" --repo "$REPO" --add-label breaking + # A breaking release needs a person to merge it + gh pr merge "$pr_number" --repo "$REPO" --disable-auto > /dev/null 2>&1 || true + { + echo "**Breaking change:** this sync requires \`$version\` (published: \`$published\`). Auto-merge is disabled." + echo + echo "~~~" + # Untrusted text: keep the cargo-semver-checks failure list only, without code fences + grep -E '^(--- failure| )' "$dir/report.txt" | tr -d '`~' | head -80 || true + echo "~~~" + } > "$work/comment.md" + gh pr comment "$pr_number" --repo "$REPO" --body-file "$work/comment.md" +fi diff --git a/.github/scripts/next-version.sh b/.github/scripts/next-version.sh new file mode 100755 index 0000000..59a68df --- /dev/null +++ b/.github/scripts/next-version.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Usage: next-version.sh +# Tries the candidate versions after from smallest to largest, and prints +# " " for the first one cargo-semver-checks accepts against the published crate. +# In 0.y.z a breaking change bumps y (Cargo's semver rule), so 0.x never needs a major bump. +# The cargo-semver-checks output for the rejected smaller bump is written to . +set -euo pipefail + +published="$1" +report="$2" +script_dir=$(cd "$(dirname "$0")" && pwd) + +if ! [[ "$published" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + echo "invalid published version: $published" >&2 + exit 1 +fi +major=${BASH_REMATCH[1]} +minor=${BASH_REMATCH[2]} +patch=${BASH_REMATCH[3]} + +if [ "$major" -eq 0 ]; then + candidates=("0.$minor.$((patch + 1)) patch false" "0.$((minor + 1)).0 minor true") +else + candidates=("$major.$minor.$((patch + 1)) patch false" "$major.$((minor + 1)).0 minor false" "$((major + 1)).0.0 major true") +fi + +: > "$report" +for candidate in "${candidates[@]}"; do + read -r version level breaking <<< "$candidate" + "$script_dir/set-version.sh" "$version" Cargo.toml Cargo.lock + # The release type is derived from the version change between the baseline and Cargo.toml + if cargo semver-checks --baseline-version "$published" > semver-output.txt 2>&1; then + rm -f semver-output.txt + echo "$version $level $breaking" + exit 0 + fi + # Any other failure (build, network) must not be mistaken for a breaking change + if ! grep -q "semver requires new .* version" semver-output.txt; then + cat semver-output.txt >&2 + exit 1 + fi + # Keep the output of the first rejected bump: it lists what broke + if [ ! -s "$report" ]; then + cp semver-output.txt "$report" + fi +done + +rm -f semver-output.txt +echo "no candidate version passed cargo-semver-checks" >&2 +exit 1 diff --git a/.github/scripts/set-version.sh b/.github/scripts/set-version.sh new file mode 100755 index 0000000..84d8586 --- /dev/null +++ b/.github/scripts/set-version.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Usage: set-version.sh +# Sets the crate version in the [package] table of Cargo.toml and in the crate's own Cargo.lock entry. +# Fails unless exactly one line changes in each file, so nothing else can be rewritten. +set -euo pipefail + +version="$1" +manifest="$2" +lockfile="$3" + +if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "invalid version: $version" >&2 + exit 1 +fi + +# Only the first version key of the [package] table +awk -v v="$version" ' + /^\[/ { in_package = ($0 == "[package]") } + in_package && !done && /^version = "[^"]*"$/ { print "version = \"" v "\""; done = 1; next } + { print } +' "$manifest" > "$manifest.new" + +# Only the version line right after the crate's own name in Cargo.lock +awk -v v="$version" ' + /^name = "dfns-sdk-rust"$/ { own = 1; print; next } + own && /^version = "[^"]*"$/ { print "version = \"" v "\""; own = 0; next } + { own = 0; print } +' "$lockfile" > "$lockfile.new" + +for file in "$manifest" "$lockfile"; do + changed=$(diff "$file" "$file.new" | grep -c '^>' || true) + if [ "$changed" -gt 1 ]; then + echo "expected at most one changed line in $file, got $changed" >&2 + rm -f "$file.new" + exit 1 + fi + mv "$file.new" "$file" +done diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 81bbfe3..0b01141 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -13,7 +13,29 @@ concurrency: cancel-in-progress: false jobs: + # Runs before the publish job and its environment, so a bad tag fails without minting a crates.io token + check-tag: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Check the tag matches Cargo.toml and is on main + run: | + version=$(awk '/^\[/ { p = ($0 == "[package]") } p && /^version = / { gsub(/"/, "", $3); print $3; exit }' Cargo.toml) + if [ "$GITHUB_REF_NAME" != "v$version" ]; then + echo "::error::Tag $GITHUB_REF_NAME does not match the Cargo.toml version $version (expected v$version)" + exit 1 + fi + if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then + echo "::error::Tag $GITHUB_REF_NAME points to a commit that is not on main" + exit 1 + fi + publish: + needs: check-tag runs-on: ubuntu-latest environment: name: crates-io diff --git a/.github/workflows/semver-check.yaml b/.github/workflows/semver-check.yaml new file mode 100644 index 0000000..4e2e1f0 --- /dev/null +++ b/.github/workflows/semver-check.yaml @@ -0,0 +1,66 @@ +name: Semver Check + +# Computes the version a monorepo sync PR must ship with, from the API change against the +# published crate. Read-only: the Version Bump workflow (workflow_run) commits the result. + +on: + pull_request: + branches: + - main + +permissions: + contents: read + +env: + CARGO_SEMVER_CHECKS_VERSION: "0.51.0" + +jobs: + semver: + # Only the monorepo's generated-SDK sync PRs, from branches of this repository + if: startsWith(github.head_ref, 'sdk-sync/') && github.event.pull_request.head.repo.full_name == github.repository + timeout-minutes: 30 + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + + - name: Install Rust + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + + - name: Install cargo-semver-checks + run: cargo install --locked "cargo-semver-checks@$CARGO_SEMVER_CHECKS_VERSION" + + - name: Compute the next version + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + published=$(curl -fsSL -A "dfns-sdk-rust CI (https://github.com/dfns/dfns-sdk-rust)" \ + https://crates.io/api/v1/crates/dfns-sdk-rust | jq -r .crate.max_stable_version) + mkdir -p semver-result + .github/scripts/next-version.sh "$published" semver-result/report.txt > semver-result/result.txt + echo "$PR_NUMBER" > semver-result/pr-number.txt + echo "$HEAD_SHA" > semver-result/head-sha.txt + echo "$published" > semver-result/published.txt + read -r version level breaking < semver-result/result.txt + { + echo "### Next version: \`$version\` ($level bump from \`$published\`)" + if [ "$breaking" = "true" ]; then + echo + echo "Breaking change against the published crate:" + echo '```' + grep -E '^(--- failure| )' semver-result/report.txt | head -60 + echo '```' + fi + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload the result + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: semver-result + path: semver-result/ + retention-days: 7 diff --git a/.github/workflows/version-bump.yaml b/.github/workflows/version-bump.yaml new file mode 100644 index 0000000..61ebc94 --- /dev/null +++ b/.github/workflows/version-bump.yaml @@ -0,0 +1,55 @@ +name: Version Bump + +# Commits the version computed by Semver Check to the monorepo sync PR, so merging that one PR +# ships the SDK update and its version together. workflow_run always runs this file from the +# default branch, so a PR cannot change what runs with write access; the artifact it reads is +# validated by .github/scripts/apply-version-bump.sh. + +on: + workflow_run: + workflows: + - Semver Check + types: + - completed + +permissions: + contents: read + +jobs: + bump: + if: >- + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success' && + startsWith(github.event.workflow_run.head_branch, 'sdk-sync/') && + github.event.workflow_run.head_repository.full_name == github.repository + timeout-minutes: 10 + runs-on: ubuntu-latest + permissions: + contents: write # commit the version to the sync PR branch + pull-requests: write # release labels, comment, disable auto-merge + actions: read # download the Semver Check artifact + concurrency: + group: version-bump-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: false + + steps: + - name: Checkout code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Download the Semver Check result + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: semver-result + path: semver-result + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + + - name: Apply the version to the sync PR + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + RUN_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} + run: .github/scripts/apply-version-bump.sh semver-result From 135c85f8743c4467045986a355105a649eb40f87 Mon Sep 17 00:00:00 2001 From: Jonas Lerebours Date: Mon, 26 Oct 2026 16:42:58 +0100 Subject: [PATCH 02/11] ci: derive the next version from one cargo-semver-checks run cargo-semver-checks reports the bump it requires but not the version, so run it once against the published crate and derive the number, instead of rerunning it for each candidate version. In 0.y.z its "major" means a y bump. --- .github/scripts/next-version.sh | 56 ++++++++++++++------------------- 1 file changed, 24 insertions(+), 32 deletions(-) diff --git a/.github/scripts/next-version.sh b/.github/scripts/next-version.sh index 59a68df..3f2a873 100755 --- a/.github/scripts/next-version.sh +++ b/.github/scripts/next-version.sh @@ -1,14 +1,12 @@ #!/usr/bin/env bash # Usage: next-version.sh -# Tries the candidate versions after from smallest to largest, and prints -# " " for the first one cargo-semver-checks accepts against the published crate. -# In 0.y.z a breaking change bumps y (Cargo's semver rule), so 0.x never needs a major bump. -# The cargo-semver-checks output for the rejected smaller bump is written to . +# Runs cargo-semver-checks once against the published crate and prints " " +# for the bump it requires. cargo-semver-checks names the required bump but not the version, so the +# number is derived here. Its full output is written to . set -euo pipefail published="$1" report="$2" -script_dir=$(cd "$(dirname "$0")" && pwd) if ! [[ "$published" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then echo "invalid published version: $published" >&2 @@ -18,33 +16,27 @@ major=${BASH_REMATCH[1]} minor=${BASH_REMATCH[2]} patch=${BASH_REMATCH[3]} -if [ "$major" -eq 0 ]; then - candidates=("0.$minor.$((patch + 1)) patch false" "0.$((minor + 1)).0 minor true") +if cargo semver-checks --baseline-version "$published" --release-type patch > "$report" 2>&1; then + required="patch" +elif required=$(grep -oE 'semver requires new (major|minor) version' "$report" | head -1 | awk '{print $4}') \ + && [ -n "$required" ]; then + : else - candidates=("$major.$minor.$((patch + 1)) patch false" "$major.$((minor + 1)).0 minor false" "$((major + 1)).0.0 major true") -fi - -: > "$report" -for candidate in "${candidates[@]}"; do - read -r version level breaking <<< "$candidate" - "$script_dir/set-version.sh" "$version" Cargo.toml Cargo.lock - # The release type is derived from the version change between the baseline and Cargo.toml - if cargo semver-checks --baseline-version "$published" > semver-output.txt 2>&1; then - rm -f semver-output.txt - echo "$version $level $breaking" - exit 0 - fi # Any other failure (build, network) must not be mistaken for a breaking change - if ! grep -q "semver requires new .* version" semver-output.txt; then - cat semver-output.txt >&2 - exit 1 - fi - # Keep the output of the first rejected bump: it lists what broke - if [ ! -s "$report" ]; then - cp semver-output.txt "$report" - fi -done + cat "$report" >&2 + exit 1 +fi -rm -f semver-output.txt -echo "no candidate version passed cargo-semver-checks" >&2 -exit 1 +# Cargo's semver: in 0.y.z, y is the breaking ("major") position and z the compatible one +if [ "$major" -eq 0 ]; then + case "$required" in + major) echo "0.$((minor + 1)).0 minor true" ;; + *) echo "0.$minor.$((patch + 1)) patch false" ;; + esac +else + case "$required" in + major) echo "$((major + 1)).0.0 major true" ;; + minor) echo "$major.$((minor + 1)).0 minor false" ;; + *) echo "$major.$minor.$((patch + 1)) patch false" ;; + esac +fi From 5fc775e23a9dde34a36b2566fc26a1343375f7b6 Mon Sep 17 00:00:00 2001 From: Jonas Lerebours Date: Mon, 26 Oct 2026 16:47:07 +0100 Subject: [PATCH 03/11] ci: check semver on every PR and never lower the version Semver Check now runs on all PRs, so hand-written changes (dependencies, Cargo.toml) also show their release impact through the release labels and, when breaking, a comment. The version is still only committed to monorepo sync PRs. The bump keeps the higher of the computed version and Cargo.toml, so an unreleased bump already on main (for example a merged breaking sync) is not lowered by a later patch-level sync. The breaking-change comment is updated in place instead of repeated. --- .github/scripts/apply-version-bump.sh | 38 ++++++++++++++++----------- .github/workflows/semver-check.yaml | 7 +++-- .github/workflows/version-bump.yaml | 13 +++++---- 3 files changed, 32 insertions(+), 26 deletions(-) diff --git a/.github/scripts/apply-version-bump.sh b/.github/scripts/apply-version-bump.sh index 279e96c..d53530e 100755 --- a/.github/scripts/apply-version-bump.sh +++ b/.github/scripts/apply-version-bump.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash # Usage: apply-version-bump.sh # Runs from the default branch (Version Bump workflow). Everything read from comes from -# a pull_request run and is untrusted: values are validated, and the only change written to the sync PR is -# the version line in Cargo.toml and Cargo.lock, made here. +# a pull_request run and is untrusted: values are validated, and the only change ever written to a PR is +# the version line in Cargo.toml and Cargo.lock of a monorepo sync PR, made here. # Env: GH_TOKEN, REPO (owner/name), RUN_HEAD_SHA and RUN_HEAD_BRANCH (from the workflow_run event). set -euo pipefail @@ -21,7 +21,7 @@ published=$(cat "$dir/published.txt") [[ "$pr_number" =~ ^[0-9]+$ ]] || { echo "invalid PR number" >&2; exit 1; } [[ "$head_sha" == "$RUN_HEAD_SHA" ]] || { echo "artifact head SHA does not match the triggering run" >&2; exit 1; } -# The PR must still be an open, same-repository sync PR into main, at the commit that was checked +# The PR must still be an open same-repository PR into main, at the commit that was checked pr=$(gh api "repos/$REPO/pulls/$pr_number") state=$(jq -r .state <<< "$pr") pr_head_ref=$(jq -r .head.ref <<< "$pr") @@ -29,30 +29,32 @@ pr_head_repo=$(jq -r .head.repo.full_name <<< "$pr") pr_head_sha=$(jq -r .head.sha <<< "$pr") pr_base=$(jq -r .base.ref <<< "$pr") if [ "$state" != "open" ] || [ "$pr_base" != "main" ] || [ "$pr_head_repo" != "$REPO" ] \ - || [ "$pr_head_ref" != "$RUN_HEAD_BRANCH" ] || [[ "$pr_head_ref" != sdk-sync/* ]]; then - echo "PR #$pr_number is not an open sync PR of $REPO into main" >&2 + || [ "$pr_head_ref" != "$RUN_HEAD_BRANCH" ]; then + echo "PR #$pr_number is not an open same-repository PR into main" >&2 exit 1 fi if [ "$pr_head_sha" != "$head_sha" ]; then - echo "::notice::PR #$pr_number moved since the check ran; the run for the new head will apply the version" + echo "::notice::PR #$pr_number moved since the check ran; the run for the new head will handle it" exit 0 fi -# Edit the PR head's Cargo.toml and Cargo.lock work=$(mktemp -d) for file in Cargo.toml Cargo.lock; do gh api -H "Accept: application/vnd.github.raw" "repos/$REPO/contents/$file?ref=$head_sha" > "$work/$file" done -cp "$work/Cargo.toml" "$work/Cargo.toml.orig" -"$script_dir/set-version.sh" "$version" "$work/Cargo.toml" "$work/Cargo.lock" +current=$(awk '/^\[/ { p = ($0 == "[package]") } p && /^version = / { gsub(/"/, "", $3); print $3; exit }' "$work/Cargo.toml") +[[ "$current" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "unexpected Cargo.toml version: $current" >&2; exit 1; } -if cmp -s "$work/Cargo.toml" "$work/Cargo.toml.orig"; then - echo "Cargo.toml is already at $version" -else +# Never lower the version: Cargo.toml may already carry an unreleased bump (an earlier merged +# sync PR, or one set by hand), which this release must keep +target=$(printf '%s\n%s\n' "$version" "$current" | sort -V | tail -1) + +if [[ "$pr_head_ref" == sdk-sync/* ]] && [ "$target" != "$current" ]; then + "$script_dir/set-version.sh" "$target" "$work/Cargo.toml" "$work/Cargo.lock" # Committed through the API so GitHub signs it (Verified), and only if the branch is still at head_sha jq -n \ --arg repo "$REPO" --arg branch "$pr_head_ref" --arg head "$head_sha" \ - --arg message "chore(release): $version" \ + --arg message "chore(release): $target" \ --arg toml "$(base64 -w0 "$work/Cargo.toml")" --arg lock "$(base64 -w0 "$work/Cargo.lock")" \ '{query: "mutation($input: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $input) { commit { oid } } }", variables: {input: { @@ -77,12 +79,18 @@ if [ "$breaking" = "true" ]; then # A breaking release needs a person to merge it gh pr merge "$pr_number" --repo "$REPO" --disable-auto > /dev/null 2>&1 || true { - echo "**Breaking change:** this sync requires \`$version\` (published: \`$published\`). Auto-merge is disabled." + echo "**Breaking change** against the published \`$published\`: the next release must be at least \`$version\`. Auto-merge is disabled." + if [[ "$pr_head_ref" != sdk-sync/* ]] && [ "$target" != "$current" ]; then + echo + echo "If this PR should be released, set \`$target\` in \`Cargo.toml\` and \`Cargo.lock\`." + fi echo echo "~~~" # Untrusted text: keep the cargo-semver-checks failure list only, without code fences grep -E '^(--- failure| )' "$dir/report.txt" | tr -d '`~' | head -80 || true echo "~~~" } > "$work/comment.md" - gh pr comment "$pr_number" --repo "$REPO" --body-file "$work/comment.md" + # One comment per PR, updated on later runs + gh pr comment "$pr_number" --repo "$REPO" --edit-last --body-file "$work/comment.md" 2> /dev/null \ + || gh pr comment "$pr_number" --repo "$REPO" --body-file "$work/comment.md" fi diff --git a/.github/workflows/semver-check.yaml b/.github/workflows/semver-check.yaml index 4e2e1f0..8d7b8a1 100644 --- a/.github/workflows/semver-check.yaml +++ b/.github/workflows/semver-check.yaml @@ -1,7 +1,8 @@ name: Semver Check -# Computes the version a monorepo sync PR must ship with, from the API change against the -# published crate. Read-only: the Version Bump workflow (workflow_run) commits the result. +# Computes the release a PR requires, from its API change against the published crate. +# Read-only: the Version Bump workflow (workflow_run) labels the PR, and commits the version +# to monorepo sync PRs. on: pull_request: @@ -16,8 +17,6 @@ env: jobs: semver: - # Only the monorepo's generated-SDK sync PRs, from branches of this repository - if: startsWith(github.head_ref, 'sdk-sync/') && github.event.pull_request.head.repo.full_name == github.repository timeout-minutes: 30 runs-on: ubuntu-latest diff --git a/.github/workflows/version-bump.yaml b/.github/workflows/version-bump.yaml index 61ebc94..7ffdf16 100644 --- a/.github/workflows/version-bump.yaml +++ b/.github/workflows/version-bump.yaml @@ -1,9 +1,9 @@ name: Version Bump -# Commits the version computed by Semver Check to the monorepo sync PR, so merging that one PR -# ships the SDK update and its version together. workflow_run always runs this file from the -# default branch, so a PR cannot change what runs with write access; the artifact it reads is -# validated by .github/scripts/apply-version-bump.sh. +# Labels each PR with the release Semver Check computed, and commits that version to monorepo +# sync PRs, so merging one sync PR ships the SDK update and its version together. workflow_run +# always runs this file from the default branch, so a PR cannot change what runs with write +# access; the artifact it reads is validated by .github/scripts/apply-version-bump.sh. on: workflow_run: @@ -20,12 +20,11 @@ jobs: if: >- github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' && - startsWith(github.event.workflow_run.head_branch, 'sdk-sync/') && github.event.workflow_run.head_repository.full_name == github.repository timeout-minutes: 10 runs-on: ubuntu-latest permissions: - contents: write # commit the version to the sync PR branch + contents: write # commit the version to sync PR branches pull-requests: write # release labels, comment, disable auto-merge actions: read # download the Semver Check artifact concurrency: @@ -46,7 +45,7 @@ jobs: run-id: ${{ github.event.workflow_run.id }} github-token: ${{ github.token }} - - name: Apply the version to the sync PR + - name: Label the PR and apply the version to sync PRs env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} From 0c20cdd1a1a88534709b3bb9f8dd62dfd6e12f10 Mon Sep 17 00:00:00 2001 From: Jonas Lerebours Date: Mon, 26 Oct 2026 17:07:41 +0100 Subject: [PATCH 04/11] ci: split the version bump into validate, commit and label steps The workflow now holds the branching: the validated result feeds a commit step that only runs on sync PRs whose version must change, and a label step that runs on every PR. Each script does one thing. --- .github/scripts/apply-version-bump.sh | 96 ----------------------- .github/scripts/commit-version.sh | 28 +++++++ .github/scripts/label-release.sh | 41 ++++++++++ .github/scripts/validate-semver-result.sh | 60 ++++++++++++++ .github/workflows/version-bump.yaml | 39 ++++++++- 5 files changed, 165 insertions(+), 99 deletions(-) delete mode 100755 .github/scripts/apply-version-bump.sh create mode 100755 .github/scripts/commit-version.sh create mode 100755 .github/scripts/label-release.sh create mode 100755 .github/scripts/validate-semver-result.sh diff --git a/.github/scripts/apply-version-bump.sh b/.github/scripts/apply-version-bump.sh deleted file mode 100755 index d53530e..0000000 --- a/.github/scripts/apply-version-bump.sh +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env bash -# Usage: apply-version-bump.sh -# Runs from the default branch (Version Bump workflow). Everything read from comes from -# a pull_request run and is untrusted: values are validated, and the only change ever written to a PR is -# the version line in Cargo.toml and Cargo.lock of a monorepo sync PR, made here. -# Env: GH_TOKEN, REPO (owner/name), RUN_HEAD_SHA and RUN_HEAD_BRANCH (from the workflow_run event). -set -euo pipefail - -dir="$1" -script_dir=$(cd "$(dirname "$0")" && pwd) - -read -r version level breaking < "$dir/result.txt" -pr_number=$(cat "$dir/pr-number.txt") -head_sha=$(cat "$dir/head-sha.txt") -published=$(cat "$dir/published.txt") - -[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "invalid version: $version" >&2; exit 1; } -[[ "$published" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "invalid published version" >&2; exit 1; } -[[ "$level" =~ ^(patch|minor|major)$ ]] || { echo "invalid level: $level" >&2; exit 1; } -[[ "$breaking" =~ ^(true|false)$ ]] || { echo "invalid breaking flag: $breaking" >&2; exit 1; } -[[ "$pr_number" =~ ^[0-9]+$ ]] || { echo "invalid PR number" >&2; exit 1; } -[[ "$head_sha" == "$RUN_HEAD_SHA" ]] || { echo "artifact head SHA does not match the triggering run" >&2; exit 1; } - -# The PR must still be an open same-repository PR into main, at the commit that was checked -pr=$(gh api "repos/$REPO/pulls/$pr_number") -state=$(jq -r .state <<< "$pr") -pr_head_ref=$(jq -r .head.ref <<< "$pr") -pr_head_repo=$(jq -r .head.repo.full_name <<< "$pr") -pr_head_sha=$(jq -r .head.sha <<< "$pr") -pr_base=$(jq -r .base.ref <<< "$pr") -if [ "$state" != "open" ] || [ "$pr_base" != "main" ] || [ "$pr_head_repo" != "$REPO" ] \ - || [ "$pr_head_ref" != "$RUN_HEAD_BRANCH" ]; then - echo "PR #$pr_number is not an open same-repository PR into main" >&2 - exit 1 -fi -if [ "$pr_head_sha" != "$head_sha" ]; then - echo "::notice::PR #$pr_number moved since the check ran; the run for the new head will handle it" - exit 0 -fi - -work=$(mktemp -d) -for file in Cargo.toml Cargo.lock; do - gh api -H "Accept: application/vnd.github.raw" "repos/$REPO/contents/$file?ref=$head_sha" > "$work/$file" -done -current=$(awk '/^\[/ { p = ($0 == "[package]") } p && /^version = / { gsub(/"/, "", $3); print $3; exit }' "$work/Cargo.toml") -[[ "$current" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "unexpected Cargo.toml version: $current" >&2; exit 1; } - -# Never lower the version: Cargo.toml may already carry an unreleased bump (an earlier merged -# sync PR, or one set by hand), which this release must keep -target=$(printf '%s\n%s\n' "$version" "$current" | sort -V | tail -1) - -if [[ "$pr_head_ref" == sdk-sync/* ]] && [ "$target" != "$current" ]; then - "$script_dir/set-version.sh" "$target" "$work/Cargo.toml" "$work/Cargo.lock" - # Committed through the API so GitHub signs it (Verified), and only if the branch is still at head_sha - jq -n \ - --arg repo "$REPO" --arg branch "$pr_head_ref" --arg head "$head_sha" \ - --arg message "chore(release): $target" \ - --arg toml "$(base64 -w0 "$work/Cargo.toml")" --arg lock "$(base64 -w0 "$work/Cargo.lock")" \ - '{query: "mutation($input: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $input) { commit { oid } } }", - variables: {input: { - branch: {repositoryNameWithOwner: $repo, branchName: $branch}, - expectedHeadOid: $head, - message: {headline: $message}, - fileChanges: {additions: [{path: "Cargo.toml", contents: $toml}, {path: "Cargo.lock", contents: $lock}]} - }}}' > "$work/mutation.json" - gh api graphql --input "$work/mutation.json" --jq .data.createCommitOnBranch.commit.oid -fi - -# Label the release level, replacing labels from an earlier run on this PR -for name in release:patch release:minor release:major breaking; do - gh pr edit "$pr_number" --repo "$REPO" --remove-label "$name" > /dev/null 2>&1 || true -done -gh label create "release:$level" --repo "$REPO" --color 0E8A16 --force > /dev/null -gh pr edit "$pr_number" --repo "$REPO" --add-label "release:$level" - -if [ "$breaking" = "true" ]; then - gh label create breaking --repo "$REPO" --color B60205 --force > /dev/null - gh pr edit "$pr_number" --repo "$REPO" --add-label breaking - # A breaking release needs a person to merge it - gh pr merge "$pr_number" --repo "$REPO" --disable-auto > /dev/null 2>&1 || true - { - echo "**Breaking change** against the published \`$published\`: the next release must be at least \`$version\`. Auto-merge is disabled." - if [[ "$pr_head_ref" != sdk-sync/* ]] && [ "$target" != "$current" ]; then - echo - echo "If this PR should be released, set \`$target\` in \`Cargo.toml\` and \`Cargo.lock\`." - fi - echo - echo "~~~" - # Untrusted text: keep the cargo-semver-checks failure list only, without code fences - grep -E '^(--- failure| )' "$dir/report.txt" | tr -d '`~' | head -80 || true - echo "~~~" - } > "$work/comment.md" - # One comment per PR, updated on later runs - gh pr comment "$pr_number" --repo "$REPO" --edit-last --body-file "$work/comment.md" 2> /dev/null \ - || gh pr comment "$pr_number" --repo "$REPO" --body-file "$work/comment.md" -fi diff --git a/.github/scripts/commit-version.sh b/.github/scripts/commit-version.sh new file mode 100755 index 0000000..f1fecef --- /dev/null +++ b/.github/scripts/commit-version.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Usage: commit-version.sh +# Sets TARGET in Cargo.toml and Cargo.lock of the PR head and commits it to the PR branch as +# "chore(release): TARGET". The commit goes through the GitHub API, so GitHub signs it (Verified), +# and only lands if the branch is still at HEAD_SHA. +# Env: GH_TOKEN, REPO, BRANCH, HEAD_SHA, TARGET (validated by validate-semver-result.sh). +set -euo pipefail + +script_dir=$(cd "$(dirname "$0")" && pwd) +work=$(mktemp -d) + +for file in Cargo.toml Cargo.lock; do + gh api -H "Accept: application/vnd.github.raw" "repos/$REPO/contents/$file?ref=$HEAD_SHA" > "$work/$file" +done +"$script_dir/set-version.sh" "$TARGET" "$work/Cargo.toml" "$work/Cargo.lock" + +jq -n \ + --arg repo "$REPO" --arg branch "$BRANCH" --arg head "$HEAD_SHA" \ + --arg message "chore(release): $TARGET" \ + --arg toml "$(base64 -w0 "$work/Cargo.toml")" --arg lock "$(base64 -w0 "$work/Cargo.lock")" \ + '{query: "mutation($input: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $input) { commit { oid } } }", + variables: {input: { + branch: {repositoryNameWithOwner: $repo, branchName: $branch}, + expectedHeadOid: $head, + message: {headline: $message}, + fileChanges: {additions: [{path: "Cargo.toml", contents: $toml}, {path: "Cargo.lock", contents: $lock}]} + }}}' > "$work/mutation.json" +gh api graphql --input "$work/mutation.json" --jq .data.createCommitOnBranch.commit.oid diff --git a/.github/scripts/label-release.sh b/.github/scripts/label-release.sh new file mode 100755 index 0000000..12ad6e1 --- /dev/null +++ b/.github/scripts/label-release.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Usage: label-release.sh +# Labels the PR with its release level (release:patch|minor|major), replacing labels from an earlier run. +# On a breaking change, also adds `breaking`, disables auto-merge and comments with the +# cargo-semver-checks failures (one comment per PR, updated on later runs). +# Env: GH_TOKEN, REPO, PR_NUMBER, LEVEL, BREAKING, VERSION, PUBLISHED (validated by validate-semver-result.sh), +# SUGGEST_VERSION (optional: the version to tell the author to set). +set -euo pipefail + +dir="$1" + +for name in release:patch release:minor release:major breaking; do + gh pr edit "$PR_NUMBER" --repo "$REPO" --remove-label "$name" > /dev/null 2>&1 || true +done +gh label create "release:$LEVEL" --repo "$REPO" --color 0E8A16 --force > /dev/null +gh pr edit "$PR_NUMBER" --repo "$REPO" --add-label "release:$LEVEL" + +if [ "$BREAKING" != "true" ]; then + exit 0 +fi + +gh label create breaking --repo "$REPO" --color B60205 --force > /dev/null +gh pr edit "$PR_NUMBER" --repo "$REPO" --add-label breaking +# A breaking release needs a person to merge it +gh pr merge "$PR_NUMBER" --repo "$REPO" --disable-auto > /dev/null 2>&1 || true + +comment=$(mktemp) +{ + echo "**Breaking change** against the published \`$PUBLISHED\`: the next release must be at least \`$VERSION\`. Auto-merge is disabled." + if [ -n "${SUGGEST_VERSION:-}" ]; then + echo + echo "If this PR should be released, set \`$SUGGEST_VERSION\` in \`Cargo.toml\` and \`Cargo.lock\`." + fi + echo + echo "~~~" + # The report comes from the pull_request run: keep the failure list only, without code fences + grep -E '^(--- failure| )' "$dir/report.txt" | tr -d '`~' | head -80 || true + echo "~~~" +} > "$comment" +gh pr comment "$PR_NUMBER" --repo "$REPO" --edit-last --body-file "$comment" 2> /dev/null \ + || gh pr comment "$PR_NUMBER" --repo "$REPO" --body-file "$comment" diff --git a/.github/scripts/validate-semver-result.sh b/.github/scripts/validate-semver-result.sh new file mode 100755 index 0000000..b287547 --- /dev/null +++ b/.github/scripts/validate-semver-result.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Usage: validate-semver-result.sh +# Runs from the default branch (Version Bump workflow). Everything in comes from a +# pull_request run and is untrusted: each value is validated, and the PR must still be an open +# same-repository PR into main at the commit that was checked. +# Writes the validated values to $GITHUB_OUTPUT for the label and commit steps. +# Env: GH_TOKEN, REPO (owner/name), RUN_HEAD_SHA and RUN_HEAD_BRANCH (from the workflow_run event). +set -euo pipefail + +dir="$1" + +read -r version level breaking < "$dir/result.txt" +pr_number=$(cat "$dir/pr-number.txt") +head_sha=$(cat "$dir/head-sha.txt") +published=$(cat "$dir/published.txt") + +[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "invalid version: $version" >&2; exit 1; } +[[ "$published" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "invalid published version" >&2; exit 1; } +[[ "$level" =~ ^(patch|minor|major)$ ]] || { echo "invalid level: $level" >&2; exit 1; } +[[ "$breaking" =~ ^(true|false)$ ]] || { echo "invalid breaking flag: $breaking" >&2; exit 1; } +[[ "$pr_number" =~ ^[0-9]+$ ]] || { echo "invalid PR number" >&2; exit 1; } +[[ "$head_sha" == "$RUN_HEAD_SHA" ]] || { echo "artifact head SHA does not match the triggering run" >&2; exit 1; } + +pr=$(gh api "repos/$REPO/pulls/$pr_number") +state=$(jq -r .state <<< "$pr") +pr_head_ref=$(jq -r .head.ref <<< "$pr") +pr_head_repo=$(jq -r .head.repo.full_name <<< "$pr") +pr_head_sha=$(jq -r .head.sha <<< "$pr") +pr_base=$(jq -r .base.ref <<< "$pr") +if [ "$state" != "open" ] || [ "$pr_base" != "main" ] || [ "$pr_head_repo" != "$REPO" ] \ + || [ "$pr_head_ref" != "$RUN_HEAD_BRANCH" ]; then + echo "PR #$pr_number is not an open same-repository PR into main" >&2 + exit 1 +fi + +moved=false +if [ "$pr_head_sha" != "$head_sha" ]; then + echo "::notice::PR #$pr_number moved since the check ran; the run for the new head will handle it" + moved=true +fi + +current=$(gh api -H "Accept: application/vnd.github.raw" "repos/$REPO/contents/Cargo.toml?ref=$head_sha" \ + | awk '/^\[/ { p = ($0 == "[package]") } p && /^version = / { gsub(/"/, "", $3); print $3; exit }') +[[ "$current" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "unexpected Cargo.toml version: $current" >&2; exit 1; } + +# Never lower the version: Cargo.toml may already carry an unreleased bump (an earlier merged +# sync PR, or one set by hand), which this release must keep +target=$(printf '%s\n%s\n' "$version" "$current" | sort -V | tail -1) + +{ + echo "pr-number=$pr_number" + echo "head-sha=$head_sha" + echo "moved=$moved" + echo "published=$published" + echo "version=$version" + echo "level=$level" + echo "breaking=$breaking" + echo "current=$current" + echo "target=$target" +} >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/version-bump.yaml b/.github/workflows/version-bump.yaml index 7ffdf16..d732c67 100644 --- a/.github/workflows/version-bump.yaml +++ b/.github/workflows/version-bump.yaml @@ -3,7 +3,7 @@ name: Version Bump # Labels each PR with the release Semver Check computed, and commits that version to monorepo # sync PRs, so merging one sync PR ships the SDK update and its version together. workflow_run # always runs this file from the default branch, so a PR cannot change what runs with write -# access; the artifact it reads is validated by .github/scripts/apply-version-bump.sh. +# access; the artifact it reads is validated by .github/scripts/validate-semver-result.sh. on: workflow_run: @@ -45,10 +45,43 @@ jobs: run-id: ${{ github.event.workflow_run.id }} github-token: ${{ github.token }} - - name: Label the PR and apply the version to sync PRs + - name: Validate the result + id: validate env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} RUN_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} - run: .github/scripts/apply-version-bump.sh semver-result + run: .github/scripts/validate-semver-result.sh semver-result + + # Sync PRs only: commit the version, unless Cargo.toml already carries it + - name: Commit the version + if: >- + steps.validate.outputs.moved == 'false' && + startsWith(github.event.workflow_run.head_branch, 'sdk-sync/') && + steps.validate.outputs.target != steps.validate.outputs.current + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + BRANCH: ${{ github.event.workflow_run.head_branch }} + HEAD_SHA: ${{ steps.validate.outputs.head-sha }} + TARGET: ${{ steps.validate.outputs.target }} + run: .github/scripts/commit-version.sh + + # All PRs: release labels, and a comment on breaking changes + - name: Label the release + if: steps.validate.outputs.moved == 'false' + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ steps.validate.outputs.pr-number }} + LEVEL: ${{ steps.validate.outputs.level }} + BREAKING: ${{ steps.validate.outputs.breaking }} + VERSION: ${{ steps.validate.outputs.version }} + PUBLISHED: ${{ steps.validate.outputs.published }} + # Hand-written PRs: the version to set if the PR should be released + SUGGEST_VERSION: >- + ${{ !startsWith(github.event.workflow_run.head_branch, 'sdk-sync/') && + steps.validate.outputs.target != steps.validate.outputs.current && + steps.validate.outputs.target || '' }} + run: .github/scripts/label-release.sh semver-result From 53a5cfecefabff24c20d922da744bb6f0f694d41 Mon Sep 17 00:00:00 2001 From: Jonas Lerebours Date: Mon, 26 Oct 2026 17:13:33 +0100 Subject: [PATCH 05/11] ci: identify sync PRs by the sync app, not only the branch name A sync PR must be opened by dfnsco-dfns-sdk-sync[bot] (by user ID) and contain only Verified web-flow commits authored by that app or by github-actions[bot] (the version commit). Anyone with write access can name a branch sdk-sync/, and a commit author can be set by email, but a pushed commit is never a Verified web-flow commit attributed to the app. Other PRs are labeled but never committed to. --- .github/scripts/validate-semver-result.sh | 25 ++++++++++++++++++++++- .github/workflows/version-bump.yaml | 13 +++++++++--- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/.github/scripts/validate-semver-result.sh b/.github/scripts/validate-semver-result.sh index b287547..98b7acd 100755 --- a/.github/scripts/validate-semver-result.sh +++ b/.github/scripts/validate-semver-result.sh @@ -4,7 +4,8 @@ # pull_request run and is untrusted: each value is validated, and the PR must still be an open # same-repository PR into main at the commit that was checked. # Writes the validated values to $GITHUB_OUTPUT for the label and commit steps. -# Env: GH_TOKEN, REPO (owner/name), RUN_HEAD_SHA and RUN_HEAD_BRANCH (from the workflow_run event). +# Env: GH_TOKEN, REPO (owner/name), RUN_HEAD_SHA and RUN_HEAD_BRANCH (from the workflow_run event), +# SYNC_APP_USER_ID (the sync app's bot user) and ACTIONS_BOT_USER_ID (github-actions[bot]). set -euo pipefail dir="$1" @@ -33,6 +34,27 @@ if [ "$state" != "open" ] || [ "$pr_base" != "main" ] || [ "$pr_head_repo" != "$ exit 1 fi +# A sync PR is one the monorepo's sync app opened, on an sdk-sync/ branch, whose commits were all made +# through the GitHub API (committer web-flow, Verified) by that app or by this workflow's version commit. +# A branch name alone can be chosen by anyone with write access, and a commit's author can be set +# by its email, but a pushed commit is never a Verified web-flow commit attributed to the app. +is_sync=false +pr_author_id=$(jq -r .user.id <<< "$pr") +if [[ "$pr_head_ref" == sdk-sync/* ]] && [ "$pr_author_id" = "$SYNC_APP_USER_ID" ]; then + foreign_commits=$(gh api --paginate "repos/$REPO/pulls/$pr_number/commits" \ + | jq -s --arg sync "$SYNC_APP_USER_ID" --arg actions "$ACTIONS_BOT_USER_ID" \ + '[add[] | select( + ((.author.id // 0 | tostring) as $a | ($a != $sync and $a != $actions)) + or .committer.login != "web-flow" + or .commit.verification.verified != true + )] | length' 2> /dev/null || echo error) + if [ "$foreign_commits" = "0" ]; then + is_sync=true + else + echo "::warning::PR #$pr_number looks like a sync PR, but not all its commits come from the sync app" + fi +fi + moved=false if [ "$pr_head_sha" != "$head_sha" ]; then echo "::notice::PR #$pr_number moved since the check ran; the run for the new head will handle it" @@ -51,6 +73,7 @@ target=$(printf '%s\n%s\n' "$version" "$current" | sort -V | tail -1) echo "pr-number=$pr_number" echo "head-sha=$head_sha" echo "moved=$moved" + echo "is-sync=$is_sync" echo "published=$published" echo "version=$version" echo "level=$level" diff --git a/.github/workflows/version-bump.yaml b/.github/workflows/version-bump.yaml index d732c67..a6d7217 100644 --- a/.github/workflows/version-bump.yaml +++ b/.github/workflows/version-bump.yaml @@ -15,6 +15,12 @@ on: permissions: contents: read +env: + # dfnsco-dfns-sdk-sync[bot], which opens the monorepo sync PRs + SYNC_APP_USER_ID: "275545256" + # github-actions[bot], which makes the version commit + ACTIONS_BOT_USER_ID: "41898282" + jobs: bump: if: >- @@ -54,11 +60,12 @@ jobs: RUN_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} run: .github/scripts/validate-semver-result.sh semver-result - # Sync PRs only: commit the version, unless Cargo.toml already carries it + # Sync PRs only (opened by the sync app, see validate-semver-result.sh): commit the version, + # unless Cargo.toml already carries it - name: Commit the version if: >- steps.validate.outputs.moved == 'false' && - startsWith(github.event.workflow_run.head_branch, 'sdk-sync/') && + steps.validate.outputs.is-sync == 'true' && steps.validate.outputs.target != steps.validate.outputs.current env: GH_TOKEN: ${{ github.token }} @@ -81,7 +88,7 @@ jobs: PUBLISHED: ${{ steps.validate.outputs.published }} # Hand-written PRs: the version to set if the PR should be released SUGGEST_VERSION: >- - ${{ !startsWith(github.event.workflow_run.head_branch, 'sdk-sync/') && + ${{ steps.validate.outputs.is-sync != 'true' && steps.validate.outputs.target != steps.validate.outputs.current && steps.validate.outputs.target || '' }} run: .github/scripts/label-release.sh semver-result From e03762fe03ced52e7a0c4e57863e0386837bc017 Mon Sep 17 00:00:00 2001 From: Jonas Lerebours Date: Mon, 26 Oct 2026 17:24:26 +0100 Subject: [PATCH 06/11] ci: keep the sync-PR check in the sync flow and explain 0.x bumps commit-version.sh now checks the PR comes from the sync app before committing, so validation stays shared and each flow is self-contained. The label step suggests a version whenever this run did not set one. Clarify how 0.x versions bump and how to move to 1.0.0. --- .github/scripts/commit-version.sh | 31 +++++++++++++++++++---- .github/scripts/next-version.sh | 5 +++- .github/scripts/validate-semver-result.sh | 25 +----------------- .github/workflows/version-bump.yaml | 12 +++++---- 4 files changed, 38 insertions(+), 35 deletions(-) diff --git a/.github/scripts/commit-version.sh b/.github/scripts/commit-version.sh index f1fecef..8298d07 100755 --- a/.github/scripts/commit-version.sh +++ b/.github/scripts/commit-version.sh @@ -1,14 +1,34 @@ #!/usr/bin/env bash # Usage: commit-version.sh -# Sets TARGET in Cargo.toml and Cargo.lock of the PR head and commits it to the PR branch as -# "chore(release): TARGET". The commit goes through the GitHub API, so GitHub signs it (Verified), -# and only lands if the branch is still at HEAD_SHA. -# Env: GH_TOKEN, REPO, BRANCH, HEAD_SHA, TARGET (validated by validate-semver-result.sh). +# Sync PRs only. Checks the PR really is a monorepo sync PR, then sets TARGET in Cargo.toml and Cargo.lock +# of the PR head and commits it to the PR branch as "chore(release): TARGET". The commit goes through the +# GitHub API, so GitHub signs it (Verified), and only lands if the branch is still at HEAD_SHA. +# Writes committed=true|false to $GITHUB_OUTPUT. +# Env: GH_TOKEN, REPO, PR_NUMBER, BRANCH, HEAD_SHA, TARGET (validated by validate-semver-result.sh), +# SYNC_APP_USER_ID (the sync app's bot user), ACTIONS_BOT_USER_ID (github-actions[bot]). set -euo pipefail script_dir=$(cd "$(dirname "$0")" && pwd) -work=$(mktemp -d) +# A sync PR is one the monorepo's sync app opened, whose commits were all made through the GitHub API +# (committer web-flow, Verified) by that app or by this workflow's version commit. Anyone with write +# access can name a branch sdk-sync/, and a commit's author can be set by its email, but a pushed commit +# is never a Verified web-flow commit attributed to the app. +pr_author_id=$(gh api "repos/$REPO/pulls/$PR_NUMBER" --jq .user.id) +foreign_commits=$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/commits" \ + | jq -s --arg sync "$SYNC_APP_USER_ID" --arg actions "$ACTIONS_BOT_USER_ID" \ + '[add[] | select( + ((.author.id // 0 | tostring) as $a | ($a != $sync and $a != $actions)) + or .committer.login != "web-flow" + or .commit.verification.verified != true + )] | length') +if [ "$pr_author_id" != "$SYNC_APP_USER_ID" ] || [ "$foreign_commits" != "0" ]; then + echo "::warning::PR #$PR_NUMBER has a sync branch name, but was not opened by the sync app or has commits from elsewhere: not setting its version" + echo "committed=false" >> "$GITHUB_OUTPUT" + exit 0 +fi + +work=$(mktemp -d) for file in Cargo.toml Cargo.lock; do gh api -H "Accept: application/vnd.github.raw" "repos/$REPO/contents/$file?ref=$HEAD_SHA" > "$work/$file" done @@ -26,3 +46,4 @@ jq -n \ fileChanges: {additions: [{path: "Cargo.toml", contents: $toml}, {path: "Cargo.lock", contents: $lock}]} }}}' > "$work/mutation.json" gh api graphql --input "$work/mutation.json" --jq .data.createCommitOnBranch.commit.oid +echo "committed=true" >> "$GITHUB_OUTPUT" diff --git a/.github/scripts/next-version.sh b/.github/scripts/next-version.sh index 3f2a873..469778a 100755 --- a/.github/scripts/next-version.sh +++ b/.github/scripts/next-version.sh @@ -27,7 +27,10 @@ else exit 1 fi -# Cargo's semver: in 0.y.z, y is the breaking ("major") position and z the compatible one +# Below 1.0, Cargo shifts semver one position: in 0.Y.z, a breaking change bumps Y (0.3.2 -> 0.4.0) and +# anything else bumps z. cargo-semver-checks calls the Y bump "major". The move to 1.0.0 is never +# automatic: set it in Cargo.toml by hand (later runs never lower the version); from then on, breaking +# changes bump the first number. if [ "$major" -eq 0 ]; then case "$required" in major) echo "0.$((minor + 1)).0 minor true" ;; diff --git a/.github/scripts/validate-semver-result.sh b/.github/scripts/validate-semver-result.sh index 98b7acd..b287547 100755 --- a/.github/scripts/validate-semver-result.sh +++ b/.github/scripts/validate-semver-result.sh @@ -4,8 +4,7 @@ # pull_request run and is untrusted: each value is validated, and the PR must still be an open # same-repository PR into main at the commit that was checked. # Writes the validated values to $GITHUB_OUTPUT for the label and commit steps. -# Env: GH_TOKEN, REPO (owner/name), RUN_HEAD_SHA and RUN_HEAD_BRANCH (from the workflow_run event), -# SYNC_APP_USER_ID (the sync app's bot user) and ACTIONS_BOT_USER_ID (github-actions[bot]). +# Env: GH_TOKEN, REPO (owner/name), RUN_HEAD_SHA and RUN_HEAD_BRANCH (from the workflow_run event). set -euo pipefail dir="$1" @@ -34,27 +33,6 @@ if [ "$state" != "open" ] || [ "$pr_base" != "main" ] || [ "$pr_head_repo" != "$ exit 1 fi -# A sync PR is one the monorepo's sync app opened, on an sdk-sync/ branch, whose commits were all made -# through the GitHub API (committer web-flow, Verified) by that app or by this workflow's version commit. -# A branch name alone can be chosen by anyone with write access, and a commit's author can be set -# by its email, but a pushed commit is never a Verified web-flow commit attributed to the app. -is_sync=false -pr_author_id=$(jq -r .user.id <<< "$pr") -if [[ "$pr_head_ref" == sdk-sync/* ]] && [ "$pr_author_id" = "$SYNC_APP_USER_ID" ]; then - foreign_commits=$(gh api --paginate "repos/$REPO/pulls/$pr_number/commits" \ - | jq -s --arg sync "$SYNC_APP_USER_ID" --arg actions "$ACTIONS_BOT_USER_ID" \ - '[add[] | select( - ((.author.id // 0 | tostring) as $a | ($a != $sync and $a != $actions)) - or .committer.login != "web-flow" - or .commit.verification.verified != true - )] | length' 2> /dev/null || echo error) - if [ "$foreign_commits" = "0" ]; then - is_sync=true - else - echo "::warning::PR #$pr_number looks like a sync PR, but not all its commits come from the sync app" - fi -fi - moved=false if [ "$pr_head_sha" != "$head_sha" ]; then echo "::notice::PR #$pr_number moved since the check ran; the run for the new head will handle it" @@ -73,7 +51,6 @@ target=$(printf '%s\n%s\n' "$version" "$current" | sort -V | tail -1) echo "pr-number=$pr_number" echo "head-sha=$head_sha" echo "moved=$moved" - echo "is-sync=$is_sync" echo "published=$published" echo "version=$version" echo "level=$level" diff --git a/.github/workflows/version-bump.yaml b/.github/workflows/version-bump.yaml index a6d7217..0c8d32c 100644 --- a/.github/workflows/version-bump.yaml +++ b/.github/workflows/version-bump.yaml @@ -60,16 +60,18 @@ jobs: RUN_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} run: .github/scripts/validate-semver-result.sh semver-result - # Sync PRs only (opened by the sync app, see validate-semver-result.sh): commit the version, - # unless Cargo.toml already carries it + # Sync PRs only: commit the version, unless Cargo.toml already carries it. + # The branch name is a pre-filter; commit-version.sh checks the PR comes from the sync app. - name: Commit the version + id: commit if: >- steps.validate.outputs.moved == 'false' && - steps.validate.outputs.is-sync == 'true' && + startsWith(github.event.workflow_run.head_branch, 'sdk-sync/') && steps.validate.outputs.target != steps.validate.outputs.current env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} + PR_NUMBER: ${{ steps.validate.outputs.pr-number }} BRANCH: ${{ github.event.workflow_run.head_branch }} HEAD_SHA: ${{ steps.validate.outputs.head-sha }} TARGET: ${{ steps.validate.outputs.target }} @@ -86,9 +88,9 @@ jobs: BREAKING: ${{ steps.validate.outputs.breaking }} VERSION: ${{ steps.validate.outputs.version }} PUBLISHED: ${{ steps.validate.outputs.published }} - # Hand-written PRs: the version to set if the PR should be released + # When this run did not set the version: the version to set if the PR should be released SUGGEST_VERSION: >- - ${{ steps.validate.outputs.is-sync != 'true' && + ${{ steps.commit.outputs.committed != 'true' && steps.validate.outputs.target != steps.validate.outputs.current && steps.validate.outputs.target || '' }} run: .github/scripts/label-release.sh semver-result From 3091787f456a51dec078e9fb4e7c2a0e6d8a907a Mon Sep 17 00:00:00 2001 From: Jonas Lerebours Date: Mon, 26 Oct 2026 17:44:01 +0100 Subject: [PATCH 07/11] ci: commit the version with a dedicated SDK release app Version Bump is split into three jobs: validate (read-only), commit-version (sync PRs, release app token from the main-restricted sdk-release environment) and label (GITHUB_TOKEN, pull-requests only). GITHUB_TOKEN no longer needs contents: write. Version commits are now authored by the release app, which triggers the PR's CI, and the sync-PR check accepts commits from the sync app and the release app only. github-actions[bot] commits are no longer accepted, so a PR workflow with a write token cannot pass as a sync commit. --- .github/scripts/commit-version.sh | 18 ++-- .github/workflows/version-bump.yaml | 128 +++++++++++++++++++++------- 2 files changed, 109 insertions(+), 37 deletions(-) diff --git a/.github/scripts/commit-version.sh b/.github/scripts/commit-version.sh index 8298d07..0e3fb6b 100755 --- a/.github/scripts/commit-version.sh +++ b/.github/scripts/commit-version.sh @@ -1,24 +1,26 @@ #!/usr/bin/env bash # Usage: commit-version.sh # Sync PRs only. Checks the PR really is a monorepo sync PR, then sets TARGET in Cargo.toml and Cargo.lock -# of the PR head and commits it to the PR branch as "chore(release): TARGET". The commit goes through the -# GitHub API, so GitHub signs it (Verified), and only lands if the branch is still at HEAD_SHA. +# of the PR head and commits it to the PR branch as "chore(release): TARGET". GH_TOKEN is the SDK release +# app's token: the commit goes through the GitHub API as that app, so GitHub signs it (Verified), it +# triggers the PR's CI, and it only lands if the branch is still at HEAD_SHA. # Writes committed=true|false to $GITHUB_OUTPUT. # Env: GH_TOKEN, REPO, PR_NUMBER, BRANCH, HEAD_SHA, TARGET (validated by validate-semver-result.sh), -# SYNC_APP_USER_ID (the sync app's bot user), ACTIONS_BOT_USER_ID (github-actions[bot]). +# SYNC_APP_USER_ID (the sync app's bot user), RELEASE_APP_SLUG (the release app, from its token). set -euo pipefail script_dir=$(cd "$(dirname "$0")" && pwd) # A sync PR is one the monorepo's sync app opened, whose commits were all made through the GitHub API -# (committer web-flow, Verified) by that app or by this workflow's version commit. Anyone with write -# access can name a branch sdk-sync/, and a commit's author can be set by its email, but a pushed commit -# is never a Verified web-flow commit attributed to the app. +# (committer web-flow, Verified) by that app or by the release app (an earlier version commit). The +# sdk-sync/** ruleset already limits those branches to both apps; this checks it independently. A commit's +# author can be set by its email, but a pushed commit is never a Verified web-flow commit attributed to an app. +release_app_user_id=$(gh api "users/$RELEASE_APP_SLUG%5Bbot%5D" --jq .id) pr_author_id=$(gh api "repos/$REPO/pulls/$PR_NUMBER" --jq .user.id) foreign_commits=$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/commits" \ - | jq -s --arg sync "$SYNC_APP_USER_ID" --arg actions "$ACTIONS_BOT_USER_ID" \ + | jq -s --arg sync "$SYNC_APP_USER_ID" --arg release "$release_app_user_id" \ '[add[] | select( - ((.author.id // 0 | tostring) as $a | ($a != $sync and $a != $actions)) + ((.author.id // 0 | tostring) as $a | ($a != $sync and $a != $release)) or .committer.login != "web-flow" or .commit.verification.verified != true )] | length') diff --git a/.github/workflows/version-bump.yaml b/.github/workflows/version-bump.yaml index 0c8d32c..e6c3b87 100644 --- a/.github/workflows/version-bump.yaml +++ b/.github/workflows/version-bump.yaml @@ -4,6 +4,11 @@ name: Version Bump # sync PRs, so merging one sync PR ships the SDK update and its version together. workflow_run # always runs this file from the default branch, so a PR cannot change what runs with write # access; the artifact it reads is validated by .github/scripts/validate-semver-result.sh. +# +# Setup: the version commit is made by the SDK release GitHub App, whose credentials live in the +# `sdk-release` environment (deployment restricted to main): variable RELEASE_APP_CLIENT_ID and +# secret RELEASE_APP_PRIVATE_KEY. A ruleset on sdk-sync/** branches lets only the sync app and the +# release app create or push them. on: workflow_run: @@ -15,27 +20,37 @@ on: permissions: contents: read +concurrency: + group: version-bump-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: false + env: # dfnsco-dfns-sdk-sync[bot], which opens the monorepo sync PRs SYNC_APP_USER_ID: "275545256" - # github-actions[bot], which makes the version commit - ACTIONS_BOT_USER_ID: "41898282" jobs: - bump: + # All PRs: check the untrusted Semver Check result and the PR it belongs to + validate: if: >- github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_repository.full_name == github.repository - timeout-minutes: 10 + timeout-minutes: 5 runs-on: ubuntu-latest permissions: - contents: write # commit the version to sync PR branches - pull-requests: write # release labels, comment, disable auto-merge + contents: read + pull-requests: read actions: read # download the Semver Check artifact - concurrency: - group: version-bump-${{ github.event.workflow_run.head_branch }} - cancel-in-progress: false + outputs: + pr-number: ${{ steps.validate.outputs.pr-number }} + head-sha: ${{ steps.validate.outputs.head-sha }} + moved: ${{ steps.validate.outputs.moved }} + published: ${{ steps.validate.outputs.published }} + version: ${{ steps.validate.outputs.version }} + level: ${{ steps.validate.outputs.level }} + breaking: ${{ steps.validate.outputs.breaking }} + current: ${{ steps.validate.outputs.current }} + target: ${{ steps.validate.outputs.target }} steps: - name: Checkout code @@ -60,37 +75,92 @@ jobs: RUN_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} run: .github/scripts/validate-semver-result.sh semver-result - # Sync PRs only: commit the version, unless Cargo.toml already carries it. - # The branch name is a pre-filter; commit-version.sh checks the PR comes from the sync app. + # Sync PRs only: commit the version, unless Cargo.toml already carries it. + # The branch name is a pre-filter; commit-version.sh checks the PR comes from the sync app. + commit-version: + needs: validate + if: >- + needs.validate.outputs.moved == 'false' && + startsWith(github.event.workflow_run.head_branch, 'sdk-sync/') && + needs.validate.outputs.target != needs.validate.outputs.current + timeout-minutes: 5 + runs-on: ubuntu-latest + environment: + name: sdk-release + deployment: false + permissions: + contents: read # the commit is made with the release app's token, not GITHUB_TOKEN + outputs: + committed: ${{ steps.commit.outputs.committed }} + + steps: + - name: Checkout code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Generate the release app token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.RELEASE_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + - name: Commit the version id: commit - if: >- - steps.validate.outputs.moved == 'false' && - startsWith(github.event.workflow_run.head_branch, 'sdk-sync/') && - steps.validate.outputs.target != steps.validate.outputs.current env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} + RELEASE_APP_SLUG: ${{ steps.app-token.outputs.app-slug }} REPO: ${{ github.repository }} - PR_NUMBER: ${{ steps.validate.outputs.pr-number }} + PR_NUMBER: ${{ needs.validate.outputs.pr-number }} BRANCH: ${{ github.event.workflow_run.head_branch }} - HEAD_SHA: ${{ steps.validate.outputs.head-sha }} - TARGET: ${{ steps.validate.outputs.target }} + HEAD_SHA: ${{ needs.validate.outputs.head-sha }} + TARGET: ${{ needs.validate.outputs.target }} run: .github/scripts/commit-version.sh - # All PRs: release labels, and a comment on breaking changes + # All PRs: release labels, and a comment on breaking changes + label: + needs: + - validate + - commit-version + # Runs whether commit-version ran, was skipped or failed + if: >- + always() && + needs.validate.result == 'success' && + needs.validate.outputs.moved == 'false' + timeout-minutes: 5 + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write # release labels, comment, disable auto-merge + actions: read # download the Semver Check artifact + + steps: + - name: Checkout code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Download the Semver Check result + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: semver-result + path: semver-result + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + - name: Label the release - if: steps.validate.outputs.moved == 'false' env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} - PR_NUMBER: ${{ steps.validate.outputs.pr-number }} - LEVEL: ${{ steps.validate.outputs.level }} - BREAKING: ${{ steps.validate.outputs.breaking }} - VERSION: ${{ steps.validate.outputs.version }} - PUBLISHED: ${{ steps.validate.outputs.published }} + PR_NUMBER: ${{ needs.validate.outputs.pr-number }} + LEVEL: ${{ needs.validate.outputs.level }} + BREAKING: ${{ needs.validate.outputs.breaking }} + VERSION: ${{ needs.validate.outputs.version }} + PUBLISHED: ${{ needs.validate.outputs.published }} # When this run did not set the version: the version to set if the PR should be released SUGGEST_VERSION: >- - ${{ steps.commit.outputs.committed != 'true' && - steps.validate.outputs.target != steps.validate.outputs.current && - steps.validate.outputs.target || '' }} + ${{ needs.commit-version.outputs.committed != 'true' && + needs.validate.outputs.target != needs.validate.outputs.current && + needs.validate.outputs.target || '' }} run: .github/scripts/label-release.sh semver-result From ac1d997073f6b4fa33ee0b5b5933015a46a35f7c Mon Sep 17 00:00:00 2001 From: Jonas Lerebours Date: Mon, 26 Oct 2026 17:48:20 +0100 Subject: [PATCH 08/11] ci: name the version-commit app dfns-sdk-semver Environment sdk-semver, variable SEMVER_APP_CLIENT_ID and secret SEMVER_APP_PRIVATE_KEY. --- .github/scripts/commit-version.sh | 12 ++++++------ .github/workflows/version-bump.yaml | 20 ++++++++++---------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/scripts/commit-version.sh b/.github/scripts/commit-version.sh index 0e3fb6b..c4a2f0e 100755 --- a/.github/scripts/commit-version.sh +++ b/.github/scripts/commit-version.sh @@ -1,26 +1,26 @@ #!/usr/bin/env bash # Usage: commit-version.sh # Sync PRs only. Checks the PR really is a monorepo sync PR, then sets TARGET in Cargo.toml and Cargo.lock -# of the PR head and commits it to the PR branch as "chore(release): TARGET". GH_TOKEN is the SDK release +# of the PR head and commits it to the PR branch as "chore(release): TARGET". GH_TOKEN is the dfns-sdk-semver # app's token: the commit goes through the GitHub API as that app, so GitHub signs it (Verified), it # triggers the PR's CI, and it only lands if the branch is still at HEAD_SHA. # Writes committed=true|false to $GITHUB_OUTPUT. # Env: GH_TOKEN, REPO, PR_NUMBER, BRANCH, HEAD_SHA, TARGET (validated by validate-semver-result.sh), -# SYNC_APP_USER_ID (the sync app's bot user), RELEASE_APP_SLUG (the release app, from its token). +# SYNC_APP_USER_ID (the sync app's bot user), SEMVER_APP_SLUG (the semver app, from its token). set -euo pipefail script_dir=$(cd "$(dirname "$0")" && pwd) # A sync PR is one the monorepo's sync app opened, whose commits were all made through the GitHub API -# (committer web-flow, Verified) by that app or by the release app (an earlier version commit). The +# (committer web-flow, Verified) by that app or by the semver app (an earlier version commit). The # sdk-sync/** ruleset already limits those branches to both apps; this checks it independently. A commit's # author can be set by its email, but a pushed commit is never a Verified web-flow commit attributed to an app. -release_app_user_id=$(gh api "users/$RELEASE_APP_SLUG%5Bbot%5D" --jq .id) +semver_app_user_id=$(gh api "users/$SEMVER_APP_SLUG%5Bbot%5D" --jq .id) pr_author_id=$(gh api "repos/$REPO/pulls/$PR_NUMBER" --jq .user.id) foreign_commits=$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/commits" \ - | jq -s --arg sync "$SYNC_APP_USER_ID" --arg release "$release_app_user_id" \ + | jq -s --arg sync "$SYNC_APP_USER_ID" --arg semver "$semver_app_user_id" \ '[add[] | select( - ((.author.id // 0 | tostring) as $a | ($a != $sync and $a != $release)) + ((.author.id // 0 | tostring) as $a | ($a != $sync and $a != $semver)) or .committer.login != "web-flow" or .commit.verification.verified != true )] | length') diff --git a/.github/workflows/version-bump.yaml b/.github/workflows/version-bump.yaml index e6c3b87..2d59c92 100644 --- a/.github/workflows/version-bump.yaml +++ b/.github/workflows/version-bump.yaml @@ -5,10 +5,10 @@ name: Version Bump # always runs this file from the default branch, so a PR cannot change what runs with write # access; the artifact it reads is validated by .github/scripts/validate-semver-result.sh. # -# Setup: the version commit is made by the SDK release GitHub App, whose credentials live in the -# `sdk-release` environment (deployment restricted to main): variable RELEASE_APP_CLIENT_ID and -# secret RELEASE_APP_PRIVATE_KEY. A ruleset on sdk-sync/** branches lets only the sync app and the -# release app create or push them. +# Setup: the version commit is made by the dfns-sdk-semver GitHub App, whose credentials live in the +# `sdk-semver` environment (deployment restricted to main): variable SEMVER_APP_CLIENT_ID and +# secret SEMVER_APP_PRIVATE_KEY. A ruleset on sdk-sync/** branches lets only the sync app and the +# semver app create or push them. on: workflow_run: @@ -86,10 +86,10 @@ jobs: timeout-minutes: 5 runs-on: ubuntu-latest environment: - name: sdk-release + name: sdk-semver deployment: false permissions: - contents: read # the commit is made with the release app's token, not GITHUB_TOKEN + contents: read # the commit is made with the semver app's token, not GITHUB_TOKEN outputs: committed: ${{ steps.commit.outputs.committed }} @@ -99,18 +99,18 @@ jobs: with: persist-credentials: false - - name: Generate the release app token + - name: Generate the semver app token id: app-token uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: - client-id: ${{ vars.RELEASE_APP_CLIENT_ID }} - private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + client-id: ${{ vars.SEMVER_APP_CLIENT_ID }} + private-key: ${{ secrets.SEMVER_APP_PRIVATE_KEY }} - name: Commit the version id: commit env: GH_TOKEN: ${{ steps.app-token.outputs.token }} - RELEASE_APP_SLUG: ${{ steps.app-token.outputs.app-slug }} + SEMVER_APP_SLUG: ${{ steps.app-token.outputs.app-slug }} REPO: ${{ github.repository }} PR_NUMBER: ${{ needs.validate.outputs.pr-number }} BRANCH: ${{ github.event.workflow_run.head_branch }} From fa9b396448fde15fa54fa0a36a9090d9dc079e3c Mon Sep 17 00:00:00 2001 From: Jonas Lerebours Date: Mon, 26 Oct 2026 19:07:26 +0100 Subject: [PATCH 09/11] ci: open release PRs from a Prepare Release workflow Replace the version commit on sync PRs with a Prepare Release workflow (workflow_dispatch). It opens a chore(release) PR, as the dfns-sdk-semver app, that sets the version in Cargo.toml and Cargo.lock: the version cargo-semver-checks requires against the published crate, or the one given as input. A forced version is never blocked; the PR shows what cargo-semver-checks requires for reference. Semver Check on PRs becomes a read-only report. Removes the workflow_run job, the sync-PR identity checks and the labels. --- .github/scripts/commit-version.sh | 51 ------- .github/scripts/label-release.sh | 41 ------ .github/scripts/open-release-pr.sh | 56 ++++++++ .github/scripts/validate-semver-result.sh | 60 -------- .github/workflows/prepare-release.yaml | 113 +++++++++++++++ .github/workflows/semver-check.yaml | 30 +--- .github/workflows/version-bump.yaml | 166 ---------------------- 7 files changed, 176 insertions(+), 341 deletions(-) delete mode 100755 .github/scripts/commit-version.sh delete mode 100755 .github/scripts/label-release.sh create mode 100755 .github/scripts/open-release-pr.sh delete mode 100755 .github/scripts/validate-semver-result.sh create mode 100644 .github/workflows/prepare-release.yaml delete mode 100644 .github/workflows/version-bump.yaml diff --git a/.github/scripts/commit-version.sh b/.github/scripts/commit-version.sh deleted file mode 100755 index c4a2f0e..0000000 --- a/.github/scripts/commit-version.sh +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env bash -# Usage: commit-version.sh -# Sync PRs only. Checks the PR really is a monorepo sync PR, then sets TARGET in Cargo.toml and Cargo.lock -# of the PR head and commits it to the PR branch as "chore(release): TARGET". GH_TOKEN is the dfns-sdk-semver -# app's token: the commit goes through the GitHub API as that app, so GitHub signs it (Verified), it -# triggers the PR's CI, and it only lands if the branch is still at HEAD_SHA. -# Writes committed=true|false to $GITHUB_OUTPUT. -# Env: GH_TOKEN, REPO, PR_NUMBER, BRANCH, HEAD_SHA, TARGET (validated by validate-semver-result.sh), -# SYNC_APP_USER_ID (the sync app's bot user), SEMVER_APP_SLUG (the semver app, from its token). -set -euo pipefail - -script_dir=$(cd "$(dirname "$0")" && pwd) - -# A sync PR is one the monorepo's sync app opened, whose commits were all made through the GitHub API -# (committer web-flow, Verified) by that app or by the semver app (an earlier version commit). The -# sdk-sync/** ruleset already limits those branches to both apps; this checks it independently. A commit's -# author can be set by its email, but a pushed commit is never a Verified web-flow commit attributed to an app. -semver_app_user_id=$(gh api "users/$SEMVER_APP_SLUG%5Bbot%5D" --jq .id) -pr_author_id=$(gh api "repos/$REPO/pulls/$PR_NUMBER" --jq .user.id) -foreign_commits=$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/commits" \ - | jq -s --arg sync "$SYNC_APP_USER_ID" --arg semver "$semver_app_user_id" \ - '[add[] | select( - ((.author.id // 0 | tostring) as $a | ($a != $sync and $a != $semver)) - or .committer.login != "web-flow" - or .commit.verification.verified != true - )] | length') -if [ "$pr_author_id" != "$SYNC_APP_USER_ID" ] || [ "$foreign_commits" != "0" ]; then - echo "::warning::PR #$PR_NUMBER has a sync branch name, but was not opened by the sync app or has commits from elsewhere: not setting its version" - echo "committed=false" >> "$GITHUB_OUTPUT" - exit 0 -fi - -work=$(mktemp -d) -for file in Cargo.toml Cargo.lock; do - gh api -H "Accept: application/vnd.github.raw" "repos/$REPO/contents/$file?ref=$HEAD_SHA" > "$work/$file" -done -"$script_dir/set-version.sh" "$TARGET" "$work/Cargo.toml" "$work/Cargo.lock" - -jq -n \ - --arg repo "$REPO" --arg branch "$BRANCH" --arg head "$HEAD_SHA" \ - --arg message "chore(release): $TARGET" \ - --arg toml "$(base64 -w0 "$work/Cargo.toml")" --arg lock "$(base64 -w0 "$work/Cargo.lock")" \ - '{query: "mutation($input: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $input) { commit { oid } } }", - variables: {input: { - branch: {repositoryNameWithOwner: $repo, branchName: $branch}, - expectedHeadOid: $head, - message: {headline: $message}, - fileChanges: {additions: [{path: "Cargo.toml", contents: $toml}, {path: "Cargo.lock", contents: $lock}]} - }}}' > "$work/mutation.json" -gh api graphql --input "$work/mutation.json" --jq .data.createCommitOnBranch.commit.oid -echo "committed=true" >> "$GITHUB_OUTPUT" diff --git a/.github/scripts/label-release.sh b/.github/scripts/label-release.sh deleted file mode 100755 index 12ad6e1..0000000 --- a/.github/scripts/label-release.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env bash -# Usage: label-release.sh -# Labels the PR with its release level (release:patch|minor|major), replacing labels from an earlier run. -# On a breaking change, also adds `breaking`, disables auto-merge and comments with the -# cargo-semver-checks failures (one comment per PR, updated on later runs). -# Env: GH_TOKEN, REPO, PR_NUMBER, LEVEL, BREAKING, VERSION, PUBLISHED (validated by validate-semver-result.sh), -# SUGGEST_VERSION (optional: the version to tell the author to set). -set -euo pipefail - -dir="$1" - -for name in release:patch release:minor release:major breaking; do - gh pr edit "$PR_NUMBER" --repo "$REPO" --remove-label "$name" > /dev/null 2>&1 || true -done -gh label create "release:$LEVEL" --repo "$REPO" --color 0E8A16 --force > /dev/null -gh pr edit "$PR_NUMBER" --repo "$REPO" --add-label "release:$LEVEL" - -if [ "$BREAKING" != "true" ]; then - exit 0 -fi - -gh label create breaking --repo "$REPO" --color B60205 --force > /dev/null -gh pr edit "$PR_NUMBER" --repo "$REPO" --add-label breaking -# A breaking release needs a person to merge it -gh pr merge "$PR_NUMBER" --repo "$REPO" --disable-auto > /dev/null 2>&1 || true - -comment=$(mktemp) -{ - echo "**Breaking change** against the published \`$PUBLISHED\`: the next release must be at least \`$VERSION\`. Auto-merge is disabled." - if [ -n "${SUGGEST_VERSION:-}" ]; then - echo - echo "If this PR should be released, set \`$SUGGEST_VERSION\` in \`Cargo.toml\` and \`Cargo.lock\`." - fi - echo - echo "~~~" - # The report comes from the pull_request run: keep the failure list only, without code fences - grep -E '^(--- failure| )' "$dir/report.txt" | tr -d '`~' | head -80 || true - echo "~~~" -} > "$comment" -gh pr comment "$PR_NUMBER" --repo "$REPO" --edit-last --body-file "$comment" 2> /dev/null \ - || gh pr comment "$PR_NUMBER" --repo "$REPO" --body-file "$comment" diff --git a/.github/scripts/open-release-pr.sh b/.github/scripts/open-release-pr.sh new file mode 100755 index 0000000..f5b9446 --- /dev/null +++ b/.github/scripts/open-release-pr.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Usage: open-release-pr.sh +# Opens the release PR "chore(release): VERSION": creates the branch release/vVERSION at BASE_SHA, +# commits the version in Cargo.toml and Cargo.lock, and opens the PR into main. GH_TOKEN is the +# dfns-sdk-semver app's token, so the branch, the commit (Verified) and the PR are the app's, and +# the PR's CI runs. +# Env: GH_TOKEN, REPO, BASE_SHA (main), VERSION, PUBLISHED, REQUIRED (what cargo-semver-checks requires), +# MODE (computed|forced). Run from a checkout of BASE_SHA. +set -euo pipefail + +report="$1" +script_dir=$(cd "$(dirname "$0")" && pwd) +branch="release/v$VERSION" + +"$script_dir/set-version.sh" "$VERSION" Cargo.toml Cargo.lock + +# Fails if the branch already exists, e.g. a release PR for this version is still open +gh api "repos/$REPO/git/refs" -f ref="refs/heads/$branch" -f sha="$BASE_SHA" > /dev/null + +jq -n \ + --arg repo "$REPO" --arg branch "$branch" --arg head "$BASE_SHA" \ + --arg message "chore(release): $VERSION" \ + --arg toml "$(base64 -w0 Cargo.toml)" --arg lock "$(base64 -w0 Cargo.lock)" \ + '{query: "mutation($input: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $input) { commit { oid } } }", + variables: {input: { + branch: {repositoryNameWithOwner: $repo, branchName: $branch}, + expectedHeadOid: $head, + message: {headline: $message}, + fileChanges: {additions: [{path: "Cargo.toml", contents: $toml}, {path: "Cargo.lock", contents: $lock}]} + }}}' > release-commit.json +gh api graphql --input release-commit.json --jq .data.createCommitOnBranch.commit.oid + +body=$(mktemp) +{ + if [ "$MODE" = "forced" ]; then + echo "Release \`$VERSION\` (version set by hand; published: \`$PUBLISHED\`)." + else + echo "Release \`$VERSION\`, the version \`cargo-semver-checks\` requires against the published \`$PUBLISHED\`." + fi + echo + if [ "$VERSION" != "$REQUIRED" ]; then + echo "For reference, \`cargo-semver-checks\` requires \`$REQUIRED\`." + echo + fi + if grep -q '^--- failure' "$report"; then + echo "Breaking changes against \`$PUBLISHED\`:" + echo + echo "~~~" + grep -E '^(--- failure| )' "$report" | head -80 + echo "~~~" + echo + fi + echo "After merging, tag the merge commit to publish: \`git tag -s v$VERSION && git push origin v$VERSION\`." +} > "$body" + +gh pr create --repo "$REPO" --base main --head "$branch" --title "chore(release): $VERSION" --body-file "$body" diff --git a/.github/scripts/validate-semver-result.sh b/.github/scripts/validate-semver-result.sh deleted file mode 100755 index b287547..0000000 --- a/.github/scripts/validate-semver-result.sh +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env bash -# Usage: validate-semver-result.sh -# Runs from the default branch (Version Bump workflow). Everything in comes from a -# pull_request run and is untrusted: each value is validated, and the PR must still be an open -# same-repository PR into main at the commit that was checked. -# Writes the validated values to $GITHUB_OUTPUT for the label and commit steps. -# Env: GH_TOKEN, REPO (owner/name), RUN_HEAD_SHA and RUN_HEAD_BRANCH (from the workflow_run event). -set -euo pipefail - -dir="$1" - -read -r version level breaking < "$dir/result.txt" -pr_number=$(cat "$dir/pr-number.txt") -head_sha=$(cat "$dir/head-sha.txt") -published=$(cat "$dir/published.txt") - -[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "invalid version: $version" >&2; exit 1; } -[[ "$published" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "invalid published version" >&2; exit 1; } -[[ "$level" =~ ^(patch|minor|major)$ ]] || { echo "invalid level: $level" >&2; exit 1; } -[[ "$breaking" =~ ^(true|false)$ ]] || { echo "invalid breaking flag: $breaking" >&2; exit 1; } -[[ "$pr_number" =~ ^[0-9]+$ ]] || { echo "invalid PR number" >&2; exit 1; } -[[ "$head_sha" == "$RUN_HEAD_SHA" ]] || { echo "artifact head SHA does not match the triggering run" >&2; exit 1; } - -pr=$(gh api "repos/$REPO/pulls/$pr_number") -state=$(jq -r .state <<< "$pr") -pr_head_ref=$(jq -r .head.ref <<< "$pr") -pr_head_repo=$(jq -r .head.repo.full_name <<< "$pr") -pr_head_sha=$(jq -r .head.sha <<< "$pr") -pr_base=$(jq -r .base.ref <<< "$pr") -if [ "$state" != "open" ] || [ "$pr_base" != "main" ] || [ "$pr_head_repo" != "$REPO" ] \ - || [ "$pr_head_ref" != "$RUN_HEAD_BRANCH" ]; then - echo "PR #$pr_number is not an open same-repository PR into main" >&2 - exit 1 -fi - -moved=false -if [ "$pr_head_sha" != "$head_sha" ]; then - echo "::notice::PR #$pr_number moved since the check ran; the run for the new head will handle it" - moved=true -fi - -current=$(gh api -H "Accept: application/vnd.github.raw" "repos/$REPO/contents/Cargo.toml?ref=$head_sha" \ - | awk '/^\[/ { p = ($0 == "[package]") } p && /^version = / { gsub(/"/, "", $3); print $3; exit }') -[[ "$current" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "unexpected Cargo.toml version: $current" >&2; exit 1; } - -# Never lower the version: Cargo.toml may already carry an unreleased bump (an earlier merged -# sync PR, or one set by hand), which this release must keep -target=$(printf '%s\n%s\n' "$version" "$current" | sort -V | tail -1) - -{ - echo "pr-number=$pr_number" - echo "head-sha=$head_sha" - echo "moved=$moved" - echo "published=$published" - echo "version=$version" - echo "level=$level" - echo "breaking=$breaking" - echo "current=$current" - echo "target=$target" -} >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/prepare-release.yaml b/.github/workflows/prepare-release.yaml new file mode 100644 index 0000000..a41a6f6 --- /dev/null +++ b/.github/workflows/prepare-release.yaml @@ -0,0 +1,113 @@ +name: Prepare Release + +# Opens the release PR "chore(release): X.Y.Z" that sets the version in Cargo.toml and Cargo.lock. +# Without a version input, the version is the one cargo-semver-checks requires against the published +# crate (never lower than Cargo.toml on main). After the PR is merged, tag the merge commit +# vX.Y.Z (signed) to publish. +# +# Setup: the dfns-sdk-semver GitHub App (Contents and Pull requests: read and write) opens the PR, +# so its CI runs. Its credentials live in the `sdk-semver` environment, restricted to main: +# variable SEMVER_APP_CLIENT_ID and secret SEMVER_APP_PRIVATE_KEY. + +on: + workflow_dispatch: + inputs: + version: + description: "Version to release (X.Y.Z). Leave empty to use the version cargo-semver-checks requires." + required: false + type: string + +permissions: + contents: read + +concurrency: + group: prepare-release + cancel-in-progress: false + +env: + CARGO_SEMVER_CHECKS_VERSION: "0.51.0" + +jobs: + release-pr: + if: github.ref == 'refs/heads/main' + timeout-minutes: 30 + runs-on: ubuntu-latest + environment: + name: sdk-semver + deployment: false + permissions: + contents: read # the branch, commit and PR are made with the dfns-sdk-semver app's token + + steps: + - name: Checkout code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Install Rust + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + + - name: Install cargo-semver-checks + run: cargo install --locked "cargo-semver-checks@$CARGO_SEMVER_CHECKS_VERSION" + + - name: Choose the version + id: version + env: + INPUT_VERSION: ${{ inputs.version }} + run: | + published=$(curl -fsSL -A "dfns-sdk-rust CI (https://github.com/dfns/dfns-sdk-rust)" \ + https://crates.io/api/v1/crates/dfns-sdk-rust | jq -r .crate.max_stable_version) + # Informational when a version is forced: a failed check must not block the release + if .github/scripts/next-version.sh "$published" semver-report.txt > semver-result.txt; then + read -r required _ _ < semver-result.txt + elif [ -n "$INPUT_VERSION" ]; then + required=unknown + echo "::warning::cargo-semver-checks failed; continuing with the forced version" + else + exit 1 + fi + current=$(awk '/^\[/ { p = ($0 == "[package]") } p && /^version = / { gsub(/"/, "", $3); print $3; exit }' Cargo.toml) + + if [ -n "$INPUT_VERSION" ]; then + if ! [[ "$INPUT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Version must be X.Y.Z, got: $INPUT_VERSION" + exit 1 + fi + version="$INPUT_VERSION" + mode=forced + else + # Keep an unreleased bump already on main + version=$(printf '%s\n%s\n' "$required" "$current" | sort -V | tail -1) + mode=computed + fi + + if [ "$version" = "$current" ]; then + echo "::error::Cargo.toml on main is already $version: tag the commit to publish it" + exit 1 + fi + + { + echo "version=$version" + echo "published=$published" + echo "required=$required" + echo "mode=$mode" + } >> "$GITHUB_OUTPUT" + echo "### Release \`$version\` ($mode; \`cargo-semver-checks\` requires \`$required\` over \`$published\`)" >> "$GITHUB_STEP_SUMMARY" + + - name: Generate the dfns-sdk-semver app token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.SEMVER_APP_CLIENT_ID }} + private-key: ${{ secrets.SEMVER_APP_PRIVATE_KEY }} + + - name: Open the release PR + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + REPO: ${{ github.repository }} + BASE_SHA: ${{ github.sha }} + VERSION: ${{ steps.version.outputs.version }} + PUBLISHED: ${{ steps.version.outputs.published }} + REQUIRED: ${{ steps.version.outputs.required }} + MODE: ${{ steps.version.outputs.mode }} + run: .github/scripts/open-release-pr.sh semver-report.txt diff --git a/.github/workflows/semver-check.yaml b/.github/workflows/semver-check.yaml index 8d7b8a1..610fc44 100644 --- a/.github/workflows/semver-check.yaml +++ b/.github/workflows/semver-check.yaml @@ -1,8 +1,7 @@ name: Semver Check -# Computes the release a PR requires, from its API change against the published crate. -# Read-only: the Version Bump workflow (workflow_run) labels the PR, and commits the version -# to monorepo sync PRs. +# Reports the release a PR requires, from its API change against the published crate, in the job +# summary. Read-only and informational: the version is set by the Prepare Release workflow. on: pull_request: @@ -24,7 +23,6 @@ jobs: - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: - ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - name: Install Rust @@ -33,33 +31,19 @@ jobs: - name: Install cargo-semver-checks run: cargo install --locked "cargo-semver-checks@$CARGO_SEMVER_CHECKS_VERSION" - - name: Compute the next version - env: - PR_NUMBER: ${{ github.event.pull_request.number }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} + - name: Report the required release run: | published=$(curl -fsSL -A "dfns-sdk-rust CI (https://github.com/dfns/dfns-sdk-rust)" \ https://crates.io/api/v1/crates/dfns-sdk-rust | jq -r .crate.max_stable_version) - mkdir -p semver-result - .github/scripts/next-version.sh "$published" semver-result/report.txt > semver-result/result.txt - echo "$PR_NUMBER" > semver-result/pr-number.txt - echo "$HEAD_SHA" > semver-result/head-sha.txt - echo "$published" > semver-result/published.txt - read -r version level breaking < semver-result/result.txt + .github/scripts/next-version.sh "$published" semver-report.txt > semver-result.txt + read -r version level breaking < semver-result.txt { - echo "### Next version: \`$version\` ($level bump from \`$published\`)" + echo "### Next release: \`$version\` ($level bump from \`$published\`)" if [ "$breaking" = "true" ]; then echo echo "Breaking change against the published crate:" echo '```' - grep -E '^(--- failure| )' semver-result/report.txt | head -60 + grep -E '^(--- failure| )' semver-report.txt | head -60 echo '```' fi } >> "$GITHUB_STEP_SUMMARY" - - - name: Upload the result - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: semver-result - path: semver-result/ - retention-days: 7 diff --git a/.github/workflows/version-bump.yaml b/.github/workflows/version-bump.yaml deleted file mode 100644 index 2d59c92..0000000 --- a/.github/workflows/version-bump.yaml +++ /dev/null @@ -1,166 +0,0 @@ -name: Version Bump - -# Labels each PR with the release Semver Check computed, and commits that version to monorepo -# sync PRs, so merging one sync PR ships the SDK update and its version together. workflow_run -# always runs this file from the default branch, so a PR cannot change what runs with write -# access; the artifact it reads is validated by .github/scripts/validate-semver-result.sh. -# -# Setup: the version commit is made by the dfns-sdk-semver GitHub App, whose credentials live in the -# `sdk-semver` environment (deployment restricted to main): variable SEMVER_APP_CLIENT_ID and -# secret SEMVER_APP_PRIVATE_KEY. A ruleset on sdk-sync/** branches lets only the sync app and the -# semver app create or push them. - -on: - workflow_run: - workflows: - - Semver Check - types: - - completed - -permissions: - contents: read - -concurrency: - group: version-bump-${{ github.event.workflow_run.head_branch }} - cancel-in-progress: false - -env: - # dfnsco-dfns-sdk-sync[bot], which opens the monorepo sync PRs - SYNC_APP_USER_ID: "275545256" - -jobs: - # All PRs: check the untrusted Semver Check result and the PR it belongs to - validate: - if: >- - github.event.workflow_run.event == 'pull_request' && - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.head_repository.full_name == github.repository - timeout-minutes: 5 - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - actions: read # download the Semver Check artifact - outputs: - pr-number: ${{ steps.validate.outputs.pr-number }} - head-sha: ${{ steps.validate.outputs.head-sha }} - moved: ${{ steps.validate.outputs.moved }} - published: ${{ steps.validate.outputs.published }} - version: ${{ steps.validate.outputs.version }} - level: ${{ steps.validate.outputs.level }} - breaking: ${{ steps.validate.outputs.breaking }} - current: ${{ steps.validate.outputs.current }} - target: ${{ steps.validate.outputs.target }} - - steps: - - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - persist-credentials: false - - - name: Download the Semver Check result - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: semver-result - path: semver-result - run-id: ${{ github.event.workflow_run.id }} - github-token: ${{ github.token }} - - - name: Validate the result - id: validate - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} - RUN_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} - run: .github/scripts/validate-semver-result.sh semver-result - - # Sync PRs only: commit the version, unless Cargo.toml already carries it. - # The branch name is a pre-filter; commit-version.sh checks the PR comes from the sync app. - commit-version: - needs: validate - if: >- - needs.validate.outputs.moved == 'false' && - startsWith(github.event.workflow_run.head_branch, 'sdk-sync/') && - needs.validate.outputs.target != needs.validate.outputs.current - timeout-minutes: 5 - runs-on: ubuntu-latest - environment: - name: sdk-semver - deployment: false - permissions: - contents: read # the commit is made with the semver app's token, not GITHUB_TOKEN - outputs: - committed: ${{ steps.commit.outputs.committed }} - - steps: - - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - persist-credentials: false - - - name: Generate the semver app token - id: app-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ vars.SEMVER_APP_CLIENT_ID }} - private-key: ${{ secrets.SEMVER_APP_PRIVATE_KEY }} - - - name: Commit the version - id: commit - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - SEMVER_APP_SLUG: ${{ steps.app-token.outputs.app-slug }} - REPO: ${{ github.repository }} - PR_NUMBER: ${{ needs.validate.outputs.pr-number }} - BRANCH: ${{ github.event.workflow_run.head_branch }} - HEAD_SHA: ${{ needs.validate.outputs.head-sha }} - TARGET: ${{ needs.validate.outputs.target }} - run: .github/scripts/commit-version.sh - - # All PRs: release labels, and a comment on breaking changes - label: - needs: - - validate - - commit-version - # Runs whether commit-version ran, was skipped or failed - if: >- - always() && - needs.validate.result == 'success' && - needs.validate.outputs.moved == 'false' - timeout-minutes: 5 - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write # release labels, comment, disable auto-merge - actions: read # download the Semver Check artifact - - steps: - - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - persist-credentials: false - - - name: Download the Semver Check result - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: semver-result - path: semver-result - run-id: ${{ github.event.workflow_run.id }} - github-token: ${{ github.token }} - - - name: Label the release - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - PR_NUMBER: ${{ needs.validate.outputs.pr-number }} - LEVEL: ${{ needs.validate.outputs.level }} - BREAKING: ${{ needs.validate.outputs.breaking }} - VERSION: ${{ needs.validate.outputs.version }} - PUBLISHED: ${{ needs.validate.outputs.published }} - # When this run did not set the version: the version to set if the PR should be released - SUGGEST_VERSION: >- - ${{ needs.commit-version.outputs.committed != 'true' && - needs.validate.outputs.target != needs.validate.outputs.current && - needs.validate.outputs.target || '' }} - run: .github/scripts/label-release.sh semver-result From 8c68a461df68f548134340d891e3d4cea6ea8153 Mon Sep 17 00:00:00 2001 From: Jonas Lerebours Date: Mon, 26 Oct 2026 19:17:13 +0100 Subject: [PATCH 10/11] ci: run cargo-semver-checks in Prepare Release only The per-PR Semver Check only reported; Prepare Release computes and shows the required version when it opens the release PR. --- .github/workflows/semver-check.yaml | 49 ----------------------------- 1 file changed, 49 deletions(-) delete mode 100644 .github/workflows/semver-check.yaml diff --git a/.github/workflows/semver-check.yaml b/.github/workflows/semver-check.yaml deleted file mode 100644 index 610fc44..0000000 --- a/.github/workflows/semver-check.yaml +++ /dev/null @@ -1,49 +0,0 @@ -name: Semver Check - -# Reports the release a PR requires, from its API change against the published crate, in the job -# summary. Read-only and informational: the version is set by the Prepare Release workflow. - -on: - pull_request: - branches: - - main - -permissions: - contents: read - -env: - CARGO_SEMVER_CHECKS_VERSION: "0.51.0" - -jobs: - semver: - timeout-minutes: 30 - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - persist-credentials: false - - - name: Install Rust - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable - - - name: Install cargo-semver-checks - run: cargo install --locked "cargo-semver-checks@$CARGO_SEMVER_CHECKS_VERSION" - - - name: Report the required release - run: | - published=$(curl -fsSL -A "dfns-sdk-rust CI (https://github.com/dfns/dfns-sdk-rust)" \ - https://crates.io/api/v1/crates/dfns-sdk-rust | jq -r .crate.max_stable_version) - .github/scripts/next-version.sh "$published" semver-report.txt > semver-result.txt - read -r version level breaking < semver-result.txt - { - echo "### Next release: \`$version\` ($level bump from \`$published\`)" - if [ "$breaking" = "true" ]; then - echo - echo "Breaking change against the published crate:" - echo '```' - grep -E '^(--- failure| )' semver-report.txt | head -60 - echo '```' - fi - } >> "$GITHUB_STEP_SUMMARY" From aa244212707f84af6383c62b9f971565edef9f68 Mon Sep 17 00:00:00 2001 From: Jonas Lerebours Date: Mon, 26 Oct 2026 19:24:51 +0100 Subject: [PATCH 11/11] ci: use the bare version as release tag Release tags are the Cargo.toml version without a v prefix (0.3.3), like the TS SDK. The release branch is release/X.Y.Z. The crates-io environment's tag rule (v*) must be changed to match. --- .github/scripts/open-release-pr.sh | 6 +++--- .github/workflows/prepare-release.yaml | 2 +- .github/workflows/publish.yaml | 7 ++++--- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/scripts/open-release-pr.sh b/.github/scripts/open-release-pr.sh index f5b9446..f837e28 100755 --- a/.github/scripts/open-release-pr.sh +++ b/.github/scripts/open-release-pr.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Usage: open-release-pr.sh -# Opens the release PR "chore(release): VERSION": creates the branch release/vVERSION at BASE_SHA, +# Opens the release PR "chore(release): VERSION": creates the branch release/VERSION at BASE_SHA, # commits the version in Cargo.toml and Cargo.lock, and opens the PR into main. GH_TOKEN is the # dfns-sdk-semver app's token, so the branch, the commit (Verified) and the PR are the app's, and # the PR's CI runs. @@ -10,7 +10,7 @@ set -euo pipefail report="$1" script_dir=$(cd "$(dirname "$0")" && pwd) -branch="release/v$VERSION" +branch="release/$VERSION" "$script_dir/set-version.sh" "$VERSION" Cargo.toml Cargo.lock @@ -50,7 +50,7 @@ body=$(mktemp) echo "~~~" echo fi - echo "After merging, tag the merge commit to publish: \`git tag -s v$VERSION && git push origin v$VERSION\`." + echo "After merging, tag the merge commit to publish: \`git tag -s $VERSION && git push origin $VERSION\`." } > "$body" gh pr create --repo "$REPO" --base main --head "$branch" --title "chore(release): $VERSION" --body-file "$body" diff --git a/.github/workflows/prepare-release.yaml b/.github/workflows/prepare-release.yaml index a41a6f6..29e0ce6 100644 --- a/.github/workflows/prepare-release.yaml +++ b/.github/workflows/prepare-release.yaml @@ -3,7 +3,7 @@ name: Prepare Release # Opens the release PR "chore(release): X.Y.Z" that sets the version in Cargo.toml and Cargo.lock. # Without a version input, the version is the one cargo-semver-checks requires against the published # crate (never lower than Cargo.toml on main). After the PR is merged, tag the merge commit -# vX.Y.Z (signed) to publish. +# X.Y.Z (signed) to publish. # # Setup: the dfns-sdk-semver GitHub App (Contents and Pull requests: read and write) opens the PR, # so its CI runs. Its credentials live in the `sdk-semver` environment, restricted to main: diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 0b01141..f6715a5 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -3,7 +3,8 @@ name: Publish to crates.io on: push: tags: - - "v*" + # Release tags are the bare version (0.3.3), like the Cargo.toml version + - "[0-9]+.[0-9]+.[0-9]+" permissions: contents: read @@ -25,8 +26,8 @@ jobs: - name: Check the tag matches Cargo.toml and is on main run: | version=$(awk '/^\[/ { p = ($0 == "[package]") } p && /^version = / { gsub(/"/, "", $3); print $3; exit }' Cargo.toml) - if [ "$GITHUB_REF_NAME" != "v$version" ]; then - echo "::error::Tag $GITHUB_REF_NAME does not match the Cargo.toml version $version (expected v$version)" + if [ "$GITHUB_REF_NAME" != "$version" ]; then + echo "::error::Tag $GITHUB_REF_NAME does not match the Cargo.toml version $version" exit 1 fi if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then