Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
97 changes: 97 additions & 0 deletions .github/workflows/stable-branch.yml
Original file line number Diff line number Diff line change
@@ -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"
8 changes: 4 additions & 4 deletions docs/getting-started/installation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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, ... }: {
# ...
Expand Down
77 changes: 77 additions & 0 deletions scripts/ci/test-update-stable-branch.sh
Original file line number Diff line number Diff line change
@@ -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"
71 changes: 71 additions & 0 deletions scripts/ci/update-stable-branch.sh
Original file line number Diff line number Diff line change
@@ -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 <release-tag> <latest-stable-tag>
# 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 <release-tag> <latest-stable-tag>" >&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:-<missing>}" >&2
exit 1
fi

echo "stable now points to $tag ($target)"