From 02de742dbf64de9990087bd9556d71998ca30018 Mon Sep 17 00:00:00 2001 From: pentaxis93 <13393192+pentaxis93@users.noreply.github.com> Date: Fri, 8 May 2026 13:09:32 -0700 Subject: [PATCH 1/6] feat(release): adopt base release ceremony Add release-check and release-cut tooling for base's container-only release surface, with workflow gates, operator documentation, changelog coverage, and shell regression/adoption tests. Refs #10 --- .github/workflows/release-metadata.yml | 44 ++++ .github/workflows/release.yml | 69 +++++ CHANGELOG.md | 5 + README.md | 4 + RELEASING.md | 101 +++++++ scripts/release-check | 313 ++++++++++++++++++++++ scripts/release-cut | 134 ++++++++++ scripts/test-release-check | 347 +++++++++++++++++++++++++ scripts/verify-release-adoption.sh | 103 ++++++++ 9 files changed, 1120 insertions(+) create mode 100644 .github/workflows/release-metadata.yml create mode 100644 .github/workflows/release.yml create mode 100644 RELEASING.md create mode 100755 scripts/release-check create mode 100755 scripts/release-cut create mode 100755 scripts/test-release-check create mode 100755 scripts/verify-release-adoption.sh diff --git a/.github/workflows/release-metadata.yml b/.github/workflows/release-metadata.yml new file mode 100644 index 0000000..d20067d --- /dev/null +++ b/.github/workflows/release-metadata.yml @@ -0,0 +1,44 @@ +name: Release Metadata + +on: + push: + branches: [main] + paths: + - "CHANGELOG.md" + - "RELEASING.md" + - "README.md" + - "Dockerfile" + - "scripts/release-check" + - "scripts/release-cut" + - "scripts/test-release-check" + - "scripts/verify-release-adoption.sh" + - ".github/workflows/release.yml" + - ".github/workflows/release-metadata.yml" + pull_request: + branches: [main] + paths: + - "CHANGELOG.md" + - "RELEASING.md" + - "README.md" + - "Dockerfile" + - "scripts/release-check" + - "scripts/release-cut" + - "scripts/test-release-check" + - "scripts/verify-release-adoption.sh" + - ".github/workflows/release.yml" + - ".github/workflows/release-metadata.yml" + +permissions: + contents: read + +jobs: + metadata: + name: Release metadata + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Check release metadata + run: ./scripts/release-check metadata diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..97bbdc4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,69 @@ +name: Release + +on: + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+" + - "v[0-9]+.[0-9]+.[0-9]+-rc.[0-9]+" + +permissions: + contents: read + +jobs: + publish: + name: Publish GitHub Release + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Require annotated tag + run: | + test "$(git cat-file -t "refs/tags/$GITHUB_REF_NAME")" = tag + + - name: Require tag target on main + run: | + set -euo pipefail + git fetch --force origin refs/heads/main:refs/remotes/origin/main + tag_commit="$(git rev-parse "refs/tags/$GITHUB_REF_NAME^{commit}")" + git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main + + - name: Install container tooling + run: | + sudo apt-get update + sudo apt-get install -y podman + + - name: Build base container image + run: | + podman build \ + --build-arg "BASE_REF=$GITHUB_REF_NAME" \ + --tag "localhost/base:$GITHUB_REF_NAME" \ + . + + - name: Verify release artifacts + run: | + ./scripts/release-check release "$GITHUB_REF_NAME" \ + --container-image "localhost/base:$GITHUB_REF_NAME" + + - name: Extract release notes + run: ./scripts/release-check notes "$GITHUB_REF_NAME" > release-notes.md + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + release_flags=() + if [[ "$GITHUB_REF_NAME" =~ ^v[0-9]+[.][0-9]+[.][0-9]+-rc[.][0-9]+$ ]]; then + release_flags+=(--prerelease) + fi + gh release create "$GITHUB_REF_NAME" \ + --title "base $GITHUB_REF_NAME" \ + --notes-file release-notes.md \ + --verify-tag \ + "${release_flags[@]}" diff --git a/CHANGELOG.md b/CHANGELOG.md index aeaf262..88d5af7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Release ceremony tooling now verifies the base changelog, Dockerfile label + surface, tag-time image identity, and GitHub Release publication path. + ### Fixed - Image builds now expose OCI and Tesserine labels for the base ref, runa ref, diff --git a/README.md b/README.md index 801c67b..482df8a 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,10 @@ image build, and runtime contract can be verified together. The built image exposes `org.tesserine.base.ref`, `org.tesserine.runa.ref`, and `org.tesserine.claude-code.version` labels for deployment inspection. +Release operation is documented in [RELEASING.md](RELEASING.md). The release +tooling verifies the changelog, Dockerfile label surface, release workflows, +and tag-time image identity before publishing GitHub releases. + ## Using with agentd Reference this image in your agent configuration: diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..b8c9c5a --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,101 @@ +# Releasing base + +Audience: the release operator cutting a base repository release or release +candidate. This document assumes access to the repository, GitHub, `gh`, and a +local container runtime compatible with Docker or Podman commands. + +## Release Identity + +base uses one repository tag for the source release and container image. The +tag is `vX.Y.Z` for stable releases and `vX.Y.Z-rc.N` for deployment release +candidates. + +Artifacts built from the tag must report that identity: + +- The container image exposes `org.opencontainers.image.revision=`. +- The container image exposes `org.tesserine.base.ref=`. +- The container image exposes `org.tesserine.runa.ref=`. + +The runa ref must be an immutable tag or full commit SHA. base verifies that +the ref is present and immutable-shaped; ecosystem verification owns proving +that the runa ref matches the release manifest. + +## Pre-Release Gate + +A releasable commit is on `main`, up to date with `origin/main`, and has a +clean working tree. `--allow-dirty` is not part of the release path. + +Before cutting a release: + +```sh +git checkout main +git pull --ff-only +git status --short +./scripts/release-check metadata +``` + +For a final tag-time check against an image built from the release tag: + +```sh +podman build \ + --build-arg BASE_REF="vX.Y.Z" \ + --tag "localhost/base:vX.Y.Z" \ + . +./scripts/release-check release "vX.Y.Z" \ + --container-image "localhost/base:vX.Y.Z" +``` + +Use `BASE_CONTAINER_RUNTIME=docker` when Docker should be used instead of +Podman. + +## Atomic Release Operation + +Stable releases and deployment release candidates use the same repo-owned +operation: + +```sh +./scripts/release-cut "vX.Y.Z" +``` + +For release candidates: + +```sh +./scripts/release-cut "vX.Y.Z-rc.N" +``` + +The command verifies the clean `main` precondition, rolls `CHANGELOG.md` from +`[Unreleased]` into `[X.Y.Z] — YYYY-MM-DD`, commits that release roll, creates +an annotated tag, and pushes `main` plus the tag. Release candidates are +immutable refs for deployment testing. A bad or superseded candidate is +corrected by cutting the next `rc.N`, not by rewriting the existing tag. + +## Post-Release Gate + +The tag push runs `.github/workflows/release.yml`. That workflow verifies the +annotated tag, builds a local container image with `BASE_REF` set to the tag, +verifies image identity, extracts release notes from `CHANGELOG.md`, and +publishes the GitHub Release. Only `vX.Y.Z-rc.N` tags are published as GitHub +prereleases. + +Manual GitHub Release creation, when needed after a workflow failure, uses the +same notes source: + +```sh +./scripts/release-check notes "vX.Y.Z" > /tmp/base-release-notes.md +gh release create "vX.Y.Z" \ + --title "base vX.Y.Z" \ + --notes-file /tmp/base-release-notes.md \ + --verify-tag +``` + +## Failure Modes + +If a published tag points at source that violates release identity checks, the +tag is invalid. If it has no external consumers, delete it locally and +remotely and re-run the release operation. If it has external consumers, leave +the bad tag in the public record and cut the next version. + +If the GitHub Release workflow fails after the tag is valid, repair the +workflow or environment and create the GitHub Release from +`scripts/release-check notes`. Do not edit release notes by hand unless the +changelog section is also corrected in source. diff --git a/scripts/release-check b/scripts/release-check new file mode 100755 index 0000000..1f9bbb2 --- /dev/null +++ b/scripts/release-check @@ -0,0 +1,313 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd -- "$script_dir/.." && pwd)" + +usage() { + cat <<'EOF' +usage: + scripts/release-check metadata + scripts/release-check notes vX.Y.Z[-rc.N] + scripts/release-check release vX.Y.Z[-rc.N] [--container-image IMAGE] +EOF +} + +die() { + printf 'release-check: %s\n' "$*" >&2 + exit 1 +} + +release_from_tag() { + local tag="$1" + [[ "$tag" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)(-rc\.[0-9]+)?$ ]] \ + || die "release version must look like vX.Y.Z or vX.Y.Z-rc.N: $tag" + printf '%s.%s.%s%s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}" "${BASH_REMATCH[4]}" +} + +check_immutable_ref_shape() { + local name="$1" + local value="$2" + + [[ "$value" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ || "$value" =~ ^[0-9a-f]{40}$ ]] \ + || die "$name must be an immutable tag or full commit SHA: $value" +} + +check_changelog_structure() { + local changelog="$repo_root/CHANGELOG.md" + [[ -f "$changelog" ]] || die "CHANGELOG.md not found" + + local unreleased_count + unreleased_count="$(grep -c '^## \[Unreleased\]$' "$changelog" || true)" + [[ "$unreleased_count" == "1" ]] \ + || die "CHANGELOG.md must contain exactly one ## [Unreleased] heading" + + awk ' + /^## / { + if ($0 == "## [Unreleased]") { + if (seen_release) { + print "CHANGELOG.md places ## [Unreleased] after a release heading" > "/dev/stderr" + exit 1 + } + next + } + + if ($0 !~ /^## \[[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*(-rc\.[0-9][0-9]*)?\] — [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]$/) { + print "CHANGELOG.md release heading is malformed: " $0 > "/dev/stderr" + exit 1 + } + + seen_release = 1 + } + ' "$changelog" || exit 1 +} + +check_release_heading() { + local version="$1" + + awk -v prefix="## [$version] — " -v version="$version" ' + index($0, prefix) == 1 { + date = substr($0, length(prefix) + 1) + if (date ~ /^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]$/) { + found = 1 + } + } + END { + if (!found) { + print "release-check: CHANGELOG.md has no release heading for [" version "]" > "/dev/stderr" + exit 1 + } + } + ' "$repo_root/CHANGELOG.md" +} + +dockerfile_arg_default() { + local arg="$1" + + awk -v arg="$arg" ' + $1 == "ARG" { + value = $2 + split(value, parts, "=") + if (parts[1] == arg) { + print parts[2] + exit + } + } + ' "$repo_root/Dockerfile" +} + +check_dockerfile_identity_surface() { + local dockerfile="$repo_root/Dockerfile" + [[ -f "$dockerfile" ]] || die "Dockerfile not found" + + grep -Eq '^ARG[[:space:]]+BASE_REF=' "$dockerfile" \ + || die "Dockerfile must declare ARG BASE_REF" + grep -Eq '^ARG[[:space:]]+RUNA_REF=' "$dockerfile" \ + || die "Dockerfile must declare ARG RUNA_REF" + grep -Fq "org.opencontainers.image.revision=\"\${BASE_REF}\"" "$dockerfile" \ + || die "Dockerfile must set org.opencontainers.image.revision from BASE_REF" + grep -Fq "org.tesserine.base.ref=\"\${BASE_REF}\"" "$dockerfile" \ + || die "Dockerfile must set org.tesserine.base.ref from BASE_REF" + grep -Fq "org.tesserine.runa.ref=\"\${RUNA_REF}\"" "$dockerfile" \ + || die "Dockerfile must set org.tesserine.runa.ref from RUNA_REF" + + local runa_default + runa_default="$(dockerfile_arg_default RUNA_REF)" + [[ -n "$runa_default" ]] || die "Dockerfile ARG RUNA_REF must have a default value" + check_immutable_ref_shape "Dockerfile ARG RUNA_REF" "$runa_default" +} + +workflow_tag_patterns() { + local workflow="$repo_root/.github/workflows/release.yml" + + awk ' + $0 == " tags:" { + inside = 1 + next + } + inside && $0 ~ /^ - / { + value = $0 + sub(/^ - /, "", value) + gsub(/"/, "", value) + print value + next + } + inside { + exit + } + ' "$workflow" +} + +check_release_workflow_surface() { + local workflow="$repo_root/.github/workflows/release.yml" + [[ -f "$workflow" ]] || die ".github/workflows/release.yml not found" + + mapfile -t patterns < <(workflow_tag_patterns) + [[ "${#patterns[@]}" == "2" ]] || die ".github/workflows/release.yml must declare exactly two release tag patterns" + [[ "${patterns[0]}" == "v[0-9]+.[0-9]+.[0-9]+" ]] \ + || die ".github/workflows/release.yml stable tag pattern does not match the documented contract" + [[ "${patterns[1]}" == "v[0-9]+.[0-9]+.[0-9]+-rc.[0-9]+" ]] \ + || die ".github/workflows/release.yml RC tag pattern does not match the documented contract" + ! grep -Eq '^ paths:' "$workflow" \ + || die ".github/workflows/release.yml tag publication must not use paths filters" +} + +check_release_metadata_workflow_surface() { + local workflow="$repo_root/.github/workflows/release-metadata.yml" + [[ -f "$workflow" ]] || die ".github/workflows/release-metadata.yml not found" + + grep -Fq 'name: Release Metadata' "$workflow" \ + || die ".github/workflows/release-metadata.yml must be the release metadata workflow" + grep -Fq ' branches: [main]' "$workflow" \ + || die ".github/workflows/release-metadata.yml must run on main" + grep -Fq ' pull_request:' "$workflow" \ + || die ".github/workflows/release-metadata.yml must run on pull requests" + grep -Fq ' paths:' "$workflow" \ + || die ".github/workflows/release-metadata.yml must path-filter branch and PR checks" + grep -Fq './scripts/release-check metadata' "$workflow" \ + || die ".github/workflows/release-metadata.yml must run release-check metadata" +} + +container_runtime() { + if [[ -n "${BASE_CONTAINER_RUNTIME:-}" ]]; then + command -v "$BASE_CONTAINER_RUNTIME" >/dev/null 2>&1 \ + || die "configured container runtime not found: $BASE_CONTAINER_RUNTIME" + printf '%s\n' "$BASE_CONTAINER_RUNTIME" + elif command -v podman >/dev/null 2>&1; then + printf 'podman\n' + elif command -v docker >/dev/null 2>&1; then + printf 'docker\n' + else + die "podman or docker is required for --container-image checks" + fi +} + +inspect_label() { + local runtime="$1" + local image="$2" + local label="$3" + + "$runtime" inspect --format "{{ index .Config.Labels \"$label\" }}" "$image" +} + +check_container_image() { + local tag="$1" + local image="$2" + local runtime revision base_ref runa_ref + + runtime="$(container_runtime)" + revision="$(inspect_label "$runtime" "$image" "org.opencontainers.image.revision")" + base_ref="$(inspect_label "$runtime" "$image" "org.tesserine.base.ref")" + runa_ref="$(inspect_label "$runtime" "$image" "org.tesserine.runa.ref")" + + [[ "$revision" == "$tag" ]] \ + || die "container image label org.opencontainers.image.revision is '$revision', expected '$tag'" + [[ "$base_ref" == "$tag" ]] \ + || die "container image label org.tesserine.base.ref is '$base_ref', expected '$tag'" + [[ -n "$runa_ref" ]] || die "container image label org.tesserine.runa.ref is missing" + check_immutable_ref_shape "container image label org.tesserine.runa.ref" "$runa_ref" +} + +emit_notes() { + local version="$1" + + awk -v prefix="## [$version] — " -v version="$version" ' + index($0, prefix) == 1 { + found = 1 + inside = 1 + next + } + inside && /^## / { + exit + } + inside { + lines[++line_count] = $0 + } + END { + if (!found) { + print "release-check: CHANGELOG.md has no release heading for [" version "]" > "/dev/stderr" + exit 1 + } + + first = 1 + while (first <= line_count && lines[first] == "") { + first++ + } + + last = line_count + while (last >= first && lines[last] == "") { + last-- + } + + for (i = first; i <= last; i++) { + print lines[i] + } + } + ' "$repo_root/CHANGELOG.md" +} + +run_metadata() { + check_changelog_structure + check_dockerfile_identity_surface + check_release_workflow_surface + check_release_metadata_workflow_surface +} + +run_release() { + local tag="$1" + shift + + local version container_image="" + version="$(release_from_tag "$tag")" + + while [[ "$#" -gt 0 ]]; do + case "$1" in + --container-image) + [[ "$#" -ge 2 ]] || die "--container-image requires an image" + container_image="$2" + shift 2 + ;; + *) + die "unknown release option: $1" + ;; + esac + done + + run_metadata + check_release_heading "$version" + [[ -z "$container_image" ]] || check_container_image "$tag" "$container_image" +} + +main() { + [[ "$#" -ge 1 ]] || { + usage >&2 + exit 2 + } + + case "$1" in + metadata) + [[ "$#" == 1 ]] || die "metadata takes no arguments" + run_metadata + ;; + notes) + [[ "$#" == 2 ]] || die "notes requires exactly one vX.Y.Z[-rc.N] argument" + emit_notes "$(release_from_tag "$2")" + ;; + release) + [[ "$#" -ge 2 ]] || die "release requires a vX.Y.Z[-rc.N] argument" + shift + run_release "$@" + ;; + -h|--help|help) + usage + ;; + *) + usage >&2 + exit 2 + ;; + esac +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/scripts/release-cut b/scripts/release-cut new file mode 100755 index 0000000..71e8825 --- /dev/null +++ b/scripts/release-cut @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd -- "$script_dir/.." && pwd)" +# shellcheck source=scripts/release-check +source "$script_dir/release-check" + +usage() { + cat <<'EOF' +usage: + scripts/release-cut vX.Y.Z[-rc.N] [--remote REMOTE] +EOF +} + +ensure_clean_main() { + local remote="$1" + local branch head remote_head + + branch="$(git -C "$repo_root" branch --show-current)" + [[ "$branch" == "main" ]] || die "release-cut must run on main, not $branch" + + git -C "$repo_root" diff --quiet \ + || die "working tree has unstaged changes" + git -C "$repo_root" diff --cached --quiet \ + || die "working tree has staged changes" + + git -C "$repo_root" fetch "$remote" main >/dev/null 2>&1 \ + || die "failed to fetch $remote main" + head="$(git -C "$repo_root" rev-parse HEAD)" + remote_head="$(git -C "$repo_root" rev-parse FETCH_HEAD)" + [[ "$head" == "$remote_head" ]] \ + || die "main is not up to date with $remote/main" +} + +ensure_tag_available() { + local remote="$1" + local tag="$2" + + ! git -C "$repo_root" rev-parse --verify --quiet "refs/tags/$tag" >/dev/null \ + || die "tag already exists locally: $tag" + if git -C "$repo_root" ls-remote --exit-code --tags "$remote" "refs/tags/$tag" >/dev/null 2>&1; then + die "tag already exists on $remote: $tag" + fi +} + +roll_changelog() { + local version="$1" + local date="$2" + local changelog="$repo_root/CHANGELOG.md" + local tmp + + [[ -f "$changelog" ]] || die "CHANGELOG.md not found" + ! grep -Fq "## [$version] — " "$changelog" \ + || die "CHANGELOG.md already has a release heading for [$version]" + + tmp="$(mktemp)" + awk -v version="$version" -v date="$date" ' + $0 == "## [Unreleased]" && !rolled { + print + print "" + print "## [" version "] — " date + rolled = 1 + next + } + { print } + END { + if (!rolled) { + print "release-cut: CHANGELOG.md has no ## [Unreleased] heading" > "/dev/stderr" + exit 1 + } + } + ' "$changelog" >"$tmp" || { + rm -f "$tmp" + exit 1 + } + mv "$tmp" "$changelog" +} + +run_release_cut() { + local tag="$1" + shift + + local remote="origin" + while [[ "$#" -gt 0 ]]; do + case "$1" in + --remote) + [[ "$#" -ge 2 ]] || die "--remote requires a value" + remote="$2" + shift 2 + ;; + *) + die "unknown release-cut option: $1" + ;; + esac + done + + local version + version="$(release_from_tag "$tag")" + + ensure_clean_main "$remote" + ensure_tag_available "$remote" "$tag" + run_metadata + roll_changelog "$version" "$(date +%F)" + run_release "$tag" + + git -C "$repo_root" add CHANGELOG.md + git -C "$repo_root" diff --cached --quiet \ + && die "release-cut produced no changelog change" + git -C "$repo_root" commit -m "chore(release): $tag" + git -C "$repo_root" tag -a "$tag" -m "Release $tag" + git -C "$repo_root" push "$remote" HEAD:main + git -C "$repo_root" push "$remote" "$tag" +} + +main() { + [[ "$#" -ge 1 ]] || { + usage >&2 + exit 2 + } + + case "$1" in + -h|--help|help) + usage + ;; + *) + run_release_cut "$@" + ;; + esac +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/scripts/test-release-check b/scripts/test-release-check new file mode 100755 index 0000000..138c617 --- /dev/null +++ b/scripts/test-release-check @@ -0,0 +1,347 @@ +#!/usr/bin/env bash +set -euo pipefail + +workspace_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +scratch="$(mktemp -d)" +trap 'rm -rf "$scratch"' EXIT + +failures=0 + +fail() { + printf 'not ok - %s\n' "$*" >&2 + failures=$((failures + 1)) +} + +pass() { + printf 'ok - %s\n' "$*" +} + +new_fixture() { + local name="$1" + local version="$2" + local root="$scratch/$name" + + mkdir -p "$root/scripts" "$root/.github/workflows" + cp "$workspace_root/scripts/release-check" "$root/scripts/release-check" + chmod +x "$root/scripts/release-check" + + cat >"$root/CHANGELOG.md" <"$root/Dockerfile" <<'EOF' +FROM scratch +ARG BASE_REF=local +ARG RUNA_REF=v0.1.2-rc.1 +LABEL org.opencontainers.image.revision="${BASE_REF}" \ + org.tesserine.base.ref="${BASE_REF}" \ + org.tesserine.runa.ref="${RUNA_REF}" +EOF + + cat >"$root/.github/workflows/release.yml" <<'EOF' +name: Release + +on: + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+" + - "v[0-9]+.[0-9]+.[0-9]+-rc.[0-9]+" +EOF + + cat >"$root/.github/workflows/release-metadata.yml" <<'EOF' +name: Release Metadata + +on: + push: + branches: [main] + paths: + - "CHANGELOG.md" + pull_request: + branches: [main] + paths: + - "CHANGELOG.md" + +permissions: + contents: read + +jobs: + metadata: + runs-on: ubuntu-latest + steps: + - run: ./scripts/release-check metadata +EOF + + printf '%s\n' "$root" +} + +run_check() { + local root="$1" + shift + (cd "$root" && bash scripts/release-check "$@") +} + +run_check_with_runtime() { + local root="$1" + local runtime="$2" + shift 2 + (cd "$root" && BASE_CONTAINER_RUNTIME="$runtime" bash scripts/release-check "$@") +} + +release_workflow_tag_patterns() { + local workflow="$1" + + awk ' + $0 == " tags:" { + inside = 1 + next + } + inside && $0 ~ /^ - / { + value = $0 + sub(/^ - /, "", value) + gsub(/"/, "", value) + print value + next + } + inside { + exit + } + ' "$workflow" +} + +documented_actions_pattern_matches() { + local pattern="$1" + local candidate="$2" + local pattern_index=0 + local candidate_index=0 + local pattern_len=${#pattern} + local candidate_len=${#candidate} + + while [[ "$pattern_index" -lt "$pattern_len" ]]; do + local char="${pattern:$pattern_index:1}" + if [[ "$char" == "[" ]]; then + local range="${pattern:$pattern_index:5}" + [[ "$range" == "[0-9]" ]] || return 1 + local one_or_more=0 + [[ "${pattern:$((pattern_index + 5)):1}" == "+" ]] && one_or_more=1 + + local start="$candidate_index" + while [[ "$candidate_index" -lt "$candidate_len" && "${candidate:$candidate_index:1}" =~ [0-9] ]]; do + candidate_index=$((candidate_index + 1)) + done + + if [[ "$one_or_more" == "1" ]]; then + [[ "$candidate_index" -gt "$start" ]] || return 1 + pattern_index=$((pattern_index + 6)) + else + [[ "$candidate_index" == "$((start + 1))" ]] || return 1 + pattern_index=$((pattern_index + 5)) + fi + continue + fi + + [[ "$candidate_index" -lt "$candidate_len" ]] || return 1 + [[ "${candidate:$candidate_index:1}" == "$char" ]] || return 1 + pattern_index=$((pattern_index + 1)) + candidate_index=$((candidate_index + 1)) + done + + [[ "$candidate_index" == "$candidate_len" ]] +} + +matches_any_documented_actions_pattern() { + local candidate="$1" + shift + + local pattern + for pattern in "$@"; do + documented_actions_pattern_matches "$pattern" "$candidate" && return 0 + done + return 1 +} + +assert_success() { + local name="$1" + shift + + if output="$("$@" 2>&1)"; then + pass "$name" + else + fail "$name" + printf '%s\n' "$output" >&2 + fi +} + +assert_failure_contains() { + local name="$1" + local expected="$2" + shift 2 + + local output + if output="$("$@" 2>&1)"; then + fail "$name" + printf 'expected failure containing %s\n' "$expected" >&2 + elif [[ "$output" == *"$expected"* ]]; then + pass "$name" + else + fail "$name" + printf 'expected failure containing %s, got:\n%s\n' "$expected" "$output" >&2 + fi +} + +assert_stdout() { + local name="$1" + local expected="$2" + shift 2 + + local output + if output="$("$@" 2>&1)" && [[ "$output" == "$expected" ]]; then + pass "$name" + else + fail "$name" + printf 'expected:\n%s\n\ngot:\n%s\n' "$expected" "$output" >&2 + fi +} + +metadata_accepts_a_coherent_base_release_surface() { + local root + root="$(new_fixture metadata-success 1.2.3)" + assert_success "${FUNCNAME[0]}" run_check "$root" metadata +} + +metadata_rejects_malformed_changelog_release_headings() { + local root + root="$(new_fixture metadata-changelog-shape 1.2.3)" + sed -i 's/## \[1.2.3\] — 2026-05-08/## [1.2.3-beta.1] — 2026-05-08/' "$root/CHANGELOG.md" + assert_failure_contains "${FUNCNAME[0]}" "release heading is malformed" run_check "$root" metadata +} + +metadata_rejects_missing_dockerfile_identity_labels() { + local root + root="$(new_fixture metadata-dockerfile-label 1.2.3)" + sed -i '/org.tesserine.base.ref/d' "$root/Dockerfile" + assert_failure_contains "${FUNCNAME[0]}" "org.tesserine.base.ref" run_check "$root" metadata +} + +notes_emit_the_matching_changelog_section_without_outer_blank_lines() { + local root + root="$(new_fixture notes-success 1.2.3)" + assert_stdout "${FUNCNAME[0]}" $'### Added\n\n- Release ceremony tooling.' run_check "$root" notes v1.2.3 +} + +notes_reject_undocumented_tag_shapes() { + local root + root="$(new_fixture notes-beta 1.2.3)" + assert_failure_contains "${FUNCNAME[0]}" "release version must look like" run_check "$root" notes v1.2.3-beta.1 +} + +release_rejects_missing_matching_changelog_heading() { + local root + root="$(new_fixture release-heading 1.2.3)" + assert_failure_contains "${FUNCNAME[0]}" "CHANGELOG.md has no release heading for [1.2.4]" run_check "$root" release v1.2.4 +} + +release_heading_lookup_uses_literal_matching() { + local root + root="$(new_fixture release-literal 1.2.3-rc.2)" + sed -i 's/1.2.3-rc.2/1.2.3-rcZ2/' "$root/CHANGELOG.md" + assert_failure_contains "${FUNCNAME[0]}" "CHANGELOG.md has no release heading for [1.2.3-rc.2]" run_check "$root" notes v1.2.3-rc.2 +} + +release_checks_container_label_identity() { + local root + root="$(new_fixture release-container 1.2.3)" + cat >"$root/fake-runtime" <<'EOF' +#!/usr/bin/env sh +set -eu +case "$1" in + inspect) + case "$3" in + *org.opencontainers.image.revision*) printf 'v1.2.3\n' ;; + *org.tesserine.base.ref*) printf 'v1.2.3\n' ;; + *org.tesserine.runa.ref*) printf 'v0.1.2-rc.1\n' ;; + *) printf '\n' ;; + esac + ;; + *) + echo "unexpected fake runtime command: $*" >&2 + exit 64 + ;; +esac +EOF + chmod +x "$root/fake-runtime" + assert_success "${FUNCNAME[0]}" run_check_with_runtime "$root" "$root/fake-runtime" release v1.2.3 --container-image localhost/base:v1.2.3 +} + +release_rejects_container_label_mismatches() { + local root + root="$(new_fixture release-container-mismatch 1.2.3)" + cat >"$root/fake-runtime" <<'EOF' +#!/usr/bin/env sh +set -eu +case "$1" in + inspect) + case "$3" in + *org.opencontainers.image.revision*) printf 'v1.2.3\n' ;; + *org.tesserine.base.ref*) printf 'v9.9.9\n' ;; + *org.tesserine.runa.ref*) printf 'v0.1.2-rc.1\n' ;; + *) printf '\n' ;; + esac + ;; + *) + exit 64 + ;; +esac +EOF + chmod +x "$root/fake-runtime" + assert_failure_contains "${FUNCNAME[0]}" "org.tesserine.base.ref" run_check_with_runtime "$root" "$root/fake-runtime" release v1.2.3 --container-image localhost/base:v1.2.3 +} + +release_workflow_tag_filters_match_the_documented_release_contract() { + local root + root="$(new_fixture workflow-patterns 1.2.3)" + assert_success "${FUNCNAME[0]}" run_check "$root" metadata + + mapfile -t patterns < <(release_workflow_tag_patterns "$root/.github/workflows/release.yml") + [[ "${patterns[*]}" == "v[0-9]+.[0-9]+.[0-9]+ v[0-9]+.[0-9]+.[0-9]+-rc.[0-9]+" ]] \ + || fail "${FUNCNAME[0]} exact patterns" + + local accepted + for accepted in v0.1.2 v10.20.300 v0.1.2-rc.1 v10.20.300-rc.400; do + matches_any_documented_actions_pattern "$accepted" "${patterns[@]}" \ + || fail "${FUNCNAME[0]} accepts $accepted" + done + + local rejected + for rejected in v0.1 v0.1.2.3 v0.1.2-beta.1 v0.1.2-rc v0.1.2-rc.x base-v0.1.2; do + ! matches_any_documented_actions_pattern "$rejected" "${patterns[@]}" \ + || fail "${FUNCNAME[0]} rejects $rejected" + done + + ! grep -Fq 'paths:' "$root/.github/workflows/release.yml" \ + || fail "${FUNCNAME[0]} no path filter" +} + +metadata_accepts_a_coherent_base_release_surface +metadata_rejects_malformed_changelog_release_headings +metadata_rejects_missing_dockerfile_identity_labels +notes_emit_the_matching_changelog_section_without_outer_blank_lines +notes_reject_undocumented_tag_shapes +release_rejects_missing_matching_changelog_heading +release_heading_lookup_uses_literal_matching +release_checks_container_label_identity +release_rejects_container_label_mismatches +release_workflow_tag_filters_match_the_documented_release_contract + +if [[ "$failures" -gt 0 ]]; then + printf '%s test(s) failed\n' "$failures" >&2 + exit 1 +fi diff --git a/scripts/verify-release-adoption.sh b/scripts/verify-release-adoption.sh new file mode 100755 index 0000000..89dedeb --- /dev/null +++ b/scripts/verify-release-adoption.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +set -euo pipefail + +workspace_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "$workspace_root/scripts/release-check" + +scratch="$(mktemp -d)" +trap 'rm -rf "$scratch"' EXIT + +seed_release_repo() { + local source_repo="$1" + local remote_repo="$2" + + mkdir -p "$source_repo/scripts" "$source_repo/.github/workflows" + cp "$workspace_root/scripts/release-check" "$source_repo/scripts/release-check" + cp "$workspace_root/scripts/release-cut" "$source_repo/scripts/release-cut" + chmod +x "$source_repo/scripts/release-check" "$source_repo/scripts/release-cut" + cp "$workspace_root/Dockerfile" "$source_repo/Dockerfile" + cp "$workspace_root/.github/workflows/release.yml" "$source_repo/.github/workflows/release.yml" + cp "$workspace_root/.github/workflows/release-metadata.yml" "$source_repo/.github/workflows/release-metadata.yml" + + cat >"$source_repo/CHANGELOG.md" <<'EOF' +# Changelog + +## [Unreleased] + +### Added + +- Release ceremony tooling. + +## [0.1.0] — 2026-04-13 + +### Added + +- Initial image. +EOF + + git -C "$source_repo" init -q + git -C "$source_repo" config user.name "base release verification" + git -C "$source_repo" config user.email "base-release-verification@example.invalid" + git -C "$source_repo" checkout -q -b main + git -C "$source_repo" -c core.excludesFile=/dev/null add . + git -C "$source_repo" commit -q -m "test: seed release verification" + + git init --bare -q "$remote_repo" + git -C "$source_repo" remote add origin "$remote_repo" + git -C "$source_repo" push -q -u origin main +} + +assert_release_state() { + local source_repo="$1" + local remote_repo="$2" + local tag="$3" + local version + + version="$(release_from_tag "$tag")" + + if ! grep -Fq "## [$version] — $(date +%F)" "$source_repo/CHANGELOG.md"; then + echo "CHANGELOG.md was not rolled to [$version] with today's date" >&2 + exit 1 + fi + + if [[ "$(git -C "$source_repo" cat-file -t "$tag")" != "tag" ]]; then + echo "$tag is not an annotated tag" >&2 + exit 1 + fi + + local release_commit tag_commit + release_commit="$(git -C "$source_repo" rev-parse HEAD)" + tag_commit="$(git -C "$source_repo" rev-list -n 1 "$tag")" + if [[ "$tag_commit" != "$release_commit" ]]; then + echo "$tag does not point at the release commit" >&2 + exit 1 + fi + + if [[ -n "$(git -C "$source_repo" status --short)" ]]; then + echo "release-cut left a dirty working tree" >&2 + exit 1 + fi + + git --git-dir="$remote_repo" rev-parse --verify --quiet refs/heads/main >/dev/null \ + || { echo "release branch was not pushed" >&2; exit 1; } + git --git-dir="$remote_repo" rev-parse --verify --quiet "refs/tags/$tag" >/dev/null \ + || { echo "$tag was not pushed" >&2; exit 1; } + + (cd "$source_repo" && ./scripts/release-check release "$tag") +} + +verify_release_cut() { + local name="$1" + local tag="$2" + local source_repo="$scratch/$name-source" + local remote_repo="$scratch/$name-origin.git" + + seed_release_repo "$source_repo" "$remote_repo" + (cd "$source_repo" && ./scripts/release-cut "$tag") + assert_release_state "$source_repo" "$remote_repo" "$tag" + + echo "verified $name release adoption for $tag" +} + +verify_release_cut stable v1.2.3 +verify_release_cut rc v1.2.4-rc.1 From 62f9ca114cf05dfa4272f7a7ebf6112cf3f38b82 Mon Sep 17 00:00:00 2001 From: pentaxis93 <13393192+pentaxis93@users.noreply.github.com> Date: Fri, 8 May 2026 16:15:21 -0700 Subject: [PATCH 2/6] fix(release): align runa checkout and atomic cuts RUNA_REF validation now shares the same tag-or-SHA checkout substrate used by the Dockerfile, so metadata acceptance cannot outrun image build capability. release-cut now publishes main and the release tag with an atomic push and restores local release state if publication fails, keeping reruns clean. Refs #10 --- .github/workflows/release-metadata.yml | 2 + CHANGELOG.md | 6 ++ Dockerfile | 4 +- RELEASING.md | 7 ++- scripts/checkout-runa-ref | 75 +++++++++++++++++++++++ scripts/release-check | 13 +++- scripts/release-cut | 22 ++++++- scripts/test-release-check | 82 +++++++++++++++++++++++++- scripts/verify-release-adoption.sh | 66 ++++++++++++++++++++- 9 files changed, 267 insertions(+), 10 deletions(-) create mode 100755 scripts/checkout-runa-ref diff --git a/.github/workflows/release-metadata.yml b/.github/workflows/release-metadata.yml index d20067d..9743946 100644 --- a/.github/workflows/release-metadata.yml +++ b/.github/workflows/release-metadata.yml @@ -8,6 +8,7 @@ on: - "RELEASING.md" - "README.md" - "Dockerfile" + - "scripts/checkout-runa-ref" - "scripts/release-check" - "scripts/release-cut" - "scripts/test-release-check" @@ -21,6 +22,7 @@ on: - "RELEASING.md" - "README.md" - "Dockerfile" + - "scripts/checkout-runa-ref" - "scripts/release-check" - "scripts/release-cut" - "scripts/test-release-check" diff --git a/CHANGELOG.md b/CHANGELOG.md index 88d5af7..7a1c5bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Release tooling now checks out `RUNA_REF` values through the same tag-or-SHA + path that the Dockerfile uses, so verifier acceptance matches build + capability. +- `release-cut` now publishes the release commit and tag with an atomic push + and restores local state after publication failures so reruns do not require + manual cleanup. - Image builds now expose OCI and Tesserine labels for the base ref, runa ref, and Claude Code version so deployment contents can be inspected without entering a container. diff --git a/Dockerfile b/Dockerfile index 8854f01..9903cc4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,8 +25,8 @@ RUN apk add --no-cache \ rust-1.89 ARG RUNA_REF=v0.1.2-rc.1 -RUN git clone --depth 1 --branch "${RUNA_REF}" \ - https://github.com/tesserine/runa.git /build/runa \ +COPY scripts/checkout-runa-ref /usr/local/bin/checkout-runa-ref +RUN checkout-runa-ref checkout "${RUNA_REF}" /build/runa \ && cd /build/runa \ && cargo build --release \ && cp target/release/runa /build/runa-bin \ diff --git a/RELEASING.md b/RELEASING.md index b8c9c5a..30327ed 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -65,8 +65,11 @@ For release candidates: The command verifies the clean `main` precondition, rolls `CHANGELOG.md` from `[Unreleased]` into `[X.Y.Z] — YYYY-MM-DD`, commits that release roll, creates -an annotated tag, and pushes `main` plus the tag. Release candidates are -immutable refs for deployment testing. A bad or superseded candidate is +an annotated tag, and atomically pushes `main` plus the tag. If that +publication push fails, the command restores the local pre-release state and +removes the generated local tag so the release can be rerun after the cause is +fixed. Release candidates are immutable refs for deployment testing. A bad or +superseded candidate is corrected by cutting the next `rc.N`, not by rewriting the existing tag. ## Post-Release Gate diff --git a/scripts/checkout-runa-ref b/scripts/checkout-runa-ref new file mode 100755 index 0000000..dd28b65 --- /dev/null +++ b/scripts/checkout-runa-ref @@ -0,0 +1,75 @@ +#!/usr/bin/env sh +set -eu + +default_runa_repo="https://github.com/tesserine/runa.git" + +checkout_runa_ref_die() { + printf 'checkout-runa-ref: %s\n' "$*" >&2 + exit 1 +} + +checkout_runa_ref_is_valid() { + checkout_runa_ref_ref="$1" + + printf '%s\n' "$checkout_runa_ref_ref" \ + | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$|^[0-9a-f]{40}$' +} + +checkout_runa_ref_is_sha() { + checkout_runa_ref_ref="$1" + + printf '%s\n' "$checkout_runa_ref_ref" | grep -Eq '^[0-9a-f]{40}$' +} + +checkout_runa_ref_check() { + checkout_runa_ref_ref="$1" + + checkout_runa_ref_is_valid "$checkout_runa_ref_ref" \ + || checkout_runa_ref_die "RUNA_REF must be an immutable tag or full commit SHA: $checkout_runa_ref_ref" +} + +checkout_runa_ref_checkout() { + checkout_runa_ref_ref="$1" + checkout_runa_ref_dest="$2" + checkout_runa_ref_repo="${3:-$default_runa_repo}" + + checkout_runa_ref_check "$checkout_runa_ref_ref" + + if checkout_runa_ref_is_sha "$checkout_runa_ref_ref"; then + mkdir -p "$checkout_runa_ref_dest" + git -C "$checkout_runa_ref_dest" init -q + git -C "$checkout_runa_ref_dest" remote add origin "$checkout_runa_ref_repo" + git -C "$checkout_runa_ref_dest" fetch --depth 1 origin "$checkout_runa_ref_ref" + git -C "$checkout_runa_ref_dest" checkout --detach FETCH_HEAD + else + git clone --depth 1 --branch "$checkout_runa_ref_ref" "$checkout_runa_ref_repo" "$checkout_runa_ref_dest" + fi +} + +checkout_runa_ref_main() { + [ "$#" -ge 1 ] || checkout_runa_ref_die "usage: scripts/checkout-runa-ref check REF | checkout REF DEST [REPO_URL]" + + case "$1" in + check) + [ "$#" -eq 2 ] || checkout_runa_ref_die "check requires exactly one REF" + checkout_runa_ref_check "$2" + ;; + checkout) + if [ "$#" -ne 3 ] && [ "$#" -ne 4 ]; then + checkout_runa_ref_die "checkout requires REF DEST [REPO_URL]" + fi + shift + checkout_runa_ref_checkout "$@" + ;; + -h|--help|help) + printf 'usage: scripts/checkout-runa-ref check REF | checkout REF DEST [REPO_URL]\n' + ;; + *) + checkout_runa_ref_die "unknown command: $1" + ;; + esac +} + +if [ "${CHECKOUT_RUNA_REF_SOURCE_ONLY:-0}" != "1" ]; then + checkout_runa_ref_main "$@" +fi diff --git a/scripts/release-check b/scripts/release-check index 1f9bbb2..92917ea 100755 --- a/scripts/release-check +++ b/scripts/release-check @@ -3,6 +3,11 @@ set -euo pipefail script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" repo_root="$(cd -- "$script_dir/.." && pwd)" +# shellcheck disable=SC2034 +CHECKOUT_RUNA_REF_SOURCE_ONLY=1 +# shellcheck source=./scripts/checkout-runa-ref +source "$script_dir/checkout-runa-ref" +unset CHECKOUT_RUNA_REF_SOURCE_ONLY usage() { cat <<'EOF' @@ -29,7 +34,7 @@ check_immutable_ref_shape() { local name="$1" local value="$2" - [[ "$value" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ || "$value" =~ ^[0-9a-f]{40}$ ]] \ + checkout_runa_ref_is_valid "$value" \ || die "$name must be an immutable tag or full commit SHA: $value" } @@ -110,6 +115,10 @@ check_dockerfile_identity_surface() { || die "Dockerfile must set org.tesserine.base.ref from BASE_REF" grep -Fq "org.tesserine.runa.ref=\"\${RUNA_REF}\"" "$dockerfile" \ || die "Dockerfile must set org.tesserine.runa.ref from RUNA_REF" + grep -Fq 'COPY scripts/checkout-runa-ref /usr/local/bin/checkout-runa-ref' "$dockerfile" \ + || die "Dockerfile must copy scripts/checkout-runa-ref" + grep -Fq "checkout-runa-ref checkout \"\${RUNA_REF}\" /build/runa" "$dockerfile" \ + || die "Dockerfile must checkout runa with scripts/checkout-runa-ref" local runa_default runa_default="$(dockerfile_arg_default RUNA_REF)" @@ -164,6 +173,8 @@ check_release_metadata_workflow_surface() { || die ".github/workflows/release-metadata.yml must run on pull requests" grep -Fq ' paths:' "$workflow" \ || die ".github/workflows/release-metadata.yml must path-filter branch and PR checks" + grep -Fq ' - "scripts/checkout-runa-ref"' "$workflow" \ + || die ".github/workflows/release-metadata.yml must run when scripts/checkout-runa-ref changes" grep -Fq './scripts/release-check metadata' "$workflow" \ || die ".github/workflows/release-metadata.yml must run release-check metadata" } diff --git a/scripts/release-cut b/scripts/release-cut index 71e8825..c7114da 100755 --- a/scripts/release-cut +++ b/scripts/release-cut @@ -1,5 +1,5 @@ #!/usr/bin/env bash -set -euo pipefail +set -eEuo pipefail script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" repo_root="$(cd -- "$script_dir/.." && pwd)" @@ -77,6 +77,17 @@ roll_changelog() { mv "$tmp" "$changelog" } +rollback_release_cut() { + local tag="$1" + local original_head="$2" + local status="$3" + + trap - ERR + git -C "$repo_root" tag -d "$tag" >/dev/null 2>&1 || true + git -C "$repo_root" reset --hard "$original_head" >/dev/null 2>&1 || true + exit "$status" +} + run_release_cut() { local tag="$1" shift @@ -101,6 +112,11 @@ run_release_cut() { ensure_clean_main "$remote" ensure_tag_available "$remote" "$tag" run_metadata + + local original_head + original_head="$(git -C "$repo_root" rev-parse HEAD)" + trap 'rollback_release_cut "$tag" "$original_head" "$?"' ERR + roll_changelog "$version" "$(date +%F)" run_release "$tag" @@ -109,8 +125,8 @@ run_release_cut() { && die "release-cut produced no changelog change" git -C "$repo_root" commit -m "chore(release): $tag" git -C "$repo_root" tag -a "$tag" -m "Release $tag" - git -C "$repo_root" push "$remote" HEAD:main - git -C "$repo_root" push "$remote" "$tag" + git -C "$repo_root" push --atomic "$remote" "HEAD:refs/heads/main" "refs/tags/$tag:refs/tags/$tag" + trap - ERR } main() { diff --git a/scripts/test-release-check b/scripts/test-release-check index 138c617..a97a806 100755 --- a/scripts/test-release-check +++ b/scripts/test-release-check @@ -24,7 +24,8 @@ new_fixture() { mkdir -p "$root/scripts" "$root/.github/workflows" cp "$workspace_root/scripts/release-check" "$root/scripts/release-check" - chmod +x "$root/scripts/release-check" + cp "$workspace_root/scripts/checkout-runa-ref" "$root/scripts/checkout-runa-ref" + chmod +x "$root/scripts/release-check" "$root/scripts/checkout-runa-ref" cat >"$root/CHANGELOG.md" <"$repo/runa.txt" + git -C "$repo" add runa.txt + git -C "$repo" commit -q -m "initial runa" + git -C "$repo" tag v1.2.3 + printf 'second\n' >"$repo/runa.txt" + git -C "$repo" commit -am "second runa" -q + local sha + sha="$(git -C "$repo" rev-parse HEAD)" + + assert_success "${FUNCNAME[0]} tag checkout" bash "$helper" checkout v1.2.3 "$tag_dest" "$repo" + assert_success "${FUNCNAME[0]} sha checkout" bash "$helper" checkout "$sha" "$sha_dest" "$repo" + + [[ "$(git -C "$tag_dest" rev-parse HEAD)" == "$(git -C "$repo" rev-list -n 1 v1.2.3)" ]] \ + || fail "${FUNCNAME[0]} tag resolves to tag commit" + [[ "$(git -C "$sha_dest" rev-parse HEAD)" == "$sha" ]] \ + || fail "${FUNCNAME[0]} sha resolves to sha commit" +} + metadata_accepts_a_coherent_base_release_surface() { local root root="$(new_fixture metadata-success 1.2.3)" @@ -231,6 +291,20 @@ metadata_rejects_missing_dockerfile_identity_labels() { assert_failure_contains "${FUNCNAME[0]}" "org.tesserine.base.ref" run_check "$root" metadata } +metadata_accepts_sha_runa_ref_defaults_when_checkout_uses_the_shared_helper() { + local root + root="$(new_fixture metadata-sha-runa-ref 1.2.3)" + sed -i 's/ARG RUNA_REF=v0.1.2-rc.1/ARG RUNA_REF=0123456789abcdef0123456789abcdef01234567/' "$root/Dockerfile" + assert_success "${FUNCNAME[0]}" run_check "$root" metadata +} + +metadata_rejects_dockerfile_runa_checkout_without_the_shared_helper() { + local root + root="$(new_fixture metadata-runa-checkout 1.2.3)" + sed -i '/checkout-runa-ref checkout/d' "$root/Dockerfile" + assert_failure_contains "${FUNCNAME[0]}" "checkout runa with scripts/checkout-runa-ref" run_check "$root" metadata +} + notes_emit_the_matching_changelog_section_without_outer_blank_lines() { local root root="$(new_fixture notes-success 1.2.3)" @@ -330,9 +404,15 @@ release_workflow_tag_filters_match_the_documented_release_contract() { || fail "${FUNCNAME[0]} no path filter" } +checkout_runa_ref_accepts_documented_immutable_refs +checkout_runa_ref_runs_under_posix_sh +checkout_runa_ref_rejects_mutable_or_malformed_refs +checkout_runa_ref_resolves_tags_and_full_shas metadata_accepts_a_coherent_base_release_surface metadata_rejects_malformed_changelog_release_headings metadata_rejects_missing_dockerfile_identity_labels +metadata_accepts_sha_runa_ref_defaults_when_checkout_uses_the_shared_helper +metadata_rejects_dockerfile_runa_checkout_without_the_shared_helper notes_emit_the_matching_changelog_section_without_outer_blank_lines notes_reject_undocumented_tag_shapes release_rejects_missing_matching_changelog_heading diff --git a/scripts/verify-release-adoption.sh b/scripts/verify-release-adoption.sh index 89dedeb..170658a 100755 --- a/scripts/verify-release-adoption.sh +++ b/scripts/verify-release-adoption.sh @@ -13,8 +13,9 @@ seed_release_repo() { mkdir -p "$source_repo/scripts" "$source_repo/.github/workflows" cp "$workspace_root/scripts/release-check" "$source_repo/scripts/release-check" + cp "$workspace_root/scripts/checkout-runa-ref" "$source_repo/scripts/checkout-runa-ref" cp "$workspace_root/scripts/release-cut" "$source_repo/scripts/release-cut" - chmod +x "$source_repo/scripts/release-check" "$source_repo/scripts/release-cut" + chmod +x "$source_repo/scripts/release-check" "$source_repo/scripts/checkout-runa-ref" "$source_repo/scripts/release-cut" cp "$workspace_root/Dockerfile" "$source_repo/Dockerfile" cp "$workspace_root/.github/workflows/release.yml" "$source_repo/.github/workflows/release.yml" cp "$workspace_root/.github/workflows/release-metadata.yml" "$source_repo/.github/workflows/release-metadata.yml" @@ -99,5 +100,68 @@ verify_release_cut() { echo "verified $name release adoption for $tag" } +verify_release_cut_recovers_from_tag_push_failure() { + local tag="v1.2.5" + local source_repo="$scratch/rejected-tag-source" + local remote_repo="$scratch/rejected-tag-origin.git" + + seed_release_repo "$source_repo" "$remote_repo" + + local original_head original_remote_main + original_head="$(git -C "$source_repo" rev-parse HEAD)" + original_remote_main="$(git --git-dir="$remote_repo" rev-parse refs/heads/main)" + + cat >"$remote_repo/hooks/update" <<'EOF' +#!/usr/bin/env sh +case "$1" in + refs/tags/*) + echo "rejecting tag update" >&2 + exit 1 + ;; +esac +exit 0 +EOF + chmod +x "$remote_repo/hooks/update" + + local output + if output="$(cd "$source_repo" && ./scripts/release-cut "$tag" 2>&1)"; then + echo "release-cut unexpectedly succeeded when the remote rejected the tag" >&2 + exit 1 + fi + if [[ "$output" != *"rejecting tag update"* ]]; then + echo "release-cut did not report the remote tag rejection" >&2 + printf '%s\n' "$output" >&2 + exit 1 + fi + + if [[ "$(git --git-dir="$remote_repo" rev-parse refs/heads/main)" != "$original_remote_main" ]]; then + echo "release-cut advanced remote main after tag push failure" >&2 + exit 1 + fi + if git --git-dir="$remote_repo" rev-parse --verify --quiet "refs/tags/$tag" >/dev/null; then + echo "release-cut left a remote tag after tag push failure" >&2 + exit 1 + fi + if [[ "$(git -C "$source_repo" rev-parse HEAD)" != "$original_head" ]]; then + echo "release-cut did not restore the local release commit after tag push failure" >&2 + exit 1 + fi + if git -C "$source_repo" rev-parse --verify --quiet "refs/tags/$tag" >/dev/null; then + echo "release-cut left the local tag after tag push failure" >&2 + exit 1 + fi + if [[ -n "$(git -C "$source_repo" status --short)" ]]; then + echo "release-cut left a dirty working tree after tag push failure" >&2 + exit 1 + fi + + rm -f "$remote_repo/hooks/update" + (cd "$source_repo" && ./scripts/release-cut "$tag") + assert_release_state "$source_repo" "$remote_repo" "$tag" + + echo "verified release-cut recovery after tag push failure" +} + verify_release_cut stable v1.2.3 verify_release_cut rc v1.2.4-rc.1 +verify_release_cut_recovers_from_tag_push_failure From 09a872e99f7bd81b89af76021df65deeef521868 Mon Sep 17 00:00:00 2001 From: pentaxis93 <13393192+pentaxis93@users.noreply.github.com> Date: Fri, 8 May 2026 16:40:59 -0700 Subject: [PATCH 3/6] fix(release): trigger documented release tags Release publication now uses a broad v-prefixed tag trigger and validates the exact release shape with release-check before container setup, so GitHub Actions minimatch semantics cannot block documented release tags. Refs #10 --- .github/workflows/release.yml | 6 +- CHANGELOG.md | 2 + scripts/release-check | 14 +++-- scripts/test-release-check | 112 +++++++++++++--------------------- 4 files changed, 59 insertions(+), 75 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 97bbdc4..7a71f73 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,8 +3,7 @@ name: Release on: push: tags: - - "v[0-9]+.[0-9]+.[0-9]+" - - "v[0-9]+.[0-9]+.[0-9]+-rc.[0-9]+" + - "v*" permissions: contents: read @@ -22,6 +21,9 @@ jobs: with: fetch-depth: 0 + - name: Validate release tag + run: ./scripts/release-check release "$GITHUB_REF_NAME" + - name: Require annotated tag run: | test "$(git cat-file -t "refs/tags/$GITHUB_REF_NAME")" = tag diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a1c5bc..d4d93d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- GitHub Release publication now triggers for documented release tags and lets + `release-check` reject malformed `v*` tags before container work begins. - Release tooling now checks out `RUNA_REF` values through the same tag-or-SHA path that the Dockerfile uses, so verifier acceptance matches build capability. diff --git a/scripts/release-check b/scripts/release-check index 92917ea..c59540a 100755 --- a/scripts/release-check +++ b/scripts/release-check @@ -152,13 +152,17 @@ check_release_workflow_surface() { [[ -f "$workflow" ]] || die ".github/workflows/release.yml not found" mapfile -t patterns < <(workflow_tag_patterns) - [[ "${#patterns[@]}" == "2" ]] || die ".github/workflows/release.yml must declare exactly two release tag patterns" - [[ "${patterns[0]}" == "v[0-9]+.[0-9]+.[0-9]+" ]] \ - || die ".github/workflows/release.yml stable tag pattern does not match the documented contract" - [[ "${patterns[1]}" == "v[0-9]+.[0-9]+.[0-9]+-rc.[0-9]+" ]] \ - || die ".github/workflows/release.yml RC tag pattern does not match the documented contract" + [[ "${#patterns[@]}" == "1" ]] || die ".github/workflows/release.yml must declare exactly one release tag pattern" + [[ "${patterns[0]}" == "v*" ]] \ + || die ".github/workflows/release.yml tag pattern must delegate v-prefixed tags to release-check validation" ! grep -Eq '^ paths:' "$workflow" \ || die ".github/workflows/release.yml tag publication must not use paths filters" + + local validation_line container_setup_line + validation_line="$(awk '/^[[:space:]]*(-[[:space:]]*)?run:[[:space:]]+\.\/scripts\/release-check release "\$GITHUB_REF_NAME"[[:space:]]*$/ { print NR; exit }' "$workflow")" + container_setup_line="$(awk '/sudo apt-get install -y podman|podman build/ { print NR; exit }' "$workflow")" + [[ -n "$validation_line" ]] && [[ -n "$container_setup_line" ]] && [[ "$validation_line" -lt "$container_setup_line" ]] \ + || die ".github/workflows/release.yml must validate the release tag before container setup" } check_release_metadata_workflow_surface() { diff --git a/scripts/test-release-check b/scripts/test-release-check index a97a806..2471e03 100755 --- a/scripts/test-release-check +++ b/scripts/test-release-check @@ -56,8 +56,19 @@ name: Release on: push: tags: - - "v[0-9]+.[0-9]+.[0-9]+" - - "v[0-9]+.[0-9]+.[0-9]+-rc.[0-9]+" + - "v*" + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: ./scripts/release-check release "$GITHUB_REF_NAME" + - run: sudo apt-get install -y podman + - run: podman build . EOF cat >"$root/.github/workflows/release-metadata.yml" <<'EOF' @@ -122,57 +133,6 @@ release_workflow_tag_patterns() { ' "$workflow" } -documented_actions_pattern_matches() { - local pattern="$1" - local candidate="$2" - local pattern_index=0 - local candidate_index=0 - local pattern_len=${#pattern} - local candidate_len=${#candidate} - - while [[ "$pattern_index" -lt "$pattern_len" ]]; do - local char="${pattern:$pattern_index:1}" - if [[ "$char" == "[" ]]; then - local range="${pattern:$pattern_index:5}" - [[ "$range" == "[0-9]" ]] || return 1 - local one_or_more=0 - [[ "${pattern:$((pattern_index + 5)):1}" == "+" ]] && one_or_more=1 - - local start="$candidate_index" - while [[ "$candidate_index" -lt "$candidate_len" && "${candidate:$candidate_index:1}" =~ [0-9] ]]; do - candidate_index=$((candidate_index + 1)) - done - - if [[ "$one_or_more" == "1" ]]; then - [[ "$candidate_index" -gt "$start" ]] || return 1 - pattern_index=$((pattern_index + 6)) - else - [[ "$candidate_index" == "$((start + 1))" ]] || return 1 - pattern_index=$((pattern_index + 5)) - fi - continue - fi - - [[ "$candidate_index" -lt "$candidate_len" ]] || return 1 - [[ "${candidate:$candidate_index:1}" == "$char" ]] || return 1 - pattern_index=$((pattern_index + 1)) - candidate_index=$((candidate_index + 1)) - done - - [[ "$candidate_index" == "$candidate_len" ]] -} - -matches_any_documented_actions_pattern() { - local candidate="$1" - shift - - local pattern - for pattern in "$@"; do - documented_actions_pattern_matches "$pattern" "$candidate" && return 0 - done - return 1 -} - assert_success() { local name="$1" shift @@ -379,29 +339,42 @@ EOF assert_failure_contains "${FUNCNAME[0]}" "org.tesserine.base.ref" run_check_with_runtime "$root" "$root/fake-runtime" release v1.2.3 --container-image localhost/base:v1.2.3 } -release_workflow_tag_filters_match_the_documented_release_contract() { +release_workflow_uses_broad_tag_filter_with_early_validation() { local root root="$(new_fixture workflow-patterns 1.2.3)" assert_success "${FUNCNAME[0]}" run_check "$root" metadata mapfile -t patterns < <(release_workflow_tag_patterns "$root/.github/workflows/release.yml") - [[ "${patterns[*]}" == "v[0-9]+.[0-9]+.[0-9]+ v[0-9]+.[0-9]+.[0-9]+-rc.[0-9]+" ]] \ - || fail "${FUNCNAME[0]} exact patterns" + [[ "${patterns[*]}" == "v*" ]] \ + || fail "${FUNCNAME[0]} broad v tag filter" - local accepted - for accepted in v0.1.2 v10.20.300 v0.1.2-rc.1 v10.20.300-rc.400; do - matches_any_documented_actions_pattern "$accepted" "${patterns[@]}" \ - || fail "${FUNCNAME[0]} accepts $accepted" - done + ! grep -Fq 'paths:' "$root/.github/workflows/release.yml" \ + || fail "${FUNCNAME[0]} no path filter" +} + +release_workflow_rejects_missing_early_validation() { + local root + root="$(new_fixture workflow-missing-validation 1.2.3)" + sed -i '/release-check release "\$GITHUB_REF_NAME"/d' "$root/.github/workflows/release.yml" + assert_failure_contains "${FUNCNAME[0]}" "validate the release tag before container setup" run_check "$root" metadata +} + +release_workflow_rejects_late_validation() { + local root + root="$(new_fixture workflow-late-validation 1.2.3)" + sed -i '/release-check release "\$GITHUB_REF_NAME"/d' "$root/.github/workflows/release.yml" + sed -i '/podman build ./a\ - run: ./scripts/release-check release "$GITHUB_REF_NAME"' "$root/.github/workflows/release.yml" + assert_failure_contains "${FUNCNAME[0]}" "validate the release tag before container setup" run_check "$root" metadata +} + +release_validation_rejects_malformed_broad_trigger_tags() { + local root + root="$(new_fixture release-malformed-tags 1.2.3)" local rejected - for rejected in v0.1 v0.1.2.3 v0.1.2-beta.1 v0.1.2-rc v0.1.2-rc.x base-v0.1.2; do - ! matches_any_documented_actions_pattern "$rejected" "${patterns[@]}" \ - || fail "${FUNCNAME[0]} rejects $rejected" + for rejected in v0.1 v0.1.2-beta.1 v0.1.2-rc v0.1.2-rc.x; do + assert_failure_contains "${FUNCNAME[0]} rejects $rejected" "release version must look like" run_check "$root" release "$rejected" done - - ! grep -Fq 'paths:' "$root/.github/workflows/release.yml" \ - || fail "${FUNCNAME[0]} no path filter" } checkout_runa_ref_accepts_documented_immutable_refs @@ -419,7 +392,10 @@ release_rejects_missing_matching_changelog_heading release_heading_lookup_uses_literal_matching release_checks_container_label_identity release_rejects_container_label_mismatches -release_workflow_tag_filters_match_the_documented_release_contract +release_workflow_uses_broad_tag_filter_with_early_validation +release_workflow_rejects_missing_early_validation +release_workflow_rejects_late_validation +release_validation_rejects_malformed_broad_trigger_tags if [[ "$failures" -gt 0 ]]; then printf '%s test(s) failed\n' "$failures" >&2 From a9af51d95d1f39059c8f5bd82f9844453a695017 Mon Sep 17 00:00:00 2001 From: pentaxis93 <13393192+pentaxis93@users.noreply.github.com> Date: Fri, 8 May 2026 17:44:13 -0700 Subject: [PATCH 4/6] fix(release): enforce SemVer tag grammar Release tag validators now reject leading-zero numeric identifiers across release-check, changelog headings, RUNA_REF tags, and the release workflow prerelease classifier. Refs #10 Refs tesserine/commons#26 --- .github/workflows/release.yml | 2 +- CHANGELOG.md | 2 ++ RELEASING.md | 4 ++++ scripts/checkout-runa-ref | 2 +- scripts/release-check | 4 ++-- scripts/test-release-check | 35 ++++++++++++++++++++++++++++++++--- 6 files changed, 42 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7a71f73..073bb66 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -61,7 +61,7 @@ jobs: run: | set -euo pipefail release_flags=() - if [[ "$GITHUB_REF_NAME" =~ ^v[0-9]+[.][0-9]+[.][0-9]+-rc[.][0-9]+$ ]]; then + if [[ "$GITHUB_REF_NAME" =~ ^v(0|[1-9][0-9]*)[.](0|[1-9][0-9]*)[.](0|[1-9][0-9]*)-rc[.](0|[1-9][0-9]*)$ ]]; then release_flags+=(--prerelease) fi gh release create "$GITHUB_REF_NAME" \ diff --git a/CHANGELOG.md b/CHANGELOG.md index d4d93d8..bbf19d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Release tag validation now rejects leading-zero numeric identifiers so base + release tags match the ecosystem SemVer grammar. - GitHub Release publication now triggers for documented release tags and lets `release-check` reject malformed `v*` tags before container work begins. - Release tooling now checks out `RUNA_REF` values through the same tag-or-SHA diff --git a/RELEASING.md b/RELEASING.md index 30327ed..368415a 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -10,6 +10,10 @@ base uses one repository tag for the source release and container image. The tag is `vX.Y.Z` for stable releases and `vX.Y.Z-rc.N` for deployment release candidates. +Release tags follow Semantic Versioning 2.0.0 numeric grammar: each integer is +either `0` or a non-zero digit followed by zero or more digits. The +ecosystem-wide codification is tracked in tesserine/commons#26. + Artifacts built from the tag must report that identity: - The container image exposes `org.opencontainers.image.revision=`. diff --git a/scripts/checkout-runa-ref b/scripts/checkout-runa-ref index dd28b65..f33d3be 100755 --- a/scripts/checkout-runa-ref +++ b/scripts/checkout-runa-ref @@ -12,7 +12,7 @@ checkout_runa_ref_is_valid() { checkout_runa_ref_ref="$1" printf '%s\n' "$checkout_runa_ref_ref" \ - | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$|^[0-9a-f]{40}$' + | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-rc\.(0|[1-9][0-9]*))?$|^[0-9a-f]{40}$' } checkout_runa_ref_is_sha() { diff --git a/scripts/release-check b/scripts/release-check index c59540a..13fdb03 100755 --- a/scripts/release-check +++ b/scripts/release-check @@ -25,7 +25,7 @@ die() { release_from_tag() { local tag="$1" - [[ "$tag" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)(-rc\.[0-9]+)?$ ]] \ + [[ "$tag" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-rc\.(0|[1-9][0-9]*))?$ ]] \ || die "release version must look like vX.Y.Z or vX.Y.Z-rc.N: $tag" printf '%s.%s.%s%s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}" "${BASH_REMATCH[4]}" } @@ -57,7 +57,7 @@ check_changelog_structure() { next } - if ($0 !~ /^## \[[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*(-rc\.[0-9][0-9]*)?\] — [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]$/) { + if ($0 !~ /^## \[(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-rc\.(0|[1-9][0-9]*))?\] — [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]$/) { print "CHANGELOG.md release heading is malformed: " $0 > "/dev/stderr" exit 1 } diff --git a/scripts/test-release-check b/scripts/test-release-check index 2471e03..ac99066 100755 --- a/scripts/test-release-check +++ b/scripts/test-release-check @@ -180,7 +180,7 @@ checkout_runa_ref_accepts_documented_immutable_refs() { local helper="$workspace_root/scripts/checkout-runa-ref" local accepted - for accepted in v0.1.2 v10.20.300 v0.1.2-rc.1 0123456789abcdef0123456789abcdef01234567; do + for accepted in v0.0.0 v0.1.2 v10.20.300 v0.1.2-rc.1 v1.2.3-rc.0 0123456789abcdef0123456789abcdef01234567; do assert_success "${FUNCNAME[0]} accepts $accepted" bash "$helper" check "$accepted" done } @@ -198,7 +198,7 @@ checkout_runa_ref_rejects_mutable_or_malformed_refs() { local helper="$workspace_root/scripts/checkout-runa-ref" local rejected - for rejected in main v0.1 v0.1.2-beta.1 0123456789abcdef0123456789abcdef0123456 0123456789ABCDEF0123456789abcdef01234567; do + for rejected in main v0.1 v0.1.2-beta.1 v01.2.3 v1.02.3 v1.2.03 v1.2.3-rc.01 0123456789abcdef0123456789abcdef0123456 0123456789ABCDEF0123456789abcdef01234567; do assert_failure_contains "${FUNCNAME[0]} rejects $rejected" "immutable tag or full commit SHA" bash "$helper" check "$rejected" done } @@ -244,6 +244,15 @@ metadata_rejects_malformed_changelog_release_headings() { assert_failure_contains "${FUNCNAME[0]}" "release heading is malformed" run_check "$root" metadata } +metadata_rejects_changelog_release_headings_with_leading_zeroes() { + local version root + + for version in 01.2.3 1.02.3 1.2.03 1.2.3-rc.01; do + root="$(new_fixture "metadata-leading-zero-$version" "$version")" + assert_failure_contains "${FUNCNAME[0]} rejects $version" "release heading is malformed" run_check "$root" metadata + done +} + metadata_rejects_missing_dockerfile_identity_labels() { local root root="$(new_fixture metadata-dockerfile-label 1.2.3)" @@ -277,6 +286,16 @@ notes_reject_undocumented_tag_shapes() { assert_failure_contains "${FUNCNAME[0]}" "release version must look like" run_check "$root" notes v1.2.3-beta.1 } +release_validation_accepts_single_zero_numeric_components() { + local root + + root="$(new_fixture release-zero-stable 0.0.0)" + assert_success "${FUNCNAME[0]} accepts stable zero version" run_check "$root" release v0.0.0 + + root="$(new_fixture release-zero-rc 1.2.3-rc.0)" + assert_success "${FUNCNAME[0]} accepts rc zero ordinal" run_check "$root" release v1.2.3-rc.0 +} + release_rejects_missing_matching_changelog_heading() { local root root="$(new_fixture release-heading 1.2.3)" @@ -367,12 +386,19 @@ release_workflow_rejects_late_validation() { assert_failure_contains "${FUNCNAME[0]}" "validate the release tag before container setup" run_check "$root" metadata } +release_workflow_uses_semver_rc_classifier() { + local workflow="$workspace_root/.github/workflows/release.yml" + + grep -Fq '[[ "$GITHUB_REF_NAME" =~ ^v(0|[1-9][0-9]*)[.](0|[1-9][0-9]*)[.](0|[1-9][0-9]*)-rc[.](0|[1-9][0-9]*)$ ]]' "$workflow" \ + || fail "${FUNCNAME[0]}" +} + release_validation_rejects_malformed_broad_trigger_tags() { local root root="$(new_fixture release-malformed-tags 1.2.3)" local rejected - for rejected in v0.1 v0.1.2-beta.1 v0.1.2-rc v0.1.2-rc.x; do + for rejected in v0.1 v0.1.2-beta.1 v0.1.2-rc v0.1.2-rc.x v01.2.3 v1.02.3 v1.2.03 v1.2.3-rc.01; do assert_failure_contains "${FUNCNAME[0]} rejects $rejected" "release version must look like" run_check "$root" release "$rejected" done } @@ -383,11 +409,13 @@ checkout_runa_ref_rejects_mutable_or_malformed_refs checkout_runa_ref_resolves_tags_and_full_shas metadata_accepts_a_coherent_base_release_surface metadata_rejects_malformed_changelog_release_headings +metadata_rejects_changelog_release_headings_with_leading_zeroes metadata_rejects_missing_dockerfile_identity_labels metadata_accepts_sha_runa_ref_defaults_when_checkout_uses_the_shared_helper metadata_rejects_dockerfile_runa_checkout_without_the_shared_helper notes_emit_the_matching_changelog_section_without_outer_blank_lines notes_reject_undocumented_tag_shapes +release_validation_accepts_single_zero_numeric_components release_rejects_missing_matching_changelog_heading release_heading_lookup_uses_literal_matching release_checks_container_label_identity @@ -395,6 +423,7 @@ release_rejects_container_label_mismatches release_workflow_uses_broad_tag_filter_with_early_validation release_workflow_rejects_missing_early_validation release_workflow_rejects_late_validation +release_workflow_uses_semver_rc_classifier release_validation_rejects_malformed_broad_trigger_tags if [[ "$failures" -gt 0 ]]; then From 2114140d84e1f751fa2508a9ce2d153ab98b36a3 Mon Sep 17 00:00:00 2001 From: pentaxis93 <13393192+pentaxis93@users.noreply.github.com> Date: Fri, 8 May 2026 18:03:25 -0700 Subject: [PATCH 5/6] fix(release): preserve release identity recovery Reject non-commit RUNA_REF SHA objects before checkout so the labeled ref matches the built commit. Document and test manual RC GitHub Release recovery with --prerelease so manual recovery matches the automated workflow classification. Refs #10 --- CHANGELOG.md | 4 +++ RELEASING.md | 13 +++++++++- scripts/checkout-runa-ref | 21 ++++++++++++++++ scripts/test-release-check | 51 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 88 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bbf19d3..6dd5de7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Release tooling now checks out `RUNA_REF` values through the same tag-or-SHA path that the Dockerfile uses, so verifier acceptance matches build capability. +- `RUNA_REF` SHA checkout now rejects non-commit objects so container labels + cannot name an annotated tag object while building the tagged commit. +- Manual GitHub Release recovery guidance now preserves prerelease + classification for release candidate tags. - `release-cut` now publishes the release commit and tag with an atomic push and restores local state after publication failures so reruns do not require manual cleanup. diff --git a/RELEASING.md b/RELEASING.md index 368415a..bcfe380 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -85,7 +85,7 @@ publishes the GitHub Release. Only `vX.Y.Z-rc.N` tags are published as GitHub prereleases. Manual GitHub Release creation, when needed after a workflow failure, uses the -same notes source: +same notes source and release classification. ```sh ./scripts/release-check notes "vX.Y.Z" > /tmp/base-release-notes.md @@ -95,6 +95,17 @@ gh release create "vX.Y.Z" \ --verify-tag ``` +For release candidates: + +```sh +./scripts/release-check notes "vX.Y.Z-rc.N" > /tmp/base-release-notes.md +gh release create "vX.Y.Z-rc.N" \ + --title "base vX.Y.Z-rc.N" \ + --notes-file /tmp/base-release-notes.md \ + --verify-tag \ + --prerelease +``` + ## Failure Modes If a published tag points at source that violates release identity checks, the diff --git a/scripts/checkout-runa-ref b/scripts/checkout-runa-ref index f33d3be..a269d3c 100755 --- a/scripts/checkout-runa-ref +++ b/scripts/checkout-runa-ref @@ -21,6 +21,23 @@ checkout_runa_ref_is_sha() { printf '%s\n' "$checkout_runa_ref_ref" | grep -Eq '^[0-9a-f]{40}$' } +checkout_runa_ref_require_commit_object() { + checkout_runa_ref_ref="$1" + checkout_runa_ref_dest="$2" + + checkout_runa_ref_object_type="$(git -C "$checkout_runa_ref_dest" cat-file -t FETCH_HEAD)" + if [ "$checkout_runa_ref_object_type" = "commit" ]; then + return 0 + fi + + checkout_runa_ref_commit="$(git -C "$checkout_runa_ref_dest" rev-parse --verify -q 'FETCH_HEAD^{commit}' || true)" + if [ -n "$checkout_runa_ref_commit" ]; then + checkout_runa_ref_die "RUNA_REF resolves to a $checkout_runa_ref_object_type object, not a commit: $checkout_runa_ref_ref targets $checkout_runa_ref_commit" + fi + + checkout_runa_ref_die "RUNA_REF resolves to a $checkout_runa_ref_object_type object, not a commit: $checkout_runa_ref_ref" +} + checkout_runa_ref_check() { checkout_runa_ref_ref="$1" @@ -40,7 +57,11 @@ checkout_runa_ref_checkout() { git -C "$checkout_runa_ref_dest" init -q git -C "$checkout_runa_ref_dest" remote add origin "$checkout_runa_ref_repo" git -C "$checkout_runa_ref_dest" fetch --depth 1 origin "$checkout_runa_ref_ref" + checkout_runa_ref_require_commit_object "$checkout_runa_ref_ref" "$checkout_runa_ref_dest" git -C "$checkout_runa_ref_dest" checkout --detach FETCH_HEAD + checkout_runa_ref_head="$(git -C "$checkout_runa_ref_dest" rev-parse HEAD)" + [ "$checkout_runa_ref_head" = "$checkout_runa_ref_ref" ] \ + || checkout_runa_ref_die "RUNA_REF checkout produced $checkout_runa_ref_head, expected $checkout_runa_ref_ref" else git clone --depth 1 --branch "$checkout_runa_ref_ref" "$checkout_runa_ref_repo" "$checkout_runa_ref_dest" fi diff --git a/scripts/test-release-check b/scripts/test-release-check index ac99066..529a6dc 100755 --- a/scripts/test-release-check +++ b/scripts/test-release-check @@ -231,6 +231,55 @@ checkout_runa_ref_resolves_tags_and_full_shas() { || fail "${FUNCNAME[0]} sha resolves to sha commit" } +checkout_runa_ref_rejects_annotated_tag_object_shas() { + local helper="$workspace_root/scripts/checkout-runa-ref" + local repo="$scratch/runa-annotated-tag-source" + local tag_object_dest="$scratch/runa-tag-object-sha" + local commit_dest="$scratch/runa-tag-target-sha" + + mkdir -p "$repo" + git -C "$repo" init -q + git -C "$repo" config user.name "base release verification" + git -C "$repo" config user.email "base-release-verification@example.invalid" + printf 'annotated tag target\n' >"$repo/runa.txt" + git -C "$repo" add runa.txt + git -C "$repo" commit -q -m "annotated tag target" + git -C "$repo" tag -a vTEST -m test + + local tag_object_sha commit_sha + tag_object_sha="$(git -C "$repo" rev-parse vTEST)" + commit_sha="$(git -C "$repo" rev-parse 'vTEST^{commit}')" + + assert_failure_contains \ + "${FUNCNAME[0]} rejects tag object sha" \ + "resolves to a tag object, not a commit: $tag_object_sha targets $commit_sha" \ + bash "$helper" checkout "$tag_object_sha" "$tag_object_dest" "$repo" + assert_success \ + "${FUNCNAME[0]} accepts target commit sha" \ + bash "$helper" checkout "$commit_sha" "$commit_dest" "$repo" +} + +manual_recovery_documents_rc_prerelease_creation() { + if awk ' + /gh release create "vX\.Y\.Z-rc\.N"/ { + inside = 1 + } + inside && /--prerelease/ { + found = 1 + } + inside && /^```$/ { + inside = 0 + } + END { + exit found ? 0 : 1 + } + ' "$workspace_root/RELEASING.md"; then + pass "${FUNCNAME[0]}" + else + fail "${FUNCNAME[0]}" + fi +} + metadata_accepts_a_coherent_base_release_surface() { local root root="$(new_fixture metadata-success 1.2.3)" @@ -407,6 +456,8 @@ checkout_runa_ref_accepts_documented_immutable_refs checkout_runa_ref_runs_under_posix_sh checkout_runa_ref_rejects_mutable_or_malformed_refs checkout_runa_ref_resolves_tags_and_full_shas +checkout_runa_ref_rejects_annotated_tag_object_shas +manual_recovery_documents_rc_prerelease_creation metadata_accepts_a_coherent_base_release_surface metadata_rejects_malformed_changelog_release_headings metadata_rejects_changelog_release_headings_with_leading_zeroes From d03288c434b5704e2ea94e3eda906b668d5c66a1 Mon Sep 17 00:00:00 2001 From: pentaxis93 <13393192+pentaxis93@users.noreply.github.com> Date: Fri, 8 May 2026 20:37:55 -0700 Subject: [PATCH 6/6] fix(release): disambiguate runa tag refs Fetch SemVer-shaped RUNA_REF values through refs/tags/ so homonymous branches cannot shadow immutable release inputs. Cover both branch-shadowed tags and branch-only SemVer names with real-git regression coverage. Refs #10 --- CHANGELOG.md | 2 ++ scripts/checkout-runa-ref | 7 ++++++- scripts/test-release-check | 41 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dd5de7..ad87fbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- `RUNA_REF` tag checkout now resolves SemVer-shaped values only through + explicit tag refs so homonymous branches cannot shadow release inputs. - Release tag validation now rejects leading-zero numeric identifiers so base release tags match the ecosystem SemVer grammar. - GitHub Release publication now triggers for documented release tags and lets diff --git a/scripts/checkout-runa-ref b/scripts/checkout-runa-ref index a269d3c..48e93a8 100755 --- a/scripts/checkout-runa-ref +++ b/scripts/checkout-runa-ref @@ -63,7 +63,12 @@ checkout_runa_ref_checkout() { [ "$checkout_runa_ref_head" = "$checkout_runa_ref_ref" ] \ || checkout_runa_ref_die "RUNA_REF checkout produced $checkout_runa_ref_head, expected $checkout_runa_ref_ref" else - git clone --depth 1 --branch "$checkout_runa_ref_ref" "$checkout_runa_ref_repo" "$checkout_runa_ref_dest" + mkdir -p "$checkout_runa_ref_dest" + git -C "$checkout_runa_ref_dest" init -q + git -C "$checkout_runa_ref_dest" remote add origin "$checkout_runa_ref_repo" + git -C "$checkout_runa_ref_dest" fetch --depth 1 origin "refs/tags/$checkout_runa_ref_ref:refs/tags/$checkout_runa_ref_ref" \ + || checkout_runa_ref_die "RUNA_REF tag does not exist: $checkout_runa_ref_ref" + git -C "$checkout_runa_ref_dest" checkout --detach "refs/tags/$checkout_runa_ref_ref" fi } diff --git a/scripts/test-release-check b/scripts/test-release-check index 529a6dc..5b2175d 100755 --- a/scripts/test-release-check +++ b/scripts/test-release-check @@ -231,6 +231,46 @@ checkout_runa_ref_resolves_tags_and_full_shas() { || fail "${FUNCNAME[0]} sha resolves to sha commit" } +checkout_runa_ref_resolves_semver_refs_only_as_tags() { + local helper="$workspace_root/scripts/checkout-runa-ref" + local shadow_repo="$scratch/runa-shadow-source" + local shadow_dest="$scratch/runa-shadow-tag" + local branch_only_repo="$scratch/runa-branch-only-source" + local branch_only_dest="$scratch/runa-branch-only" + + mkdir -p "$shadow_repo" + git -C "$shadow_repo" init -q + git -C "$shadow_repo" config user.name "base release verification" + git -C "$shadow_repo" config user.email "base-release-verification@example.invalid" + printf 'tag\n' >"$shadow_repo/runa.txt" + git -C "$shadow_repo" add runa.txt + git -C "$shadow_repo" commit -q -m "tag runa" + local tag_sha + tag_sha="$(git -C "$shadow_repo" rev-parse HEAD)" + git -C "$shadow_repo" tag v0.1.2 + printf 'branch\n' >"$shadow_repo/runa.txt" + git -C "$shadow_repo" commit -am "branch runa" -q + git -C "$shadow_repo" branch v0.1.2 + + assert_success "${FUNCNAME[0]} prefers explicit tag ref" bash "$helper" checkout v0.1.2 "$shadow_dest" "$shadow_repo" + [[ "$(git -C "$shadow_dest" rev-parse HEAD)" == "$tag_sha" ]] \ + || fail "${FUNCNAME[0]} homonymous branch does not shadow tag" + + mkdir -p "$branch_only_repo" + git -C "$branch_only_repo" init -q + git -C "$branch_only_repo" config user.name "base release verification" + git -C "$branch_only_repo" config user.email "base-release-verification@example.invalid" + printf 'branch only\n' >"$branch_only_repo/runa.txt" + git -C "$branch_only_repo" add runa.txt + git -C "$branch_only_repo" commit -q -m "branch only runa" + git -C "$branch_only_repo" branch v0.2.3 + + assert_failure_contains \ + "${FUNCNAME[0]} rejects branch-only semver ref" \ + "RUNA_REF tag does not exist: v0.2.3" \ + bash "$helper" checkout v0.2.3 "$branch_only_dest" "$branch_only_repo" +} + checkout_runa_ref_rejects_annotated_tag_object_shas() { local helper="$workspace_root/scripts/checkout-runa-ref" local repo="$scratch/runa-annotated-tag-source" @@ -456,6 +496,7 @@ checkout_runa_ref_accepts_documented_immutable_refs checkout_runa_ref_runs_under_posix_sh checkout_runa_ref_rejects_mutable_or_malformed_refs checkout_runa_ref_resolves_tags_and_full_shas +checkout_runa_ref_resolves_semver_refs_only_as_tags checkout_runa_ref_rejects_annotated_tag_object_shas manual_recovery_documents_rc_prerelease_creation metadata_accepts_a_coherent_base_release_surface