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..3f3b377 --- /dev/null +++ b/.github/actions/upload-release-assets/test-upload-release-assets.sh @@ -0,0 +1,118 @@ +#!/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 edit") ;; + "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() { ! grep -q -- "$2" <<< "$1" || bad "unexpected '$2' in: $1"; } +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' +has "$LAST_CALLS" 'release edit v1.2.3 --repo org/repo --title v1.2.3' + +run fail "refuses a published release" false "$SHA" +has "$LAST_OUT" 'was published before the upload' +lacks "$LAST_CALLS" 'release edit' +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' +lacks "$LAST_CALLS" 'release edit' + +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..992128f --- /dev/null +++ b/.github/actions/upload-release-assets/upload-release-assets.sh @@ -0,0 +1,69 @@ +#!/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::Failed to look up release ${TAG} $1. See the gh error above." + 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" + +# The release workflow titles the draft " (assets pending)"; drop the +# marker now that the draft is complete. +gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --title "$TAG" > /dev/null +echo "Draft ${TAG} has all its assets and can be published." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 34d1efd..bf685f3 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 @@ -41,13 +41,18 @@ on: required: false type: boolean default: false + assets: + description: 'The caller attaches assets with upload-release-assets. The draft is titled " (assets pending)" until the upload completes.' + required: false + type: boolean + 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 +124,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 +145,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 +158,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 +168,7 @@ jobs: - name: Read release context id: ctx + if: ${{ steps.pending.outputs.frozen != 'true' }} env: CLIFF_CONFIG: ${{ inputs.cliff_config }} run: | @@ -181,6 +196,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 +204,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 +233,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 +260,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,14 +287,17 @@ 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 }} SHA: ${{ steps.version.outputs.sha }} + ASSETS: ${{ inputs.assets }} run: | set -euo pipefail @@ -287,6 +306,12 @@ jobs: prerelease=true fi + # upload-release-assets drops the marker once the assets are attached. + title="$VERSION" + if [ "$ASSETS" = "true" ]; then + title="${VERSION} (assets pending)" + fi + release_state=$(gh release view "$VERSION" --repo "$GITHUB_REPOSITORY" \ --json isDraft --jq 'if .isDraft then "draft" else "published" end' 2>/dev/null || echo "missing") @@ -329,7 +354,7 @@ jobs: gh release create "$VERSION" \ --repo "$GITHUB_REPOSITORY" \ --draft \ - --title "$VERSION" \ + --title "$title" \ --notes-file notes.md \ --target "$SHA" \ --prerelease="$prerelease" @@ -339,10 +364,23 @@ 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" \ - --title "$VERSION" \ + --title "$title" \ --notes-file notes.md \ --target "$SHA" \ --prerelease="$prerelease" @@ -355,7 +393,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 +429,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 +440,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 +462,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..399985a 100644 --- a/.github/workflows/test-actions.yml +++ b/.github/workflows/test-actions.yml @@ -62,6 +62,12 @@ 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 + - name: Test repo-settings.sh + run: scripts/test-repo-settings.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..df02edc 100644 --- a/README.md +++ b/README.md @@ -277,6 +277,7 @@ It configures: - **Merge strategy** — merge commits only by default, configurable via `REPO_ALLOW_MERGE_COMMIT`, `REPO_ALLOW_SQUASH_MERGE`, `REPO_ALLOW_REBASE_MERGE` (these also restrict the ruleset's allowed merge methods; at least one must be enabled or `repo-settings` fails); auto-merge enabled, delete branch on merge - **Secret scanning** — enabled - **Branch protection** — a "protect-main" ruleset on the default branch (and any `REPO_RULESET_BRANCHES` patterns): requires PRs with 1 approval, dismisses stale reviews, requires review thread resolution, enforces commit signatures, requires approval of the latest push when `REPO_REQUIRE_LAST_PUSH_APPROVAL` is `true`, prevents direct pushes/deletions/non-fast-forwards +- **Release tags** — two rulesets on `v*` tags, since a bypass applies to a whole ruleset: "restrict-release-tag-creation" lets only GitHub Actions create them, and "protect-release-tags" stops anyone, workflows included, from moving or deleting them. A draft must therefore be tagged by a Release run with `create_tag` before it can be published (see [Publishing a release](#publishing-a-release)). No admin bypass. The repository settings are configurable via make variables (set them in your `Makefile` or pass them on the command line, e.g. `make repo-settings REPO_STATUS_CHECKS='["CI","lint"]' REPO_RULESET_BRANCHES='["release/*"]'`): @@ -299,11 +300,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 +644,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. With the `restrict-release-tag-creation` ruleset from `make repo-settings`, GitHub rejects it. + +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 the draft's title loses its "(assets pending)" suffix (callers with `assets: true`), or the run 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. Pass `assets: true` to the release call and upload with `upload-release-assets`. The draft is then titled "vX (assets pending)" until the upload completes. The action 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 +669,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 +696,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/repo-settings.sh b/scripts/repo-settings.sh index 1829a41..674e02f 100755 --- a/scripts/repo-settings.sh +++ b/scripts/repo-settings.sh @@ -3,7 +3,7 @@ set -euo pipefail # Reconcile a repository's GitHub settings: labels, merge strategy, secret -# scanning and branch protection ruleset. +# scanning, branch protection ruleset and release tag ruleset. # # All configuration is passed through environment variables. Defaults are the # single source of truth in common.mk and are always passed by the make target; @@ -142,14 +142,42 @@ echo " branch up-to-date: $REPO_REQUIRE_BRANCH_UP_TO_DATE" echo " required status checks: $REPO_STATUS_CHECKS_EFFECTIVE" echo " admin bypass: $REPO_ADMIN_BYPASS" -existing=$("$GH" api "repos/$REPO/rulesets" -q '.[] | select(.name=="protect-main") | .id' 2> /dev/null || true) -if [ -n "$existing" ]; then - "$GH" api "repos/$REPO/rulesets/$existing" -X PUT --input <(echo "$RULESET_JSON") > /dev/null - echo " Updated existing ruleset (id: $existing)" -else - "$GH" api "repos/$REPO/rulesets" -X POST --input <(echo "$RULESET_JSON") > /dev/null - echo " Created new ruleset" -fi +# upsert_ruleset : creates or updates the repository ruleset named in +# . includes_parents=false keeps an organization ruleset of the same name +# out of the lookup; it cannot be updated through the repository endpoint. +upsert_ruleset() { + local json=$1 name existing + name=$(echo "$json" | "$JQ" -r .name) + existing=$("$GH" api "repos/$REPO/rulesets?includes_parents=false" \ + -q ".[] | select(.name==\"$name\") | .id" 2> /dev/null || true) + if [ -n "$existing" ]; then + "$GH" api "repos/$REPO/rulesets/$existing" -X PUT --input <(echo "$json") > /dev/null + echo " Updated existing ruleset $name (id: $existing)" + else + "$GH" api "repos/$REPO/rulesets" -X POST --input <(echo "$json") > /dev/null + echo " Created new ruleset $name" + fi +} + +upsert_ruleset "$RULESET_JSON" + +# Release tags take two rulesets, because a bypass applies to every rule in its +# ruleset: +# - restrict-release-tag-creation: only GitHub Actions (integration 15368), in +# a create_tag run, creates v* tags. Publishing a draft whose tag does not +# exist yet creates the tag as the publishing user, so this rejects +# publishing a draft that was not frozen first. No admin bypass: admins are +# who publishes. +# - protect-release-tags: nobody, workflows included, moves or deletes a v* tag. +echo " Configuring release tag rulesets..." +upsert_ruleset '{ "name": "restrict-release-tag-creation", "target": "tag", "enforcement": "active", + "conditions": { "ref_name": { "include": ["refs/tags/v*"], "exclude": [] } }, + "rules": [ { "type": "creation" } ], + "bypass_actors": [ { "actor_id": 15368, "actor_type": "Integration", "bypass_mode": "always" } ] }' +upsert_ruleset '{ "name": "protect-release-tags", "target": "tag", "enforcement": "active", + "conditions": { "ref_name": { "include": ["refs/tags/v*"], "exclude": [] } }, + "rules": [ { "type": "update" }, { "type": "deletion" } ], + "bypass_actors": [] }' echo " Configuring merge commit settings..." "$GH" api "repos/$REPO" -X PATCH \ diff --git a/scripts/test-release-workflow.sh b/scripts/test-release-workflow.sh new file mode 100755 index 0000000..6618938 --- /dev/null +++ b/scripts/test-release-workflow.sh @@ -0,0 +1,221 @@ +#!/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