diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 00e85e8..80af5d4 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -140,9 +140,41 @@ A library that bundles a small CLI keeps it in its own module (`cmd//go.mo `go-binary-release.yml` builds with the newest patch release of the Go minor version the module declares: `go 1.26.0` (or `go 1.26`) builds with the latest 1.26.x. A `toolchain` line counts when its minor version is newer. The `go` directive stays the floor for users; it does not pin the compiler of the shipped binaries, so a patch release of Go reaches them on the next release. +## API compatibility gate + +`go-package-release.yml` checks the exported API of the candidate against the previous published version with a pinned [`gorelease`](https://pkg.go.dev/golang.org/x/exp/cmd/gorelease) before it creates the tag (ADR-0017 and REL-003 in otfabric/engineering). A rejected version stops the job before *Create and push annotated tag*: nothing is tagged or published. + +**Previous version.** The highest exact release tag of the module (`vX.Y.Z`, or `/vX.Y.Z` for a nested module) in the major of its module path, compared as semantic versions. Prereleases, moving tags (`v1`, `v1.2`) and other modules' or majors' tags never count. The job fails, and never picks another base, when that tag is not an ancestor of the release commit or its `go.mod` names another module. + +| Situation | Base | Result | +|---|---|---| +| A previous version exists | that version | `gorelease -base= -version=` | +| No previous version (first release) | `none` | gorelease checks the module alone; the evidence records no previous version | +| First release of a `/vN` module path | `none` | the version is `vN.0.0` | +| `bump: major` to v2 or later without a `/vN` module path | — | fails: a new major is a module path change | +| `bump: major` from v0 | the v0 version | `v1.0.0`, with the v0 rules below | + +**Results.** + +| `result` | Meaning | `api-compatibility: enforce` | `report` | +|---|---|---|---| +| `pass` | gorelease accepts the version | release | release | +| `incompatible` | gorelease rejects it (wrong bump, or an incompatible change in v1+), or a v0 rule fails | stop, no tag | warning, release | +| `error` | gorelease did not reach a result: not installed, base not downloadable, dirty tree | stop, no tag | warning, release | + +An `error` is never read as a pass and never reported as an incompatibility: the step output and the evidence keep them apart. + +**v0.** gorelease accepts any change against a v0 base, so the workflow applies the rules of ADR-0017 to its report: a v0 **patch** must not change the exported API at all; a v0 minor or major with incompatible changes needs a `### Breaking` heading in its `RELEASE.md` section. + +**Evidence.** Every run that resolved a version writes `release-evidence.json` (artifact `release-evidence`, and an asset of the GitHub release): commit, module path and directory, previous and proposed version, the gorelease version and result with the listed changes, the Go toolchain, vet, test and race results, consumer results, the recorded exceptions and baseline, the release-notes source, and what the release job does **not** run (coverage, govulncheck, lint, fuzz, interop: go-ci evidence on the same commit). + +**Consumers.** `consumers` lists direct dependants to build, vet and test against the candidate before tagging (`owner/repo` or `owner/repo:dir`, one per line). Each is cloned into a temporary directory with a `replace` that is never committed. A separate, read-only job runs them: the read token exists only while cloning and downloading, and consumer code builds and tests offline. Any failure stops the release and names the consumer, its revision, the version it requires and the candidate commit. + +**Staged adoption.** `api-compatibility: report` records the result without stopping the release. otfabric/engineering's REL-003 reports a caller in report mode or below v3.8.0 (v3.7.0 has no gate). + ## Release notes via RELEASE.md -Both release workflows support an optional `RELEASE.md` file in the repo root. When a section matching the new tag is found, its content is used as the GitHub Release body. Otherwise a commit-log changelog is generated automatically. +Both release workflows support an optional `RELEASE.md` file in the repo root (for `go-package-release.yml`, in `module-dir`; a nested module's section heading is its version without the directory prefix, `## v0.3.0`). When a section matching the new tag is found, its content is used as the GitHub Release body. Otherwise a commit-log changelog is generated automatically. ```markdown ## v1.3.0 @@ -378,6 +410,12 @@ Fork and Dependabot runs never receive Actions secrets. Any other run without th | `bump` | *(required)* | `patch`, `minor`, or `major` | | `update-major-tag` | `false` | Maintain a moving `v1`-style tag | | `release-name-prefix` | `""` | Prefix for the GitHub Release title | +| `test-exclude-regex` | `(/examples($\|/)\|/cmd($\|/))` | Packages left out of the release tests (`grep -Ev`) | +| `module-dir` | `.` | Module to release; a nested module's tags carry the directory as prefix (`sub/v0.3.0`) | +| `api-compatibility` | `enforce` | `enforce` stops before tagging on `incompatible` or `error`; `report` records and releases ([API compatibility gate](#api-compatibility-gate)) | +| `gorelease-version` | `v0.0.0-20261014143641-f0251eb4aa3e` | Exact version of `golang.org/x/exp/cmd/gorelease`; never `latest` | +| `gorelease-go-version` | `1.26` | Lowest Go minor that builds that gorelease; the newer of this and the module's `go` line is used | +| `consumers` | `""` | Dependants built, vetted and tested against the candidate before tagging, one `owner/repo[:dir]` per line | ### go-binary-release.yml @@ -400,6 +438,7 @@ Both release workflows expose outputs for downstream chaining: | `version` | `1.2.3` | Package, Binary | | `tag` | `v1.2.3` | Package, Binary | | `previous-tag` | `v1.2.2` | Package, Binary | +| `api-result` | `pass` | Package | ## Required secrets diff --git a/.github/workflows/go-package-release.yml b/.github/workflows/go-package-release.yml index dbdf65b..4a389a9 100644 --- a/.github/workflows/go-package-release.yml +++ b/.github/workflows/go-package-release.yml @@ -3,8 +3,11 @@ # Go Package Release — Reusable Workflow # # Creates a semver-tagged release for a Go module (library / package). -# Validates module integrity and runs tests (including race detector) before -# tagging. Optionally reads hand-written release notes from a RELEASE.md file. +# Validates module integrity, runs tests (including the race detector) and +# checks API compatibility against the previous published version with a +# pinned gorelease before it creates the tag (ADR-0017 in otfabric/engineering, +# REL-003). Optionally builds and tests direct consumers against the candidate. +# Reads hand-written release notes from RELEASE.md. # # RELEASE.md format (optional): # @@ -15,14 +18,14 @@ # ## v1.1.0 # ... # -# When a matching section is found for the new tag, its content is used as the -# GitHub release body. Otherwise, a commit-log changelog is generated. +# When a matching section is found for the new version, its content is used as +# the GitHub release body. Otherwise, a commit-log changelog is generated. # # Caller example: # # jobs: # release: -# uses: otfabric/.github/.github/workflows/go-package-release.yml@v2 +# uses: otfabric/.github/.github/workflows/go-package-release.yml@v3.8.0 # with: # bump: minor # secrets: @@ -69,23 +72,67 @@ on: required: false type: boolean default: false + module-dir: + description: >- + Directory of the module to release, relative to the repository root. + A nested module's tags carry the directory as prefix + (tlsccm/v0.3.0) and only that module's tags are considered. + required: false + type: string + default: "." + api-compatibility: + description: >- + enforce (default): stop before tagging when gorelease rejects the + version, when it cannot run, or when a v0 rule is broken. report: + record the same result in the summary and the evidence file and + release anyway (staged adoption only; REL-003 reports it). + required: false + type: string + default: enforce + gorelease-version: + description: >- + Exact module version of golang.org/x/exp/cmd/gorelease. Never latest: + moving it changes what the gate accepts. + required: false + type: string + default: v0.0.0-20261014143641-f0251eb4aa3e + gorelease-go-version: + description: >- + Lowest Go minor that builds gorelease at gorelease-version (x/exp + requires go 1.26.0 at the default). The newer of this and the + module's go directive builds and runs it. + required: false + type: string + default: "1.26" + consumers: + description: >- + Direct consumers built, vetted and tested against the candidate + before tagging, one per line: owner/repo or owner/repo:dir. Each is + cloned into a temporary directory with a local replace that is never + committed. Empty: no consumer job. + required: false + type: string + default: "" outputs: version: description: "Released version without v prefix (e.g. 1.2.3)." value: ${{ jobs.release-package.outputs.version }} tag: - description: "Git tag created (e.g. v1.2.3)." + description: "Git tag created (e.g. v1.2.3, or sub/v1.2.3 for a nested module)." value: ${{ jobs.release-package.outputs.tag }} previous-tag: - description: "Previous semver tag before this release (e.g. v1.2.2)." + description: "Previous release tag of this module (v0.0.0, prefixed for a nested module, when there is none)." value: ${{ jobs.release-package.outputs.previous_tag }} + api-result: + description: "pass | incompatible | error: the API compatibility result (a non-pass result releases only with api-compatibility: report)." + value: ${{ jobs.release-package.outputs.api_result }} secrets: OTFABRIC_READ_TOKEN: description: "PAT with read access to private github.com/otfabric/* modules." required: true permissions: - contents: write + contents: read env: GOPRIVATE: github.com/otfabric/* @@ -95,14 +142,185 @@ concurrency: cancel-in-progress: false jobs: + consumers: + name: Consumers + if: inputs.consumers != '' + runs-on: ubuntu-latest + timeout-minutes: ${{ inputs.timeout-minutes }} + # Consumer code runs in this job: it can only read, the checkout keeps no + # credentials, and the read token exists only in the fetch step, which + # runs no consumer code (clone, go mod download, go list). + permissions: + contents: read + steps: + - name: Checkout candidate + uses: actions/checkout@v7 + with: + path: candidate + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version: stable + check-latest: true + cache: false + + - name: Fetch consumers + shell: bash + env: + TOKEN: ${{ secrets.OTFABRIC_READ_TOKEN }} + CONSUMERS: ${{ inputs.consumers }} + MODULE_DIR: ${{ inputs.module-dir }} + CANDIDATE: ${{ github.workspace }}/candidate + CONSUMER_BASE_URL: https://github.com + run: | + set -uo pipefail + # Clone each consumer into RUNNER_TEMP, point it at the candidate with + # a local replace (never committed), and download what it needs. + # Nothing here runs consumer code. One line per consumer goes to + # consumers.txt: n|repository|dir|revision|requires|state. + if [ -n "$TOKEN" ]; then + export GIT_CONFIG_COUNT=1 + export GIT_CONFIG_KEY_0="url.https://x-access-token:${TOKEN}@github.com/.insteadOf" + export GIT_CONFIG_VALUE_0="https://github.com/" + fi + dir="${MODULE_DIR#./}"; dir="${dir%/}"; [ -n "$dir" ] || dir=. + cand="$CANDIDATE/$dir" + [ -f "$cand/go.mod" ] || { echo "::error::no go.mod in module-dir '$MODULE_DIR'"; exit 1; } + path=$(awk '$1 == "module" { gsub(/"/, "", $2); print $2; exit }' "$cand/go.mod") + work="$RUNNER_TEMP/consumers"; mkdir -p "$work" + manifest="$work/consumers.txt"; : > "$manifest" + echo "$path" > "$work/module-path" + n=0 + while IFS= read -r line || [ -n "$line" ]; do + line=$(printf '%s' "$line" | sed -e 's/#.*//' -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') + [ -n "$line" ] || continue + n=$((n + 1)) + if ! printf '%s' "$line" | grep -Eq '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(:[A-Za-z0-9_./-]+)?$' || printf '%s' "$line" | grep -q '\.\.'; then + echo "::error::consumer '$line': expected owner/repo or owner/repo:dir" + printf '%s|%s||||%s\n' "$n" "${line//|/}" "invalid entry" >> "$manifest"; continue + fi + repo=${line%%:*}; sub=. + [ "$repo" = "$line" ] || sub=${line#*:} + dest="$work/$n"; log="$work/$n.fetch.log" + if ! git clone --quiet --depth 1 "$CONSUMER_BASE_URL/$repo" "$dest" > "$log" 2>&1; then + echo "::error::consumer $repo: clone failed"; cat "$log" + printf '%s|%s|%s|||%s\n' "$n" "$repo" "$sub" "clone failed" >> "$manifest"; continue + fi + rev=$(git -C "$dest" rev-parse HEAD) + if [ ! -f "$dest/$sub/go.mod" ]; then + echo "::error::consumer $repo@$rev: no go.mod in $sub" + printf '%s|%s|%s|%s||%s\n' "$n" "$repo" "$sub" "$rev" "no go.mod" >> "$manifest"; continue + fi + req=$(awk -v p="$path" ' + /^require[[:space:]]*\(/ { block = 1; next } + block && /^\)/ { block = 0; next } + block && $1 == p { print $2; exit } + $1 == "require" && $2 == p { print $3; exit }' "$dest/$sub/go.mod") + if [ -z "$req" ]; then + echo "::error::consumer $repo@$rev ($sub) does not require $path" + printf '%s|%s|%s|%s||%s\n' "$n" "$repo" "$sub" "$rev" "does not require $path" >> "$manifest"; continue + fi + state=fetched + ( + cd "$dest/$sub" && + go mod edit -replace="$path=$cand" && + GOWORK=off GOFLAGS=-mod=mod go mod download && + GOWORK=off GOFLAGS=-mod=mod go list -deps -test ./... > /dev/null + ) > "$log" 2>&1 || state="fetch failed" + if [ "$state" != fetched ]; then + echo "::error::consumer $repo@$rev ($sub): resolving its dependencies against the candidate failed"; tail -n 40 "$log" + fi + printf '%s|%s|%s|%s|%s|%s\n' "$n" "$repo" "$sub" "$rev" "$req" "$state" >> "$manifest" + done <<< "$CONSUMERS" + [ "$n" -gt 0 ] || { echo "::error::consumers is set but lists no consumer"; exit 1; } + exit 0 + + - name: Build and test consumers + shell: bash + env: + CANDIDATE_SHA: ${{ github.sha }} + run: | + set -uo pipefail + # Build, vet and test every fetched consumer offline, without + # credentials. Every consumer is reported; the step fails if any + # consumer failed here or in the fetch step. + work="$RUNNER_TEMP/consumers" + manifest="$work/consumers.txt" + path=$(cat "$work/module-path") + results="$RUNNER_TEMP/consumer-results.jsonl"; : > "$results" + record() { # repository dir revision requires status log-file + python3 -c ' + import json, os, sys + k = ["repository", "dir", "revision", "requires", "status"] + r = dict(zip(k, sys.argv[1:6]), candidate=os.environ["CANDIDATE_SHA"]) + r["log_tail"] = open(sys.argv[6]).read().splitlines()[-20:] if os.path.exists(sys.argv[6]) else [] + print(json.dumps(r))' "$@" >> "$results" + } + failed=0 + while IFS='|' read -r n repo sub rev req state; do + log="$work/$n.log" + if [ "$state" != fetched ]; then + record "$repo" "$sub" "$rev" "$req" "$state" "$work/$n.fetch.log"; failed=1; continue + fi + status=pass + ( + cd "$work/$n/$sub" && + GOWORK=off GOPROXY=off GOFLAGS=-mod=mod go build ./... && + GOWORK=off GOPROXY=off GOFLAGS=-mod=mod go vet ./... && + GOWORK=off GOPROXY=off GOFLAGS=-mod=mod go test -count=1 ./... + ) > "$log" 2>&1 || status=failed + if [ "$status" = pass ]; then + echo "consumer $repo@$rev ($sub, requires $path $req): build, vet and test pass against $CANDIDATE_SHA" + else + echo "::error::consumer $repo@$rev ($sub, requires $path $req) fails against $path at $CANDIDATE_SHA" + tail -n 40 "$log" + failed=1 + fi + record "$repo" "$sub" "$rev" "$req" "$status" "$log" + done < "$manifest" + if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + { + echo "## Consumers of $path at $CANDIDATE_SHA" + echo + echo "| Consumer | Dir | Revision | Requires | Result |" + echo "|---|---|---|---|---|" + python3 -c ' + import json, sys + for l in open(sys.argv[1]): + r = json.loads(l) + print("| %s | %s | %s | %s | %s |" % (r["repository"], r["dir"], r["revision"][:12], r["requires"], r["status"]))' "$results" + } >> "$GITHUB_STEP_SUMMARY" + fi + exit "$failed" + + - name: Upload consumer results + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: consumer-results + path: ${{ runner.temp }}/consumer-results.jsonl + if-no-files-found: ignore + release-package: name: Release + needs: consumers + # Runs when the consumer job passed or was not requested; never after a + # consumer failure. + if: ${{ !cancelled() && (needs.consumers.result == 'success' || needs.consumers.result == 'skipped') }} runs-on: ubuntu-latest timeout-minutes: ${{ inputs.timeout-minutes }} + permissions: + contents: write + defaults: + run: + working-directory: ${{ inputs.module-dir }} outputs: version: ${{ steps.version.outputs.version }} tag: ${{ steps.version.outputs.tag }} previous_tag: ${{ steps.version.outputs.previous_tag }} + api_result: ${{ steps.api.outputs.result }} steps: - name: Checkout @@ -113,6 +331,7 @@ jobs: - name: Verify default branch shell: bash + working-directory: . env: DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} CURRENT_REF: ${{ github.ref_name }} @@ -123,13 +342,76 @@ jobs: exit 1 fi + - name: Resolve module + id: module + shell: bash + working-directory: . + env: + MODULE_DIR: ${{ inputs.module-dir }} + GORELEASE_GO: ${{ inputs.gorelease-go-version }} + run: | + set -euo pipefail + dir="${MODULE_DIR#./}"; dir="${dir%/}"; [ -n "$dir" ] || dir=. + case "/$dir/" in + //*|*/../*) echo "::error::module-dir must be a relative path inside the repository, got '$MODULE_DIR'"; exit 1 ;; + esac + printf '%s' "$dir" | grep -Eq '^[A-Za-z0-9._/-]+$' || { echo "::error::module-dir '$MODULE_DIR' has characters outside [A-Za-z0-9._/-]"; exit 1; } + [ -f "$dir/go.mod" ] || { echo "::error::no go.mod in module-dir '$MODULE_DIR'"; exit 1; } + path=$(awk '$1 == "module" { gsub(/"/, "", $2); print $2; exit }' "$dir/go.mod") + [ -n "$path" ] || { echo "::error::no module directive in $dir/go.mod"; exit 1; } + prefix=""; [ "$dir" = . ] || prefix="$dir/" + path_major=0 + if [[ "$path" =~ /v([0-9]+)$ ]]; then + path_major=${BASH_REMATCH[1]} + [ "$path_major" -ge 2 ] || { echo "::error::module path $path: a /vN suffix starts at /v2"; exit 1; } + fi + go_minor=$(sed -nE 's/^go[[:space:]]+([0-9]+\.[0-9]+).*/\1/p' "$dir/go.mod" | head -1) + [ -n "$go_minor" ] || { echo "::error::no go directive in $dir/go.mod"; exit 1; } + printf '%s' "$GORELEASE_GO" | grep -Eq '^[0-9]+\.[0-9]+$' || { echo "::error::gorelease-go-version must be a Go minor such as 1.26, got '$GORELEASE_GO'"; exit 1; } + gorelease_go=$(printf '%s\n%s\n' "$GORELEASE_GO" "$go_minor" | sort -t. -k1,1n -k2,2n | tail -1) + { + echo "dir=$dir" + echo "path=$path" + echo "prefix=$prefix" + echo "path_major=$path_major" + echo "go_minor=$go_minor" + echo "gorelease_go=$gorelease_go" + } >> "$GITHUB_OUTPUT" + echo "module $path in $dir (tag prefix '${prefix}', go $go_minor; gorelease built with Go $gorelease_go)" + + - name: Set up Go for gorelease + uses: actions/setup-go@v7 + with: + go-version: ${{ steps.module.outputs.gorelease_go }} + check-latest: true + cache: false + + - name: Install gorelease + id: gorelease + shell: bash + working-directory: ${{ runner.temp }} + env: + GORELEASE_VERSION: ${{ inputs.gorelease-version }} + run: | + set -euo pipefail + if ! printf '%s' "$GORELEASE_VERSION" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then + echo "::error::gorelease-version must be an exact module version, got '$GORELEASE_VERSION' (never latest or a branch)" + exit 1 + fi + GOBIN="$RUNNER_TEMP/gorelease-bin" GOFLAGS= GOWORK=off go install "golang.org/x/exp/cmd/gorelease@$GORELEASE_VERSION" + { + echo "path=$RUNNER_TEMP/gorelease-bin/gorelease" + echo "goroot=$(go env GOROOT)" + echo "go=$(go env GOVERSION)" + } >> "$GITHUB_OUTPUT" + - name: Set up Go uses: actions/setup-go@v7 with: - go-version-file: go.mod + go-version-file: ${{ inputs.module-dir }}/go.mod cache: true cache-dependency-path: | - go.sum + ${{ inputs.module-dir }}/go.sum **/go.sum - name: Configure private module access @@ -154,6 +436,7 @@ jobs: run: go mod verify - name: go vet + id: vet if: inputs.run-vet shell: bash run: go vet ./... @@ -173,18 +456,26 @@ jobs: printf 'list=%s\n' "${pkgs[*]}" >> "$GITHUB_OUTPUT" - name: Test + id: test shell: bash run: go test -count=1 -timeout=120s ${{ steps.pkgs.outputs.list }} - name: Race test + id: race shell: bash run: go test -count=1 -timeout=120s -race ${{ steps.pkgs.outputs.list }} - name: Determine next version id: version shell: bash + working-directory: . env: BUMP: ${{ inputs.bump }} + MODULE_DIR: ${{ steps.module.outputs.dir }} + MODULE_PATH: ${{ steps.module.outputs.path }} + PREFIX: ${{ steps.module.outputs.prefix }} + PATH_MAJOR: ${{ steps.module.outputs.path_major }} + SHA: ${{ github.sha }} run: | set -euo pipefail @@ -196,31 +487,69 @@ jobs: ;; esac - previous_tag="$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | sort -V | tail -1)" - if [ -z "$previous_tag" ]; then - previous_tag="v0.0.0" + # The previous published version: the highest exact release tag + # (vX.Y.Z, no prerelease, no moving tag) of this module in the + # major of its module path. Never another module's or major's tag. + prefix_re=$(printf '%s' "$PREFIX" | sed 's/[.]/\\./g') + re="^${prefix_re}v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$" + tags=$(git tag -l "${PREFIX}v*" | grep -E "$re" || true) + previous_tag=$(printf '%s\n' "$tags" | while read -r t; do + [ -n "$t" ] || continue + v=${t#"$PREFIX"v}; IFS=. read -r a b c <<< "$v" + if [ "$PATH_MAJOR" -ge 2 ]; then [ "$a" = "$PATH_MAJOR" ] || continue; else [ "$a" -le 1 ] || continue; fi + echo "$a $b $c $t" + done | sort -k1,1n -k2,2n -k3,3n | tail -1 | awk '{ print $4 }') + + if [ -n "$previous_tag" ]; then + previous_version=${previous_tag#"$PREFIX"v} + if ! git merge-base --is-ancestor "refs/tags/$previous_tag" "$SHA"; then + echo "::error::The previous release $previous_tag is not an ancestor of $SHA. Refusing to compare with another version; fix the history or the tag." + exit 1 + fi + gomod="go.mod"; [ "$MODULE_DIR" = . ] || gomod="$MODULE_DIR/go.mod" + base_path=$(git show "refs/tags/$previous_tag:$gomod" 2>/dev/null | awk '$1 == "module" { gsub(/"/, "", $2); print $2; exit }' || true) + if [ "$base_path" != "$MODULE_PATH" ]; then + echo "::error::$previous_tag has module path '${base_path:-}', not $MODULE_PATH. Refusing to compare with another module." + exit 1 + fi + base="v$previous_version" + else + # First release of this module, or of this major. + if [ "$PATH_MAJOR" -ge 2 ]; then previous_version="$PATH_MAJOR.0.0"; else previous_version="0.0.0"; fi + previous_tag="${PREFIX}v0.0.0" + base=none fi - - previous_version="${previous_tag#v}" IFS='.' read -r major minor patch <<< "$previous_version" - case "$BUMP" in - major) major=$((major + 1)); minor=0; patch=0 ;; - minor) minor=$((minor + 1)); patch=0 ;; - patch) patch=$((patch + 1)) ;; - esac + if [ "$base" = none ] && [ "$PATH_MAJOR" -ge 2 ]; then + major=$PATH_MAJOR; minor=0; patch=0 # the first vN release is vN.0.0 + else + case "$BUMP" in + major) major=$((major + 1)); minor=0; patch=0 ;; + minor) minor=$((minor + 1)); patch=0 ;; + patch) patch=$((patch + 1)) ;; + esac + fi + if [ "$major" -ge 2 ] && [ "$major" != "$PATH_MAJOR" ]; then + echo "::error::v$major.0.0 needs the module path to end in /v$major ($MODULE_PATH does not). A new major is a module path change, decided by an ADR in the repository." + exit 1 + fi version="${major}.${minor}.${patch}" - tag="v${version}" - major_tag="v${major}" - - echo "previous_tag=$previous_tag" >> "$GITHUB_OUTPUT" - echo "previous_version=$previous_version" >> "$GITHUB_OUTPUT" - echo "version=$version" >> "$GITHUB_OUTPUT" - echo "tag=$tag" >> "$GITHUB_OUTPUT" - echo "major_tag=$major_tag" >> "$GITHUB_OUTPUT" + { + echo "previous_tag=$previous_tag" + echo "previous_version=$previous_version" + echo "base=$base" + echo "base_major=${previous_version%%.*}" + echo "version=$version" + echo "version_tag=v$version" + echo "tag=${PREFIX}v$version" + echo "major_tag=${PREFIX}v$major" + } >> "$GITHUB_OUTPUT" + echo "module $MODULE_PATH: previous $previous_tag (base $base), proposed ${PREFIX}v$version" - name: Fail if tag already exists + id: tagcheck shell: bash env: TAG: ${{ steps.version.outputs.tag }} @@ -231,12 +560,89 @@ jobs: exit 1 fi + - name: API compatibility + id: api + shell: bash + env: + MODE: ${{ inputs.api-compatibility }} + BUMP: ${{ inputs.bump }} + BASE: ${{ steps.version.outputs.base }} + BASE_MAJOR: ${{ steps.version.outputs.base_major }} + VERSION: ${{ steps.version.outputs.version_tag }} + GORELEASE: ${{ steps.gorelease.outputs.path }} + GORELEASE_GOROOT: ${{ steps.gorelease.outputs.goroot }} + run: | + set -uo pipefail + case "$MODE" in + enforce|report) ;; + *) echo "::error::api-compatibility must be enforce or report, got '$MODE'"; exit 1 ;; + esac + report="$RUNNER_TEMP/gorelease.txt"; errlog="$RUNNER_TEMP/gorelease.err" + : > "$report"; : > "$errlog" + code=0 + if [ -z "$GORELEASE" ] || [ ! -x "$GORELEASE" ]; then + echo "gorelease: not installed ('$GORELEASE')" > "$errlog"; code=127 + else + PATH="$GORELEASE_GOROOT/bin:$PATH" GOWORK=off "$GORELEASE" -base="$BASE" -version="$VERSION" > "$report" 2> "$errlog" || code=$? + fi + incompatible=false; compatible=false + grep -q '^## incompatible changes' "$report" && incompatible=true + grep -q '^## compatible changes' "$report" && compatible=true + # Exit 0: valid. Exit 1 with a summary: gorelease rejected the + # version. Anything else: the tool did not reach a result, which is + # not an incompatibility and not a pass. + if [ "$code" -eq 0 ]; then + result=pass + elif [ "$code" -eq 1 ] && grep -q '^# summary' "$report"; then + result=incompatible; reason="gorelease rejects $VERSION against $BASE" + else + result=error; reason="gorelease did not reach a result (exit $code): $(tail -n 1 "$errlog")" + fi + # gorelease never rejects against a v0 base; the v0 rules of + # ADR-0017 are read from its report. + v0_breaking=false + if [ "$result" = pass ] && [ "$BASE" != none ] && [ "$BASE_MAJOR" = 0 ]; then + if [ "$BUMP" = patch ] && { [ "$incompatible" = true ] || [ "$compatible" = true ]; }; then + result=incompatible; reason="a v0 patch must not change the exported API ($VERSION against $BASE)" + elif [ "$incompatible" = true ]; then + v0_breaking=true + fi + fi + { + echo "result=$result" + echo "exit_code=$code" + echo "incompatible=$incompatible" + echo "compatible=$compatible" + echo "v0_breaking=$v0_breaking" + echo "report=$report" + } >> "$GITHUB_OUTPUT" + if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + { + echo "## API compatibility: $result" + echo + echo "\`gorelease -base=$BASE -version=$VERSION\` exited $code (mode: $MODE)." + echo + echo '```text' + cat "$report" "$errlog" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + fi + cat "$report"; cat "$errlog" >&2 + if [ "$result" = pass ]; then + echo "API compatibility: $VERSION against $BASE passes" + elif [ "$MODE" = enforce ]; then + echo "::error::$reason. No tag was created." + exit 1 + else + echo "::warning::$reason (api-compatibility: report, releasing anyway)" + fi + - name: Generate release notes id: notes shell: bash env: PREV_TAG: ${{ steps.version.outputs.previous_tag }} - TAG: ${{ steps.version.outputs.tag }} + TAG: ${{ steps.version.outputs.version_tag }} run: | set -euo pipefail out="$RUNNER_TEMP/release-notes.md" @@ -258,10 +664,10 @@ jobs: { echo "## Changes since ${PREV_TAG}" echo - if [ "$PREV_TAG" = "v0.0.0" ]; then - git log --pretty=format:"- %s (%h)" + if git rev-parse -q --verify "refs/tags/${PREV_TAG}" >/dev/null; then + git log "${PREV_TAG}..HEAD" --pretty=format:"- %s (%h)" -- . else - git log "${PREV_TAG}..HEAD" --pretty=format:"- %s (%h)" + git log --pretty=format:"- %s (%h)" -- . fi } > "$out" fi @@ -269,6 +675,165 @@ jobs: echo "file=$out" >> "$GITHUB_OUTPUT" echo "used_release_md=$used_release_md" >> "$GITHUB_OUTPUT" + - name: Check v0 breaking notes + id: v0notes + if: steps.api.outputs.v0_breaking == 'true' + shell: bash + env: + MODE: ${{ inputs.api-compatibility }} + VERSION: ${{ steps.version.outputs.version_tag }} + NOTES_FILE: ${{ steps.notes.outputs.file }} + USED_RELEASE_MD: ${{ steps.notes.outputs.used_release_md }} + run: | + set -euo pipefail + if [ "$USED_RELEASE_MD" = true ] && grep -Eq '^### Breaking' "$NOTES_FILE"; then + echo "documented=true" >> "$GITHUB_OUTPUT" + echo "v0 incompatible changes in $VERSION are listed under ### Breaking" + exit 0 + fi + echo "documented=false" >> "$GITHUB_OUTPUT" + msg="$VERSION has incompatible API changes against a v0 base; its RELEASE.md section needs a '### Breaking' heading with the consumer assessment" + if [ "$MODE" = enforce ]; then + echo "::error::$msg. No tag was created." + exit 1 + fi + echo "::warning::$msg (api-compatibility: report)" + + - name: Download consumer results + if: needs.consumers.result == 'success' + uses: actions/download-artifact@v8 + with: + name: consumer-results + path: ${{ runner.temp }}/consumers + + - name: Write release evidence + id: evidence + # Also after a gate failure, so a stopped release has its evidence. + if: ${{ !cancelled() && steps.version.outcome == 'success' }} + shell: bash + env: + REPOSITORY: ${{ github.repository }} + SHA: ${{ github.sha }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }} + WORKFLOW_REF: ${{ github.workflow_ref }} + MODULE_PATH: ${{ steps.module.outputs.path }} + MODULE_DIR: ${{ steps.module.outputs.dir }} + PREFIX: ${{ steps.module.outputs.prefix }} + BUMP: ${{ inputs.bump }} + BASE: ${{ steps.version.outputs.base }} + PREV_TAG: ${{ steps.version.outputs.previous_tag }} + VERSION: ${{ steps.version.outputs.version_tag }} + TAG: ${{ steps.version.outputs.tag }} + MODE: ${{ inputs.api-compatibility }} + API_RESULT: ${{ steps.api.outputs.result }} + API_EXIT: ${{ steps.api.outputs.exit_code }} + API_REPORT: ${{ steps.api.outputs.report }} + V0_BREAKING: ${{ steps.api.outputs.v0_breaking }} + V0_DOCUMENTED: ${{ steps.v0notes.outputs.documented }} + GORELEASE_VERSION: ${{ inputs.gorelease-version }} + GORELEASE_GO: ${{ steps.gorelease.outputs.go }} + TEST_PACKAGES: ${{ steps.pkgs.outputs.list }} + VET_OUTCOME: ${{ steps.vet.outcome }} + TEST_OUTCOME: ${{ steps.test.outcome }} + RACE_OUTCOME: ${{ steps.race.outcome }} + API_OUTCOME: ${{ steps.api.outcome }} + TAG_EXISTS_OUTCOME: ${{ steps.tagcheck.outcome }} + CONSUMERS_RESULT: ${{ needs.consumers.result }} + NOTES_FROM_RELEASE_MD: ${{ steps.notes.outputs.used_release_md }} + run: | + set -euo pipefail + out="$RUNNER_TEMP/release-evidence.json" + GO_VERSION=$(go env GOVERSION) OUT="$out" python3 - <<'PY' + import datetime, hashlib, json, os, pathlib, re + + e = os.environ + + + def outcome(name): + # success / failure / skipped / "" (did not run) -> pass / fail / not run + return {"success": "pass", "failure": "fail"}.get(e.get(name, ""), "not run") + + + def api_changes(path): + # gorelease prints "# ", then "## incompatible changes" and + # "## compatible changes" sections, then "# summary". + found, pkg, cur = {"incompatible": [], "compatible": []}, "", None + if not path or not os.path.exists(path): + return found + for line in pathlib.Path(path).read_text().splitlines(): + if line.startswith("## "): + cur = {"## incompatible changes": "incompatible", "## compatible changes": "compatible"}.get(line.strip()) + elif line.startswith("# "): + pkg, cur = ("" if line.strip() == "# summary" else line[2:].strip()), None + elif cur and line.strip(): + found[cur].append(f"{pkg}: {line.strip()}" if pkg else line.strip()) + return found + + + def recorded(rel): + f = pathlib.Path(e.get("GITHUB_WORKSPACE", ".")) / rel + if not f.exists(): + return {"file": rel, "present": False} + text = f.read_text() + return {"file": rel, "present": True, "sha256": hashlib.sha256(text.encode()).hexdigest(), + "rules": sorted(set(re.findall(r"rule:\s*['\"]?([A-Z]+-[A-Z0-9]+)", text)))} + + + consumers = {"requested": e["CONSUMERS_RESULT"] != "skipped", "job": e["CONSUMERS_RESULT"], "results": []} + results = pathlib.Path(e["RUNNER_TEMP"]) / "consumers" / "consumer-results.jsonl" + if results.exists(): + consumers["results"] = [json.loads(l) for l in results.read_text().splitlines() if l.strip()] + + base = e["BASE"] + api_ran = e.get("API_RESULT", "") != "" + not_run = ("not run by the release workflow: go-ci runs it on pull requests and pushes; " + "only a go-ci run of this exact commit is evidence for this release") + evidence = { + "schema": "otfabric-release-evidence-v1", + "repository": e["REPOSITORY"], + "commit": e["SHA"], + "workflow": {"ref": e["WORKFLOW_REF"], "run": e["RUN_URL"]}, + "generated_at": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "module": {"path": e["MODULE_PATH"], "dir": e["MODULE_DIR"], "tag_prefix": e["PREFIX"]}, + "previous_version": None if base == "none" else base, + "previous_tag": None if base == "none" else e["PREV_TAG"], + "proposed_version": e["VERSION"], + "tag": e["TAG"], + "bump": e["BUMP"], + "tag_free": outcome("TAG_EXISTS_OUTCOME"), + "api_compatibility": { + "mode": e["MODE"], + "tool": "golang.org/x/exp/cmd/gorelease@" + e["GORELEASE_VERSION"], + "tool_go": e.get("GORELEASE_GO", ""), + "base": base, + "result": e["API_RESULT"] if api_ran else ("error" if e.get("API_OUTCOME") == "failure" else "not run"), + "exit_code": int(e["API_EXIT"]) if e.get("API_EXIT") else None, + "v0_breaking": e.get("V0_BREAKING") == "true", + "v0_breaking_documented": (e.get("V0_DOCUMENTED") == "true") if e.get("V0_BREAKING") == "true" else None, + "changes": api_changes(e.get("API_REPORT", "")), + }, + "go": {"tested_with": e["GO_VERSION"], + "note": "the toolchain selected by the module's go.mod; the Go version matrix runs in go-ci"}, + "tests": {"packages": e["TEST_PACKAGES"].split(), "vet": outcome("VET_OUTCOME"), + "test": outcome("TEST_OUTCOME"), "race": outcome("RACE_OUTCOME")}, + "consumers": consumers, + "release_notes": {"source": {"true": "RELEASE.md", "false": "commit log"}.get(e.get("NOTES_FROM_RELEASE_MD", ""), "not run")}, + "exceptions": recorded(".otfabric/exceptions.yaml"), + "baseline": recorded(".otfabric/baseline.yaml"), + "not_run": {k: not_run for k in ("coverage", "govulncheck", "lint", "fuzz", "interop")}, + } + pathlib.Path(e["OUT"]).write_text(json.dumps(evidence, indent=2) + "\n") + PY + echo "file=$out" >> "$GITHUB_OUTPUT" + cat "$out" + + - name: Upload release evidence + if: ${{ !cancelled() && steps.evidence.outputs.file != '' }} + uses: actions/upload-artifact@v7 + with: + name: release-evidence + path: ${{ steps.evidence.outputs.file }} + - name: Create and push annotated tag shell: bash env: @@ -300,6 +865,7 @@ jobs: TAG: ${{ steps.version.outputs.tag }} PREFIX: ${{ inputs.release-name-prefix }} NOTES_FILE: ${{ steps.notes.outputs.file }} + EVIDENCE: ${{ steps.evidence.outputs.file }} SHA: ${{ github.sha }} run: | set -euo pipefail @@ -313,14 +879,20 @@ jobs: --verify-tag \ --title "$title" \ --notes-file "$NOTES_FILE" \ - --target "$SHA" + --target "$SHA" \ + "$EVIDENCE" - name: Release summary shell: bash env: BUMP: ${{ inputs.bump }} + MODULE_PATH: ${{ steps.module.outputs.path }} PREV_TAG: ${{ steps.version.outputs.previous_tag }} + BASE: ${{ steps.version.outputs.base }} TAG: ${{ steps.version.outputs.tag }} + API_RESULT: ${{ steps.api.outputs.result }} + MODE: ${{ inputs.api-compatibility }} + CONSUMERS_RESULT: ${{ needs.consumers.result }} MAJOR_TAG_UPDATED: ${{ inputs.update-major-tag }} USED_RELEASE_MD: ${{ steps.notes.outputs.used_release_md }} run: | @@ -330,9 +902,14 @@ jobs: echo "| Item | Value |" echo "|------|-------|" echo "| Repository | \`${{ github.repository }}\` |" + echo "| Module | \`$MODULE_PATH\` |" echo "| Bump | $BUMP |" echo "| Previous tag | $PREV_TAG |" + echo "| API base | $BASE |" + echo "| API compatibility | $API_RESULT ($MODE) |" + echo "| Consumers | $CONSUMERS_RESULT |" echo "| New tag | $TAG |" echo "| Major tag updated | $MAJOR_TAG_UPDATED |" echo "| Notes from RELEASE.md | $USED_RELEASE_MD |" + echo "| Evidence | release-evidence.json (release asset) |" } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/selftest.yml b/.github/workflows/selftest.yml index feaa489..fc7b4c4 100644 --- a/.github/workflows/selftest.yml +++ b/.github/workflows/selftest.yml @@ -291,3 +291,38 @@ jobs: steps: - uses: actions/checkout@v7 - run: scripts/selftest/rollout.sh + + # go-package-release.yml's API gate (otfabric/engineering ADR-0017): + # previous-version resolution, the pinned gorelease, the v0 rules, the + # evidence file and the step order, on fixture repositories and a file:// + # module proxy. Nothing is tagged or pushed. + release-gate: + name: Release API gate cases + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version: "1.26" + cache: false + - name: Install the pinned gorelease + run: | + pin=$(awk '/^ gorelease-version:/ { f = 1 } f && $1 == "default:" { print $2; exit }' .github/workflows/go-package-release.yml) + GOBIN="$RUNNER_TEMP/bin" GOFLAGS= go install "golang.org/x/exp/cmd/gorelease@$pin" + echo "GORELEASE=$RUNNER_TEMP/bin/gorelease" >> "$GITHUB_ENV" + - run: scripts/selftest/release-gate.sh + + # go-package-release.yml's consumers job: a consumer that no longer compiles + # stops the release and is named with its revision. + release-consumers: + name: Release consumer cases + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version: stable + cache: false + - run: scripts/selftest/consumers.sh diff --git a/AGENTS.md b/AGENTS.md index 4f8e643..05c1519 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +37,8 @@ This repository follows **otfabric/engineering policy 1.2**, profile **infrastru | release notes extraction | `scripts/selftest/release-notes.sh` | | the Go version of release builds | `scripts/selftest/go-version.sh` | | `scripts/rollout/pr-workflow.py` | `scripts/selftest/rollout.sh` | +| the API gate of `go-package-release.yml` (version resolution, gorelease, v0 rules, evidence) | `GORELEASE= scripts/selftest/release-gate.sh` | +| its consumers job | `scripts/selftest/consumers.sh` | | `docs-check.yml` docsync step | `scripts/selftest/docs-check.sh` | | `pr-changes.yml` classification | `scripts/selftest/pr-changes.sh` | | `actions/required-gate` | `scripts/selftest/required-gate.sh` | diff --git a/scripts/selftest/consumers.sh b/scripts/selftest/consumers.sh new file mode 100755 index 0000000..0e30bd1 --- /dev/null +++ b/scripts/selftest/consumers.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +# Cases of the consumers job of go-package-release.yml: each consumer is +# cloned, pointed at the candidate with a temporary replace, and built, vetted +# and tested; a failure names the consumer, its revision and the error. The +# exact step scripts run on fixture repositories cloned over file://, offline. +set -euo pipefail + +root=$(cd "$(dirname "$0")/../.." && pwd) +wf="$root/.github/workflows/go-package-release.yml" +sel="$root/scripts/selftest" +work=$(mktemp -d) +cleanup() { chmod -R u+w "$work" 2>/dev/null || true; rm -rf "$work"; } +trap cleanup EXIT + +for s in "Fetch consumers" "Build and test consumers"; do + python3 "$sel/extract-step.py" "$wf" "$s" > "$work/$s.sh" +done + +export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 +export GIT_AUTHOR_NAME=selftest GIT_AUTHOR_EMAIL=selftest@example.invalid +export GIT_COMMITTER_NAME=selftest GIT_COMMITTER_EMAIL=selftest@example.invalid +export GOPROXY=off GOSUMDB=off GOFLAGS= GOWORK=off GOTOOLCHAIN=local GOMODCACHE="$work/modcache" + +# The candidate: F changed signature (a break), G added. +cand="$work/candidate" +mkdir -p "$cand" +printf 'module example.com/lib\n\ngo 1.23\n' > "$cand/go.mod" +printf 'package lib\n\nfunc F(x int) int { return x }\n\nfunc G() int { return 2 }\n' > "$cand/lib.go" + +remote() { # owner/repo dir go-source [module-path-required] + local r="$work/remotes/$1" + mkdir -p "$r/$2" + git -C "$r" init -q -b main 2> /dev/null || true + { + printf 'module example.com/%s\n\ngo 1.23\n' "$(basename "$1")" + [ -z "${4:-}" ] || printf '\nrequire (\n\t%s v1.0.0\n)\n' "$4" + } > "$r/$2/go.mod" + printf '%s\n' "$3" > "$r/$2/main.go" + git -C "$r" add -A && git -C "$r" commit -q -m fixture +} +remote otfabric/good . 'package main + +import "example.com/lib" + +func main() { println(lib.G()) }' example.com/lib +remote otfabric/broken . 'package main + +import "example.com/lib" + +func main() { println(lib.F()) }' example.com/lib +remote otfabric/mono tools 'package main + +import "example.com/lib" + +func main() { println(lib.F(1)) }' example.com/lib +remote otfabric/unrelated . 'package main + +func main() {}' +git -C "$work/remotes/otfabric/good" rev-parse HEAD > "$work/good.rev" +git -C "$work/remotes/otfabric/broken" rev-parse HEAD > "$work/broken.rev" + +fail=0 +consumers() { # case consumer-list -> fetch-exit build-exit; results in $res + local out="$work/out/$1" + mkdir -p "$out/tmp" + res="$out/tmp/consumer-results.jsonl" + fetch=0 build=0 + (env RUNNER_TEMP="$out/tmp" TOKEN= CONSUMERS="$2" MODULE_DIR=. CANDIDATE="$cand" \ + CONSUMER_BASE_URL="file://$work/remotes" bash --noprofile --norc -eo pipefail "$work/Fetch consumers.sh") \ + > "$out/log" 2>&1 || fetch=$? + if [ "$fetch" = 0 ]; then + (env RUNNER_TEMP="$out/tmp" CANDIDATE_SHA=0123456789abcdef GITHUB_STEP_SUMMARY="$out/summary.md" \ + bash --noprofile --norc -eo pipefail "$work/Build and test consumers.sh") >> "$out/log" 2>&1 || build=$? + fi + log="$out/log" +} +check() { # case description python-expression over rows (results), log + if python3 -c ' +import json, os, sys +f, log = sys.argv[1], open(sys.argv[2]).read() +rows = {r["repository"]: r for r in map(json.loads, open(f))} if os.path.exists(f) else {} +good, broken = open(sys.argv[4]).read().strip(), open(sys.argv[5]).read().strip() +sys.exit(0 if eval(sys.argv[3]) else 1)' "$res" "$log" "$3" "$work/good.rev" "$work/broken.rev"; then + echo "ok $1: $2" + else + echo "FAIL $1: $2" + sed 's/^/ | /' "$log" + [ -f "$res" ] && sed 's/^/ | /' "$res" + fail=1 + fi +} + +consumers mixed 'otfabric/good +# a comment line +otfabric/broken +otfabric/mono:tools +otfabric/unrelated +otfabric/missing +../evil +otfabric/mono:../../x' +[ "$fetch" = 0 ] && [ "$build" = 1 ] && echo "ok mixed: fetch passes, build step fails" || + { echo "FAIL mixed: fetch exit $fetch, build exit $build; want 0, 1"; sed 's/^/ | /' "$log"; fail=1; } +check mixed "good consumer passes at its revision" 'rows["otfabric/good"]["status"] == "pass" and rows["otfabric/good"]["revision"] == good and rows["otfabric/good"]["requires"] == "v1.0.0"' +check mixed "broken consumer: repository, revision, requirement and compile error" 'rows["otfabric/broken"]["status"] == "failed" and rows["otfabric/broken"]["revision"] == broken and rows["otfabric/broken"]["requires"] == "v1.0.0" and any("not enough arguments" in l for l in rows["otfabric/broken"]["log_tail"])' +check mixed "the error annotation names consumer and revision" '"::error::consumer otfabric/broken@" + broken in log and "requires example.com/lib v1.0.0" in log' +check mixed "consumer in a subdirectory" 'rows["otfabric/mono"]["status"] == "pass" and rows["otfabric/mono"]["dir"] == "tools"' +check mixed "a repository that does not require the module fails" 'rows["otfabric/unrelated"]["status"] == "does not require example.com/lib"' +check mixed "a clone failure is reported, not skipped" 'rows["otfabric/missing"]["status"] == "clone failed"' +check mixed "path escapes are refused" 'rows["../evil"]["status"] == "invalid entry" and rows["otfabric/mono:../../x"]["status"] == "invalid entry"' +check mixed "the replace stays in the temporary clone" 'b"replace" not in open(sys.argv[1].rsplit("/", 1)[0] + "/../../../remotes/otfabric/good/go.mod", "rb").read()' +check mixed "summary table lists every consumer" 'len([l for l in open(os.path.dirname(sys.argv[2]) + "/summary.md") if l.startswith("| ") and not l.startswith("| Consumer")]) == 7' +check mixed "every result names the candidate revision" 'all(r["candidate"] == "0123456789abcdef" for r in rows.values())' + +consumers passing 'otfabric/good +otfabric/mono:tools' +[ "$fetch" = 0 ] && [ "$build" = 0 ] && echo "ok passing: all consumers pass, the job passes" || + { echo "FAIL passing: fetch exit $fetch, build exit $build; want 0, 0"; sed 's/^/ | /' "$log"; fail=1; } + +consumers empty '# nothing but a comment' +[ "$fetch" = 1 ] && echo "ok empty: a consumers input without a consumer fails" || + { echo "FAIL empty: fetch exit $fetch; want 1"; sed 's/^/ | /' "$log"; fail=1; } + +# The job: consumer code never runs with credentials or write access. +if python3 - "$wf" <<'EOF'; then +import re, sys +text = open(sys.argv[1]).read() +job = text.split("\n consumers:\n", 1)[1].split("\n release-package:\n", 1)[0] +problems = [] +if not re.search(r"^ permissions:\n contents: read$", job, re.M): + problems.append("the job is not read-only") +if "persist-credentials: false" not in job: + problems.append("the checkout keeps credentials") +if "git config --global" in job: + problems.append("the job writes a global git config") +steps = dict(zip(re.findall(r"^ - name: (.+)$", job, re.M), re.split(r"^ - name: .+$", job, flags=re.M)[1:])) +with_secret = [n for n, b in steps.items() if "secrets." in b] +if with_secret != ["Fetch consumers"]: + problems.append(f"secrets reach {with_secret}, want only Fetch consumers") +fetch = steps["Fetch consumers"] +if re.search(r"go (build|vet|test)\b", fetch): + problems.append("Fetch consumers runs consumer code") +build = steps["Build and test consumers"] +for cmd in ("go build", "go vet", "go test"): + if not re.search(r"GOPROXY=off GOFLAGS=-mod=mod " + cmd, build): + problems.append(f"{cmd} is not offline") +for p in problems: + print("FAIL job:", p) +sys.exit(1 if problems else 0) +EOF + echo "ok job: read-only, no persisted or global credentials, the token only in the fetch step, consumer code runs offline" +else + fail=1 +fi +exit $fail diff --git a/scripts/selftest/modproxy.py b/scripts/selftest/modproxy.py new file mode 100755 index 0000000..a71a614 --- /dev/null +++ b/scripts/selftest/modproxy.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Publish one version of a module from a fixture git repository to a local +file:// module proxy (GOPROXY=file://), so selftests can stand in for a +published release without the network or a real tag push. + +Usage: modproxy.py +The module path is read from /go.mod at . Nested modules +(subdirectories with their own go.mod) are left out of the zip, as the go +command does. Standard library only; fixture module paths are lower case, so +no path escaping is needed. +""" +import io +import json +import pathlib +import subprocess +import sys +import tarfile +import zipfile + +proxy, repo, ref, moddir, version = sys.argv[1:6] +moddir = moddir.strip("/") or "." +prefix = "" if moddir == "." else moddir + "/" +tar = subprocess.run(["git", "-C", repo, "archive", "--format=tar", ref], + check=True, capture_output=True).stdout +files = {} +with tarfile.open(fileobj=io.BytesIO(tar)) as t: + for m in t.getmembers(): + if m.isfile() and m.name.startswith(prefix): + files[m.name[len(prefix):]] = t.extractfile(m).read() +gomod = files["go.mod"] +path = next(l.split()[1].strip('"') for l in gomod.decode().splitlines() if l.startswith("module ")) +nested = {n.rsplit("/", 1)[0] + "/" for n in files if n.endswith("/go.mod")} +root = pathlib.Path(proxy, path, "@v") +root.mkdir(parents=True, exist_ok=True) +(root / f"{version}.mod").write_bytes(gomod) +(root / f"{version}.info").write_text(json.dumps({"Version": version, "Time": "2026-01-01T00:00:00Z"})) +buf = io.BytesIO() +with zipfile.ZipFile(buf, "w") as z: + for name, data in sorted(files.items()): + if any(name.startswith(d) for d in nested): + continue + z.writestr(f"{path}@{version}/{name}", data) +(root / f"{version}.zip").write_bytes(buf.getvalue()) +listing = root / "list" +versions = set(listing.read_text().split()) if listing.exists() else set() +listing.write_text("\n".join(sorted(versions | {version})) + "\n") +print(f"published {path}@{version}") diff --git a/scripts/selftest/release-gate.sh b/scripts/selftest/release-gate.sh new file mode 100755 index 0000000..20fbeaf --- /dev/null +++ b/scripts/selftest/release-gate.sh @@ -0,0 +1,448 @@ +#!/usr/bin/env bash +# Cases of the release gate of go-package-release.yml (otfabric/engineering +# ADR-0017, REL-003): previous-version resolution, API compatibility with the +# pinned gorelease, the v0 rules and the release evidence. The exact step +# scripts run, in workflow order, on fixture repositories whose "published" +# versions live in a file:// module proxy. Nothing is pushed; fixture tags +# exist only in temporary repositories. +# +# Needs the pinned gorelease (the workflow's gorelease-version default) as +# $GORELEASE or on PATH, and a Go toolchain that runs it. +set -euo pipefail + +root=$(cd "$(dirname "$0")/../.." && pwd) +wf="$root/.github/workflows/go-package-release.yml" +sel="$root/scripts/selftest" +work=$(mktemp -d) +cleanup() { chmod -R u+w "$work" 2>/dev/null || true; rm -rf "$work"; } +trap cleanup EXIT + +pin=$(awk '/^ gorelease-version:/ { f = 1 } f && $1 == "default:" { print $2; exit }' "$wf") +GORELEASE=${GORELEASE:-$(command -v gorelease || true)} +if [ -z "$GORELEASE" ] || [ ! -x "$GORELEASE" ]; then + echo "FAIL no gorelease: install golang.org/x/exp/cmd/gorelease@$pin and set GORELEASE" + exit 1 +fi +GORELEASE="$(cd "$(dirname "$GORELEASE")" && pwd)/$(basename "$GORELEASE")" +have=$(go version -m "$GORELEASE" | awk '$1 == "mod" && $2 == "golang.org/x/exp" { print $3 }') +[ "$have" = "$pin" ] || { echo "FAIL $GORELEASE is golang.org/x/exp@$have; the workflow pins $pin"; exit 1; } + +steps="$work/steps" +mkdir -p "$steps" +for s in "Resolve module" "Determine next version" "Fail if tag already exists" "API compatibility" \ + "Generate release notes" "Check v0 breaking notes" "Write release evidence"; do + python3 "$sel/extract-step.py" "$wf" "$s" > "$steps/$s.sh" +done + +export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 +export GIT_AUTHOR_NAME=selftest GIT_AUTHOR_EMAIL=selftest@example.invalid +export GIT_COMMITTER_NAME=selftest GIT_COMMITTER_EMAIL=selftest@example.invalid +export GOPROXY="file://$work/proxy" GOSUMDB=off GOPRIVATE= GONOPROXY= GONOSUMDB= GOFLAGS= GOWORK=off +export GOTOOLCHAIN=local GOMODCACHE="$work/modcache" +goroot=$(go env GOROOT) +govers=$(go env GOVERSION) + +API_F='package fx + +// F is part of the API. +func F() int { return 1 }' +API_SAME='package fx + +// F is part of the API. +func F() int { return 2 }' +API_ADD='package fx + +// F is part of the API. +func F() int { return 1 } + +// G is new. +func G() int { return 2 }' +API_BREAK='package fx + +// F is part of the API. +func F(x int) int { return x }' + +n=0 +newrepo() { # sets repo and mp (a module path no other case uses) + n=$((n + 1)) + repo="$work/r$n" + mp="example.com/c$n" + mkdir -p "$repo" + git -C "$repo" init -q -b main +} +src() { # dir module-path source + mkdir -p "$repo/$1" + printf 'module %s\n\ngo 1.23\n' "$2" > "$repo/$1/go.mod" + printf '%s\n' "$3" > "$repo/$1/fx.go" +} +commit() { git -C "$repo" add -A && git -C "$repo" commit -q -m "$1"; } +tag() { git -C "$repo" tag -a "$1" -m "$1" "${2:-HEAD}"; } +publish() { python3 "$sel/modproxy.py" "$work/proxy" "$repo" "$1" "${2:-.}" "$3" > /dev/null; } # ref dir version + +run() { # step cwd env... + local step=$1 dir=$2 + shift 2 + : > "$out/$step.out" + echo "## $step" >> "$out/log" + (cd "$dir" && env "$@" GITHUB_OUTPUT="$out/$step.out" GITHUB_STEP_SUMMARY="$out/summary.md" \ + bash --noprofile --norc -eo pipefail "$steps/$step.sh") >> "$out/log" 2>&1 +} +o() { # step key: an output of a step that ran + [ -f "$out/$1.out" ] || return 0 + sed -n "s/^$2=//p" "$out/$1.out" | tail -1 +} + +tool="" +api_env=() +gate() { # case module-dir bump mode: the release job's steps, in its order + local name=$1 dir=$2 bump=$3 mode=$4 mdir + out="$work/out/$name" + mkdir -p "$out/tmp" + : > "$out/log" + export RUNNER_TEMP="$out/tmp" + stop=none + if ! run "Resolve module" "$repo" MODULE_DIR="$dir" GORELEASE_GO=1.26; then + stop=module + return + fi + mdir=$(o "Resolve module" dir) + if ! run "Determine next version" "$repo" BUMP="$bump" MODULE_DIR="$mdir" \ + MODULE_PATH="$(o "Resolve module" path)" PREFIX="$(o "Resolve module" prefix)" \ + PATH_MAJOR="$(o "Resolve module" path_major)" SHA="$(git -C "$repo" rev-parse HEAD)"; then + stop=version + return + fi + if ! run "Fail if tag already exists" "$repo/$mdir" TAG="$(o "Determine next version" tag)"; then + stop=tagcheck + elif ! run "API compatibility" "$repo/$mdir" MODE="$mode" BUMP="$bump" \ + BASE="$(o "Determine next version" base)" BASE_MAJOR="$(o "Determine next version" base_major)" \ + VERSION="$(o "Determine next version" version_tag)" GORELEASE="${tool:-$GORELEASE}" \ + GORELEASE_GOROOT="$goroot" "${api_env[@]+"${api_env[@]}"}"; then + stop=api + elif ! run "Generate release notes" "$repo/$mdir" PREV_TAG="$(o "Determine next version" previous_tag)" \ + TAG="$(o "Determine next version" version_tag)"; then + stop=notes + elif [ "$(o "API compatibility" v0_breaking)" = true ] && + ! run "Check v0 breaking notes" "$repo/$mdir" MODE="$mode" VERSION="$(o "Determine next version" version_tag)" \ + NOTES_FILE="$(o "Generate release notes" file)" USED_RELEASE_MD="$(o "Generate release notes" used_release_md)"; then + stop=v0notes + fi + # The evidence step runs whenever the version was resolved, also after a + # gate failure. + run "Write release evidence" "$repo/$mdir" GITHUB_WORKSPACE="$repo" REPOSITORY=otfabric/selftest \ + SHA="$(git -C "$repo" rev-parse HEAD)" RUN_URL=https://example.invalid/run WORKFLOW_REF=selftest \ + MODULE_PATH="$(o "Resolve module" path)" MODULE_DIR="$mdir" PREFIX="$(o "Resolve module" prefix)" \ + BUMP="$bump" BASE="$(o "Determine next version" base)" PREV_TAG="$(o "Determine next version" previous_tag)" \ + VERSION="$(o "Determine next version" version_tag)" TAG="$(o "Determine next version" tag)" MODE="$mode" \ + API_RESULT="$(o "API compatibility" result)" API_EXIT="$(o "API compatibility" exit_code)" \ + API_REPORT="$(o "API compatibility" report)" API_OUTCOME="$([ "$stop" = api ] && echo failure || echo success)" \ + V0_BREAKING="$(o "API compatibility" v0_breaking)" V0_DOCUMENTED="$(o "Check v0 breaking notes" documented)" \ + GORELEASE_VERSION="$pin" GORELEASE_GO="$govers" TEST_PACKAGES="$(o "Resolve module" path)" \ + VET_OUTCOME=success TEST_OUTCOME=success RACE_OUTCOME=success \ + TAG_EXISTS_OUTCOME="$([ "$stop" = tagcheck ] && echo failure || echo success)" \ + CONSUMERS_RESULT=skipped NOTES_FROM_RELEASE_MD="$(o "Generate release notes" used_release_md)" || + { echo "evidence step failed" >> "$out/log"; stop="$stop+evidence"; } +} + +fail=0 +expect() { # case want-stop want-api-result want-tag [want-base] + local api tag base + api=$(o "API compatibility" result) + tag=$(o "Determine next version" tag) + base=$(o "Determine next version" base) + if [ "$stop" = "$2" ] && [ "$api" = "$3" ] && [ "$tag" = "$4" ] && { [ $# -lt 5 ] || [ "$base" = "$5" ]; }; then + echo "ok $1: stops at $stop, api '$api', tag '$tag'${5:+, base $base}" + else + echo "FAIL $1: stops at $stop, api '$api', tag '$tag', base '$base'; want $2, '$3', '$4'${5:+, base $5}" + sed 's/^/ | /' "$out/log" + fail=1 + fi +} +logged() { # case text: the run's log says text + if grep -qF -- "$2" "$out/log"; then + echo "ok $1: log has '$2'" + else + echo "FAIL $1: log lacks '$2'" + sed 's/^/ | /' "$out/log" + fail=1 + fi +} +evidence() { # case python-expression over e (the evidence JSON) and head + local f="$out/tmp/release-evidence.json" + if [ -f "$f" ] && python3 -c ' +import json, sys +e = json.load(open(sys.argv[1])); head = sys.argv[2] +sys.exit(0 if eval(sys.argv[3]) else 1)' "$f" "$(git -C "$repo" rev-parse HEAD)" "$2"; then + echo "ok $1: evidence $2" + else + echo "FAIL $1: evidence $2" + [ -f "$f" ] && sed 's/^/ | /' "$f" + fail=1 + fi +} +v1base() { # a published v1.0.0 with API_F, then the candidate source + newrepo + src . "$mp" "$API_F" + commit base + tag v1.0.0 + publish v1.0.0 . v1.0.0 + src . "$mp" "$1" + commit candidate +} +v0base() { + newrepo + src . "$mp" "$API_F" + commit base + tag v0.1.0 + publish v0.1.0 . v0.1.0 + src . "$mp" "$1" + commit candidate +} + +# --- v1: gorelease decides -------------------------------------------------- +v1base "$API_SAME" +gate identical-patch . patch enforce +expect identical-patch none pass v1.0.1 v1.0.0 +evidence identical-patch 'e["commit"] == head and e["module"]["path"].startswith("example.com/") and e["previous_version"] == "v1.0.0" and e["proposed_version"] == "v1.0.1" and e["api_compatibility"]["result"] == "pass" and e["tests"]["race"] == "pass" and e["consumers"]["requested"] is False and "coverage" in e["not_run"] and "govulncheck" in e["not_run"]' + +v1base "$API_ADD" +gate additive-patch . patch enforce +expect additive-patch api incompatible v1.0.1 +logged additive-patch "No tag was created." +evidence additive-patch 'e["api_compatibility"]["result"] == "incompatible" and any("G" in c for c in e["api_compatibility"]["changes"]["compatible"])' + +v1base "$API_ADD" +gate additive-minor . minor enforce +expect additive-minor none pass v1.1.0 + +v1base "$API_BREAK" +gate incompatible-patch . patch enforce +expect incompatible-patch api incompatible v1.0.1 + +v1base "$API_BREAK" +gate incompatible-minor . minor enforce +expect incompatible-minor api incompatible v1.1.0 +evidence incompatible-minor 'e["api_compatibility"]["changes"]["incompatible"] and e["api_compatibility"]["exit_code"] == 1' + +v1base "$API_BREAK" +gate incompatible-major-same-path . major enforce +expect incompatible-major-same-path version "" "" +logged incompatible-major-same-path "needs the module path to end in /v2" + +v1base "$API_BREAK" +gate report-mode . minor report +expect report-mode none incompatible v1.1.0 +logged report-mode "::warning::gorelease rejects v1.1.0 against v1.0.0" + +# --- v0: ADR-0017 rules on top of gorelease's report ------------------------ +v0base "$API_SAME" +gate v0-identical-patch . patch enforce +expect v0-identical-patch none pass v0.1.1 + +v0base "$API_ADD" +gate v0-additive-patch . patch enforce +expect v0-additive-patch api incompatible v0.1.1 +logged v0-additive-patch "a v0 patch must not change the exported API" + +v0base "$API_ADD" +gate v0-additive-minor . minor enforce +expect v0-additive-minor none pass v0.2.0 + +v0base "$API_BREAK" +gate v0-breaking-undocumented . minor enforce +expect v0-breaking-undocumented v0notes pass v0.2.0 +logged v0-breaking-undocumented "needs a '### Breaking' heading" +evidence v0-breaking-undocumented 'e["api_compatibility"]["v0_breaking"] is True and e["api_compatibility"]["v0_breaking_documented"] is False' + +v0base "$API_BREAK" +printf '## v0.2.0\n\n### Breaking\n\n- F takes x; callers pass 1.\n\n---\n' > "$repo/RELEASE.md" +commit notes +gate v0-breaking-documented . minor enforce +expect v0-breaking-documented none pass v0.2.0 +evidence v0-breaking-documented 'e["api_compatibility"]["v0_breaking_documented"] is True and e["release_notes"]["source"] == "RELEASE.md"' + +v0base "$API_BREAK" +printf '## v1.0.0\n\n### Breaking\n\n- F takes x.\n\n---\n' > "$repo/RELEASE.md" +commit notes +gate v0-to-v1 . major enforce +expect v0-to-v1 none pass v1.0.0 v0.1.0 + +# --- first releases and majors ----------------------------------------------- +newrepo +src . "$mp" "$API_F" +commit first +gate first-release . minor enforce +expect first-release none pass v0.1.0 none +evidence first-release 'e["previous_version"] is None and e["api_compatibility"]["base"] == "none"' + +newrepo +src . "$mp" "$API_F" +commit base +tag v1.0.0 +publish v1.0.0 . v1.0.0 +src . "$mp/v2" "$API_BREAK" +commit v2 +gate first-v2 . major enforce +expect first-v2 none pass v2.0.0 none + +newrepo +src . "$mp/v2" "$API_F" +commit base +tag v2.0.0 +publish v2.0.0 . v2.0.0 +tag v1.9.0 +tag v3.0.0 +src . "$mp/v2" "$API_ADD" +commit candidate +gate v2-minor-ignores-other-majors . minor enforce +expect v2-minor-ignores-other-majors none pass v2.1.0 v2.0.0 + +v1base "$API_ADD" +tag v1.1.0-rc.1 +tag v1 +tag v1.1 +gate prerelease-and-moving-tags . minor enforce +expect prerelease-and-moving-tags none pass v1.1.0 v1.0.0 + +newrepo +src . "$mp" "$API_F" +commit base +tag v1.9.0 +publish v1.9.0 . v1.9.0 +src . "$mp" "$API_ADD" +commit v1.10 +tag v1.10.0 +publish v1.10.0 . v1.10.0 +src . "$mp" "$API_ADD" +printf '\n// H is new.\nfunc H() int { return 3 }\n' >> "$repo/fx.go" +commit candidate +gate semver-order . minor enforce +expect semver-order none pass v1.11.0 v1.10.0 + +# --- a wrong base stops the release; it is never replaced -------------------- +v1base "$API_SAME" +git -C "$repo" checkout -q -b side HEAD~1 +src . "$mp" "$API_ADD" +commit side +tag v1.1.0 +publish v1.1.0 . v1.1.0 +git -C "$repo" checkout -q main +gate base-not-ancestor . patch enforce +expect base-not-ancestor version "" "" +logged base-not-ancestor "v1.1.0 is not an ancestor" + +newrepo +src . example.com/other "$API_F" +commit base +tag v1.0.0 +src . "$mp" "$API_SAME" +commit candidate +gate base-other-module . patch enforce +expect base-other-module version "" "" +logged base-other-module "Refusing to compare with another module" + +newrepo +src . "$mp" "$API_F" +commit base +tag v1.0.0 +src . "$mp" "$API_SAME" +commit candidate +gate base-unpublished . patch enforce +expect base-unpublished api error v1.0.1 v1.0.0 +evidence base-unpublished 'e["api_compatibility"]["result"] == "error" and e["previous_version"] == "v1.0.0"' + +# --- nested modules ------------------------------------------------------------ +newrepo +src . "$mp" "$API_F" +src sub "$mp/sub" "$API_F" +commit base +tag v1.0.0 +tag sub/v0.1.0 +publish sub/v0.1.0 sub v0.1.0 +tag v1.1.0 +src sub "$mp/sub" "$API_ADD" +commit candidate +gate nested sub minor enforce +expect nested none pass sub/v0.2.0 v0.1.0 +evidence nested 'e["tag"] == "sub/v0.2.0" and e["module"]["dir"] == "sub" and e["previous_tag"] == "sub/v0.1.0"' + +newrepo +src . "$mp" "$API_F" +src tools/sub "$mp/tools/sub" "$API_F" +commit base +tag v1.0.0 +gate nested-first ./tools/sub/ patch enforce +expect nested-first none pass tools/sub/v0.0.1 none + +newrepo +src . "$mp" "$API_F" +commit base +gate module-dir-escape ../x patch enforce +expect module-dir-escape module "" "" + +# --- the tool, not the API ------------------------------------------------------- +v1base "$API_SAME" +tool="$work/no-such-gorelease" +gate missing-tool . patch enforce +tool="" +expect missing-tool api error v1.0.1 +logged missing-tool "gorelease did not reach a result (exit 127)" + +v1base "$API_SAME" +api_env=(GOPROXY=off) +gate tool-failure . patch enforce +expect tool-failure api error v1.0.1 +logged tool-failure "module lookup disabled by GOPROXY=off" + +v1base "$API_SAME" +gate tool-failure-report . patch report +api_env=() +expect tool-failure-report none error v1.0.1 +logged tool-failure-report "::warning::gorelease did not reach a result" + +v1base "$API_SAME" +gate invalid-mode . patch warn +expect invalid-mode api "" v1.0.1 + +v1base "$API_SAME" +gate invalid-bump . huge enforce +expect invalid-bump version "" "" + +# --- the job: the gate runs before anything is tagged or published ------------- +if python3 - "$wf" <<'EOF'; then +import re, sys +text = open(sys.argv[1]).read() +problems = [] +if "@latest" in text: + problems.append("the workflow installs something @latest") +job = text.split("\n release-package:\n", 1)[1] +names = re.findall(r"^ - name: (.+)$", job, re.M) +blocks = re.split(r"^ - name: .+$", job, flags=re.M)[1:] +step_if = {n: (re.search(r"^ if: (.+)$", b, re.M) or [None, ""])[1] for n, b in zip(names, blocks)} +coe = {n for n, b in zip(names, blocks) if re.search(r"^ continue-on-error:", b, re.M)} +order = ["Determine next version", "Fail if tag already exists", "API compatibility", "Check v0 breaking notes", + "Create and push annotated tag", "Update moving major tag", "Create GitHub release"] +idx = [names.index(s) for s in order] +if idx != sorted(idx): + problems.append(f"step order {order} is not kept: {idx}") +for s in order[:4]: + if s in coe: + problems.append(f"{s} has continue-on-error") +for s in order[4:]: + if re.search(r"always\(\)|failure\(\)|cancelled\(\)", step_if[s]): + problems.append(f"{s} runs after a failed step ({step_if[s]})") +head = job.split("\n steps:\n", 1)[0] +gate_if = " if: ${{ !cancelled() && (needs.consumers.result == 'success' || needs.consumers.result == 'skipped') }}" +if "\n needs: consumers\n" not in head or gate_if not in head.split("\n"): + problems.append("release-package does not wait for, or ignores, the consumer job") +if not re.search(r"^ gorelease-version:\n(?: .*\n)*? default: v\d+\.\d+\.\d+-\d{14}-[0-9a-f]{12}$", text, re.M): + problems.append("gorelease-version default is not an exact pseudo-version") +for p in problems: + print("FAIL job:", p) +sys.exit(1 if problems else 0) +EOF + echo "ok job: gate and v0 check run before the tag, the tag steps never run after a failure, consumers gate the job, gorelease pinned" +else + fail=1 +fi +exit $fail