From 7adcb529539ec441a0f28a6fdb8b66a14cbee8a9 Mon Sep 17 00:00:00 2001 From: Marko Petzold Date: Sat, 24 Oct 2026 22:40:21 +0200 Subject: [PATCH] pr.sh: pull requests from a laptop, the same in every repository 'just merge' and 'just release-patch|minor|major' fetch scripts/pr.sh at v1 and run it, so the flow is maintained once: clean tree, branch must contain origin/main, the next version from main's version (no double bump), a release commit holding only the version files, auto-merge, and waiting for the release run. A repository keeps only its names and a _write-version recipe (templates/repo/Justfile.release). release-image.yml replaces {tag} in build-args with the release tag: after a merge github.ref_name is 'main', not the tag. The dry-run fixture fails its build when the placeholder is not replaced. --- .github/workflows/release-image.yml | 7 +- .github/workflows/selftest.yml | 4 + README.md | 29 ++++ scripts/pr.sh | 201 +++++++++++++++++++++++++++ scripts/test_pr.sh | 208 ++++++++++++++++++++++++++++ templates/repo/Justfile.release | 39 ++++++ tests/fixtures/release/Dockerfile | 5 +- 7 files changed, 490 insertions(+), 3 deletions(-) create mode 100755 scripts/pr.sh create mode 100755 scripts/test_pr.sh create mode 100644 templates/repo/Justfile.release diff --git a/.github/workflows/release-image.yml b/.github/workflows/release-image.yml index e382526..476d6e5 100644 --- a/.github/workflows/release-image.yml +++ b/.github/workflows/release-image.yml @@ -60,7 +60,10 @@ on: type: string default: "." build-args: - description: Newline-separated KEY=VALUE build arguments. + description: >- + Newline-separated KEY=VALUE build arguments; {tag} in a value becomes + the release tag (VERSION={tag} -> VERSION=v1.2.3; a dry run gets the + tag it would release). type: string default: "" platforms: @@ -268,7 +271,7 @@ jobs: --label "org.opencontainers.image.version=$VERSION" --metadata-file "$RUNNER_TEMP/metadata.json") while IFS= read -r arg; do - [ -n "$arg" ] && args+=(--build-arg "$arg") + [ -n "$arg" ] && args+=(--build-arg "${arg//\{tag\}/$VERSION}") done <<<"$BUILD_ARGS" if [ "$PUSH" = true ]; then echo "$TOKEN" | docker login -u oauth2accesstoken --password-stdin "https://$REGISTRY" diff --git a/.github/workflows/selftest.yml b/.github/workflows/selftest.yml index ca72cfc..e01e8ea 100644 --- a/.github/workflows/selftest.yml +++ b/.github/workflows/selftest.yml @@ -65,6 +65,8 @@ jobs: run: python3 -I scripts/test_sarif_summary.py - name: release.sh unit tests run: bash scripts/test_release.sh + - name: pr.sh unit tests + run: bash scripts/test_pr.sh # Public repository: no internal document names or plan IDs. - name: public-repo hygiene run: | @@ -258,6 +260,7 @@ jobs: version-file: tests/fixtures/release/version.txt dockerfile: tests/fixtures/release/Dockerfile context: tests/fixtures/release + build-args: VERSION={tag} workload-identity-provider: dry-run-unused service-account: dry-run-unused artifact-prefix: version-file- @@ -274,6 +277,7 @@ jobs: version-file: tests/fixtures/node-pass-npm/package.json dockerfile: tests/fixtures/release/Dockerfile context: tests/fixtures/release + build-args: VERSION={tag} workload-identity-provider: dry-run-unused service-account: dry-run-unused artifact-prefix: package-json- diff --git a/README.md b/README.md index 006dd81..4f545ba 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,35 @@ deployments from `main` only, variable `RELEASE_APP_CLIENT_ID`, secret repository. Callers grant `contents: read` only and pass `secrets: inherit`: environment secrets reach a reusable workflow only that way. +## Pull requests from a laptop + +`just merge` and `just release-patch|minor|major` behave the same in every +repository: the Justfile fetches [scripts/pr.sh](scripts/pr.sh) at `v1` (with +`gh`) and runs it. A repository only names its release, its version file and +the files a bump changes, and writes the version in `_write-version`; copy +[templates/repo/Justfile.release](templates/repo/Justfile.release). + +- `just merge [TITLE]`: pushes the current feature branch (also when origin + does not have it yet), opens the pull request and turns on auto-merge. It + refuses a changed version and a title starting with `release `. +- `just release-patch`: the next version is computed from the version on + `origin/main`, so a second run on the same branch does not bump twice. The + release commit `release v` holds only the listed files and sits on + top of the branch's work; on an up-to-date `main` it goes on a new branch + `release/v`. It then waits for the merge and for the release run, and + ends back on `main` (detached on `origin/main` in a worktree). + +Onboarding a repository onto this flow, besides the caller files: + +- `main` ruleset: squash merges only; repository setting "default commit + message" = pull request title (so the merge commit carries the checked + title). +- Environment `release`: deployment branches `main` only; variable + `RELEASE_APP_CLIENT_ID`, secret `RELEASE_APP_PRIVATE_KEY`. +- The release GitHub App installed on the repository and listed as bypass + actor of the `release tags v*` ruleset. +- The publisher binding of the identity provider for the repository. + ## Exceptions Each exception needs a reason and an expiry, and is reviewed in the pull diff --git a/scripts/pr.sh b/scripts/pr.sh new file mode 100755 index 0000000..8ae229e --- /dev/null +++ b/scripts/pr.sh @@ -0,0 +1,201 @@ +#!/usr/bin/env bash +# Pull requests into main from a laptop: the logic behind `just merge` and +# `just release-patch|minor|major` in every repository. Run from the repository +# root; needs git, gh (logged in) and jq. The repository's Justfile fetches this +# script at IronFlock/workflows@v1 and runs it, so the flow is the same +# everywhere (see "Pull requests from a laptop" in the README). +# +# pr.sh merge [] +# a no-release pull request from the current feature branch (pushed even +# if origin does not have it yet), titled <title> (default: the subject +# of the branch's first commit), merged automatically (squash) once the +# required checks pass. The version must equal main's, and the title must +# not start with "release ": such a merge releases nothing. +# +# pr.sh release <patch|minor|major> <name> <version-file> <workflow> <file>... +# a release pull request. The next version is computed from the version +# on origin/main (so a second run on the same branch does not bump twice) +# and written by `just _write-version <version>`. Only the listed files +# are committed, as `release <name> v<version>`, on top of the work of the +# current feature branch, or on a new branch release/v<version> when run +# on an up-to-date main. The pull request merges automatically once the +# required checks pass; its merge starts <workflow> (the image and the +# tag), and this waits for that run. +# +# Env: PR_WAIT_SECONDS (default 20) between polls. +set -euo pipefail + +wait_s=${PR_WAIT_SECONDS:-20} + +die() { echo "$*" >&2; exit 1; } + +# version <file> [<rev>]: a package.json's `version`, otherwise the content +# without whitespace (same rule as release.sh). +version() { + local content + if [ -n "${2:-}" ]; then + content=$(git show "$2:$1" 2>/dev/null) || return 0 + else + content=$(cat "$1") + fi + if [ "$(basename "$1")" = package.json ]; then + jq -r '.version // empty' <<<"$content" + else + tr -d '[:space:]' <<<"$content" + fi +} + +next_version() { # next_version <level> <x.y.z> + local major minor patch + IFS=. read -r major minor patch <<<"$2" + case "$1" in + patch) echo "$major.$minor.$((patch + 1))" ;; + minor) echo "$major.$((minor + 1)).0" ;; + major) echo "$((major + 1)).0.0" ;; + *) die "level must be patch, minor or major, got '$1'" ;; + esac +} + +require_clean() { + local dirty + dirty=$(git status --porcelain) + [ -z "$dirty" ] || die "uncommitted changes: commit them first, with a message describing the work +$dirty" +} + +# The title is checked like release-intent.yml does: a title starting with +# "release " (any case) belongs to a release pull request. +release_title() { + [[ "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" == "release "* ]] +} + +open_pr() { # open_pr <branch>: number of the branch's open pull request, if any + gh pr list --head "$1" --state open --json number --jq '.[0].number // empty' +} + +await_merge() { # await_merge <branch>: waits up to 40 minutes + local state="" + echo "==> waiting for the required checks and the merge of $1" + for _ in $(seq 1 120); do + state=$(gh pr view "$1" --json state --jq .state) + case "$state" in + MERGED) return 0 ;; + CLOSED) die "the pull request of $1 was closed without merging" ;; + esac + sleep "$wait_s" + done + die "the pull request of $1 is not merged after 40 minutes: gh pr checks $1" +} + +# Back on main after the merge; in a worktree main is checked out elsewhere, +# so this continues detached on origin/main. +leave_branch() { + git fetch --quiet origin main + if git switch --quiet main 2>/dev/null; then + git pull --quiet --ff-only + else + git switch --quiet --detach origin/main + fi + git branch -D "$1" >/dev/null 2>&1 || true +} + +cmd_merge() { + [ $# -ge 1 ] || die "usage: pr.sh merge <version-file> [<title>]" + local vf=$1 title=${2:-} branch here there + branch=$(git rev-parse --abbrev-ref HEAD) + if [ "$branch" = main ] || [ "$branch" = HEAD ]; then + die "merge: run on a feature branch" + fi + require_clean + git fetch --quiet origin main + git merge-base --is-ancestor origin/main HEAD || + die "merge: $branch does not contain origin/main (git rebase origin/main)" + here=$(version "$vf") + there=$(version "$vf" origin/main) + [ "$here" = "$there" ] || + die "merge: $branch changes the version ($there -> $here): that is a release (just release-patch)" + [ -n "$title" ] || title=$(git log --reverse --format=%s origin/main..HEAD | head -n1) + [ -n "$title" ] || die "merge: no commits on $branch beyond main" + if release_title "$title"; then + die "merge: '$title' starts with 'release ' (the title of release pull requests); pass another title" + fi + git push --quiet -u origin "$branch" + if [ -z "$(open_pr "$branch")" ]; then + gh pr create --base main --head "$branch" --title "$title" \ + --body "Opened by \`just merge\`. Merged automatically once the required checks pass; the merge releases nothing." + fi + gh pr merge "$branch" --squash --auto --delete-branch + echo "==> $title: merges automatically once the required checks pass" +} + +cmd_release() { + [ $# -ge 5 ] || die "usage: pr.sh release <patch|minor|major> <name> <version-file> <workflow> <file>..." + local level=$1 name=$2 vf=$3 workflow=$4 + shift 4 + local base next tag title branch pr sha run + require_clean + git fetch --quiet origin main + branch=$(git rev-parse --abbrev-ref HEAD) + if [ "$branch" = main ]; then + [ "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" ] || + die "release: main is not up to date (git pull --ff-only)" + elif [ "$branch" = HEAD ] || ! git merge-base --is-ancestor origin/main HEAD; then + die "release: run on main or on a branch that contains origin/main (git rebase origin/main)" + fi + + base=$(version "$vf" origin/main) + [ -n "$base" ] || die "release: $vf has no version on origin/main" + next=$(next_version "$level" "$base") + tag="v$next" + title="release $name $tag" + if [ "$(version "$vf")" != "$next" ]; then + just _write-version "$next" + [ "$(version "$vf")" = "$next" ] || die "release: just _write-version $next left $vf at $(version "$vf")" + fi + echo "==> $title (main has $base)" + + if [ "$branch" = main ]; then + branch="release/$tag" + git switch --quiet -c "$branch" + fi + pr=$(open_pr "$branch") + # The title before the push: the checks that the push starts read it. + if [ -n "$pr" ]; then + gh pr edit "$pr" --title "$title" >/dev/null + fi + git add -- "$@" + if ! git diff --cached --quiet; then + git commit --quiet -m "$title" + fi + [ -z "$(git status --porcelain)" ] || + die "release: _write-version changed files outside the list ($*): $(git status --porcelain | tr '\n' ' ')" + git push --quiet -u origin "$branch" + if [ -z "$pr" ]; then + gh pr create --base main --head "$branch" --title "$title" \ + --body "Opened by \`just release-$level\`. Merged automatically once the required checks pass; the merge publishes the release and sets the tag $tag." + fi + gh pr merge "$branch" --squash --auto --delete-branch + await_merge "$branch" + sha=$(gh pr view "$branch" --json mergeCommit --jq '.mergeCommit.oid') + leave_branch "$branch" + + echo "==> waiting for the release run of $tag ($workflow on $sha)" + run="" + for _ in $(seq 1 30); do + run=$(gh run list --workflow "$workflow" --commit "$sha" --json databaseId,event \ + --jq 'map(select(.event == "push")) | .[0].databaseId // empty') + [ -n "$run" ] && break + sleep 5 + done + [ -n "$run" ] || die "no run of $workflow started for $sha: check GitHub Actions" + gh run watch "$run" --exit-status --interval "$wait_s" >/dev/null || + die "the release run $run of $tag failed: gh run view $run --log-failed (a re-run is safe)" + git fetch --quiet origin tag "$tag" + echo "==> $tag published and tagged by CI (run $run)" +} + +case "${1:-}" in + merge) shift; cmd_merge "$@" ;; + release) shift; cmd_release "$@" ;; + *) die "usage: pr.sh merge|release ..." ;; +esac diff --git a/scripts/test_pr.sh b/scripts/test_pr.sh new file mode 100755 index 0000000..705ff88 --- /dev/null +++ b/scripts/test_pr.sh @@ -0,0 +1,208 @@ +#!/usr/bin/env bash +# Tests for pr.sh against a local bare remote, with stubs for gh and just +# (they record their calls; nothing reaches GitHub). Run: bash scripts/test_pr.sh +set -euo pipefail + +script="$(cd "$(dirname "$0")" && pwd)/pr.sh" +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +fail=0 + +export GIT_AUTHOR_NAME=test GIT_AUTHOR_EMAIL=test@example.invalid +export GIT_COMMITTER_NAME=test GIT_COMMITTER_EMAIL=test@example.invalid +export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 +export PR_WAIT_SECONDS=0 + +# gh: `pr list` prints the number in $STUB/pr (if any), `pr create` writes it, +# `pr view` reports MERGED and the merge commit $STUB/sha, `run list` run 42, +# `run watch` exits with $STUB/watch (default 0). Every call goes to $STUB/log. +mkdir -p "$work/bin" +cat >"$work/bin/gh" <<'EOF' +#!/usr/bin/env bash +echo "gh $*" >>"$STUB/log" +case "$1 $2" in + "pr list") cat "$STUB/pr" 2>/dev/null || true ;; + "pr create") echo 7 >"$STUB/pr" ;; + "pr view") + case "$*" in + *mergeCommit*) cat "$STUB/sha" ;; + *) echo MERGED ;; + esac ;; + "run list") echo 42 ;; + "run watch") exit "$(cat "$STUB/watch" 2>/dev/null || echo 0)" ;; +esac +EOF +# just: `_write-version <v>` writes the version file named in $STUB/vf (a +# package.json through jq) and, if $STUB/extra exists, touches that file too. +cat >"$work/bin/just" <<'EOF' +#!/usr/bin/env bash +echo "just $*" >>"$STUB/log" +[ "$1" = _write-version ] || exit 1 +vf=$(cat "$STUB/vf") +if [ "$(basename "$vf")" = package.json ]; then + jq --arg v "$2" '.version = $v' "$vf" >"$vf.tmp" && mv "$vf.tmp" "$vf" +else + echo "$2" >"$vf" +fi +if [ -f "$STUB/extra" ]; then echo x >>"$(cat "$STUB/extra")"; fi +EOF +chmod +x "$work/bin/gh" "$work/bin/just" +export PATH="$work/bin:$PATH" + +n=0 +# setup <version-file> <version>: a fresh remote with main at <version> and a +# clone in $repo with a feature branch `feat` holding one work commit. +setup() { + n=$((n + 1)) + export STUB="$work/stub-$n" + mkdir -p "$STUB" + : >"$STUB/log" + echo "$1" >"$STUB/vf" + origin="$work/origin-$n.git" + repo="$work/repo-$n" + git init -q --bare -b main "$origin" + git init -q -b main "$repo" + if [ "$(basename "$1")" = package.json ]; then + mkdir -p "$repo/$(dirname "$1")" + printf '{\n "name": "x",\n "version": "%s"\n}\n' "$2" >"$repo/$1" + else + mkdir -p "$repo/$(dirname "$1")" + echo "$2" >"$repo/$1" + fi + echo a >"$repo/work.txt" + git -C "$repo" add -A + git -C "$repo" commit -q -m "initial" + git -C "$repo" remote add origin "$origin" + git -C "$repo" push -q -u origin main + git -C "$repo" switch -q -c feat + echo b >>"$repo/work.txt" + git -C "$repo" commit -q -am "fix the widget" + # The merge commit the stub reports, and the tag CI would have set on it. + git -C "$repo" rev-parse HEAD >"$STUB/sha" +} + +# The tag that a successful release run leaves on origin. +ci_tag() { git -C "$repo" push -q origin "HEAD:refs/tags/$1"; } + +check() { # check <name> <expected exit> <pr.sh args...> + local name=$1 want=$2 got=0 + shift 2 + (cd "$repo" && "$script" "$@") >"$work/out" 2>&1 || got=$? + if [ "$got" = "$want" ]; then + echo "ok $name" + else + echo "FAIL $name: exit $got, expected $want" + sed 's/^/ /' "$work/out" + fail=1 + fi +} + +expect() { # expect <name> <command...>: the command succeeds + local name=$1 + shift + if "$@" >/dev/null 2>&1; then echo "ok $name"; else echo "FAIL $name"; fail=1; fi +} + +# shellcheck disable=SC2329 # called through expect +logged() { grep -qF -- "$1" "$STUB/log"; } +# shellcheck disable=SC2329 # called through expect +not_logged() { ! grep -qF -- "$1" "$STUB/log"; } +subject() { git -C "$repo" log -1 --format=%s "$1"; } +files_of() { git -C "$repo" show --name-only --format= "$1" | tr '\n' ' '; } + +# ---- merge +setup VERSION 1.2.3 +check "merge: from a feature branch" 0 merge VERSION +expect "merge: the branch is on origin" git -C "$origin" rev-parse --verify feat +expect "merge: title = the first commit's subject" logged "gh pr create --base main --head feat --title fix the widget" +expect "merge: squash auto-merge" logged "gh pr merge feat --squash --auto --delete-branch" +: >"$STUB/log" +check "merge: an existing pull request is reused" 0 merge VERSION "docs: another title" +expect "merge: no second pull request" not_logged "gh pr create" + +setup VERSION 1.2.3 +git -C "$repo" switch -q main +check "merge: refused on main" 1 merge VERSION + +setup VERSION 1.2.3 +echo dirty >>"$repo/work.txt" +check "merge: refused with uncommitted changes" 1 merge VERSION + +setup VERSION 1.2.3 +echo 1.2.4 >"$repo/VERSION" +git -C "$repo" commit -q -am "bump by hand" +check "merge: refused when the version changes" 1 merge VERSION + +setup VERSION 1.2.3 +check "merge: refused for a title starting with 'Release '" 1 merge VERSION "Release notes cleanup" +expect "merge: nothing pushed after a refusal" not_logged "gh pr" + +setup VERSION 1.2.3 +git -C "$repo" switch -q main +echo c >>"$repo/work.txt" +git -C "$repo" commit -q -am "on main" +git -C "$repo" push -q origin main +git -C "$repo" switch -q feat +check "merge: refused when the branch lacks origin/main" 1 merge VERSION + +# ---- release +setup VERSION 1.2.3 +ci_tag v1.2.4 +check "release: patch from a feature branch" 0 release patch x VERSION release.yml VERSION +expect "release: commit 'release x v1.2.4' on origin/feat" test "$(subject origin/feat 2>/dev/null || git -C "$origin" log -1 --format=%s feat)" = "release x v1.2.4" +expect "release: the release commit holds only VERSION" test "$(files_of "$(git -C "$origin" rev-parse feat)")" = "VERSION " +expect "release: work commit below the release commit" test "$(git -C "$origin" log -1 --format=%s feat~1)" = "fix the widget" +expect "release: pull request titled like the commit" logged "gh pr create --base main --head feat --title release x v1.2.4" +expect "release: waits for the release run" logged "gh run watch 42 --exit-status" +expect "release: run looked up on the merge commit" logged "gh run list --workflow release.yml --commit $(cat "$STUB/sha")" +expect "release: back on main" test "$(git -C "$repo" rev-parse --abbrev-ref HEAD)" = main +expect "release: tag fetched" git -C "$repo" rev-parse --verify refs/tags/v1.2.4 + +setup VERSION 1.2.3 +ci_tag v1.2.4 +echo 1.2.4 >"$repo/VERSION" +git -C "$repo" commit -q -am "release x v1.2.4" +echo 7 >"$STUB/pr" +check "release: a second run on the same branch does not bump twice" 0 release patch x VERSION release.yml VERSION +expect "release: still v1.2.4" test "$(git -C "$origin" show feat:VERSION)" = 1.2.4 +expect "release: no _write-version when already bumped" not_logged "just _write-version" +expect "release: existing pull request retitled" logged "gh pr edit 7 --title release x v1.2.4" +expect "release: no second pull request" not_logged "gh pr create" + +setup VERSION 1.2.3 +git -C "$repo" switch -q main +git -C "$repo" merge -q --ff-only feat +git -C "$repo" push -q origin main +ci_tag v1.3.0 +check "release: minor from an up-to-date main" 0 release minor x VERSION release.yml VERSION +expect "release: branch release/v1.3.0 on origin" test "$(git -C "$origin" log -1 --format=%s release/v1.3.0)" = "release x v1.3.0" + +setup VERSION 1.2.3 +git -C "$repo" switch -q main +git -C "$repo" commit -q --allow-empty -m "local only" +check "release: refused on a main ahead of origin" 1 release patch x VERSION release.yml VERSION + +setup VERSION 1.2.3 +ci_tag v2.0.0 +check "release: major" 0 release major x VERSION release.yml VERSION +expect "release: 2.0.0" test "$(git -C "$origin" show feat:VERSION)" = 2.0.0 + +setup VERSION 1.2.3 +echo work.txt >"$STUB/extra" +check "release: refused when _write-version touches a file outside the list" 1 release patch x VERSION release.yml VERSION +expect "release: nothing pushed" not_logged "gh pr create" + +setup VERSION 1.2.3 +echo 1 >"$STUB/watch" +check "release: a failed release run fails" 1 release patch x VERSION release.yml VERSION + +setup VERSION 1.2.3 +check "release: unknown level" 1 release huge x VERSION release.yml VERSION + +setup backend/package.json 1.4.22 +ci_tag v1.4.23 +check "release: package.json in a subdirectory" 0 release patch reauth-login backend/package.json release.yml backend/package.json +expect "release: package.json at 1.4.23" test "$(git -C "$origin" show feat:backend/package.json | jq -r .version)" = 1.4.23 + +[ "$fail" = 0 ] && echo "all pr.sh tests passed" +exit "$fail" diff --git a/templates/repo/Justfile.release b/templates/repo/Justfile.release new file mode 100644 index 0000000..5c6a3fd --- /dev/null +++ b/templates/repo/Justfile.release @@ -0,0 +1,39 @@ +# ------------------------------------------------------------------ release -- +# Pull requests into main, from IronFlock/workflows (scripts/pr.sh at v1): +# just merge [TITLE] a no-release pull request from this feature branch +# just release-patch a release pull request (-minor, -major): the bump +# from main's version on top of this branch (or on a +# new branch release/v<x> from an up-to-date main); +# its merge publishes the image and sets the tag in CI +# Both merge automatically once ci and security are green. +RELEASE_NAME := "example" +VERSION_FILE := "VERSION" +# Every file _write-version changes; the release commit holds exactly these. +VERSION_FILES := "VERSION" + +# Writes the version; called by pr.sh with the next version. +_write-version version: + printf '%s\n' "{{version}}" > {{VERSION_FILE}} + +[positional-arguments] +_pr *args: + #!/usr/bin/env bash + set -euo pipefail + script=$(mktemp) + trap 'rm -f "$script"' EXIT + gh api -H 'Accept: application/vnd.github.raw' \ + 'repos/IronFlock/workflows/contents/scripts/pr.sh?ref=v1' > "$script" + bash "$script" "$@" + +[positional-arguments] +merge TITLE="": + just _pr merge {{VERSION_FILE}} "$1" + +release-patch: + just _pr release patch {{RELEASE_NAME}} {{VERSION_FILE}} release.yml {{VERSION_FILES}} + +release-minor: + just _pr release minor {{RELEASE_NAME}} {{VERSION_FILE}} release.yml {{VERSION_FILES}} + +release-major: + just _pr release major {{RELEASE_NAME}} {{VERSION_FILE}} release.yml {{VERSION_FILES}} diff --git a/tests/fixtures/release/Dockerfile b/tests/fixtures/release/Dockerfile index 20dfb1d..979d5e7 100644 --- a/tests/fixtures/release/Dockerfile +++ b/tests/fixtures/release/Dockerfile @@ -1,6 +1,9 @@ # Minimal image for the release workflow's dry run: a RUN step proves the -# build executes natively on each platform's runner. +# build executes natively on each platform's runner, and the VERSION build +# argument proves that {tag} in build-args became the release tag. FROM alpine:3.24 +ARG VERSION +RUN case "$VERSION" in v[0-9]*) ;; *) echo "VERSION='$VERSION': {tag} was not replaced" >&2; exit 1 ;; esac RUN uname -m > /arch USER 65534:65534 CMD ["cat", "/arch"]