From 7e2392c6157c8f81c6eecb68d52c728f3ad57d3c Mon Sep 17 00:00:00 2001 From: alexander <259883306+AlexHardAtWork@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:00:10 +0000 Subject: [PATCH 1/5] fix(release): keep draft assets consistent with the draft's commit --- .../actions/upload-release-assets/action.yml | 29 +++ .../test-upload-release-assets.sh | 114 ++++++++++ .../upload-release-assets.sh | 64 ++++++ .github/workflows/release.yml | 64 +++++- .github/workflows/test-actions.yml | 4 + README.md | 39 +++- scripts/test-release-workflow.sh | 211 ++++++++++++++++++ 7 files changed, 502 insertions(+), 23 deletions(-) create mode 100644 .github/actions/upload-release-assets/action.yml create mode 100755 .github/actions/upload-release-assets/test-upload-release-assets.sh create mode 100755 .github/actions/upload-release-assets/upload-release-assets.sh create mode 100755 scripts/test-release-workflow.sh diff --git a/.github/actions/upload-release-assets/action.yml b/.github/actions/upload-release-assets/action.yml new file mode 100644 index 0000000..e10674c --- /dev/null +++ b/.github/actions/upload-release-assets/action.yml @@ -0,0 +1,29 @@ +name: Upload release assets +description: >- + Upload assets to a draft release, but only while it is still a draft that + targets the commit the assets were built from. +inputs: + tag: + description: Tag name of the draft release. + required: true + sha: + description: Full commit SHA the assets were built from. + required: true + files: + description: Files to upload, one path or glob per line. Each must match at least one file. + required: true + token: + description: Token with contents write access. + required: false + default: ${{ github.token }} +runs: + using: composite + steps: + - name: Upload release assets + shell: bash + env: + GH_TOKEN: ${{ inputs.token }} + TAG: ${{ inputs.tag }} + SHA: ${{ inputs.sha }} + FILES: ${{ inputs.files }} + run: bash "$GITHUB_ACTION_PATH/upload-release-assets.sh" diff --git a/.github/actions/upload-release-assets/test-upload-release-assets.sh b/.github/actions/upload-release-assets/test-upload-release-assets.sh new file mode 100755 index 0000000..f6677ef --- /dev/null +++ b/.github/actions/upload-release-assets/test-upload-release-assets.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Runs upload-release-assets.sh against a stub gh whose release state lives in +# files, so a test can publish or move the draft mid-upload. + +ROOT="$(cd "$(dirname "$0")" && pwd)" +TESTDIR="$(mktemp -d)" +trap 'rm -rf "$TESTDIR"' EXIT + +SHA=1111111111111111111111111111111111111111 +OTHER=2222222222222222222222222222222222222222 + +mkdir -p "$TESTDIR/bin" +# State: $STATE/draft (true|false), $STATE/target, $STATE/missing (exists = +# no release). $STATE/on-upload, if present, is run by `gh release upload`. +cat > "$TESTDIR/bin/gh" << 'GH' +#!/usr/bin/env bash +set -euo pipefail +echo "gh $*" >> "$STATE/calls" +case "$1 $2" in + "release view") + [ ! -e "$STATE/missing" ] || exit 1 + echo "$(cat "$STATE/draft") $(cat "$STATE/target")" + ;; + "release upload") + if [ -e "$STATE/on-upload" ]; then bash "$STATE/on-upload"; fi + ;; + *) echo "unexpected gh call: $*" >&2; exit 2 ;; +esac +GH +chmod +x "$TESTDIR/bin/gh" + +fail=0 + +# run [files] [on-upload] +run() { + local want=$1 desc=$2 got out + export STATE="$TESTDIR/state-$RANDOM" + mkdir -p "$STATE" + echo "$3" > "$STATE/draft" + echo "$4" > "$STATE/target" + [ -z "${6:-}" ] || echo "$6" > "$STATE/on-upload" + + if out=$(cd "$TESTDIR/work" && PATH="$TESTDIR/bin:$PATH" TAG=v1.2.3 SHA="${SHA_OVERRIDE:-$SHA}" \ + FILES="${5:-bin/app-* +checksums.txt}" GITHUB_REPOSITORY=org/repo bash "$ROOT/upload-release-assets.sh" 2>&1); then + got=pass + else + got=fail + fi + + if [ "$got" = "$want" ]; then + echo "ok $desc" + else + echo "FAIL $desc: expected $want, got $got" + printf " %s\n" "$out" + fail=1 + fi + LAST_OUT=$out + LAST_CALLS=$(cat "$STATE/calls" 2> /dev/null || true) +} + +mkdir -p "$TESTDIR/work/bin" +touch "$TESTDIR/work/bin/app-linux" "$TESTDIR/work/bin/app-darwin" \ + "$TESTDIR/work/checksums.txt" "$TESTDIR/work/with space.txt" + +# has / lacks : assert on the last run. +has() { grep -q -- "$2" <<< "$1" || bad "expected '$2' in: $1"; } +lacks() { if grep -q -- "$2" <<< "$1"; then bad "unexpected '$2' in: $1"; fi; } +bad() { + echo "FAIL $*" + fail=1 +} + +run pass "uploads to a draft that targets the built commit" true "$SHA" +has "$LAST_CALLS" 'release upload v1.2.3 bin/app-darwin bin/app-linux checksums.txt --repo org/repo --clobber' + +run fail "refuses a published release" false "$SHA" +has "$LAST_OUT" 'was published before the upload' +lacks "$LAST_CALLS" 'release upload' + +run fail "refuses a draft moved to another commit" true "$OTHER" +has "$LAST_OUT" "now targets $OTHER" +lacks "$LAST_CALLS" 'release upload' + +run fail "refuses a draft that targets a branch" true main + +# The stub runs this itself, so it expands $STATE there. +# shellcheck disable=SC2016 +run fail "reports a release published during the upload" true "$SHA" "" 'echo false > "$STATE/draft"' +has "$LAST_OUT" 'was published during the upload' + +run fail "fails when a pattern matches nothing" true "$SHA" "bin/app-* +provenance/*.intoto.jsonl" +lacks "$LAST_CALLS" 'release upload' + +run pass "keeps a path with spaces whole" true "$SHA" "with space.txt" +has "$LAST_CALLS" 'release upload v1.2.3 with space.txt --repo' + +SHA_OVERRIDE=1111111 run fail "rejects a short sha" true 1111111 +unset SHA_OVERRIDE + +export STATE="$TESTDIR/missing" +mkdir -p "$STATE" +touch "$STATE/missing" +if (cd "$TESTDIR/work" && PATH="$TESTDIR/bin:$PATH" TAG=v1.2.3 SHA=$SHA FILES=checksums.txt \ + GITHUB_REPOSITORY=org/repo bash "$ROOT/upload-release-assets.sh" > /dev/null 2>&1); then + bad "a missing release passed" +else + echo "ok fails when the release does not exist" +fi + +exit "$fail" diff --git a/.github/actions/upload-release-assets/upload-release-assets.sh b/.github/actions/upload-release-assets/upload-release-assets.sh new file mode 100755 index 0000000..b1e3fd0 --- /dev/null +++ b/.github/actions/upload-release-assets/upload-release-assets.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Uploads release assets only to a draft that still targets the commit they +# were built from. Publishing makes a release immutable, and a newer run may +# have moved the draft on, so both are checked before and after the upload. +# +# Env: TAG, SHA (full commit), FILES (one path or glob per line), +# GITHUB_REPOSITORY, GH_TOKEN. +set -euo pipefail + +: "${TAG:?TAG is required}" +: "${SHA:?SHA is required}" +: "${FILES:?FILES is required}" +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" + +if [[ ! "$SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::sha must be a full commit SHA, got '${SHA}'." + exit 1 +fi + +files=() +shopt -s nullglob +while IFS= read -r pattern; do + pattern="${pattern#"${pattern%%[![:space:]]*}"}" + pattern="${pattern%"${pattern##*[![:space:]]}"}" + [ -n "$pattern" ] || continue + # Unquoted on purpose: the pattern is a glob. An empty IFS keeps paths + # with spaces whole. + IFS= + # shellcheck disable=SC2206 + matches=($pattern) + unset IFS + if [ ${#matches[@]} -eq 0 ]; then + echo "::error::No file matches '${pattern}'." + exit 1 + fi + files+=("${matches[@]}") +done <<< "$FILES" +shopt -u nullglob + +# check : fails unless TAG is a draft targeting SHA. +check() { + local state is_draft target + if ! state=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \ + --json isDraft,targetCommitish --jq '"\(.isDraft) \(.targetCommitish)"'); then + echo "::error::Release ${TAG} not found $1." + exit 1 + fi + read -r is_draft target <<< "$state" + + if [ "$is_draft" != "true" ]; then + echo "::error::Release ${TAG} was published $1. Published releases are immutable, so its assets are missing or were built from another commit. Do not publish while the Release run is building assets; cut the next version." + exit 1 + fi + if [ "$target" != "$SHA" ]; then + echo "::error::Draft ${TAG} now targets ${target}, but these assets were built from ${SHA} (checked $1). A newer run owns the draft; it builds its own assets." + exit 1 + fi +} + +check "before the upload" +echo "Uploading ${#files[@]} assets to draft ${TAG} at ${SHA}." +# --clobber makes a retry idempotent; it can only replace assets on a draft. +gh release upload "$TAG" "${files[@]}" --repo "$GITHUB_REPOSITORY" --clobber +check "during the upload" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 34d1efd..ac0a265 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,7 +2,7 @@ name: Release # Reusable workflow that computes version and notes with git-cliff and # creates or updates a draft release. The tag is created during a run when -# `create_tag` is enabled (the default), or by publishing the draft. Publishing +# `create_tag` is enabled, or by publishing the draft. Publishing # the draft triggers repo-specific build jobs (Docker, Helm, docs, assets). # # The caller's stub carries the triggers (workflow_dispatch, optionally push @@ -43,11 +43,11 @@ on: default: false outputs: version: - description: 'The computed/released version.' - value: ${{ jobs.prepare.outputs.version }} + description: 'The computed/released version, or the retried draft on the asset retry path.' + value: ${{ jobs.prepare.outputs.version || jobs.validate-retry.outputs.version }} sha: - description: 'The commit SHA being released.' - value: ${{ jobs.prepare.outputs.sha }} + description: 'The commit SHA being released, or the commit the retried draft targets.' + value: ${{ jobs.prepare.outputs.sha || jobs.validate-retry.outputs.sha }} draft_updated: description: 'Whether the draft release was created or updated.' value: ${{ jobs.prepare.outputs.draft_updated }} @@ -119,10 +119,13 @@ jobs: # A draft whose tag does not exist yet is the normal, update-in-place # case. A draft whose tag does exist is a release already cut with - # create_tag, so a second run must not touch it. + # create_tag: it is frozen for review and publishing, so no run may touch + # it. A push skips with a notice; a manual run fails. - name: Guard - no tagged draft release already pending + id: pending env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + EVENT_NAME: ${{ github.event_name }} run: | set -euo pipefail @@ -137,6 +140,11 @@ jobs: done if [ ${#blocking_drafts[@]} -gt 0 ]; then + if [ "$EVENT_NAME" = "push" ]; then + echo "::notice::Draft release ${blocking_drafts[*]} is tagged and frozen for publishing; leaving it untouched." + echo "frozen=true" >> "$GITHUB_OUTPUT" + exit 0 + fi echo "::error::A draft release with an existing tag is already pending: ${blocking_drafts[*]}. Publish it to ship, or delete the draft to abandon it. Release tags cannot be deleted where protect-release-tags is active, so if its tag exists that version is burned: cut the next patch." exit 1 fi @@ -145,6 +153,7 @@ jobs: # consumers get the shared cliff.toml and the notes guard without keeping # their own copies. job.workflow_* is github.com only. - name: Check out release tooling + if: ${{ steps.pending.outputs.frozen != 'true' }} uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: repository: ${{ job.workflow_repository }} @@ -154,6 +163,7 @@ jobs: - name: Read release context id: ctx + if: ${{ steps.pending.outputs.frozen != 'true' }} env: CLIFF_CONFIG: ${{ inputs.cliff_config }} run: | @@ -181,6 +191,7 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Guard - nothing is missing from the notes + if: ${{ steps.pending.outputs.frozen != 'true' }} uses: ./.release-tooling/.github/actions/check-release-notes with: range: ${{ steps.ctx.outputs.previous }}..HEAD @@ -188,7 +199,7 @@ jobs: - name: Guard - something releasable landed id: releasable - if: ${{ inputs.version == '' }} + if: ${{ inputs.version == '' && steps.pending.outputs.frozen != 'true' }} env: PREVIOUS_TAG: ${{ steps.ctx.outputs.previous }} EVENT_NAME: ${{ github.event_name }} @@ -217,7 +228,7 @@ jobs: - name: Compute version id: version - if: ${{ steps.releasable.outputs.releasable != 'false' }} + if: ${{ steps.releasable.outputs.releasable != 'false' && steps.pending.outputs.frozen != 'true' }} env: INPUT_VERSION: ${{ inputs.version }} COMPUTED_VERSION: ${{ steps.ctx.outputs.computed }} @@ -244,7 +255,7 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Render release notes - if: ${{ steps.releasable.outputs.releasable != 'false' }} + if: ${{ steps.releasable.outputs.releasable != 'false' && steps.pending.outputs.frozen != 'true' }} env: VERSION: ${{ steps.version.outputs.version }} SHA: ${{ steps.version.outputs.sha }} @@ -271,10 +282,12 @@ jobs: # publish. A push run reuses the pending draft: it updates the same # version in place, or renames the draft when the computed version moves. # Updating target_commitish keeps the commit that publish will tag at the - # latest commit the notes cover. + # latest commit the notes cover. Assets built for another commit or + # version are deleted first, so a draft only carries assets built from + # its own target. - name: Create or update draft release id: release - if: ${{ !inputs.dry_run && steps.releasable.outputs.releasable != 'false' }} + if: ${{ !inputs.dry_run && steps.releasable.outputs.releasable != 'false' && steps.pending.outputs.frozen != 'true' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ steps.version.outputs.version }} @@ -339,6 +352,19 @@ jobs: else echo "Updating draft release ${VERSION} at ${SHA}." fi + + current_target=$(gh release view "$draft_source" --repo "$GITHUB_REPOSITORY" \ + --json targetCommitish --jq '.targetCommitish') + if [ "$draft_source" != "$VERSION" ] || [ "$current_target" != "$SHA" ]; then + assets=$(gh release view "$draft_source" --repo "$GITHUB_REPOSITORY" \ + --json assets --jq '.assets[].name') + while IFS= read -r asset; do + [ -n "$asset" ] || continue + echo "Deleting stale asset ${asset} (built for ${draft_source} at ${current_target})." + gh release delete-asset "$draft_source" "$asset" --repo "$GITHUB_REPOSITORY" --yes + done <<< "$assets" + fi + gh release edit "$draft_source" \ --repo "$GITHUB_REPOSITORY" \ --tag "$VERSION" \ @@ -355,7 +381,7 @@ jobs: # step exists for callers that need the tag before publishing. - name: Create tag id: tag - if: ${{ !inputs.dry_run && inputs.create_tag && steps.releasable.outputs.releasable != 'false' }} + if: ${{ !inputs.dry_run && inputs.create_tag && steps.releasable.outputs.releasable != 'false' && steps.pending.outputs.frozen != 'true' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ steps.version.outputs.version }} @@ -391,6 +417,9 @@ jobs: permissions: # Draft releases are not visible to a read-only token. contents: write + outputs: + version: ${{ inputs.retry_assets_for }} + sha: ${{ steps.draft.outputs.sha }} steps: - name: Guard - retry is not a dry run if: ${{ inputs.dry_run }} @@ -399,6 +428,7 @@ jobs: exit 1 - name: Check the draft release exists + id: draft env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ inputs.retry_assets_for }} @@ -420,3 +450,13 @@ jobs: exit 1 ;; esac + + # Assets are rebuilt from the commit the draft targets, so it must be + # a commit, not a branch that moves. + sha=$(gh release view "$VERSION" --repo "$GITHUB_REPOSITORY" \ + --json targetCommitish --jq '.targetCommitish') + if [[ ! "$sha" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::Draft ${VERSION} targets '${sha}', not a commit SHA. Re-run the Release workflow to point it at a commit." + exit 1 + fi + echo "sha=${sha}" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/test-actions.yml b/.github/workflows/test-actions.yml index 3dffa58..f702259 100644 --- a/.github/workflows/test-actions.yml +++ b/.github/workflows/test-actions.yml @@ -62,6 +62,10 @@ jobs: run: scripts/test-renovate-relock.sh - name: Test check-release-notes.sh run: .github/actions/check-release-notes/test-check-release-notes.sh + - name: Test upload-release-assets.sh + run: .github/actions/upload-release-assets/test-upload-release-assets.sh + - name: Test the release workflow steps + run: scripts/test-release-workflow.sh # yq is not on the runner image but is in this repository's dev shell, so diff --git a/README.md b/README.md index 2e83981..661040f 100644 --- a/README.md +++ b/README.md @@ -299,11 +299,12 @@ The repo ships composite actions that consuming repositories reference directly. Pin them to a SHA with a version comment, as the action-pin check requires. The `<40-char-sha>` placeholders below are not copy-pasteable — substitute real digests: -| Action | Description | -| -------------------------------- | ------------------------------------------------------- | -| `.github/actions/setup-nix` | Install upstream Nix and enable the shared Cachix cache | -| `.github/actions/get-go-version` | Extract the Go version from `flake.nix` | -| `.github/actions/diff-check` | Fail if a command left uncommitted changes behind | +| Action | Description | +| --------------------------------------- | -------------------------------------------------------------------- | +| `.github/actions/setup-nix` | Install upstream Nix and enable the shared Cachix cache | +| `.github/actions/get-go-version` | Extract the Go version from `flake.nix` | +| `.github/actions/diff-check` | Fail if a command left uncommitted changes behind | +| `.github/actions/upload-release-assets` | Upload assets only to a draft that targets the commit they came from | `setup-nix` replaces the two-step Nix installer + Cachix preamble that every job needs before it can use `nix develop` as its shell. Secrets are not visible @@ -642,9 +643,17 @@ See [`.github/workflows/release-self.yml`](.github/workflows/release-self.yml) f `release-self.yml` also shows the push wiring. The `inputs` context is empty on `push`, so the caller selects each value by event; passing an empty string to a boolean input fails workflow validation. A push run passes `create_tag: false`, so it never creates a tag or publishes. If no `feat`, `fix`, `perf` or breaking change landed since the previous tag, the run succeeds without touching the draft. A manual run still fails in that case unless `version` is supplied. +#### Publishing a release + +A push moves the untagged draft to its own commit, so never publish that draft directly: a merge landing during review changes what you publish, and published releases are immutable. + +1. Run the Release workflow manually with `create_tag` and without `dry_run`. This tags the draft at the reviewed commit and freezes it: push runs then leave it untouched. +2. Wait until that run, including its asset job, has finished. +3. Review the draft and publish it. + #### Attaching release assets -If your caller builds assets, add a job that depends on the release job and gate it on both the call result and the draft output. The example assumes the caller also exposes `retry_assets_for` for recovery: +If your caller builds assets, add a job that depends on the release job and gate it on both the call result and the draft output. Upload with `upload-release-assets`: it refuses a release that was published, or a draft that moved to another commit, while the assets were building. The `version` and `sha` outputs also cover the retry path. The example assumes the caller also exposes `retry_assets_for` for recovery: ```yaml assets: @@ -659,15 +668,23 @@ If your caller builds assets, add a job that depends on the release job and gate id-token: write attestations: write env: - VERSION: ${{ inputs.retry_assets_for || needs.release.outputs.version }} + VERSION: ${{ needs.release.outputs.version }} steps: - name: Checkout code uses: actions/checkout@<40-char-sha> # v7 with: - ref: ${{ needs.release.outputs.sha || inputs.retry_assets_for }} + ref: ${{ needs.release.outputs.sha }} fetch-depth: 0 persist-credentials: false - # ... build, sign, attest, upload assets to the draft release ... + # ... build, sign, attest ... + - name: Upload assets to the draft release + uses: opendefensecloud/dev-kit/.github/actions/upload-release-assets@<40-char-sha> # + with: + tag: ${{ needs.release.outputs.version }} + sha: ${{ needs.release.outputs.sha }} + files: | + bin/app-*-* + bin/checksums.txt* ``` #### Requirements @@ -678,8 +695,8 @@ If your caller builds assets, add a job that depends on the release job and gate - **Default branch only**: Releases must be cut from the repository's default branch. A branch trigger must name the default branch; a tag trigger is accepted only when the tag points at a commit on it. Other triggers are rejected. - **Merge commits**: The shared config renders the merge commits on the default branch. A commit pushed directly to the branch is reported by the notes guard and fails the release unless `force` is set. Squash and rebase merges are treated the same way. - **Prior release tag**: The repository must already have a release tag. git-cliff derives the changelog boundary from the previous tag, and the workflow fails if there is none. Passing an explicit `version` does not bypass this. -- **Draft maintenance**: A non-dry run creates the draft for the computed version or updates the existing draft in place (title, notes, prerelease flag and target commit). If the computed version moves while the draft is still unpublished, the draft is renamed to the new version and updated. There is one pending draft, not one per push. The `draft_updated` output is `true` whenever the draft is ready, whether this run created or updated it. -- **Tag creation**: The tag is created during the run when `create_tag` is true (the default); a push-triggered run passes `false`, so it only maintains the draft. Otherwise publishing the draft creates the tag from the draft's target commit. The draft is written before the tag, so a failed tag leaves an updateable draft and no tag, and re-running retries. The workflow never deletes a tag. +- **Draft maintenance**: A non-dry run creates the draft for the computed version or updates the existing draft in place (title, notes, prerelease flag and target commit). If the computed version moves while the draft is still unpublished, the draft is renamed to the new version and updated. Moving or renaming a draft deletes its assets, so a draft only carries assets built from its own target. A tagged draft is frozen: a push run skips it with a notice, a manual run fails. There is one pending draft, not one per push. The `draft_updated` output is `true` whenever the draft is ready, whether this run created or updated it. +- **Tag creation**: The tag is created during the run when `create_tag` is true; a push-triggered run passes `false`, so it only maintains the draft. Otherwise publishing the draft creates the tag from the draft's target commit. The draft is written before the tag, so a failed tag leaves an updateable draft and no tag, and re-running retries. The workflow never deletes a tag. - **Tag immutability**: Where a `protect-release-tags` ruleset is active, a `v*` tag cannot be moved or deleted. Before a tag exists, abandon a draft by deleting it. Once a tag exists (for example after a run with `create_tag`), publish that draft or cut the next patch: deleting the draft leaves the tag behind and burns the version. - **Publishing the draft**: Publishing creates the tag at the draft's target commit (the latest commit covered by the last run) and triggers downstream workflows (Docker, Helm, docs). Prerelease versions (containing `-`) are marked as prereleases automatically. Publishing a draft whose tag already exists leaves that tag in place. - **Asset retry**: `retry_assets_for` skips version and notes and must name an existing **draft** release. The workflow rejects a published or missing release, and rejects the combination with `dry_run`. Asset jobs must gate on `needs..result == 'success'` (see Attaching release assets above), otherwise `always()` would run them after a failed validation. diff --git a/scripts/test-release-workflow.sh b/scripts/test-release-workflow.sh new file mode 100755 index 0000000..d9568e5 --- /dev/null +++ b/scripts/test-release-workflow.sh @@ -0,0 +1,211 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Runs steps from release.yml itself (extracted with yq), so the test cannot +# drift from what CI runs, against a stub gh whose releases live in files. + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +WORKFLOW="$ROOT/.github/workflows/release.yml" +TESTDIR="$(mktemp -d)" +trap 'rm -rf "$TESTDIR"' EXIT + +# Keep the developer's git config (commit signing, hooks) out of the test repo. +export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 + +OLD=1111111111111111111111111111111111111111 +NEW=2222222222222222222222222222222222222222 + +# step : prints the run script of that step. +step() { + local run + run="$(yq ".jobs.$1.steps[] | select(.name == \"$2\") | .run" "$WORKFLOW")" + [[ -n "$run" && "$run" != null ]] || { + echo "FAIL: step '$2' not found in job $1" >&2 + exit 1 + } + printf '%s\n' "$run" +} + +# Releases: $STATE/releases//{draft,target,assets}. The stub answers the +# exact queries release.yml makes, with their --jq already applied. +mkdir -p "$TESTDIR/bin" +cat > "$TESTDIR/bin/gh" << 'GH' +#!/usr/bin/env bash +set -euo pipefail +echo "gh $*" >> "$STATE/calls" +rel="$STATE/releases/${3:-}" +case "$1 $2" in + "release list") + for d in "$STATE"/releases/*/; do + [ -d "$d" ] || continue + [ "$(cat "$d/draft")" = true ] && basename "$d" + done + ;; + "release view") + [ -d "$rel" ] || exit 1 + case "$*" in + *"--json isDraft "*) [ "$(cat "$rel/draft")" = true ] && echo draft || echo published ;; + *"--json targetCommitish "*) cat "$rel/target" ;; + *"--json assets "*) cat "$rel/assets" 2>/dev/null || true ;; + *) echo "unexpected view: $*" >&2; exit 2 ;; + esac + ;; + "release delete-asset"|"release edit"|"release create") ;; + *) echo "unexpected gh call: $*" >&2; exit 2 ;; +esac +GH +chmod +x "$TESTDIR/bin/gh" + +# A repository whose only local tag is v1.0.0. +REPO="$TESTDIR/repo" +mkdir -p "$REPO" +git -C "$REPO" init -q -b main +git -C "$REPO" -c user.email=t@example.com -c user.name=T commit -q --allow-empty -m "chore: init" +git -C "$REPO" tag v1.0.0 + +fail=0 +report() { # report + if [[ "$2" == "$1" ]]; then echo "ok $3"; else + echo "FAIL $3: expected '$1', got '$2'" + fail=1 + fi +} + +# fresh: new stub state and step output file. +fresh() { + export STATE="$TESTDIR/state-$RANDOM$RANDOM" + mkdir -p "$STATE/releases" + : > "$STATE/calls" + export GITHUB_OUTPUT="$STATE/output" + : > "$GITHUB_OUTPUT" +} + +release() { # release [asset...] + local d="$STATE/releases/$1" + mkdir -p "$d" + echo "$2" > "$d/draft" + echo "$3" > "$d/target" + shift 3 + : > "$d/assets" + for a in "$@"; do echo "$a" >> "$d/assets"; done +} + +# exec_step