From 4470fbc9c47dec85fd320cfb3fbd52c8e5b6de36 Mon Sep 17 00:00:00 2001 From: Marko Petzold Date: Sat, 24 Oct 2026 20:27:00 +0200 Subject: [PATCH 1/3] Release on merge (paused): release-intent, merge-triggered release-image, tag job --- .github/workflows/release-image.yml | 169 ++++++++++++++++++------ .github/workflows/release-intent.yml | 78 +++++++++++ .github/workflows/release-tag.yml | 87 +++++++++++++ .github/workflows/selftest.yml | 13 +- README.md | 43 ++++-- scripts/release.sh | 165 +++++++++++++++++++++++ scripts/test_release.sh | 187 +++++++++++++++++++++++++++ templates/workflows/ci-go.yml | 16 ++- templates/workflows/release.yml | 36 ++++++ templates/workflows/security.yml | 2 +- 10 files changed, 736 insertions(+), 60 deletions(-) create mode 100644 .github/workflows/release-intent.yml create mode 100644 .github/workflows/release-tag.yml create mode 100755 scripts/release.sh create mode 100755 scripts/test_release.sh create mode 100644 templates/workflows/release.yml diff --git a/.github/workflows/release-image.yml b/.github/workflows/release-image.yml index f033bb8..006bf65 100644 --- a/.github/workflows/release-image.yml +++ b/.github/workflows/release-image.yml @@ -1,29 +1,41 @@ -# Reusable release of a container image, triggered by a version tag. +# Reusable release of a container image, run by the merge of a release pull +# request into main (a push to main whose squash-merge title is +# `release `, see release-intent.yml). # -# verify the tag equals ; the tagged commit is -# part of the release branch (main); the tag does not exist in the -# registry yet (released images are never overwritten) -# -# Checks are not repeated here: the release branch is protected by a ruleset -# that only accepts pull requests whose required checks (ci-ok, security / -# report) passed, so every commit on it is green. +# verify the version file names = the tag in the +# merge title; the commit is part of the release branch (main); the +# tag does not exist yet; the image is not in the registry yet +# (released images are never overwritten) # build one native build per platform (no QEMU), pushed by digest, with # BuildKit SBOM + SLSA provenance attestations attached # publish one multi-arch index tagged and `latest`; SBOM artifact, # digest in the job summary +# tag the annotated git tag on the merged commit +# +# Checks are not repeated here: the release branch is protected by a ruleset +# that only accepts pull requests whose required checks (ci-ok, security / +# report) passed, so every commit on it is green. +# +# Re-runs are safe: when the image already exists from this very commit (its +# revision label), the build is skipped and only the tag is set; when the tag +# is already on this commit, nothing is left to do. # # Push credentials come from GCP Workload Identity Federation: no key is # stored anywhere. The identity provider grants the publisher service account -# only to tag runs of an allowed repository through this workflow, so pull -# requests, branches and other workflows cannot push — and because the checks -# above live in this file, no caller can release without them. +# only to main-branch pushes (and version tags) of an allowed repository +# through this workflow, so pull requests, other branches and other workflows +# cannot push — and because the checks above live in this file, no caller can +# release without them. # -# Run anywhere except on a matching tag (e.g. workflow_dispatch on a branch), -# the workflow is a dry run: it verifies and builds every platform, and pushes -# nothing. +# A pushed version tag still releases (the flow before releases on merge; the +# tag must equal the version file, and no git tag is set). Anywhere else +# (pull requests, other branches, workflow_dispatch) the workflow is a dry run: +# it verifies and builds every platform, and pushes nothing. # # Callers grant -# permissions: { contents: read, id-token: write } +# permissions: { contents: write, id-token: write } +# (contents: write only for the tag job; contents: read suffices with +# create-tag: false). name: release-image on: @@ -65,6 +77,21 @@ on: description: Also move the `latest` tag to this release. type: boolean default: true + create-tag: + description: >- + Set the git tag after the release. A repository that releases two + images in one run sets false on both and tags once with + release-tag.yml after both. + type: boolean + default: true + artifact-prefix: + description: Prefix of this call's artifact names; required when one run calls this workflow twice. + type: string + default: "" + harness-ref: + description: Ref of IronFlock/workflows the harness scripts are taken from. + type: string + default: v1 workload-identity-provider: description: Full resource name of the GCP workload identity provider. type: string @@ -80,8 +107,10 @@ on: tag: value: ${{ jobs.verify.outputs.tag }} -permissions: - contents: read +# No workflow-level permissions: every job but `tag` names its own. `tag` +# takes the caller's grant, so a caller that grants only contents: read (the +# tag-triggered flow, the self-test's dry runs) still starts; it runs only on +# the merge of a release pull request, whose caller grants contents: write. defaults: run: @@ -96,41 +125,53 @@ jobs: id-token: write outputs: tag: ${{ steps.version.outputs.tag }} + mode: ${{ steps.version.outputs.mode }} push: ${{ steps.version.outputs.push }} + build: ${{ steps.registry.outputs.build || steps.version.outputs.build }} registry: ${{ steps.version.outputs.registry }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false fetch-depth: 0 - - name: tag = version file + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: IronFlock/workflows + ref: ${{ inputs.harness-ref }} + path: .harness + persist-credentials: false + sparse-checkout: scripts + sparse-checkout-cone-mode: false + - name: move harness out of the workspace + run: | + mv .harness "$RUNNER_TEMP/harness" + echo "HARNESS=$RUNNER_TEMP/harness" >> "$GITHUB_ENV" + - name: what this run releases id: version env: VERSION_FILE: ${{ inputs.version-file }} - PREFIX: ${{ inputs.tag-prefix }} + TAG_PREFIX: ${{ inputs.tag-prefix }} IMAGE: ${{ inputs.image }} + EVENT: ${{ github.event_name }} + REF: ${{ github.ref }} REF_TYPE: ${{ github.ref_type }} REF_NAME: ${{ github.ref_name }} + RELEASE_BRANCH: ${{ inputs.release-branch }} + COMMIT_TITLE: ${{ github.event.head_commit.message }} + SHA: ${{ github.sha }} run: | - case "$VERSION_FILE" in - package.json | */package.json) version=$(jq -r '.version // empty' "$VERSION_FILE") ;; - *) version=$(tr -d '[:space:]' < "$VERSION_FILE") ;; + "$HARNESS/scripts/release.sh" mode + mode=$(sed -n 's/^mode=//p' "$GITHUB_OUTPUT" | tail -n1) + case "$mode" in + tag | release) push=true; build=true ;; + released) push=false; build=false ;; + *) push=false; build=true ;; esac - [ -n "$version" ] || { echo "::error::$VERSION_FILE is empty"; exit 1; } - tag="${PREFIX}${version}" - echo "tag=$tag" >> "$GITHUB_OUTPUT" - echo "registry=${IMAGE%%/*}" >> "$GITHUB_OUTPUT" - if [ "$REF_TYPE" = tag ]; then - if [ "$REF_NAME" != "$tag" ]; then - echo "::error::tag $REF_NAME does not match $VERSION_FILE ($tag)" - exit 1 - fi - echo "push=true" >> "$GITHUB_OUTPUT" - echo "release $IMAGE:$tag" - else - echo "push=false" >> "$GITHUB_OUTPUT" - echo "::notice::dry run on $REF_TYPE $REF_NAME: builds $IMAGE:$tag, pushes nothing" - fi + { + echo "push=$push" + echo "build=$build" + echo "registry=${IMAGE%%/*}" + } >> "$GITHUB_OUTPUT" - name: the commit is part of ${{ inputs.release-branch }} if: steps.version.outputs.push == 'true' env: @@ -158,22 +199,37 @@ jobs: token_format: access_token access_token_lifetime: 300s - name: the tag is not in the registry yet + id: registry if: steps.version.outputs.push == 'true' env: TOKEN: ${{ steps.auth.outputs.access_token }} REGISTRY: ${{ steps.version.outputs.registry }} IMAGE: ${{ inputs.image }} TAG: ${{ steps.version.outputs.tag }} + MODE: ${{ steps.version.outputs.mode }} + SHA: ${{ github.sha }} run: | echo "$TOKEN" | docker login -u oauth2accesstoken --password-stdin "https://$REGISTRY" if docker buildx imagetools inspect "$IMAGE:$TAG" >/dev/null 2>&1; then - echo "::error::$IMAGE:$TAG already exists; released images are never overwritten — release a new version" + revisions=$(docker buildx imagetools inspect "$IMAGE:$TAG" --format '{{json .Image}}' | + jq -r '[.. | objects | select(has("Labels")) | .Labels["org.opencontainers.image.revision"] // empty] | unique | join(" ")') + docker logout "https://$REGISTRY" + # A re-run of this very release whose tag step failed: the image is + # complete, only the git tag is missing. + if [ "$MODE" = release ] && [ "$revisions" = "$SHA" ]; then + echo "::notice::$IMAGE:$TAG was already published from $SHA: only the git tag is set" + echo "build=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "::error::$IMAGE:$TAG already exists (revision ${revisions:-unknown}); released images are never overwritten — release a new version" exit 1 fi docker logout "https://$REGISTRY" + echo "build=true" >> "$GITHUB_OUTPUT" build: needs: verify + if: needs.verify.outputs.build == 'true' name: build ${{ matrix.platform }} strategy: fail-fast: true @@ -212,6 +268,7 @@ jobs: SOURCE_URL: ${{ github.server_url }}/${{ github.repository }} REVISION: ${{ github.sha }} VERSION: ${{ needs.verify.outputs.tag }} + ARTIFACT_PREFIX: ${{ inputs.artifact-prefix }} run: | args=(--platform "$PLATFORM" -f "$DOCKERFILE" --label "org.opencontainers.image.source=$SOURCE_URL" @@ -236,7 +293,7 @@ jobs: touch "$RUNNER_TEMP/digests/${digest#sha256:}" docker logout "https://$REGISTRY" fi - echo "artifact=digest-$(echo "$PLATFORM" | tr '/' '-')" >> "$GITHUB_OUTPUT" + echo "artifact=${ARTIFACT_PREFIX}digest-$(echo "$PLATFORM" | tr '/' '-')" >> "$GITHUB_OUTPUT" - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: needs.verify.outputs.push == 'true' with: @@ -247,7 +304,7 @@ jobs: publish: needs: [verify, build] - if: needs.verify.outputs.push == 'true' + if: needs.verify.outputs.push == 'true' && needs.verify.outputs.build == 'true' runs-on: ubuntu-24.04 timeout-minutes: 15 permissions: @@ -258,7 +315,7 @@ jobs: steps: - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: - pattern: digest-* + pattern: ${{ inputs.artifact-prefix }}digest-* path: ${{ runner.temp }}/digests merge-multiple: true - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 @@ -304,7 +361,35 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: - name: release-sbom + name: ${{ inputs.artifact-prefix }}release-sbom path: ${{ runner.temp }}/sbom if-no-files-found: error retention-days: 90 + + tag: + needs: [verify, publish] + # After a publish, or on a re-run whose image was already complete. + if: >- + always() && inputs.create-tag && needs.verify.result == 'success' && + needs.verify.outputs.mode == 'release' && + (needs.publish.result == 'success' || needs.verify.outputs.build == 'false') + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: IronFlock/workflows + ref: ${{ inputs.harness-ref }} + persist-credentials: false + sparse-checkout: scripts + sparse-checkout-cone-mode: false + - name: git tag ${{ needs.verify.outputs.tag }} + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + TAG: ${{ needs.verify.outputs.tag }} + SHA: ${{ github.sha }} + MESSAGE: ${{ github.event.head_commit.message }} + run: | + MESSAGE=$(head -n1 <<<"$MESSAGE") + MESSAGE=${MESSAGE% (#*)} scripts/release.sh tag diff --git a/.github/workflows/release-intent.yml b/.github/workflows/release-intent.yml new file mode 100644 index 0000000..0e1cf25 --- /dev/null +++ b/.github/workflows/release-intent.yml @@ -0,0 +1,78 @@ +# Reusable check: every pull request into main is either +# a release PR the version rises and the title is `release ` +# (`just release-patch`); its merge starts the release +# a no-release PR the title starts with `no-release:` (`just pr`) and the +# version is unchanged; its merge releases nothing +# Anything else fails. Callers list this job in the `needs` of their `ci-ok`, +# so the main-branch ruleset enforces it. Outside a pull request it passes. +# +# The title is read from the event, so a title edited after the checks ran is +# judged again only by the next push. The release workflow re-checks the +# merged commit's title and version itself. +name: release-intent + +on: + workflow_call: + inputs: + version-file: + description: File holding the version (one line, without the tag prefix), or a package.json (its `version`). + type: string + required: true + tag-prefix: + type: string + default: v + harness-ref: + description: Ref of IronFlock/workflows the harness scripts are taken from. + type: string + default: v1 + outputs: + kind: + description: release or no-release (empty outside a pull request). + value: ${{ jobs.intent.outputs.kind }} + +permissions: + contents: read + +defaults: + run: + shell: bash + +jobs: + intent: + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + kind: ${{ steps.intent.outputs.kind }} + steps: + - name: not a pull request + if: github.event_name != 'pull_request' + run: echo "::notice::no pull request, no release intent to check" + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: github.event_name == 'pull_request' + with: + persist-credentials: false + fetch-depth: 0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: github.event_name == 'pull_request' + with: + repository: IronFlock/workflows + ref: ${{ inputs.harness-ref }} + path: .harness + persist-credentials: false + sparse-checkout: scripts + sparse-checkout-cone-mode: false + - name: move harness out of the workspace + if: github.event_name == 'pull_request' + run: | + mv .harness "$RUNNER_TEMP/harness" + echo "HARNESS=$RUNNER_TEMP/harness" >> "$GITHUB_ENV" + - name: release or no-release + id: intent + if: github.event_name == 'pull_request' + env: + VERSION_FILE: ${{ inputs.version-file }} + TAG_PREFIX: ${{ inputs.tag-prefix }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + TITLE: ${{ github.event.pull_request.title }} + run: '"$HARNESS/scripts/release.sh" intent' diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml new file mode 100644 index 0000000..f05ad68 --- /dev/null +++ b/.github/workflows/release-tag.yml @@ -0,0 +1,87 @@ +# Reusable: the git tag of a release that publishes several images in one run. +# Each image job calls release-image.yml with create-tag: false; this job runs +# after all of them and sets the annotated tag on the +# merged commit, as release-image.yml does for a single image. Outside the +# merge of a release pull request it does nothing. +# +# Callers grant +# permissions: { contents: write } +name: release-tag + +on: + workflow_call: + inputs: + version-file: + description: File holding the version (one line, without the tag prefix), or a package.json (its `version`). + type: string + required: true + tag-prefix: + type: string + default: v + release-branch: + type: string + default: main + harness-ref: + description: Ref of IronFlock/workflows the harness scripts are taken from. + type: string + default: v1 + outputs: + tag: + value: ${{ jobs.tag.outputs.tag }} + +permissions: + contents: read + +defaults: + run: + shell: bash + +jobs: + tag: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: write + outputs: + tag: ${{ steps.version.outputs.tag }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: IronFlock/workflows + ref: ${{ inputs.harness-ref }} + path: .harness + persist-credentials: false + sparse-checkout: scripts + sparse-checkout-cone-mode: false + - name: move harness out of the workspace + run: | + mv .harness "$RUNNER_TEMP/harness" + echo "HARNESS=$RUNNER_TEMP/harness" >> "$GITHUB_ENV" + - name: what this run releases + id: version + env: + VERSION_FILE: ${{ inputs.version-file }} + TAG_PREFIX: ${{ inputs.tag-prefix }} + EVENT: ${{ github.event_name }} + REF: ${{ github.ref }} + REF_TYPE: ${{ github.ref_type }} + REF_NAME: ${{ github.ref_name }} + RELEASE_BRANCH: ${{ inputs.release-branch }} + COMMIT_TITLE: ${{ github.event.head_commit.message }} + SHA: ${{ github.sha }} + run: '"$HARNESS/scripts/release.sh" mode' + - name: git tag ${{ steps.version.outputs.tag }} + if: steps.version.outputs.mode == 'release' + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + TAG: ${{ steps.version.outputs.tag }} + SHA: ${{ github.sha }} + MESSAGE: ${{ github.event.head_commit.message }} + run: | + MESSAGE=$(head -n1 <<<"$MESSAGE") + MESSAGE=${MESSAGE% (#*)} "$HARNESS/scripts/release.sh" tag diff --git a/.github/workflows/selftest.yml b/.github/workflows/selftest.yml index 9ca0091..ca72cfc 100644 --- a/.github/workflows/selftest.yml +++ b/.github/workflows/selftest.yml @@ -15,7 +15,6 @@ name: selftest on: pull_request: - push: branches: [main] schedule: # Weekly: scanner databases and rule sets change without a commit here. @@ -64,6 +63,8 @@ jobs: run: shellcheck scripts/*.sh - name: sarif-summary unit tests run: python3 -I scripts/test_sarif_summary.py + - name: release.sh unit tests + run: bash scripts/test_release.sh # Public repository: no internal document names or plan IDs. - name: public-repo hygiene run: | @@ -243,8 +244,10 @@ jobs: code-scanning: false harness-ref: ${{ github.sha }} - # Dry run of the release workflow (not on a tag: builds both platforms - # natively, pushes nothing, needs no credentials). + # Dry run of the release workflow (not the merge of a release pull request: + # builds both platforms natively, pushes nothing, needs no credentials). + # The decisions of a real release (merge title, existing tag, re-run) are + # covered by the release.sh unit tests in `scripts`. release-dry-run: uses: ./.github/workflows/release-image.yml permissions: @@ -257,6 +260,8 @@ jobs: context: tests/fixtures/release workload-identity-provider: dry-run-unused service-account: dry-run-unused + artifact-prefix: version-file- + harness-ref: ${{ github.sha }} # The version of a Node repository lives in package.json. release-dry-run-package-json: @@ -271,6 +276,8 @@ jobs: context: tests/fixtures/release workload-identity-provider: dry-run-unused service-account: dry-run-unused + artifact-prefix: package-json- + harness-ref: ${{ github.sha }} assert-planted: needs: [security-planted, lint-planted] diff --git a/README.md b/README.md index 3e14426..0b0ef56 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,9 @@ The inputs of each workflow are documented in its `on.workflow_call` block. | [python-gate.yml](.github/workflows/python-gate.yml) | `lint`, `unit`, `build` | Python gate: uv from `tools.lock`; with a `uv.lock`, `uv sync --frozen` (all groups) before the recipes, otherwise the recipes bring their own tools (pinned `uvx`) | | [security.yml](.github/workflows/security.yml) | `gitleaks`, `deps`, `semgrep`, `sbom`, `config`, `licenses`, `image`, `report` | security screening; `report` applies `fail-on` | | [workflows-lint.yml](.github/workflows/workflows-lint.yml) | `lint` | actionlint, zizmor, pinact on the caller's workflows; always blocking | -| [release-image.yml](.github/workflows/release-image.yml) | `verify`, `build`, `publish` | release of a container image from a version tag: only commits on the protected `main`; native multi-arch build, SBOM + provenance attached, keyless push | +| [release-intent.yml](.github/workflows/release-intent.yml) | `intent` | every pull request is a release PR (version rises, title `release `) or a no-release PR (title `no-release: ...`, version unchanged); listed in the `needs` of `ci-ok` | +| [release-image.yml](.github/workflows/release-image.yml) | `verify`, `build`, `publish`, `tag` | release of a container image on the merge of a release PR into the protected `main`; native multi-arch build, SBOM + provenance attached, keyless push, then the git tag; re-runs are safe | +| [release-tag.yml](.github/workflows/release-tag.yml) | `tag` | the git tag of a release that publishes several images in one run (each `release-image.yml` call with `create-tag: false`) | | [selftest.yml](.github/workflows/selftest.yml) | — | proves the harness against [tests/fixtures](tests/fixtures) | Gates for Node, Python and chart/shell repositories follow the same pattern @@ -86,12 +88,30 @@ the results in their Security tab. ## Protected main branch Each repository protects `main` with a ruleset: changes only through pull -requests, which must be up to date with `main` and pass the required checks -`ci-ok` and `security / report`; no force pushes, no deletion, no bypass. So -every commit on `main` is green, and `release-image.yml` only checks that the -tagged commit is on `main`. `ci-ok` is a small job at the end of the caller's -`ci.yml` that succeeds only when every other job in it succeeded (see -[templates/workflows/ci-go.yml](templates/workflows/ci-go.yml)). +requests, squash-merged, which must be up to date with `main` and pass the +required checks `ci-ok` and `security / report`; no force pushes, no deletion, +no bypass. So every commit on `main` is green, and `release-image.yml` only +checks that the released commit is on `main`. `ci-ok` is a small job at the end +of the caller's `ci.yml` that succeeds only when every other job in it +succeeded (see [templates/workflows/ci-go.yml](templates/workflows/ci-go.yml)). + +The check workflows (`ci.yml`, `security.yml`) run on pull requests into +`main`, plus the weekly security scan; a run after the merge would test the +same tree again. Each pull request is one of two kinds, enforced by +`release-intent.yml`: + +| Kind | Made with | Title | Version | After the merge | +|---|---|---|---|---| +| release PR | `just release-patch` (`-minor`, `-major`) | `release v` | rises | `release.yml` publishes the image and sets the tag | +| no-release PR | `just pr` | `no-release: ` | unchanged | nothing runs | + +`release.yml` runs on pushes to `main` that touch the version file, and its job +only when the squash-merge title is a release title (`release v...`; +[templates/workflows/release.yml](templates/workflows/release.yml)). The +identity provider grants the publisher service account only to main-branch +pushes (and version tags) through `release-image.yml`. Its `tag` job needs +`contents: write` from the caller, and the GitHub Actions app as a bypass actor +of the repository's `release tags v*` tag ruleset. ## Exceptions @@ -115,15 +135,18 @@ The summary lists exceptions that expire within 30 days. [scripts/install-tool.sh](scripts/install-tool.sh); Go tools and semgrep are pinned in the workflows' `env` blocks; actions are pinned to commit SHAs. - **Self-test.** [selftest.yml](.github/workflows/selftest.yml) runs on every - change and weekly. The fixtures plant one finding per scanner (a fake key, + pull request and weekly. The fixtures plant one finding per scanner (a fake key, a reachable call into a vulnerable `golang.org/x/text`, a secret written to the console, a root container on an outdated base image, an unpinned action, a failing test); the self-test is green only when each one is reported and the clean fixture passes with every `fail-on` category set. Alerts that GitHub raises for the fixtures are expected. -- **Releases.** Callers use the moving tag `v1`. Move it only to a commit - whose self-test is green: +- **Releases.** Callers use the moving tag `v1`. Move it only to the merge + commit of a pull request whose self-test was green (squash merge of an + up-to-date branch: the same tree): `git tag -f v1 && git push -f origin v1`. Breaking input changes get `v2`. - **Report script.** `python3 -I scripts/test_sarif_summary.py` tests the categories, `fail-on`, VEX expiry and the code-scanning copy. +- **Release script.** `bash scripts/test_release.sh` tests the release intent, + the run modes of `release-image.yml` and the idempotent tag. diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..c7a34cc --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# Release decisions shared by release-intent.yml and release-image.yml, run +# from the repository root of a full checkout (history and tags). +# +# release.sh version [] +# print the version in : a package.json's `version`, otherwise the +# file's content without whitespace. With the file is read at that +# commit; a file missing there prints nothing. +# +# release.sh intent +# the release intent of a pull request into main. Env: VERSION_FILE, +# TAG_PREFIX, BASE_SHA, HEAD_SHA, TITLE. Every pull request is one of +# release the version rises; the title is `release ` +# no-release the title starts with `no-release:`; the version is unchanged +# anything else fails. Writes kind= (and tag= for a release) to GITHUB_OUTPUT. +# +# release.sh mode +# what a run of release-image.yml does. Env: VERSION_FILE, TAG_PREFIX, +# EVENT, REF, REF_TYPE, REF_NAME, RELEASE_BRANCH, COMMIT_TITLE, SHA. +# tag a pushed version tag (the flow before releases on merge) +# release a push to the release branch whose commit is a release +# PR's squash merge: title `release `, tag not +# set yet +# released the same, but the tag is already on this commit: a re-run +# after a complete release, nothing left to do +# dry-run anything else: build, push nothing +# Writes mode= and tag= to GITHUB_OUTPUT. +# +# release.sh tag +# set the annotated tag of a release through the GitHub API. Env: TAG, +# SHA, MESSAGE, REPO, GH_TOKEN. A tag already on SHA passes (re-run); a +# tag on another commit fails. +set -euo pipefail + +out() { + if [ -n "${GITHUB_OUTPUT:-}" ]; then echo "$1" >> "$GITHUB_OUTPUT"; fi +} + +# version [] +version() { + local file=$1 rev=${2:-} content + if [ -n "$rev" ]; then + content=$(git show "$rev:$file" 2>/dev/null) || return 0 + else + content=$(cat "$file") + fi + case "$file" in + package.json | */package.json) jq -r '.version // empty' <<<"$content" ;; + *) tr -d '[:space:]' <<<"$content" ;; + esac +} + +# higher : a is a higher version than b (version sort; b may be empty) +higher() { + [ "$1" != "$2" ] && [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -n1)" = "$1" ] +} + +intent() { + local base head tag + base=$(version "$VERSION_FILE" "$BASE_SHA") + head=$(version "$VERSION_FILE" "$HEAD_SHA") + [ -n "$head" ] || { echo "::error::$VERSION_FILE has no version at $HEAD_SHA"; return 1; } + + if [[ "$TITLE" == no-release:* ]]; then + if [ "$head" != "$base" ]; then + echo "::error::a no-release pull request must not change the version ($VERSION_FILE: $base -> $head)" + return 1 + fi + echo "no-release pull request: merging it releases nothing" + out kind=no-release + return 0 + fi + + if [ "$head" = "$base" ]; then + echo "::error::the version is unchanged ($head): bump it for a release (just release-patch) or start the title with 'no-release:' (just pr)" + return 1 + fi + if ! higher "$head" "$base"; then + echo "::error::the version must rise: $VERSION_FILE goes from $base to $head" + return 1 + fi + tag="${TAG_PREFIX}${head}" + if ! [[ "$TITLE" =~ ^release\ [^\ ]+\ (.+)$ ]] || [ "${BASH_REMATCH[1]}" != "$tag" ]; then + echo "::error::a release pull request is titled 'release $tag' (just release-patch), got '$TITLE'" + return 1 + fi + echo "release pull request: merging it releases $tag" + out kind=release + out "tag=$tag" +} + +mode() { + local v tag title + v=$(version "$VERSION_FILE") + [ -n "$v" ] || { echo "::error::$VERSION_FILE is empty"; return 1; } + tag="${TAG_PREFIX}${v}" + out "tag=$tag" + + if [ "$REF_TYPE" = tag ]; then + if [ "$REF_NAME" != "$tag" ]; then + echo "::error::tag $REF_NAME does not match $VERSION_FILE ($tag)" + return 1 + fi + echo "release $tag from its tag" + out mode=tag + return 0 + fi + + if [ "$EVENT" = push ] && [ "$REF" = "refs/heads/$RELEASE_BRANCH" ]; then + # The squash merge of a release pull request: `release (#n)`. + title=$(head -n1 <<<"$COMMIT_TITLE") + title=${title% (#*)} + if ! [[ "$title" =~ ^release\ [^\ ]+\ (.+)$ ]]; then + echo "::error::'$title' is not the merge of a release pull request: nothing to release" + return 1 + fi + if [ "${BASH_REMATCH[1]}" != "$tag" ]; then + echo "::error::the merged release pull request names ${BASH_REMATCH[1]}, but $VERSION_FILE holds $tag" + return 1 + fi + if git rev-parse --quiet --verify "refs/tags/$tag" >/dev/null; then + if [ "$(git rev-parse "$tag^{commit}")" = "$SHA" ]; then + echo "::notice::$tag is already released from this commit: nothing to do" + out mode=released + return 0 + fi + echo "::error::$tag is already released from another commit" + return 1 + fi + echo "release $tag from $RELEASE_BRANCH" + out mode=release + return 0 + fi + + echo "::notice::dry run on $EVENT $REF: builds $tag, pushes nothing" + out mode=dry-run +} + +tag() { + local existing object + if existing=$(gh api "repos/$REPO/git/ref/tags/$TAG" --jq '.object.type + " " + .object.sha' 2>/dev/null); then + object=${existing#* } + if [ "${existing%% *}" = tag ]; then + object=$(gh api "repos/$REPO/git/tags/$object" --jq .object.sha) + fi + if [ "$object" = "$SHA" ]; then + echo "$TAG is already on $SHA" + return 0 + fi + echo "::error::$TAG exists on $object, not on $SHA" + return 1 + fi + object=$(gh api "repos/$REPO/git/tags" -f tag="$TAG" -f message="$MESSAGE" \ + -f object="$SHA" -f type=commit --jq .sha) + gh api "repos/$REPO/git/refs" -f ref="refs/tags/$TAG" -f sha="$object" >/dev/null + echo "tagged $TAG on $SHA" +} + +case "${1:-}" in + version) version "$2" "${3:-}" ;; + intent) intent ;; + mode) mode ;; + tag) tag ;; + *) echo "usage: release.sh version [] | intent | mode | tag" >&2; exit 2 ;; +esac diff --git a/scripts/test_release.sh b/scripts/test_release.sh new file mode 100755 index 0000000..8541dcd --- /dev/null +++ b/scripts/test_release.sh @@ -0,0 +1,187 @@ +#!/usr/bin/env bash +# Tests of release.sh against throwaway git repositories: +# bash scripts/test_release.sh +set -euo pipefail + +script="$(cd "$(dirname "$0")" && pwd)/release.sh" +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +failures=0 + +check() { # check + local name=$1 want=$2 got=0 + shift 2 + "$@" >"$work/log" 2>&1 || got=$? + if { [ "$want" = 0 ] && [ "$got" = 0 ]; } || { [ "$want" = 1 ] && [ "$got" != 0 ]; }; then + echo "ok $name" + else + echo "FAIL $name (exit $got, wanted $want)" + sed 's/^/ /' "$work/log" + failures=$((failures + 1)) + fi +} + +expect_output() { # expect_output + if grep -qx -- "$2" "$GITHUB_OUTPUT"; then + echo "ok $1" + else + echo "FAIL $1: '$2' not in output:" + sed 's/^/ /' "$GITHUB_OUTPUT" + failures=$((failures + 1)) + fi +} + +# repo : a repository with one commit +repo() { + mkdir -p "$1" + git -C "$1" init -q -b main + git -C "$1" config user.email t@example.invalid + git -C "$1" config user.name test + setv "$1" "$2" "$3" + git -C "$1" add -A + git -C "$1" commit -qm initial +} + +setv() { # setv + case "$2" in + package.json) printf '{\n "name": "x",\n "version": "%s"\n}\n' "$3" > "$1/$2" ;; + *) printf '%s\n' "$3" > "$1/$2" ;; + esac +} + +commit() { # commit : commit every change, print the sha + touch "$1/change-$RANDOM" + git -C "$1" add -A + git -C "$1" commit -qm "$2" + git -C "$1" rev-parse HEAD +} + +intent() { # intent + (cd "$1" && VERSION_FILE=$2 TAG_PREFIX=v BASE_SHA=$3 HEAD_SHA=$4 TITLE=$5 "$script" intent) +} + +for vf in VERSION package.json; do + r="$work/intent-$vf" + repo "$r" "$vf" 1.2.3 + base=$(git -C "$r" rev-parse HEAD) + + same=$(commit "$r" "docs") + check "$vf: no-release, version unchanged" 0 intent "$r" "$vf" "$base" "$same" "no-release: fix a typo" + check "$vf: unchanged version without the marker" 1 intent "$r" "$vf" "$base" "$same" "fix a typo" + check "$vf: release title without a bump" 1 intent "$r" "$vf" "$base" "$same" "release x v1.2.3" + + git -C "$r" checkout -q "$base" + setv "$r" "$vf" 1.2.4 + bump=$(commit "$r" "bump") + export GITHUB_OUTPUT="$work/out-$vf" + : > "$GITHUB_OUTPUT" + check "$vf: release PR" 0 intent "$r" "$vf" "$base" "$bump" "release x v1.2.4" + expect_output "$vf: release PR reports its tag" "tag=v1.2.4" + unset GITHUB_OUTPUT + check "$vf: bump marked no-release" 1 intent "$r" "$vf" "$base" "$bump" "no-release: sneaky" + check "$vf: bump without a release title" 1 intent "$r" "$vf" "$base" "$bump" "fix a typo" + check "$vf: release title names another version" 1 intent "$r" "$vf" "$base" "$bump" "release x v1.2.5" + check "$vf: release title with text after the tag" 1 intent "$r" "$vf" "$base" "$bump" "release x v1.2.4 and more" + + git -C "$r" checkout -q "$base" + setv "$r" "$vf" 1.2.2 + down=$(commit "$r" "downgrade") + check "$vf: lower version" 1 intent "$r" "$vf" "$base" "$down" "release x v1.2.2" + + git -C "$r" checkout -q "$base" + setv "$r" "$vf" 1.10.0 + minor=$(commit "$r" "minor") + check "$vf: 1.2.3 -> 1.10.0 is higher (version sort)" 0 intent "$r" "$vf" "$base" "$minor" "release x v1.10.0" +done + +mode() { # mode <dir> <event> <ref> <ref type> <ref name> <commit title> + (cd "$1" && VERSION_FILE=VERSION TAG_PREFIX=v EVENT=$2 REF=$3 REF_TYPE=$4 REF_NAME=$5 \ + RELEASE_BRANCH=main COMMIT_TITLE=$6 SHA=$(git rev-parse HEAD) "$script" mode) +} + +r="$work/mode" +repo "$r" VERSION 2.0.0 +export GITHUB_OUTPUT="$work/out-mode" + +: > "$GITHUB_OUTPUT" +check "merge of a release PR releases" 0 mode "$r" push refs/heads/main branch main "release x v2.0.0 (#12)" +expect_output "merge of a release PR: mode release" "mode=release" +expect_output "merge of a release PR: tag" "tag=v2.0.0" +check "merge title with the PR body below" 0 mode "$r" push refs/heads/main branch main $'release x v2.0.0 (#12)\n\nbody' +check "merge of a no-release PR is refused" 1 mode "$r" push refs/heads/main branch main "no-release: docs (#13)" +check "merge naming another version is refused" 1 mode "$r" push refs/heads/main branch main "release x v1.9.0 (#14)" +git -C "$r" tag -a v2.0.0 -m "release x v2.0.0" +: > "$GITHUB_OUTPUT" +check "a re-run after a complete release passes" 0 mode "$r" push refs/heads/main branch main "release x v2.0.0 (#12)" +expect_output "re-run after a complete release: mode released" "mode=released" +commit "$r" "release x v2.0.0 again" >/dev/null +check "a version tagged on another commit is not released again" 1 mode "$r" push refs/heads/main branch main "release x v2.0.0 (#15)" + +: > "$GITHUB_OUTPUT" +check "a pushed tag matching the version releases" 0 mode "$r" push refs/tags/v2.0.0 tag v2.0.0 "anything" +expect_output "pushed tag: mode tag" "mode=tag" +check "a pushed tag not matching the version fails" 1 mode "$r" push refs/tags/v2.0.1 tag v2.0.1 "anything" + +: > "$GITHUB_OUTPUT" +check "a pull request is a dry run" 0 mode "$r" pull_request refs/pull/3/merge branch 3/merge "x" +expect_output "pull request: mode dry-run" "mode=dry-run" +: > "$GITHUB_OUTPUT" +check "a push to another branch is a dry run" 0 mode "$r" push refs/heads/feature branch feature "release x v2.0.0" +expect_output "other branch: mode dry-run" "mode=dry-run" +: > "$GITHUB_OUTPUT" +check "workflow_dispatch on main is a dry run" 0 mode "$r" workflow_dispatch refs/heads/main branch main "release x v2.0.0" +expect_output "workflow_dispatch: mode dry-run" "mode=dry-run" + +unset GITHUB_OUTPUT + +# release.sh tag against a fake `gh` that keeps refs and tag objects in a folder. +mkdir -p "$work/bin" "$work/gh" +cat > "$work/bin/gh" <<'FAKE' +#!/usr/bin/env bash +set -euo pipefail +state=$FAKE_GH_STATE +path=$2 +shift 2 +object="" message="" sha="" ref="" +while [ $# -gt 0 ]; do + case "$1" in + -f) + case "$2" in + object=*) object=${2#*=} ;; + message=*) message=${2#*=} ;; + sha=*) sha=${2#*=} ;; + ref=*) ref=${2#*=} ;; + esac + shift 2 ;; + --jq) shift 2 ;; + *) shift ;; + esac +done +case "$path" in + */git/ref/tags/*) cat "$state/ref-${path##*/}" 2>/dev/null || exit 1 ;; + */git/tags/*) cat "$state/obj-${path##*/}" ;; + */git/tags) + obj=t$RANDOM + echo "$object" > "$state/obj-$obj" + echo "$message" > "$state/msg-$obj" + echo "$obj" ;; + */git/refs) echo "tag $sha" > "$state/ref-${ref##*/}" ;; + *) echo "fake gh: unexpected $path" >&2; exit 2 ;; +esac +FAKE +chmod +x "$work/bin/gh" + +tag() { # tag <tag> <sha> + PATH="$work/bin:$PATH" FAKE_GH_STATE="$work/gh" REPO=o/r TAG=$1 SHA=$2 \ + MESSAGE="release x $1" GH_TOKEN=unused "$script" tag +} +check "tag: a new tag is created" 0 tag v3.0.0 aaa +if grep -qx "release x v3.0.0" "$work"/gh/msg-*; then echo "ok tag: annotated with the release title"; else + echo "FAIL tag: message missing"; failures=$((failures + 1)); fi +check "tag: a re-run on the same commit passes" 0 tag v3.0.0 aaa +check "tag: the same tag on another commit fails" 1 tag v3.0.0 bbb +echo "commit ccc" > "$work/gh/ref-v3.0.1" +check "tag: a lightweight tag on the same commit passes" 0 tag v3.0.1 ccc + +[ "$failures" = 0 ] || { echo "$failures failure(s)"; exit 1; } +echo "all release.sh tests passed" diff --git a/templates/workflows/ci-go.yml b/templates/workflows/ci-go.yml index 4063443..96d293d 100644 --- a/templates/workflows/ci-go.yml +++ b/templates/workflows/ci-go.yml @@ -1,10 +1,11 @@ -# Caller template for a Go repository: gate + workflow lint. -# Copy to .github/workflows/ci.yml and adjust the `with:` block. +# Caller template for a Go repository: gate + workflow lint + release intent. +# Copy to .github/workflows/ci.yml and adjust the `with:` blocks. +# Runs on pull requests into main only: the ruleset lets nothing reach main +# without this check, so a run after the merge would test the same tree again. name: ci on: pull_request: - push: branches: [main] workflow_dispatch: @@ -25,11 +26,18 @@ jobs: workflows: uses: IronFlock/workflows/.github/workflows/workflows-lint.yml@v1 + # Every pull request is a release PR (version bump, `just release-patch`) or + # a no-release PR (title `no-release: ...`, `just pr`). + intent: + uses: IronFlock/workflows/.github/workflows/release-intent.yml@v1 + with: + version-file: VERSION + # The one required check of the main-branch ruleset: green only when every # job above succeeded (skipped or cancelled counts as failed). ci-ok: if: ${{ always() }} - needs: [gate, workflows] + needs: [gate, workflows, intent] runs-on: ubuntu-latest timeout-minutes: 5 steps: diff --git a/templates/workflows/release.yml b/templates/workflows/release.yml new file mode 100644 index 0000000..fbfcaf8 --- /dev/null +++ b/templates/workflows/release.yml @@ -0,0 +1,36 @@ +# Caller template for the release of a container image. +# Copy to .github/workflows/release.yml and adjust `paths` and the `with:` block. +# +# The merge of a release pull request (`just release-patch`: version bump, +# title `release <name> v<version>`) publishes the image and sets the tag. +# A no-release merge starts nothing: `paths` holds only the version file, and +# when a no-release PR touches it anyway (a dependency in package.json) the +# job below is skipped by its merge title and no runner starts. +# Run by hand (workflow_dispatch) it is a dry run: both platform builds, no push. +name: release + +on: + push: + branches: [main] + paths: [VERSION] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + image: + if: github.event_name == 'workflow_dispatch' || startsWith(github.event.head_commit.message, 'release ') + uses: IronFlock/workflows/.github/workflows/release-image.yml@v1 + permissions: + contents: write + id-token: write + with: + image: europe-docker.pkg.dev/<project>/<repository>/<name> + version-file: VERSION + workload-identity-provider: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }} + service-account: <publisher>@<project>.iam.gserviceaccount.com diff --git a/templates/workflows/security.yml b/templates/workflows/security.yml index 6a7c380..ec51e59 100644 --- a/templates/workflows/security.yml +++ b/templates/workflows/security.yml @@ -5,8 +5,8 @@ name: security on: pull_request: - push: branches: [main] + # Weekly on main: catches vulnerabilities disclosed against unchanged code. schedule: - cron: "17 6 * * 1" workflow_dispatch: From 4a9b6bd86a7aecd0d99d7b1fe12a0985298260b1 Mon Sep 17 00:00:00 2001 From: Marko Petzold <marko.petzold@record-evolution.de> Date: Sat, 24 Oct 2026 21:09:22 +0200 Subject: [PATCH 2/3] release on merge: the version decides, the tag comes from CI, dry run on release PRs - release-intent: no title marker any more. An unchanged version is a no-release pull request (any title that does not read like a release); a rising version is a release pull request titled `release <name> v<x>`. - release-image: no tag job; it needs read rights only, so a release pull request can run it as a dry run (every platform build, no push). - release-tag: the tag of every release, a job of its own after all image jobs, so a tag always means "in the registry". - templates: release.yml = image + tag; ci-go.yml adds release-build (the repository's release.yml as a dry run on release pull requests); ci-ok accepts it skipped only on no-release pull requests. - README: the two pull request kinds, recovery from a failed release run, central logic with thin per-repository callers. --- .github/workflows/release-image.yml | 54 +++++----------------------- .github/workflows/release-intent.yml | 10 +++--- .github/workflows/release-tag.yml | 11 +++--- README.md | 42 +++++++++++++++------- scripts/release.sh | 18 +++++----- scripts/test_release.sh | 7 ++-- templates/workflows/ci-go.yml | 32 +++++++++++++---- templates/workflows/release.yml | 33 ++++++++++++----- 8 files changed, 111 insertions(+), 96 deletions(-) diff --git a/.github/workflows/release-image.yml b/.github/workflows/release-image.yml index 006bf65..e382526 100644 --- a/.github/workflows/release-image.yml +++ b/.github/workflows/release-image.yml @@ -10,15 +10,18 @@ # BuildKit SBOM + SLSA provenance attestations attached # publish one multi-arch index tagged <tag> and `latest`; SBOM artifact, # digest in the job summary -# tag the annotated git tag <tag> on the merged commit +# +# The git tag is set afterwards by release-tag.yml (a job of its own in the +# caller, `needs` this one), so this workflow never needs write access to the +# repository: release pull requests call it as a dry run with read rights. # # Checks are not repeated here: the release branch is protected by a ruleset # that only accepts pull requests whose required checks (ci-ok, security / # report) passed, so every commit on it is green. # # Re-runs are safe: when the image already exists from this very commit (its -# revision label), the build is skipped and only the tag is set; when the tag -# is already on this commit, nothing is left to do. +# revision label), the build is skipped (release-tag.yml then sets the tag); +# when the tag is already on this commit, nothing is left to do. # # Push credentials come from GCP Workload Identity Federation: no key is # stored anywhere. The identity provider grants the publisher service account @@ -33,9 +36,7 @@ # it verifies and builds every platform, and pushes nothing. # # Callers grant -# permissions: { contents: write, id-token: write } -# (contents: write only for the tag job; contents: read suffices with -# create-tag: false). +# permissions: { contents: read, id-token: write } name: release-image on: @@ -77,13 +78,6 @@ on: description: Also move the `latest` tag to this release. type: boolean default: true - create-tag: - description: >- - Set the git tag after the release. A repository that releases two - images in one run sets false on both and tags once with - release-tag.yml after both. - type: boolean - default: true artifact-prefix: description: Prefix of this call's artifact names; required when one run calls this workflow twice. type: string @@ -107,10 +101,8 @@ on: tag: value: ${{ jobs.verify.outputs.tag }} -# No workflow-level permissions: every job but `tag` names its own. `tag` -# takes the caller's grant, so a caller that grants only contents: read (the -# tag-triggered flow, the self-test's dry runs) still starts; it runs only on -# the merge of a release pull request, whose caller grants contents: write. +# No workflow-level permissions: every job names its own (contents: read, +# id-token: write), so a release pull request's dry run starts with read rights. defaults: run: @@ -365,31 +357,3 @@ jobs: path: ${{ runner.temp }}/sbom if-no-files-found: error retention-days: 90 - - tag: - needs: [verify, publish] - # After a publish, or on a re-run whose image was already complete. - if: >- - always() && inputs.create-tag && needs.verify.result == 'success' && - needs.verify.outputs.mode == 'release' && - (needs.publish.result == 'success' || needs.verify.outputs.build == 'false') - runs-on: ubuntu-24.04 - timeout-minutes: 5 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: IronFlock/workflows - ref: ${{ inputs.harness-ref }} - persist-credentials: false - sparse-checkout: scripts - sparse-checkout-cone-mode: false - - name: git tag ${{ needs.verify.outputs.tag }} - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - TAG: ${{ needs.verify.outputs.tag }} - SHA: ${{ github.sha }} - MESSAGE: ${{ github.event.head_commit.message }} - run: | - MESSAGE=$(head -n1 <<<"$MESSAGE") - MESSAGE=${MESSAGE% (#*)} scripts/release.sh tag diff --git a/.github/workflows/release-intent.yml b/.github/workflows/release-intent.yml index 0e1cf25..63f503a 100644 --- a/.github/workflows/release-intent.yml +++ b/.github/workflows/release-intent.yml @@ -1,9 +1,9 @@ -# Reusable check: every pull request into main is either -# a release PR the version rises and the title is `release <name> <tag>` +# Reusable check: the version file decides what a pull request into main is +# a release PR the version rises; the title must be `release <name> <tag>` # (`just release-patch`); its merge starts the release -# a no-release PR the title starts with `no-release:` (`just pr`) and the -# version is unchanged; its merge releases nothing -# Anything else fails. Callers list this job in the `needs` of their `ci-ok`, +# a no-release PR the version is unchanged (`just merge`); any title that +# does not read like a release; its merge releases nothing +# Anything else fails (a lower version, a release title without a bump). Callers list this job in the `needs` of their `ci-ok`, # so the main-branch ruleset enforces it. Outside a pull request it passes. # # The title is read from the event, so a title edited after the checks ran is diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index f05ad68..3fbd2bd 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -1,8 +1,9 @@ -# Reusable: the git tag of a release that publishes several images in one run. -# Each image job calls release-image.yml with create-tag: false; this job runs -# after all of them and sets the annotated tag <tag-prefix><version> on the -# merged commit, as release-image.yml does for a single image. Outside the -# merge of a release pull request it does nothing. +# Reusable: the git tag of a release. The caller's tag job `needs` every image +# job (release-image.yml, one per image) and sets the annotated tag +# <tag-prefix><version> on the merged commit once all images are in the +# registry, so a tag always means "released". Outside the merge of a release +# pull request it does nothing; on a re-run whose tag is already on this +# commit it passes. # # Callers grant # permissions: { contents: write } diff --git a/README.md b/README.md index 0b0ef56..1f2ced5 100644 --- a/README.md +++ b/README.md @@ -45,9 +45,9 @@ The inputs of each workflow are documented in its `on.workflow_call` block. | [python-gate.yml](.github/workflows/python-gate.yml) | `lint`, `unit`, `build` | Python gate: uv from `tools.lock`; with a `uv.lock`, `uv sync --frozen` (all groups) before the recipes, otherwise the recipes bring their own tools (pinned `uvx`) | | [security.yml](.github/workflows/security.yml) | `gitleaks`, `deps`, `semgrep`, `sbom`, `config`, `licenses`, `image`, `report` | security screening; `report` applies `fail-on` | | [workflows-lint.yml](.github/workflows/workflows-lint.yml) | `lint` | actionlint, zizmor, pinact on the caller's workflows; always blocking | -| [release-intent.yml](.github/workflows/release-intent.yml) | `intent` | every pull request is a release PR (version rises, title `release <name> <tag>`) or a no-release PR (title `no-release: ...`, version unchanged); listed in the `needs` of `ci-ok` | -| [release-image.yml](.github/workflows/release-image.yml) | `verify`, `build`, `publish`, `tag` | release of a container image on the merge of a release PR into the protected `main`; native multi-arch build, SBOM + provenance attached, keyless push, then the git tag; re-runs are safe | -| [release-tag.yml](.github/workflows/release-tag.yml) | `tag` | the git tag of a release that publishes several images in one run (each `release-image.yml` call with `create-tag: false`) | +| [release-intent.yml](.github/workflows/release-intent.yml) | `intent` | the version file decides: a rise makes a release PR (title `release <name> <tag>` required), an unchanged version a no-release PR; output `kind` gates the release dry run; listed in the `needs` of `ci-ok` | +| [release-image.yml](.github/workflows/release-image.yml) | `verify`, `build`, `publish` | release of a container image on the merge of a release PR into the protected `main`; native multi-arch build, SBOM + provenance attached, keyless push; on a release PR a dry run (every platform build, no push); re-runs are safe; read rights only | +| [release-tag.yml](.github/workflows/release-tag.yml) | `tag` | the annotated git tag of a release, after every image job of the caller; a tag always means "in the registry"; needs `contents: write` | | [selftest.yml](.github/workflows/selftest.yml) | — | proves the harness against [tests/fixtures](tests/fixtures) | Gates for Node, Python and chart/shell repositories follow the same pattern @@ -85,6 +85,18 @@ as artifacts (`security-findings-*`, `security-sbom`) for 90 days. A workflow th calls `security.yml` more than once per run gives each call its own `artifact-prefix`. Public repositories also get the results in their Security tab. +## Central logic, thin callers + +The logic lives here: the reusable workflows, the scripts and the tool pins +(`tools.lock`). Each repository keeps only thin caller files — `ci.yml`, +`security.yml`, `release.yml` — because GitHub starts workflows only from files +in the repository itself. A caller holds the triggers, the repository's settings +(image, version file, directory, targets) and `uses: IronFlock/workflows/...@v1`, +plus jobs only that repository needs (integration tests, a second image). Copy +them from [templates/workflows](templates/workflows). A change to the logic is +made once here and reaches every repository when `v1` moves after a green +self-test; a change to triggers or settings needs a pull request per repository. + ## Protected main branch Each repository protects `main` with a ruleset: changes only through pull @@ -97,21 +109,27 @@ succeeded (see [templates/workflows/ci-go.yml](templates/workflows/ci-go.yml)). The check workflows (`ci.yml`, `security.yml`) run on pull requests into `main`, plus the weekly security scan; a run after the merge would test the -same tree again. Each pull request is one of two kinds, enforced by -`release-intent.yml`: +same tree again. The version file decides what a pull request is +(`release-intent.yml`): + +| Kind | Made with | Title | Version | On the pull request | After the merge | +|---|---|---|---|---|---| +| release PR | `just release-patch` (`-minor`, `-major`) | `release <name> v<version>` | rises | ci (incl. the release dry run: every platform build, no push) + security | `release.yml` publishes the image, then sets the tag | +| no-release PR | `just merge` | anything that does not read like a release | unchanged | ci + security | nothing runs | -| Kind | Made with | Title | Version | After the merge | -|---|---|---|---|---| -| release PR | `just release-patch` (`-minor`, `-major`) | `release <name> v<version>` | rises | `release.yml` publishes the image and sets the tag | -| no-release PR | `just pr` | `no-release: <what>` | unchanged | nothing runs | +If a release run fails after the merge, `main` holds the new version without +an image or tag; nothing reads it (deployments take versions from the +registry). A transient or permission failure: fix the cause and re-run the run +(safe: nothing is pushed twice, the tag comes last). A code failure: fix it in +the next release PR; the unreleased version stays a gap in the history. `release.yml` runs on pushes to `main` that touch the version file, and its job only when the squash-merge title is a release title (`release <name> v...`; [templates/workflows/release.yml](templates/workflows/release.yml)). The identity provider grants the publisher service account only to main-branch -pushes (and version tags) through `release-image.yml`. Its `tag` job needs -`contents: write` from the caller, and the GitHub Actions app as a bypass actor -of the repository's `release tags v*` tag ruleset. +pushes (and version tags) through `release-image.yml`. The caller's `tag` job +(`release-tag.yml`) needs `contents: write`, and the GitHub Actions app as a +bypass actor of the repository's `release tags v*` tag ruleset. ## Exceptions diff --git a/scripts/release.sh b/scripts/release.sh index c7a34cc..5410c43 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -9,9 +9,11 @@ # # release.sh intent # the release intent of a pull request into main. Env: VERSION_FILE, -# TAG_PREFIX, BASE_SHA, HEAD_SHA, TITLE. Every pull request is one of +# TAG_PREFIX, BASE_SHA, HEAD_SHA, TITLE. The version file decides: # release the version rises; the title is `release <name> <tag>` -# no-release the title starts with `no-release:`; the version is unchanged +# (just release-patch|minor|major) +# no-release the version is unchanged (just merge); any title except +# one that reads like a release # anything else fails. Writes kind= (and tag= for a release) to GITHUB_OUTPUT. # # release.sh mode @@ -61,20 +63,16 @@ intent() { head=$(version "$VERSION_FILE" "$HEAD_SHA") [ -n "$head" ] || { echo "::error::$VERSION_FILE has no version at $HEAD_SHA"; return 1; } - if [[ "$TITLE" == no-release:* ]]; then - if [ "$head" != "$base" ]; then - echo "::error::a no-release pull request must not change the version ($VERSION_FILE: $base -> $head)" + if [ "$head" = "$base" ]; then + if [[ "$TITLE" =~ ^release\ [^\ ]+\ ${TAG_PREFIX}[0-9] ]]; then + echo "::error::the title reads like a release, but $VERSION_FILE is unchanged ($head): bump it (just release-patch) or retitle the pull request" return 1 fi - echo "no-release pull request: merging it releases nothing" + echo "no-release pull request (version unchanged): merging it releases nothing" out kind=no-release return 0 fi - if [ "$head" = "$base" ]; then - echo "::error::the version is unchanged ($head): bump it for a release (just release-patch) or start the title with 'no-release:' (just pr)" - return 1 - fi if ! higher "$head" "$base"; then echo "::error::the version must rise: $VERSION_FILE goes from $base to $head" return 1 diff --git a/scripts/test_release.sh b/scripts/test_release.sh index 8541dcd..2b0c136 100755 --- a/scripts/test_release.sh +++ b/scripts/test_release.sh @@ -66,8 +66,8 @@ for vf in VERSION package.json; do base=$(git -C "$r" rev-parse HEAD) same=$(commit "$r" "docs") - check "$vf: no-release, version unchanged" 0 intent "$r" "$vf" "$base" "$same" "no-release: fix a typo" - check "$vf: unchanged version without the marker" 1 intent "$r" "$vf" "$base" "$same" "fix a typo" + check "$vf: no-release, version unchanged" 0 intent "$r" "$vf" "$base" "$same" "fix a typo" + check "$vf: no-release, any title" 0 intent "$r" "$vf" "$base" "$same" "deps: bump express (dependabot)" check "$vf: release title without a bump" 1 intent "$r" "$vf" "$base" "$same" "release x v1.2.3" git -C "$r" checkout -q "$base" @@ -78,7 +78,6 @@ for vf in VERSION package.json; do check "$vf: release PR" 0 intent "$r" "$vf" "$base" "$bump" "release x v1.2.4" expect_output "$vf: release PR reports its tag" "tag=v1.2.4" unset GITHUB_OUTPUT - check "$vf: bump marked no-release" 1 intent "$r" "$vf" "$base" "$bump" "no-release: sneaky" check "$vf: bump without a release title" 1 intent "$r" "$vf" "$base" "$bump" "fix a typo" check "$vf: release title names another version" 1 intent "$r" "$vf" "$base" "$bump" "release x v1.2.5" check "$vf: release title with text after the tag" 1 intent "$r" "$vf" "$base" "$bump" "release x v1.2.4 and more" @@ -108,7 +107,7 @@ check "merge of a release PR releases" 0 mode "$r" push refs/heads/main branch m expect_output "merge of a release PR: mode release" "mode=release" expect_output "merge of a release PR: tag" "tag=v2.0.0" check "merge title with the PR body below" 0 mode "$r" push refs/heads/main branch main $'release x v2.0.0 (#12)\n\nbody' -check "merge of a no-release PR is refused" 1 mode "$r" push refs/heads/main branch main "no-release: docs (#13)" +check "merge of a no-release PR is refused" 1 mode "$r" push refs/heads/main branch main "docs: typo (#13)" check "merge naming another version is refused" 1 mode "$r" push refs/heads/main branch main "release x v1.9.0 (#14)" git -C "$r" tag -a v2.0.0 -m "release x v2.0.0" : > "$GITHUB_OUTPUT" diff --git a/templates/workflows/ci-go.yml b/templates/workflows/ci-go.yml index 96d293d..a7ff90f 100644 --- a/templates/workflows/ci-go.yml +++ b/templates/workflows/ci-go.yml @@ -26,25 +26,43 @@ jobs: workflows: uses: IronFlock/workflows/.github/workflows/workflows-lint.yml@v1 - # Every pull request is a release PR (version bump, `just release-patch`) or - # a no-release PR (title `no-release: ...`, `just pr`). + # The version file decides: a bump makes a release PR (`just release-patch`, + # title `release <name> v<version>`), an unchanged version a no-release PR + # (`just merge`) whose merge releases nothing. intent: uses: IronFlock/workflows/.github/workflows/release-intent.yml@v1 with: version-file: VERSION + # Release pull requests only: the release workflow as a dry run (every + # platform build, no push, no tag), so a broken image build never reaches + # main. contents: write only because GitHub checks the called workflow's + # tag job against this grant even though the job is skipped here (it runs + # on a push to main only); fork pull requests get a read-only token anyway. + release-build: + needs: [intent] + if: needs.intent.outputs.kind == 'release' + uses: ./.github/workflows/release.yml + permissions: + contents: write + id-token: write + # The one required check of the main-branch ruleset: green only when every - # job above succeeded (skipped or cancelled counts as failed). + # job above succeeded (skipped or cancelled counts as failed; release-build + # may be skipped, on a no-release pull request). ci-ok: if: ${{ always() }} - needs: [gate, workflows, intent] + needs: [gate, workflows, intent, release-build] runs-on: ubuntu-latest timeout-minutes: 5 steps: - name: all ci jobs succeeded env: - RESULTS: ${{ join(needs.*.result, ' ') }} + REQUIRED: ${{ needs.gate.result }} ${{ needs.workflows.result }} ${{ needs.intent.result }} + RELEASE_BUILD: ${{ needs.release-build.result }} + KIND: ${{ needs.intent.outputs.kind }} run: | - echo "results: $RESULTS" - for r in $RESULTS; do [ "$r" = success ] || exit 1; done + echo "required: $REQUIRED, release-build: $RELEASE_BUILD ($KIND)" + for r in $REQUIRED; do [ "$r" = success ] || exit 1; done + if [ "$KIND" = release ]; then [ "$RELEASE_BUILD" = success ]; else [ "$RELEASE_BUILD" = skipped ]; fi diff --git a/templates/workflows/release.yml b/templates/workflows/release.yml index fbfcaf8..7f20a25 100644 --- a/templates/workflows/release.yml +++ b/templates/workflows/release.yml @@ -1,18 +1,24 @@ # Caller template for the release of a container image. -# Copy to .github/workflows/release.yml and adjust `paths` and the `with:` block. +# Copy to .github/workflows/release.yml and adjust `paths` and the `with:` blocks. # # The merge of a release pull request (`just release-patch`: version bump, -# title `release <name> v<version>`) publishes the image and sets the tag. -# A no-release merge starts nothing: `paths` holds only the version file, and -# when a no-release PR touches it anyway (a dependency in package.json) the -# job below is skipped by its merge title and no runner starts. -# Run by hand (workflow_dispatch) it is a dry run: both platform builds, no push. +# title `release <name> v<version>`) publishes the image and then sets the tag +# `v<version>` on the merged commit, so a tag always means "in the registry". +# A no-release merge (`just merge`) starts nothing: `paths` holds only the +# version file, and when a no-release PR touches it anyway (a dependency in a +# package.json) the jobs below are skipped by the merge title, so no runner +# starts. Run by hand (workflow_dispatch) it is a dry run: every platform +# build, no push, no tag. +# +# ci.yml calls this workflow on release pull requests (workflow_call) as the +# release dry run, so a broken image build never reaches main. name: release on: push: branches: [main] paths: [VERSION] + workflow_call: workflow_dispatch: permissions: @@ -24,13 +30,24 @@ concurrency: jobs: image: - if: github.event_name == 'workflow_dispatch' || startsWith(github.event.head_commit.message, 'release ') + if: github.event_name != 'push' || startsWith(github.event.head_commit.message, 'release ') uses: IronFlock/workflows/.github/workflows/release-image.yml@v1 permissions: - contents: write + contents: read id-token: write with: image: europe-docker.pkg.dev/<project>/<repository>/<name> version-file: VERSION workload-identity-provider: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }} service-account: <publisher>@<project>.iam.gserviceaccount.com + + # The tag once every image is in the registry (a second image: add its job + # to `needs`). Only on the merge of a release pull request. + tag: + needs: [image] + if: github.event_name == 'push' && startsWith(github.event.head_commit.message, 'release ') + uses: IronFlock/workflows/.github/workflows/release-tag.yml@v1 + permissions: + contents: write + with: + version-file: VERSION From 5eec61ec96c34303e0cab157e92357f3e504477c Mon Sep 17 00:00:00 2001 From: Marko Petzold <marko.petzold@record-evolution.de> Date: Sat, 24 Oct 2026 21:12:09 +0200 Subject: [PATCH 3/3] selftest: actionlint config for the templates' local release.yml; SC2153 note The caller templates call the repository's own release.yml, which exists only once a template is copied; .github/actionlint.yaml ignores exactly that message for templates/workflows. TITLE in release.sh intent comes from the environment. --- .github/actionlint.yaml | 8 ++++++++ scripts/release.sh | 1 + 2 files changed, 9 insertions(+) create mode 100644 .github/actionlint.yaml diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000..9a96261 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,8 @@ +# actionlint configuration of this repository. +paths: + # The caller templates call the repository's own release.yml + # (./.github/workflows/release.yml), which exists only once a template is + # copied into a repository. + templates/workflows/**/*.yml: + ignore: + - 'could not read reusable workflow file for "./.github/workflows/release.yml"' diff --git a/scripts/release.sh b/scripts/release.sh index 5410c43..6688250 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -64,6 +64,7 @@ intent() { [ -n "$head" ] || { echo "::error::$VERSION_FILE has no version at $HEAD_SHA"; return 1; } if [ "$head" = "$base" ]; then + # shellcheck disable=SC2153 # TITLE comes from the environment (see the header) if [[ "$TITLE" =~ ^release\ [^\ ]+\ ${TAG_PREFIX}[0-9] ]]; then echo "::error::the title reads like a release, but $VERSION_FILE is unchanged ($head): bump it (just release-patch) or retitle the pull request" return 1