diff --git a/.github/workflows/release-metadata.yml b/.github/workflows/release-metadata.yml new file mode 100644 index 0000000..9743946 --- /dev/null +++ b/.github/workflows/release-metadata.yml @@ -0,0 +1,46 @@ +name: Release Metadata + +on: + push: + branches: [main] + paths: + - "CHANGELOG.md" + - "RELEASING.md" + - "README.md" + - "Dockerfile" + - "scripts/checkout-runa-ref" + - "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/checkout-runa-ref" + - "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..073bb66 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,71 @@ +name: Release + +on: + push: + tags: + - "v*" + +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: 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 + + - 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|[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" \ + --title "base $GITHUB_REF_NAME" \ + --notes-file release-notes.md \ + --verify-tag \ + "${release_flags[@]}" diff --git a/CHANGELOG.md b/CHANGELOG.md index aeaf262..ad87fbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,29 @@ 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 +- `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 + `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. +- `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. - 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/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..bcfe380 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,119 @@ +# 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. + +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=`. +- 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 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 + +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 and release classification. + +```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 +``` + +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 +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/checkout-runa-ref b/scripts/checkout-runa-ref new file mode 100755 index 0000000..48e93a8 --- /dev/null +++ b/scripts/checkout-runa-ref @@ -0,0 +1,101 @@ +#!/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|[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() { + checkout_runa_ref_ref="$1" + + 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" + + 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" + 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 + 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 +} + +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 new file mode 100755 index 0000000..13fdb03 --- /dev/null +++ b/scripts/release-check @@ -0,0 +1,328 @@ +#!/usr/bin/env bash +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' +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|[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]}" +} + +check_immutable_ref_shape() { + local name="$1" + local value="$2" + + checkout_runa_ref_is_valid "$value" \ + || 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|[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 + } + + 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" + 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)" + [[ -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[@]}" == "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() { + 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/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" +} + +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..c7114da --- /dev/null +++ b/scripts/release-cut @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +set -eEuo 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" +} + +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 + + 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 + + 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" + + 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 --atomic "$remote" "HEAD:refs/heads/main" "refs/tags/$tag:refs/tags/$tag" + trap - ERR +} + +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..5b2175d --- /dev/null +++ b/scripts/test-release-check @@ -0,0 +1,524 @@ +#!/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" + 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" <"$root/Dockerfile" <<'EOF' +FROM scratch +ARG BASE_REF=local +ARG RUNA_REF=v0.1.2-rc.1 +COPY scripts/checkout-runa-ref /usr/local/bin/checkout-runa-ref +RUN checkout-runa-ref checkout "${RUNA_REF}" /build/runa +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*" + +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' +name: Release Metadata + +on: + push: + branches: [main] + paths: + - "CHANGELOG.md" + - "scripts/checkout-runa-ref" + pull_request: + branches: [main] + paths: + - "CHANGELOG.md" + - "scripts/checkout-runa-ref" + +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" +} + +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 +} + +checkout_runa_ref_accepts_documented_immutable_refs() { + local helper="$workspace_root/scripts/checkout-runa-ref" + + local accepted + 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 +} + +checkout_runa_ref_runs_under_posix_sh() { + local helper="$workspace_root/scripts/checkout-runa-ref" + + [[ "$(head -n 1 "$helper")" == "#!/usr/bin/env sh" ]] \ + || fail "${FUNCNAME[0]} uses /usr/bin/env sh" + ! grep -Eq '\[\[|BASH_SOURCE|pipefail|(^|[[:space:]])local[[:space:]]' "$helper" \ + || fail "${FUNCNAME[0]} avoids bash-only syntax" +} + +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 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 +} + +checkout_runa_ref_resolves_tags_and_full_shas() { + local helper="$workspace_root/scripts/checkout-runa-ref" + local repo="$scratch/runa-source" + local tag_dest="$scratch/runa-tag" + local sha_dest="$scratch/runa-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 'first\n' >"$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" +} + +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" + 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)" + 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_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)" + sed -i '/org.tesserine.base.ref/d' "$root/Dockerfile" + 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)" + 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_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)" + 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_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*" ]] \ + || fail "${FUNCNAME[0]} broad v tag filter" + + ! 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_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 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 +} + +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 +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 +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 + 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..170658a --- /dev/null +++ b/scripts/verify-release-adoption.sh @@ -0,0 +1,167 @@ +#!/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/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/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" + + 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_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