From ff34137826080acd7cdd8a70d8b23ed50339ca24 Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Thu, 17 Sep 2026 13:18:26 +0300 Subject: [PATCH 1/6] feat: add release-version.yml Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 2 +- .github/workflows/release-version.yml | 123 ++++++++++++++++++++++++++ tests/lib/fake-gh | 55 ++++++++++++ tests/lib/step.sh | 78 ++++++++++++++++ tests/release-version.test.sh | 97 ++++++++++++++++++++ 5 files changed, 354 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/release-version.yml create mode 100755 tests/lib/fake-gh create mode 100644 tests/lib/step.sh create mode 100755 tests/release-version.test.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a25125..b892ca0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,7 @@ jobs: persist-credentials: false - name: Shellcheck - run: shellcheck scripts/*.sh tests/*.sh + run: shellcheck -x scripts/*.sh tests/*.sh tests/lib/*.sh tests/lib/fake-gh - name: Run script tests run: | diff --git a/.github/workflows/release-version.yml b/.github/workflows/release-version.yml new file mode 100644 index 0000000..366a7a9 --- /dev/null +++ b/.github/workflows/release-version.yml @@ -0,0 +1,123 @@ +# Reusable release version calculation. +# +# Reads VERSION and finds the revision N for this commit: the revision of a +# v+N_pre tag that already points at HEAD (a rerun resumes that +# release), otherwise the highest N over existing v+N and +# v+N_pre tags plus one, or 1 when none exist. +# +# Releases are cut only from the default branch, because the tags +# stage-release.yml creates point at the commit this run built. Callers must +# serialize main runs with a concurrency group, or two runs can compute the +# same N; stage-release.yml then fails the second one. A run for a commit +# older than an already released commit fails. + +name: Release version + +on: + workflow_call: + inputs: + version-file: + description: 'Path to the VERSION file' + required: false + default: 'VERSION' + type: string + runs-on: + description: 'Runner to use' + required: false + default: 'ubuntu-latest' + type: string + outputs: + upstream-version: + description: 'Upstream version from the VERSION file, without a v prefix' + value: ${{ jobs.version.outputs.upstream-version }} + revision: + description: 'Revision number N for this commit' + value: ${{ jobs.version.outputs.revision }} + debian-version: + description: '-' + value: ${{ jobs.version.outputs.debian-version }} + prerelease-tag: + description: 'v+_pre' + value: ${{ jobs.version.outputs.prerelease-tag }} + stable-tag: + description: 'v+' + value: ${{ jobs.version.outputs.stable-tag }} + +permissions: + contents: read + +# Run steps with -eo pipefail rather than the implicit bash -e. +defaults: + run: + shell: bash + +jobs: + version: + runs-on: ${{ inputs.runs-on }} + outputs: + upstream-version: ${{ steps.version.outputs.upstream }} + revision: ${{ steps.version.outputs.revision }} + debian-version: ${{ steps.version.outputs.debian_version }} + prerelease-tag: ${{ steps.version.outputs.prerelease_tag }} + stable-tag: ${{ steps.version.outputs.stable_tag }} + steps: + - name: Require the default branch + env: + REF: ${{ github.ref }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + if [ "$REF" != "refs/heads/$DEFAULT_BRANCH" ]; then + echo "::error::Releases are cut only from $DEFAULT_BRANCH; this run is on $REF" + exit 1 + fi + + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Calculate version and revision + id: version + env: + VERSION_FILE: ${{ inputs.version-file }} + run: | + UPSTREAM=$(tr -d '[:space:]' < "$VERSION_FILE") + UPSTREAM="${UPSTREAM#v}" + # Debian upstream-version characters, without + and _ which the tags use. + if ! [[ $UPSTREAM =~ ^[0-9][0-9A-Za-z.~-]*$ ]]; then + echo "::error::$VERSION_FILE must hold a version such as 1.2.3, got '$UPSTREAM'" + exit 1 + fi + + # A run for a commit older than an existing release (for example a + # re-run of an old main run) would release stale code under a + # higher revision. + NEWER_TAG=$(comm -23 <(git tag --contains HEAD -l "v*+*" | sort) \ + <(git tag --points-at HEAD -l "v*+*" | sort) | head -n1) + if [ -n "$NEWER_TAG" ]; then + echo "::error::A newer commit is already released as $NEWER_TAG; this run is for an older commit" + exit 1 + fi + + RELEASED_TAG=$(git tag --points-at HEAD -l "v${UPSTREAM}+*_pre" | sort -V | tail -n1) + if [[ $RELEASED_TAG =~ \+([0-9]+)_pre$ ]]; then + REVISION="${BASH_REMATCH[1]}" + echo "This commit already has $RELEASED_TAG; resuming that release" + else + MAX_REVISION=0 + while IFS= read -r tag; do + if [[ $tag =~ \+([0-9]+)(_.*)?$ ]] && [ "${BASH_REMATCH[1]}" -gt "$MAX_REVISION" ]; then + MAX_REVISION="${BASH_REMATCH[1]}" + fi + done < <(git tag -l "v${UPSTREAM}+*") + REVISION=$((MAX_REVISION + 1)) + fi + + { + echo "upstream=$UPSTREAM" + echo "revision=$REVISION" + echo "debian_version=${UPSTREAM}-${REVISION}" + echo "prerelease_tag=v${UPSTREAM}+${REVISION}_pre" + echo "stable_tag=v${UPSTREAM}+${REVISION}" + } | tee -a "$GITHUB_OUTPUT" diff --git a/tests/lib/fake-gh b/tests/lib/fake-gh new file mode 100755 index 0000000..14607b1 --- /dev/null +++ b/tests/lib/fake-gh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Fake gh for release tests. Releases are JSON files in $FAKE_GH_STATE named +# after their tag; every call is appended to $FAKE_GH_STATE/calls.log. +set -euo pipefail + +STATE=${FAKE_GH_STATE:?} +echo "$*" >> "$STATE/calls.log" + +release_file() { printf '%s/%s.json' "$STATE" "$1"; } + +[ "$1" = release ] || { echo "fake gh: unsupported: $*" >&2; exit 2; } +shift +cmd=$1 +shift + +case "$cmd" in + list) + jq -s '.' "$STATE"/*.json 2>/dev/null || echo '[]' + ;; + view) + tag=$1 + if [ ! -f "$(release_file "$tag")" ]; then + echo "release not found" >&2 + exit 1 + fi + cat "$(release_file "$tag")" + ;; + delete) + rm -f "$(release_file "$1")" + ;; + create) + tag=$1 + shift + draft=false prerelease=false target='' assets=() + while [ $# -gt 0 ]; do + case "$1" in + --draft) draft=true ;; + --prerelease) prerelease=true ;; + --target) target=$2; shift ;; + --title|--notes-file) shift ;; + *) assets+=("$(basename "$1")") ;; + esac + shift + done + [ "${FAKE_GH_FAIL_CREATE:-}" = "$tag" ] && { echo "upload failed" >&2; exit 1; } + jq -n --arg tag "$tag" --argjson draft "$draft" --argjson pre "$prerelease" \ + --arg target "$target" '$ARGS.positional | {tagName: $tag, isDraft: $draft, + isPrerelease: $pre, targetCommitish: $target, assets: map({name: .})}' \ + --args "${assets[@]}" > "$(release_file "$tag")" + ;; + *) + echo "fake gh: unsupported: release $cmd" >&2 + exit 2 + ;; +esac diff --git a/tests/lib/step.sh b/tests/lib/step.sh new file mode 100644 index 0000000..9d90cf2 --- /dev/null +++ b/tests/lib/step.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Helpers for testing a workflow step's run script outside GitHub Actions. +# Source from a tests/*.test.sh file. + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +FAILURES=0 + +# extract_step -> path of the script file +extract_step() { + local out + out=$(mktemp) + NAME="$3" yq ".jobs[\"$2\"].steps[] | select(.name == strenv(NAME)) | .run" \ + "$ROOT/.github/workflows/$1" > "$out" + if [ ! -s "$out" ] || [ "$(cat "$out")" = null ]; then + echo "no step '$3' in job '$2' of $1" >&2 + exit 1 + fi + echo "$out" +} + +# run_step