From d1efb3bc1c1bf79a37c571637108d7d6050275c5 Mon Sep 17 00:00:00 2001 From: Juan Leni Date: Tue, 18 Aug 2026 09:51:28 +0200 Subject: [PATCH] feat(nix): maintain a stable release branch --- .github/workflows/ci.yml | 3 + .github/workflows/stable-branch.yml | 97 +++++++++++++++++++++++++ docs/getting-started/installation.mdx | 8 +- scripts/ci/test-update-stable-branch.sh | 77 ++++++++++++++++++++ scripts/ci/update-stable-branch.sh | 71 ++++++++++++++++++ 5 files changed, 252 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/stable-branch.yml create mode 100755 scripts/ci/test-update-stable-branch.sh create mode 100755 scripts/ci/update-stable-branch.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 955fb976..d098ca6e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -546,6 +546,9 @@ jobs: - name: AUR -git pkgver tests run: ./scripts/aur/test-vcs-pkgver.sh + - name: Stable branch update tests + run: ./scripts/ci/test-update-stable-branch.sh + # --- Dependency policy audit --- # Runs `cargo deny check` (via `just audit`) on every push and non-docs PR: # RustSec advisories PLUS license / bans / sources policy, matching the diff --git a/.github/workflows/stable-branch.yml b/.github/workflows/stable-branch.yml new file mode 100644 index 00000000..bdcc603b --- /dev/null +++ b/.github/workflows/stable-branch.yml @@ -0,0 +1,97 @@ +name: Stable branch + +on: + # The release builder publishes the GitHub Release before its final asset + # upload step completes, so the job below waits for that exact CI job. + release: + types: [published] + # Bootstrap the branch when this workflow first lands. Later edits are safe: + # the updater is idempotent and still gates on the released commit's CI. + push: + branches: [main] + paths: + - .github/workflows/stable-branch.yml + - scripts/ci/update-stable-branch.sh + workflow_dispatch: + +concurrency: + group: stable-branch + cancel-in-progress: false + +permissions: + contents: read + actions: read + +jobs: + update: + name: Point stable at the latest GA release + runs-on: ubuntu-latest + timeout-minutes: 65 + permissions: + contents: write + actions: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: main + fetch-depth: 0 + + - name: Resolve the authoritative stable release + id: release + env: + GH_TOKEN: ${{ github.token }} + EVENT_TAG: ${{ github.event.release.tag_name }} + run: | + set -euo pipefail + latest_json="$(gh api "repos/$GITHUB_REPOSITORY/releases/latest")" + latest_tag="$(jq -r '.tag_name' <<<"$latest_json")" + draft="$(jq -r '.draft' <<<"$latest_json")" + prerelease="$(jq -r '.prerelease' <<<"$latest_json")" + + if [[ ! "$latest_tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "latest GitHub release is not a GA semver tag: $latest_tag" >&2 + exit 1 + fi + if [ "$draft" != "false" ] || [ "$prerelease" != "false" ]; then + echo "latest GitHub release is not a published GA release" >&2 + exit 1 + fi + + tag="${EVENT_TAG:-$latest_tag}" + if [ "$tag" != "$latest_tag" ]; then + echo "$tag is not the latest stable release ($latest_tag); skipping" + echo "update=false" >>"$GITHUB_OUTPUT" + exit 0 + fi + + git fetch --force origin "refs/tags/$tag:refs/tags/$tag" + sha="$(git rev-parse --verify "refs/tags/$tag^{commit}")" + echo "tag=$tag" >>"$GITHUB_OUTPUT" + echo "latest_tag=$latest_tag" >>"$GITHUB_OUTPUT" + echo "sha=$sha" >>"$GITHUB_OUTPUT" + echo "update=true" >>"$GITHUB_OUTPUT" + + - name: Require the release build and asset upload to be green + if: steps.release.outputs.update == 'true' + env: + GH_TOKEN: ${{ github.token }} + CI_GREEN_MAX_ATTEMPTS: "120" + run: | + ./scripts/require-ci-green.sh \ + "${{ steps.release.outputs.sha }}" \ + "release / Upload release assets" + + - name: Update stable with compare-and-swap protection + if: steps.release.outputs.update == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + # The release gate can wait for up to an hour. Re-read `latest` at the + # mutation boundary so an older queued event cannot briefly rewind + # the branch after a newer GA was published. + latest_tag="$(gh api \ + "repos/$GITHUB_REPOSITORY/releases/latest" \ + --jq '.tag_name')" + ./scripts/ci/update-stable-branch.sh \ + "${{ steps.release.outputs.tag }}" \ + "$latest_tag" diff --git a/docs/getting-started/installation.mdx b/docs/getting-started/installation.mdx index f3da0004..c42a7c6e 100644 --- a/docs/getting-started/installation.mdx +++ b/docs/getting-started/installation.mdx @@ -155,21 +155,21 @@ kache is also published to Homebrew, APT, winget, Scoop, Chocolatey, and the AUR ## Nix -The repo is a flake. Unlike the methods above it builds kache from source, with the Rust toolchain pinned in `rust-toolchain.toml` rather than whatever rustc your nixpkgs happens to ship. +The repo is a flake. Unlike the methods above it builds kache from source, with the Rust toolchain pinned in `rust-toolchain.toml` rather than whatever rustc your nixpkgs happens to ship. Use the `stable` branch: it advances only after a non-prerelease GitHub release has passed the gated release build. The default `main` branch contains unreleased development work. ```sh # Run it once without installing -nix run github:kunobi-ninja/kache -- --version +nix run github:kunobi-ninja/kache/stable -- --version # Install into your profile -nix profile install github:kunobi-ninja/kache +nix profile install github:kunobi-ninja/kache/stable ``` To consume it from your own flake, add the input and apply the overlay. The overlay provides `pkgs.kache` and `pkgs.kache-rust-toolchain`: ```nix { - inputs.kache.url = "github:kunobi-ninja/kache"; + inputs.kache.url = "github:kunobi-ninja/kache/stable"; outputs = { nixpkgs, kache, ... }: { # ... diff --git a/scripts/ci/test-update-stable-branch.sh b/scripts/ci/test-update-stable-branch.sh new file mode 100755 index 00000000..3e8150c2 --- /dev/null +++ b/scripts/ci/test-update-stable-branch.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +updater="$root/scripts/ci/update-stable-branch.sh" +tmp="$(mktemp -d "${TMPDIR:-/tmp}/kache-stable-branch-test.XXXXXX")" +trap 'rm -rf "$tmp"' EXIT + +remote="$tmp/remote.git" +repo="$tmp/repo" +git init --bare --quiet "$remote" +git init --quiet --initial-branch=main "$repo" +git -C "$repo" config user.name "kache stable branch test" +git -C "$repo" config user.email "stable-branch-test@example.invalid" +git -C "$repo" config commit.gpgSign false +git -C "$repo" config tag.gpgSign false +git -C "$repo" remote add origin "$remote" + +printf '1.0.0\n' >"$repo/version" +git -C "$repo" add version +git -C "$repo" commit --quiet -m "release 1.0.0" +git -C "$repo" tag v1.0.0 +v1="$(git -C "$repo" rev-parse HEAD)" + +printf '1.1.0-rc.1\n' >"$repo/version" +git -C "$repo" commit --quiet -am "release 1.1.0-rc.1" +git -C "$repo" tag v1.1.0-rc.1 + +printf '1.1.0\n' >"$repo/version" +git -C "$repo" commit --quiet -am "release 1.1.0" +git -C "$repo" tag v1.1.0 +v2="$(git -C "$repo" rev-parse HEAD)" +git -C "$repo" push --quiet origin main --tags + +stable_sha() { + git ls-remote --refs "$remote" refs/heads/stable | awk 'NR == 1 { print $1 }' +} + +run_updater() { + (cd "$repo" && STABLE_BRANCH_REMOTE="$remote" bash "$updater" "$@") +} + +# Prereleases are rejected even if a caller incorrectly labels one "latest". +if run_updater v1.1.0-rc.1 v1.1.0-rc.1 >/dev/null 2>&1; then + echo "prerelease unexpectedly updated stable" >&2 + exit 1 +fi +[ -z "$(stable_sha)" ] + +# The first stable release creates the branch. +run_updater v1.0.0 v1.0.0 >/dev/null +[ "$(stable_sha)" = "$v1" ] + +# A delayed older event is a successful no-op, never a rewind. +run_updater v1.0.0 v1.1.0 >/dev/null +[ "$(stable_sha)" = "$v1" ] + +# A newer GA release advances the branch; repeating it is idempotent. +run_updater v1.1.0 v1.1.0 >/dev/null +[ "$(stable_sha)" = "$v2" ] +run_updater v1.1.0 v1.1.0 >/dev/null +[ "$(stable_sha)" = "$v2" ] + +# A divergent manual branch move is not overwritten, even when the requested +# tag is still GitHub's latest release. +git -C "$repo" switch --quiet --detach "$v1" +printf 'diverged\n' >"$repo/version" +git -C "$repo" commit --quiet -am "divergent stable branch" +diverged="$(git -C "$repo" rev-parse HEAD)" +git -C "$repo" push --quiet --force origin "$diverged:refs/heads/stable" +if run_updater v1.1.0 v1.1.0 >/dev/null 2>&1; then + echo "divergent stable branch was unexpectedly overwritten" >&2 + exit 1 +fi +[ "$(stable_sha)" = "$diverged" ] + +echo "stable branch update tests passed" diff --git a/scripts/ci/update-stable-branch.sh b/scripts/ci/update-stable-branch.sh new file mode 100755 index 00000000..a4e0a454 --- /dev/null +++ b/scripts/ci/update-stable-branch.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Move the Nix-facing `stable` branch to the latest stable release tag. +# +# The caller is responsible for resolving the authoritative latest GitHub +# release and for checking its release CI. Keeping the ref update here makes +# the destructive part small, testable, and race-safe. +# +# Usage: update-stable-branch.sh +# Env: STABLE_BRANCH_REMOTE (default: origin; overridden by the local test) +set -euo pipefail + +tag="${1:-}" +latest_tag="${2:-}" +[ -n "$tag" ] && [ -n "$latest_tag" ] || { + echo "usage: update-stable-branch.sh " >&2 + exit 2 +} + +stable_tag_pattern='^v[0-9]+\.[0-9]+\.[0-9]+$' +if [[ ! "$tag" =~ $stable_tag_pattern ]]; then + echo "refusing non-stable release tag: $tag" >&2 + exit 1 +fi +if [[ ! "$latest_tag" =~ $stable_tag_pattern ]]; then + echo "refusing invalid latest stable tag: $latest_tag" >&2 + exit 1 +fi + +# A delayed/re-run release event must never rewind the branch. +if [ "$tag" != "$latest_tag" ]; then + echo "$tag is not the latest stable release ($latest_tag); leaving stable unchanged" + exit 0 +fi + +remote="${STABLE_BRANCH_REMOTE:-origin}" +target="$(git rev-parse --verify "refs/tags/$tag^{commit}")" +current="$(git ls-remote --refs "$remote" refs/heads/stable | awk 'NR == 1 { print $1 }')" + +if [ "$current" = "$target" ]; then + echo "stable already points to $tag ($target)" + exit 0 +fi + +if [ -z "$current" ]; then + # Creation is non-forced, so a concurrent creator wins instead of being + # overwritten. + git push "$remote" "$target:refs/heads/stable" +else + # Releases are cut from main, so every GA update must be a fast-forward. + # Fetch the observed object explicitly: ls-remote tells us its identity but + # does not guarantee that the commit exists in this checkout. + git fetch --quiet "$remote" refs/heads/stable + if ! git merge-base --is-ancestor "$current" "$target"; then + echo "refusing non-fast-forward stable move: $current -> $target" >&2 + exit 1 + fi + + # Pin the value observed above. A human or another workflow moving the ref + # between read and write makes this fail closed rather than clobbering it. + git push "$remote" \ + "--force-with-lease=refs/heads/stable:$current" \ + "$target:refs/heads/stable" +fi + +published="$(git ls-remote --refs "$remote" refs/heads/stable | awk 'NR == 1 { print $1 }')" +if [ "$published" != "$target" ]; then + echo "stable verification failed: expected $target, found ${published:-}" >&2 + exit 1 +fi + +echo "stable now points to $tag ($target)"