From a1ca5b9b51835b1054d87eba28462a49ae1c434b Mon Sep 17 00:00:00 2001 From: Cevat Batuhan Tolon Date: Mon, 10 Aug 2026 18:16:01 +0300 Subject: [PATCH 1/3] ci: PR-managed releases, coverage report, semver-checks, OpenSSF Scorecard, SHA-keyed run dedupe --- .claude/skills/review-for-release/SKILL.md | 2 +- .github/workflows/ci.yml | 45 ++++++++- .github/workflows/release.yml | 109 ++++++++++++++------- .github/workflows/scorecard.yml | 49 +++++++++ .github/workflows/security.yml | 5 +- RELEASING.md | 40 ++++---- scripts/README.md | 10 +- scripts/promote.sh | 2 + scripts/tag-release.sh | 56 ----------- scripts/watch-release.sh | 29 ++++++ 10 files changed, 226 insertions(+), 121 deletions(-) create mode 100644 .github/workflows/scorecard.yml delete mode 100755 scripts/tag-release.sh create mode 100755 scripts/watch-release.sh diff --git a/.claude/skills/review-for-release/SKILL.md b/.claude/skills/review-for-release/SKILL.md index 52780ba..721a463 100644 --- a/.claude/skills/review-for-release/SKILL.md +++ b/.claude/skills/review-for-release/SKILL.md @@ -45,7 +45,7 @@ together — `#[cfg(unix)] #[cfg(not(unix))]` compiles to nothing, silently. Three tests here never ran for months because of one. Grep for consecutive cfg lines and read each pair. -**Check the counts.** Twenty macro arguments, ten crates, fourteen ci.yml jobs, +**Check the counts.** Twenty macro arguments, ten crates, fifteen ci.yml jobs, eleven changelogs, twenty-six core examples. Every one of those numbers appears in documentation somewhere; recount whenever a list grows. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88aca08..c140b09 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,11 +6,11 @@ on: pull_request: workflow_dispatch: -# One job cancels the previous run on the same branch. A pull request pushed -# three times in a minute should not occupy three runners proving the same -# thing about two obsolete commits. +# Keyed on the *commit*, not the branch: a push to dev and the pull request +# it sits under would otherwise run the whole pipeline twice for the same +# SHA — one run cancels the other instead. concurrency: - group: ci-${{ github.workflow }}-${{ github.ref }} + group: ci-${{ github.workflow }}-${{ github.event.pull_request.head.sha || github.sha }} cancel-in-progress: true permissions: @@ -338,7 +338,7 @@ jobs: --exclude '^https://github\.com/ctolon/' --exclude '^https://docs\.rs/dynamic-config' README.md ROADMAP.md CONTRIBUTING.md SECURITY.md RELEASING.md - AGENTS.md CHANGELOG.md docs/*.md + AGENTS.md CHANGELOG.md docs/*.md scripts/README.md dynamic-config-*/README.md dynamic-config-*/CHANGELOG.md .claude/skills/*/SKILL.md .github/ISSUE_TEMPLATE/*.md fail: true @@ -367,6 +367,41 @@ jobs: # oldest `trybuild` anyone could build its test suite with. - run: cargo +stable check -p dynamic-config --locked --all-features + coverage: + name: coverage report + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + with: + components: llvm-tools-preview + - uses: Swatinem/rust-cache@v2 + - uses: taiki-e/install-action@cargo-llvm-cov + # A report, not a gate: a threshold that fights refactoring teaches + # people to game the number. The trend lives in the job summaries; the + # lcov artifact feeds anything that wants detail. + - run: > + cargo llvm-cov --workspace --features full + --exclude dynamic-config-etcd --exclude dynamic-config-consul + --exclude dynamic-config-nats --exclude dynamic-config-vault + --exclude dynamic-config-redis --exclude dynamic-config-s3 + --exclude dynamic-config-firestore --exclude dynamic-config-embedded + --lcov --output-path lcov.info + - name: summary + run: | + { + echo '## Coverage' + echo '```' + cargo llvm-cov report --summary-only + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + - uses: actions/upload-artifact@v4 + with: + name: coverage-lcov + path: lcov.info + retention-days: 14 + actionlint: name: workflows lint clean runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b386582..c7a0d29 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,18 +1,49 @@ name: Release -# Publishing is triggered by a tag, and the tag is the only thing that decides -# what gets published. Anything else — a branch, a manual click with a version -# box — makes "what is on crates.io" a question rather than a fact. +# Releases are managed through the pull-request pipeline, not by hand-pushed +# tags: merging a version-bump PR into `main` IS the release. Every push to +# `main` lands here; the `decide` job checks whether the workspace version is +# new, and everything downstream is skipped when it is not — so a docs merge +# is a green no-op and a version bump is a release. +# +# The tag is an *output* of a successful publish, minted by CI at the merge +# commit. Since `main` only accepts pull requests that passed the gates, a +# release inherits the whole review pipeline by construction. on: push: - tags: ["v*"] + branches: [main] + workflow_dispatch: permissions: contents: read jobs: + decide: + name: is this a release? + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + version: ${{ steps.check.outputs.version }} + release: ${{ steps.check.outputs.release }} + steps: + - uses: actions/checkout@v7 + - id: check + run: | + version=$(grep -m1 '^version' Cargo.toml | cut -d'"' -f2) + echo "version=$version" >> "$GITHUB_OUTPUT" + + if git ls-remote --exit-code --tags origin "refs/tags/v$version" >/dev/null 2>&1; then + echo "v$version is already tagged — nothing to release." + echo "release=false" >> "$GITHUB_OUTPUT" + else + echo "v$version is new — this push is a release." + echo "release=true" >> "$GITHUB_OUTPUT" + fi + verify: name: verify before publishing + needs: decide + if: needs.decide.outputs.release == 'true' runs-on: ubuntu-latest timeout-minutes: 45 steps: @@ -20,36 +51,23 @@ jobs: - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - # The tag and the manifest have to agree. They diverge exactly once, on - # the release where somebody forgot, and then the registry has a version - # nothing in the repository names. - - name: the tag matches the workspace version - run: | - tagged="${GITHUB_REF_NAME#v}" - declared=$(grep -m1 '^version' Cargo.toml | cut -d'"' -f2) - - if [ "$tagged" != "$declared" ]; then - echo "::error::tag $GITHUB_REF_NAME does not match workspace version $declared" - exit 1 - fi - - name: the changelog names this version + env: + VERSION: ${{ needs.decide.outputs.version }} run: | - version="${GITHUB_REF_NAME#v}" - - if ! grep -q "^## \[$version\]" CHANGELOG.md; then - echo "::error file=CHANGELOG.md::no section for $version" + if ! grep -q "^## \[$VERSION\]" CHANGELOG.md; then + echo "::error file=CHANGELOG.md::no section for $VERSION" exit 1 fi - - run: cargo fmt --all -- --check # `etcd-client` compiles its gRPC stubs at build time and needs `protoc`. - run: sudo apt-get update && sudo apt-get install -y protobuf-compiler + - run: cargo fmt --all -- --check - run: cargo clippy --workspace --all-targets --all-features # The same excludes as ci.yml's test job: the container suites need # Docker images pulled and a long leash, and they gate merges to main — - # a tag build re-proving them adds an hour, not confidence. The embedded - # crate has its own features and no `full`. + # a release build re-proving them adds an hour, not confidence. The + # embedded crate has its own features and no `full`. - run: > cargo test --workspace --features full --exclude dynamic-config-etcd @@ -62,9 +80,16 @@ jobs: --exclude dynamic-config-embedded - run: cargo test -p dynamic-config-embedded --features std,async + # The version bump keeps the semver promise it makes: compared against + # the baseline already on crates.io. Pre-1.0, a breaking change needs + # the minor bumped — this is the check that notices when it was not. + - uses: taiki-e/install-action@cargo-semver-checks + - run: cargo semver-checks --workspace + publish: name: publish to crates.io - needs: verify + needs: [decide, verify] + if: needs.decide.outputs.release == 'true' runs-on: ubuntu-latest timeout-minutes: 45 environment: crates-io @@ -88,8 +113,8 @@ jobs: # # Idempotent on purpose: a version already on the registry is *skipped*, # not died on. crates.io versions are permanent, so after a partial - # failure the only way forward is a rerun that publishes the remainder — - # a pipeline that trips over its own earlier success cannot recover. + # failure — a rate limit, most likely — the only way forward is a rerun + # that publishes the remainder. - name: publish, in dependency order run: | publish() { @@ -97,7 +122,7 @@ jobs: if out=$(cargo publish -p "$1" 2>&1); then echo "published $1" elif grep -qE "already (uploaded|exists)" <<<"$out"; then - echo "$1 v$(grep -m1 '^version' Cargo.toml | cut -d'"' -f2) is already on the registry — skipped" + echo "$1 is already on the registry — skipped" else printf '%s\n' "$out" return 1 @@ -124,21 +149,34 @@ jobs: env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} - github-release: - name: GitHub release - needs: publish + tag-and-release: + name: tag and GitHub release + needs: [decide, publish] + if: needs.decide.outputs.release == 'true' runs-on: ubuntu-latest timeout-minutes: 10 permissions: contents: write steps: - uses: actions/checkout@v7 - - name: notes from the changelog + + # The tag marks what was actually published: it is minted only after + # every crate is on the registry, at the merge commit that did it. + - name: tag the release + env: + VERSION: ${{ needs.decide.outputs.version }} run: | - version="${GITHUB_REF_NAME#v}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "v$VERSION" -m "dynamic-config $VERSION" "$GITHUB_SHA" + git push origin "v$VERSION" + - name: notes from the changelog + env: + VERSION: ${{ needs.decide.outputs.version }} + run: | # The section for this version, up to the next one. - awk -v v="$version" ' + awk -v v="$VERSION" ' $0 ~ "^## \\[" v "\\]" { found = 1; next } found && /^## \[/ { exit } found { print } @@ -146,6 +184,9 @@ jobs: echo "--- notes ---" cat release-notes.md + - uses: softprops/action-gh-release@v3 with: + tag_name: v${{ needs.decide.outputs.version }} + name: v${{ needs.decide.outputs.version }} body_path: release-notes.md diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 0000000..176db56 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,49 @@ +name: Scorecard + +# OpenSSF Scorecard: the repository's own supply-chain posture — branch +# protection, token permissions, pinned dependencies, dangerous workflow +# patterns — scored and uploaded to code scanning, where a regression shows +# up as an alert rather than a surprise. +# +# Runs on `dev` because Scorecard only publishes results from the *default* +# branch, and `dev` is it here. The schedule keeps the score honest when the +# repository is quiet. +on: + push: + branches: [dev] + schedule: + - cron: "23 7 * * 1" + workflow_dispatch: + +permissions: read-all + +jobs: + analysis: + name: supply-chain posture + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + # SARIF upload into code scanning. + security-events: write + # Signed publishing to the OpenSSF REST API (the public badge). + id-token: write + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - uses: ossf/scorecard-action@v2.4.2 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + + - uses: actions/upload-artifact@v4 + with: + name: scorecard-sarif + path: results.sarif + retention-days: 30 + + - uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: results.sarif diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 40b5ef4..9c24a60 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -12,9 +12,10 @@ on: - cron: "17 6 * * 1" workflow_dispatch: -# A push during a run makes the older run's answer obsolete. +# Keyed on the commit, like ci.yml: a dev push and its pull request must +# not run the same checks twice for the same SHA. concurrency: - group: security-${{ github.workflow }}-${{ github.ref }} + group: security-${{ github.event.pull_request.head.sha || github.sha }} cancel-in-progress: true permissions: diff --git a/RELEASING.md b/RELEASING.md index d9fc609..7de447c 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -27,40 +27,42 @@ CI publishes it in the first wave alongside the macros. Work lands on `dev`. `main` is production: it accepts no direct pushes — not even from admins — only pull requests whose gates ("CI is green", "Security is green") have passed, merged with a linear history (squash or rebase). -Releases are cut from `main`, by tag. + +**Merging a version bump into `main` is the release.** There is no tag to +push by hand: `release.yml` runs on every push to `main`, checks whether the +workspace version is new, and — only then — verifies (including +`cargo semver-checks` against the published baseline), publishes in waves, +and mints the tag and the GitHub release itself, at the merge commit. ```text -feature work ──▶ dev ──(pull request, gates green)──▶ main ──(tag vX.Y.Z)──▶ crates.io +feature work ──▶ dev ──(PR, gates green)──▶ main + │ + version unchanged: green no-op + version new: verify ─▶ publish ─▶ tag + release ``` ## Releasing -`cargo release` prepares; CI publishes. The split is deliberate: a laptop cannot -push to crates.io without the checks having run. +`cargo release` prepares; the merge publishes. The split is deliberate: a +laptop cannot reach crates.io at all, and nothing reaches it without the +gates in front of it. ```sh cargo install cargo-release just -# On a branch cut from main: +# On dev (or a branch that lands there): cargo release patch --execute # 0.0.1 -> 0.0.2: bump + changelogs + commit cargo release minor --execute # 0.0.1 -> 0.1.0, which pre-1.0 is a break -``` -That runs `just check`, bumps every crate, moves each `## [Unreleased]` section -under a dated version heading, and commits — it does **not** push or tag, -because `main` only takes pull requests. Open the PR, let the gates pass, -merge, then tag the merge commit on `main`: - -```sh -git checkout main && git pull -git tag -a vX.Y.Z -m "dynamic-config X.Y.Z" -git push origin vX.Y.Z +./scripts/promote.sh # PR, gates, merge — the merge releases +./scripts/watch-release.sh # watch the run the merge set off ``` -The tag is what starts [`release.yml`](.github/workflows/release.yml), which -verifies the tag matches the manifest and the changelog names the version, -then publishes in three waves. Tag pushes are not branch pushes, so branch -protection does not stand in their way. +`cargo release` runs `just check`, bumps every crate, moves each +`## [Unreleased]` section under a dated version heading, and commits — it +does **not** push or tag; that is CI's job, after publishing succeeded. A +crates.io *rate limit* mid-publish just needs the window waited out and the +job re-run — publishing is idempotent, already-uploaded crates are skipped. ### Before you run it diff --git a/scripts/README.md b/scripts/README.md index 050b489..5405769 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -8,8 +8,10 @@ around the checks. Each one is safe to re-run and says what it did. | `ci-local.sh` | The whole CI gate locally, in the order that fails fastest. `--quick` skips the slow suites (containers, MSRV). | | `watch-ci.sh` | Watches the newest CI run for the current branch; on failure, prints the failed jobs' logs. | | `promote.sh` | `dev` → `main`: pushes, opens the pull request if it is not already open, waits for the gates, merges (rebase), and re-syncs `dev` onto the new `main`. | -| `tag-release.sh vX.Y.Z` | On `main`: verifies the tag matches the workspace version and the changelog names it, tags, pushes the tag, and watches the release workflow it starts. | +| `watch-release.sh` | Watches the Release run the latest merge to `main` set off, and says how to recover from a crates.io rate limit. | -The release itself is: land everything on `dev` → `./scripts/promote.sh` → -`./scripts/tag-release.sh vX.Y.Z`. Nothing here talks to crates.io; the tag -does, through CI, with the checks in front of it. +The release itself is a pull request: run `cargo release patch --execute` on +a branch (it bumps, rewrites changelogs and commits — nothing else), land it +on `dev`, and `./scripts/promote.sh`. The merge to `main` is what publishes; +CI mints the tag and the GitHub release afterwards. Nothing here talks to +crates.io directly. diff --git a/scripts/promote.sh b/scripts/promote.sh index 52900cf..c760cf5 100755 --- a/scripts/promote.sh +++ b/scripts/promote.sh @@ -54,3 +54,5 @@ git reset --hard origin/main git push --force-with-lease origin dev echo "── promoted. main is at $(git rev-parse --short origin/main)." +echo "if this bumped the workspace version, the merge just started a release:" +echo " ./scripts/watch-release.sh" diff --git a/scripts/tag-release.sh b/scripts/tag-release.sh deleted file mode 100755 index f237a42..0000000 --- a/scripts/tag-release.sh +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env bash -# Tags a release on main and watches the pipeline it starts. -# -# ./scripts/tag-release.sh v0.0.1 -# -# The tag is the release: release.yml verifies, publishes to crates.io in -# waves, and cuts the GitHub release. This script only makes sure the tag it -# pushes is one that workflow will accept — the same checks, run where a -# mistake is still free. -set -euo pipefail -cd "$(dirname "$0")/.." - -tag="${1:?usage: tag-release.sh vX.Y.Z}" -case "$tag" in - v[0-9]*.[0-9]*.[0-9]*) ;; - *) echo "'$tag' does not look like vX.Y.Z"; exit 1 ;; -esac -version="${tag#v}" - -echo "── the tag goes on main, at origin's tip" -git fetch origin -declared=$(grep -m1 '^version' Cargo.toml | cut -d'"' -f2) -if [ "$version" != "$declared" ]; then - echo "tag $tag does not match the workspace version $declared — release.yml would refuse it too" - exit 1 -fi - -if ! grep -q "^## \[$version\]" CHANGELOG.md; then - echo "CHANGELOG.md has no section for $version — release notes come from it" - exit 1 -fi - -if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then - echo "tag $tag already exists locally — a crates.io version is permanent, so nothing is re-tagged" - exit 1 -fi - -echo "── tagging origin/main" -git tag -a "$tag" -m "dynamic-config $version" origin/main -git push origin "$tag" - -echo "── the tag is pushed; watching the release run" -# The run takes a moment to appear after the tag lands. -for _ in 1 2 3 4 5 6; do - run_id=$(gh run list --workflow Release --limit 1 --json databaseId,headBranch \ - -q ".[] | select(.headBranch == \"$tag\") | .databaseId" | head -1) - [ -n "$run_id" ] && break - sleep 5 -done - -if [ -z "${run_id:-}" ]; then - echo "no release run visible yet — watch it at: gh run list --workflow Release" - exit 0 -fi - -gh run watch "$run_id" --exit-status && echo "── released: $tag is publishing/published." diff --git a/scripts/watch-release.sh b/scripts/watch-release.sh new file mode 100755 index 0000000..b58a4bf --- /dev/null +++ b/scripts/watch-release.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Watches the newest Release run on main and reports the outcome. +# +# There is nothing to *start* by hand any more: merging a version-bump PR +# into main is the release, and this only watches what that merge set off. +set -euo pipefail +cd "$(dirname "$0")/.." + +run_id=$(gh run list --workflow Release --branch main --limit 1 --json databaseId -q '.[0].databaseId') + +if [ -z "$run_id" ]; then + echo "no Release run found — has anything been merged to main?" + exit 1 +fi + +echo "watching Release run $run_id…" + +if gh run watch "$run_id" --exit-status; then + gh run view "$run_id" --json jobs \ + -q '.jobs[] | .name + ": " + (.conclusion // .status)' +else + echo + echo "── failing steps' logs ──" + gh run view "$run_id" --log-failed + echo + echo "A crates.io *rate limit* just needs patience: wait out the window it" + echo "names, then: gh run rerun $run_id --failed (publishing is idempotent)" + exit 1 +fi From ecd79f912086532b08248dd4c1e9062e338a3c37 Mon Sep 17 00:00:00 2001 From: Cevat Batuhan Tolon Date: Mon, 10 Aug 2026 18:23:38 +0300 Subject: [PATCH 2/3] ci: pin every action to a commit SHA; promote via auto-merge --- .github/workflows/ci.yml | 114 ++++++++++++++++---------- .github/workflows/claude.yml | 4 +- .github/workflows/publish-dry-run.yml | 8 +- .github/workflows/release.yml | 26 +++--- .github/workflows/scorecard.yml | 8 +- .github/workflows/security.yml | 24 +++--- scripts/promote.sh | 28 +++++-- 7 files changed, 131 insertions(+), 81 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c140b09..c17db9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,9 +28,10 @@ jobs: # A hung job should fail, not sit on a runner for six hours. timeout-minutes: 10 steps: - - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master with: + toolchain: stable components: rustfmt - run: cargo fmt --all -- --check @@ -39,11 +40,12 @@ jobs: # A hung job should fail, not sit on a runner for six hours. timeout-minutes: 30 steps: - - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master with: + toolchain: stable components: clippy - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 # `etcd-client` compiles its gRPC stubs at build time and needs the # `protoc` binary, which the runner image does not carry. - run: sudo apt-get update && sudo apt-get install -y protobuf-compiler @@ -58,9 +60,11 @@ jobs: runs-on: ${{ matrix.os }} timeout-minutes: 45 steps: - - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master + with: + toolchain: stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 # The watcher is the reason this runs on three operating systems: notify # uses a different backend on each, and directory watching plus atomic # saves behave differently enough to be worth proving every time. @@ -114,9 +118,11 @@ jobs: # A hung job should fail, not sit on a runner for six hours. timeout-minutes: 60 steps: - - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master + with: + toolchain: stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 # Real servers, no mocks. A mock of etcd would only ever confirm what we # already believed about etcd — and three of the facts these tests pin (a # lazy gRPC connect, a bucket that must already exist, a first blocking @@ -170,11 +176,12 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 steps: - - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master with: + toolchain: stable targets: thumbv7em-none-eabihf - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 # A host build cannot check `no_std`: `std` is in the sysroot and links # itself in. Building for a target that has no `std` at all is the only # way to know. @@ -194,10 +201,14 @@ jobs: # A hung job should fail, not sit on a runner for six hours. timeout-minutes: 30 steps: - - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - - uses: taiki-e/install-action@cargo-hack + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master + with: + toolchain: stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + - uses: taiki-e/install-action@7f4eb899022d8fe70b20c4f3de697aa85c309026 # v2 + with: + tool: cargo-hack # Every combination has to compile, not just the ones we happen to test. # # `--depth 2` rather than the full powerset: the full set is thousands @@ -216,9 +227,11 @@ jobs: # A hung job should fail, not sit on a runner for six hours. timeout-minutes: 20 steps: - - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master + with: + toolchain: stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 # figment's behaviour is this crate's behaviour, so its contract gets its # own job: an upgrade that changes merging or environment parsing shows up # here rather than in a user's incident. @@ -229,9 +242,11 @@ jobs: # A hung job should fail, not sit on a runner for six hours. timeout-minutes: 30 steps: - - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master + with: + toolchain: stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 # Not a regression gate — a shared runner is too noisy for that. It keeps # the benchmark compiling and the README's figures reproducible by # anyone who wants to check them. @@ -284,12 +299,14 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable - - uses: dtolnay/rust-toolchain@master + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master + with: + toolchain: stable + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master with: toolchain: ${{ matrix.rust }} - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 # The lockfile has to be produced by a cargo new enough to honour # `resolver.incompatible-rust-versions = "fallback"` from .cargo/config.toml. # Left to the old toolchain, resolution takes the newest release of every @@ -312,9 +329,11 @@ jobs: env: RUSTDOCFLAGS: -D warnings --cfg docsrs steps: - - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@nightly - - uses: Swatinem/rust-cache@v2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master + with: + toolchain: nightly + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 # `etcd-client` compiles its gRPC stubs at build time and needs the # `protoc` binary, which the runner image does not carry. - run: sudo apt-get update && sudo apt-get install -y protobuf-compiler @@ -327,10 +346,10 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 # A README is the first thing anyone reads and the last thing anyone # tests. A dead anchor in it is a small bug that is very easy to ship. - - uses: lycheeverse/lychee-action@v2 + - uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2 with: args: >- --no-progress @@ -348,10 +367,14 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 steps: - - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@nightly - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master + with: + toolchain: nightly + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master + with: + toolchain: stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 # A `serde = "1.0"` that actually needs 1.0.210 compiles against a fresh # lockfile and breaks for the first user with an older one. This resolves # *our* requirements to their oldest allowed version and builds against @@ -372,12 +395,15 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master with: + toolchain: stable components: llvm-tools-preview - - uses: Swatinem/rust-cache@v2 - - uses: taiki-e/install-action@cargo-llvm-cov + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + - uses: taiki-e/install-action@7f4eb899022d8fe70b20c4f3de697aa85c309026 # v2 + with: + tool: cargo-llvm-cov # A report, not a gate: a threshold that fights refactoring teaches # people to game the number. The trend lives in the job summaries; the # lcov artifact feeds anything that wants detail. @@ -396,7 +422,7 @@ jobs: cargo llvm-cov report --summary-only echo '```' } >> "$GITHUB_STEP_SUMMARY" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: coverage-lcov path: lcov.info @@ -407,11 +433,11 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 # The workflows are code that only runs in production. actionlint # catches the typo'd `if:` expression and the misspelled input before a # release does. - - uses: raven-actions/actionlint@v2 + - uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2 # The one status branch protection requires. Requiring every job by name # breaks the moment a matrix row is renamed; requiring this gate never diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 0f7135c..0baa518 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -42,11 +42,11 @@ jobs: id-token: write actions: read steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 1 - - uses: anthropics/claude-code-action@v1 + - uses: anthropics/claude-code-action@6b082c41935b4c8a3b8b0ef85ba4ba4d9eeb8975 # v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} # No prompt: tag mode. The action reads the mention, the thread and diff --git a/.github/workflows/publish-dry-run.yml b/.github/workflows/publish-dry-run.yml index c38963c..03d8589 100644 --- a/.github/workflows/publish-dry-run.yml +++ b/.github/workflows/publish-dry-run.yml @@ -27,9 +27,11 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 steps: - - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master + with: + toolchain: stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 # Only the macro crate can be dry-run before a release: every other crate # here depends on one below it by exact version, and a dry run resolves diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c7a0d29..01e3d30 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,7 @@ jobs: version: ${{ steps.check.outputs.version }} release: ${{ steps.check.outputs.release }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - id: check run: | version=$(grep -m1 '^version' Cargo.toml | cut -d'"' -f2) @@ -47,9 +47,11 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 45 steps: - - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master + with: + toolchain: stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - name: the changelog names this version env: @@ -83,7 +85,9 @@ jobs: # The version bump keeps the semver promise it makes: compared against # the baseline already on crates.io. Pre-1.0, a breaking change needs # the minor bumped — this is the check that notices when it was not. - - uses: taiki-e/install-action@cargo-semver-checks + - uses: taiki-e/install-action@7f4eb899022d8fe70b20c4f3de697aa85c309026 # v2 + with: + tool: cargo-semver-checks - run: cargo semver-checks --workspace publish: @@ -94,9 +98,11 @@ jobs: timeout-minutes: 45 environment: crates-io steps: - - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master + with: + toolchain: stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 # `cargo publish` verifies each tarball by *building* it, and # etcd-client compiles its gRPC stubs at build time — the same protoc @@ -158,7 +164,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 # The tag marks what was actually published: it is minted only after # every crate is on the registry, at the merge commit that did it. @@ -185,7 +191,7 @@ jobs: echo "--- notes ---" cat release-notes.md - - uses: softprops/action-gh-release@v3 + - uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3 with: tag_name: v${{ needs.decide.outputs.version }} name: v${{ needs.decide.outputs.version }} diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 176db56..fe51bd2 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -28,22 +28,22 @@ jobs: # Signed publishing to the OpenSSF REST API (the public badge). id-token: write steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false - - uses: ossf/scorecard-action@v2.4.2 + - uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2 with: results_file: results.sarif results_format: sarif publish_results: true - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: scorecard-sarif path: results.sarif retention-days: 30 - - uses: github/codeql-action/upload-sarif@v3 + - uses: github/codeql-action/upload-sarif@c4dd10e44af883a891fe31ced449bcb4a6728b9b # v3 with: sarif_file: results.sarif diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 9c24a60..4a3caef 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -27,8 +27,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@v7 - - uses: EmbarkStudios/cargo-deny-action@v2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2 with: # The full `check` — advisories, licences, sources *and* bans — so a # duplicate-dependency policy in deny.toml is enforced somewhere @@ -40,9 +40,11 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master + with: + toolchain: stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 # Every crate here declares `#![forbid(unsafe_code)]`, which the compiler # enforces. This proves the declaration is still there rather than # trusting that nobody removed it in a hurry. @@ -63,9 +65,11 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master + with: + toolchain: stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 # The property is enforced by tests rather than by review, so this job # exists to make sure those tests are the ones that ran. Named # deliberately: a green tick against this line is the claim being made. @@ -84,11 +88,11 @@ jobs: contents: read pull-requests: write steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 # Flags a pull request that adds a dependency with a known advisory or a # licence outside the allow-list, at the point where changing course is # still cheap. - - uses: actions/dependency-review-action@v5 + - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5 with: fail-on-severity: low comment-summary-in-pr: on-failure diff --git a/scripts/promote.sh b/scripts/promote.sh index c760cf5..51fb99c 100755 --- a/scripts/promote.sh +++ b/scripts/promote.sh @@ -34,17 +34,29 @@ if [ -z "$pr" ]; then fi echo "pull request #$pr" -echo "── waiting for the gates" -# --fail-fast so a red gate stops the wait instead of running out the clock. -if ! gh pr checks "$pr" --watch --fail-fast; then - echo - echo "a gate is red — the merge is off. See: gh pr checks $pr" +echo "── arming auto-merge and waiting" +# Auto-merge instead of watching checks ourselves: the same commit can carry +# check runs from a cancelled twin (the push-run the PR-run deduplicated), +# and only GitHub's own merge logic knows which one counts. Auto-merge fires +# exactly when branch protection is satisfied — required gates green, +# conversations resolved. +gh pr merge "$pr" --rebase --auto + +deadline=$((SECONDS + 3600)) +while [ "$(gh pr view "$pr" --json state -q .state)" = "OPEN" ]; do + if [ "$SECONDS" -ge "$deadline" ]; then + echo "not merged within an hour — see what is holding it: gh pr view $pr" + echo "(a red gate, or an unresolved conversation; auto-merge stays armed)" + exit 1 + fi + sleep 30 +done + +if [ "$(gh pr view "$pr" --json state -q .state)" != "MERGED" ]; then + echo "the pull request closed without merging — investigate: gh pr view $pr" exit 1 fi -echo "── merging (rebase, linear history)" -gh pr merge "$pr" --rebase - echo "── re-syncing dev onto the new main" # A rebase-merge gives the commits new SHAs on main, so dev is re-pointed at # main rather than dragging duplicate history around. --force-with-lease so a From e5b1db88e209817bc3985edaca3040370eba4bcc Mon Sep 17 00:00:00 2001 From: Cevat Batuhan Tolon Date: Mon, 10 Aug 2026 18:33:50 +0300 Subject: [PATCH 3/3] =?UTF-8?q?ci:=20drop=20dev=20push=20twins=20=E2=80=94?= =?UTF-8?q?=20required-check=20gates=20and=20dedupe=20cancellation=20do=20?= =?UTF-8?q?not=20mix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 16 +++++++++++----- .github/workflows/security.yml | 9 +++++---- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c17db9f..3712602 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,16 +1,22 @@ name: CI +# No push trigger for `dev`: everything on dev travels through a pull +# request anyway, and a push run would twin the PR run on the same commit — +# under *required* checks, a deduplicating cancellation poisons the gate +# (a cancelled twin's "CI is green" reads as failure and blocks the merge). +# `main` keeps its push trigger because a rebase-merge mints a new SHA that +# no PR run ever checked. on: push: - branches: [main, dev] + branches: [main] pull_request: workflow_dispatch: -# Keyed on the *commit*, not the branch: a push to dev and the pull request -# it sits under would otherwise run the whole pipeline twice for the same -# SHA — one run cancels the other instead. +# One run cancels the previous on the same ref: a pull request pushed three +# times in a minute should not occupy three runners proving the same thing +# about two obsolete commits. concurrency: - group: ci-${{ github.workflow }}-${{ github.event.pull_request.head.sha || github.sha }} + group: ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true permissions: diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 4a3caef..2a9b404 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -4,18 +4,19 @@ name: Security # its own as well as on every change. A dependency that was clean on Monday is # not necessarily clean on Friday, and nothing about this repository has to # change for that to happen. +# No push trigger for `dev` — same reasoning as ci.yml: dev travels through +# pull requests, and a push twin under required checks poisons the gate. on: push: - branches: [main, dev] + branches: [main] pull_request: schedule: - cron: "17 6 * * 1" workflow_dispatch: -# Keyed on the commit, like ci.yml: a dev push and its pull request must -# not run the same checks twice for the same SHA. +# A push during a run makes the older run's answer obsolete. concurrency: - group: security-${{ github.event.pull_request.head.sha || github.sha }} + group: security-${{ github.ref }} cancel-in-progress: true permissions: