Skip to content
Merged
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
180 changes: 180 additions & 0 deletions .github/workflows/promote-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
name: Promote pre-release to stable

# Promote a soaked pre-release (a vX.Y.Z-beta.N image published by release.yml)
# to the stable channel. The Docker image is copied by DIGEST — the exact bytes
# that soaked become :vX.Y.Z + :latest, never a fresh rebuild. The standalone
# release binaries are rebuilt from the same commit so their filenames and
# embedded version read as the stable version.
#
# Trigger manually once the beta has soaked. There is no automatic promotion.

on:
workflow_dispatch:
inputs:
beta_tag:
description: 'Pre-release tag to promote (e.g. v1.5.0-beta.3)'
required: true
type: string

permissions:
contents: read

concurrency:
group: rustnzb-release-promote
cancel-in-progress: false

jobs:
resolve:
runs-on: [self-hosted]
outputs:
beta_tag: ${{ steps.v.outputs.beta_tag }}
stable_tag: ${{ steps.v.outputs.stable_tag }}
sha: ${{ steps.v.outputs.sha }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Validate the pre-release tag and resolve the stable version
id: v
env:
# Passed through the environment, never interpolated into the script,
# so the dispatch input cannot inject shell.
BETA_INPUT: ${{ inputs.beta_tag }}
run: |
beta="$BETA_INPUT"
case "$beta" in
v[0-9]*-alpha*|v[0-9]*-beta*|v[0-9]*-rc*) ;;
*) echo "not a pre-release tag: $beta" >&2; exit 1 ;;
esac
git fetch --no-tags origin main
git fetch origin "refs/tags/$beta:refs/tags/$beta"
sha=$(git rev-parse "refs/tags/$beta^{commit}")
git merge-base --is-ancestor "$sha" origin/main \
|| { echo "$beta is not reachable from main" >&2; exit 1; }
# Strip the first -suffix: v1.5.0-beta.3 -> v1.5.0
stable="${beta%%-*}"
case "$stable" in v[0-9]*) ;; *) echo "bad stable version: $stable" >&2; exit 1 ;; esac
{
echo "beta_tag=$beta"
echo "stable_tag=$stable"
echo "sha=$sha"
} >> "$GITHUB_OUTPUT"
printf 'promoting %s -> %s (%s)\n' "$beta" "$stable" "$sha"

promote-image:
needs: resolve
permissions:
contents: read
packages: write
runs-on: [self-hosted, docker, publish]
env:
BETA: ${{ needs.resolve.outputs.beta_tag }}
STABLE: ${{ needs.resolve.outputs.stable_tag }}
SKOPEO_IMAGE: quay.io/skopeo/stable:v1.19@sha256:77e6c8901f6976bdfa87b8b14c40b96de754794c492d8ed295f77fba1454a4ab
steps:
- name: Digest-copy the soaked beta image to :stable and :latest
env:
GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
FORGEJO_TOKEN: ${{ secrets.FORGEJO_CARGO_TOKEN }}
OLD_GHCR_TOKEN: ${{ secrets.OLD_GHCR_TOKEN }}
run: |
docker run --rm --entrypoint sh \
-e GH_TOKEN -e FORGEJO_TOKEN -e OLD_GHCR_TOKEN -e GITHUB_ACTOR -e BETA -e STABLE \
"$SKOPEO_IMAGE" -ec '
ghcr=ghcr.io/thedancingdeveloper-org/rustnzbd
forgejo=repo.indexarr.net/indexarr/rustnzbd
old=ghcr.io/ausagentsmith-org/rustnzb
gh="$GITHUB_ACTOR:$GH_TOKEN"
fj="$FORGEJO_TOKEN:$FORGEJO_TOKEN"
og="AusAgentSmith:$OLD_GHCR_TOKEN"
# The exact soaked digest is the single source for every copy.
digest=$(skopeo inspect --creds "$gh" --format "{{.Digest}}" "docker://$ghcr:$BETA")
test -n "$digest"
src="docker://$ghcr@$digest"
for target in \
"$ghcr:$STABLE|$gh" "$ghcr:latest|$gh" \
"$forgejo:$STABLE|$fj" "$forgejo:latest|$fj" \
"$old:$STABLE|$og" "$old:latest|$og"; do
ref="${target%%|*}"; creds="${target#*|}"
skopeo copy --multi-arch all --src-creds "$gh" --dest-creds "$creds" \
"$src" "docker://$ref"
got=$(skopeo inspect --creds "$creds" --format "{{.Digest}}" "docker://$ref")
test "$got" = "$digest"
printf "copied %s (%s)\n" "$ref" "$got"
done
printf "promoted soaked digest %s to %s + latest\n" "$digest" "$STABLE"
'

publish-stable-release:
needs: resolve
permissions:
contents: write
runs-on: [self-hosted, docker, publish]
env:
STABLE: ${{ needs.resolve.outputs.stable_tag }}
SHA: ${{ needs.resolve.outputs.sha }}
CROSS_IMAGE: repo.indexarr.net/indexarr/rustnzb-ci-cross@sha256:89f1f570acb0f8e6514ffcca39bd9f26305263d95274e4c170eedf867d113ad9
steps:
- uses: actions/checkout@v7
with:
ref: ${{ needs.resolve.outputs.sha }}
fetch-depth: 0
- name: Build stable release assets from the soaked commit
env:
GIT_AUTH_TOKEN: ${{ secrets.FORGEJO_CARGO_TOKEN }}
run: |
printf '%s' "$GIT_AUTH_TOKEN" | docker login repo.indexarr.net --username x-access-token --password-stdin >/dev/null
container="rustnzb-promote-assets-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
cleanup() { docker rm -f "$container" >/dev/null 2>&1 || true; }
trap cleanup EXIT
docker create --name "$container" -w /workspace \
-e GIT_AUTH_TOKEN -e CI_COMMIT_SHA="$SHA" \
-e CI_COMMIT_TAG="$STABLE" -e CI_PIPELINE_NUMBER="$GITHUB_RUN_ID" \
"$CROSS_IMAGE" sh -ec '
ci/tasks/frontend-build
ci/tasks/build-linux
ci/tasks/build-linux-arm64
ci/tasks/build-windows
ci/tasks/package-release
' >/dev/null
docker cp . "$container:/workspace"
docker start -a "$container"
mkdir -p .ci-output/packages
docker cp "$container:/workspace/.ci-output/packages/." .ci-output/packages/
(cd .ci-output/packages && sha256sum -c "SHA256SUMS-${STABLE}.txt")
- uses: actions/upload-artifact@v4
with:
name: rustnzb-${{ needs.resolve.outputs.stable_tag }}-release-assets
path: .ci-output/packages/*
if-no-files-found: error
retention-days: 30
- name: Create the stable GitHub release from the promoted commit
env:
# Deliberately the default GITHUB_TOKEN: a tag/release it creates does
# NOT re-trigger release.yml, so the stable image stays the
# digest-copied soaked artifact rather than a fresh rebuild that would
# overwrite :latest.
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
api="https://api.github.com/repos/${GITHUB_REPOSITORY}"
auth=(-H 'Accept: application/vnd.github+json' -H "Authorization: Bearer $GH_TOKEN" -H 'X-GitHub-Api-Version: 2022-11-28')
release_id=$(curl -fsS "${auth[@]}" "$api/releases/tags/$STABLE" | jq -r .id 2>/dev/null || true)
if [ -z "$release_id" ] || [ "$release_id" = null ]; then
payload=$(jq -nc --arg tag "$STABLE" --arg sha "$SHA" \
'{tag_name:$tag,target_commitish:$sha,name:("rustnzb "+$tag),draft:false,prerelease:false,generate_release_notes:true}')
release_id=$(curl -fsS -X POST "${auth[@]}" -H 'Content-Type: application/json' \
--data "$payload" "$api/releases" | jq -r .id)
fi
test -n "$release_id" && test "$release_id" != null
for file in .ci-output/packages/rustnzb-* .ci-output/packages/SHA256SUMS-*; do
name=$(basename "$file")
old_id=$(curl -fsS "${auth[@]}" "$api/releases/$release_id/assets?per_page=100" \
| jq -r --arg name "$name" '.[] | select(.name==$name) | .id' | head -n1)
if [ -n "$old_id" ]; then
curl -fsS -X DELETE "${auth[@]}" "$api/releases/assets/$old_id" >/dev/null
fi
curl -fsS -X POST "${auth[@]}" -H 'Content-Type: application/octet-stream' \
--data-binary "@$file" \
"https://uploads.github.com/repos/${GITHUB_REPOSITORY}/releases/$release_id/assets?name=$name" >/dev/null
done
printf 'published stable release %s\n' "$STABLE"
29 changes: 24 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,23 @@ jobs:
IMAGE: ghcr.io/thedancingdeveloper-org/rustnzbd
steps:
- uses: actions/checkout@v7
# A pre-release tag (v1.2.3-beta.N / -alpha / -rc) publishes the moving
# `:beta` channel and never touches `:latest`, so it can soak before a
# stable promotion (see promote-release.yml). A plain vX.Y.Z tag is the
# stable channel and moves `:latest` as before.
- name: Determine release channel
id: channel
run: |
case "${GITHUB_REF_NAME}" in
*-alpha*|*-beta*|*-rc*) moving=beta ;;
*) moving=latest ;;
esac
echo "moving=$moving" >> "$GITHUB_OUTPUT"
if [ "$moving" = beta ]; then
echo "pre-release channel: moving tag :beta (:latest left untouched)"
else
echo "stable channel: moving tag :latest"
fi
# Authenticate to Docker Hub first -- setup-qemu-action pulls its
# binfmt image from Docker Hub too, and doing that unauthenticated
# hits the shared runner's unauthenticated pull rate limit.
Expand Down Expand Up @@ -66,7 +83,7 @@ jobs:
provenance: false
tags: |
${{ env.IMAGE }}:${{ github.ref_name }}
${{ env.IMAGE }}:latest
${{ env.IMAGE }}:${{ steps.channel.outputs.moving }}
${{ env.IMAGE }}:sha-${{ github.sha }}
build-args: |
RUSTNZB_BUILD_REF=${{ github.ref_name }}
Expand All @@ -93,10 +110,11 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
FORGEJO_TOKEN: ${{ secrets.FORGEJO_CARGO_TOKEN }}
MOVING_TAG: ${{ steps.channel.outputs.moving }}
SKOPEO_IMAGE: quay.io/skopeo/stable:v1.19@sha256:77e6c8901f6976bdfa87b8b14c40b96de754794c492d8ed295f77fba1454a4ab
run: |
docker run --rm --entrypoint sh \
-e GH_TOKEN -e FORGEJO_TOKEN -e GITHUB_ACTOR -e GITHUB_REF_NAME \
-e GH_TOKEN -e FORGEJO_TOKEN -e GITHUB_ACTOR -e GITHUB_REF_NAME -e MOVING_TAG \
"$SKOPEO_IMAGE" -ec '
src="docker://ghcr.io/thedancingdeveloper-org/rustnzbd:$GITHUB_REF_NAME"
dst="docker://repo.indexarr.net/indexarr/rustnzbd:$GITHUB_REF_NAME"
Expand All @@ -106,7 +124,7 @@ jobs:
skopeo copy --multi-arch all \
--src-creds "$GITHUB_ACTOR:$GH_TOKEN" \
--dest-creds "$FORGEJO_TOKEN:$FORGEJO_TOKEN" "$src" \
docker://repo.indexarr.net/indexarr/rustnzbd:latest
"docker://repo.indexarr.net/indexarr/rustnzbd:$MOVING_TAG"
src_digest=$(skopeo inspect --creds "$GITHUB_ACTOR:$GH_TOKEN" --format "{{.Digest}}" "$src")
dst_digest=$(skopeo inspect --creds "$FORGEJO_TOKEN:$FORGEJO_TOKEN" --format "{{.Digest}}" "$dst")
test "$src_digest" = "$dst_digest"
Expand All @@ -116,10 +134,11 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
OLD_GHCR_TOKEN: ${{ secrets.OLD_GHCR_TOKEN }}
MOVING_TAG: ${{ steps.channel.outputs.moving }}
SKOPEO_IMAGE: quay.io/skopeo/stable:v1.19@sha256:77e6c8901f6976bdfa87b8b14c40b96de754794c492d8ed295f77fba1454a4ab
run: |
docker run --rm --entrypoint sh \
-e GH_TOKEN -e OLD_GHCR_TOKEN -e GITHUB_ACTOR -e GITHUB_REF_NAME \
-e GH_TOKEN -e OLD_GHCR_TOKEN -e GITHUB_ACTOR -e GITHUB_REF_NAME -e MOVING_TAG \
"$SKOPEO_IMAGE" -ec '
src="docker://ghcr.io/thedancingdeveloper-org/rustnzbd:$GITHUB_REF_NAME"
dst="docker://ghcr.io/ausagentsmith-org/rustnzb:$GITHUB_REF_NAME"
Expand All @@ -129,7 +148,7 @@ jobs:
skopeo copy --multi-arch all \
--src-creds "$GITHUB_ACTOR:$GH_TOKEN" \
--dest-creds "AusAgentSmith:$OLD_GHCR_TOKEN" "$src" \
docker://ghcr.io/ausagentsmith-org/rustnzb:latest
"docker://ghcr.io/ausagentsmith-org/rustnzb:$MOVING_TAG"
src_digest=$(skopeo inspect --creds "$GITHUB_ACTOR:$GH_TOKEN" --format "{{.Digest}}" "$src")
dst_digest=$(skopeo inspect --creds "AusAgentSmith:$OLD_GHCR_TOKEN" --format "{{.Digest}}" "$dst")
test "$src_digest" = "$dst_digest"
Expand Down
37 changes: 37 additions & 0 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,43 @@
GitHub is the release authority for rustnzb. Release tags use the `vX.Y.Z`
format and must point to a commit reachable from `main`.

## Release channels

The container image is published to GHCR (`…/rustnzbd`) on three tag channels:

- **`:latest`** — the current stable release. Moved only by a stable
`vX.Y.Z` release.
- **`:beta`** — the newest pre-release, for soak testing before a stable
promotion. A pre-release tag **never** moves `:latest`.
- **`:vX.Y.Z`** / **`:vX.Y.Z-beta.N`** — immutable, one per tag.

Pre-release tags use a suffix: `vX.Y.Z-beta.N` (also `-rc.N` / `-alpha.N`).
They are flagged as GitHub pre-releases and publish only `:vX.Y.Z-beta.N` and
`:beta`. Cutting a pre-release tag is how a build enters soak testing.

## Beta soak and promotion

Prefer shipping a stable release only after a pre-release has soaked:

1. Tag a pre-release on a `main` commit, e.g. `v1.5.0-beta.1`, and push it.
The release workflow publishes `:v1.5.0-beta.1` + `:beta` and a GitHub
pre-release. `:latest` is untouched.
2. Let it soak. Bump the suffix (`-beta.2`, …) for fixes during soak.
3. When satisfied, run the **Promote pre-release to stable** workflow
(`workflow_dispatch`) with the soaked tag. It copies the **exact image
digest** that soaked to `:v1.5.0` + `:latest`, then rebuilds the standalone
release binaries from the same commit and publishes the stable GitHub
release.

Because promotion copies the soaked image by digest, `:v1.5.0` is byte-identical
to the beta that was tested; only the tag changes. The image's own embedded
build ref therefore still reads the pre-release version it was built as — that
is the promoted lineage, not a rebuild. The standalone binaries are rebuilt and
carry the stable version.

A stable `vX.Y.Z` tag pushed directly (a hotfix without soak) still works and
takes the rebuild path below.

## Release checklist

1. Update the workspace version in `Cargo.toml` and any user-visible version
Expand Down
Loading