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/.github/workflows/release-image.yml b/.github/workflows/release-image.yml index f033bb8..e382526 100644 --- a/.github/workflows/release-image.yml +++ b/.github/workflows/release-image.yml @@ -1,26 +1,39 @@ -# 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 # +# 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 (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 -# 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 } @@ -65,6 +78,14 @@ on: description: Also move the `latest` tag to this release. 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 +101,8 @@ on: tag: value: ${{ jobs.verify.outputs.tag }} -permissions: - contents: read +# 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: @@ -96,41 +117,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 +191,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 +260,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 +285,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 +296,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 +307,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 +353,7 @@ 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 diff --git a/.github/workflows/release-intent.yml b/.github/workflows/release-intent.yml new file mode 100644 index 0000000..63f503a --- /dev/null +++ b/.github/workflows/release-intent.yml @@ -0,0 +1,78 @@ +# Reusable check: the version file decides what a pull request into main is +# a release PR the version rises; the title must be `release ` +# (`just release-patch`); its merge starts the release +# 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 +# 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..3fbd2bd --- /dev/null +++ b/.github/workflows/release-tag.yml @@ -0,0 +1,88 @@ +# 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 +# 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 } +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..1f2ced5 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` | the version file decides: a rise makes a release PR (title `release ` 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 @@ -83,15 +85,51 @@ 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 -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. 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 v` | 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 | + +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 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`. 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 @@ -115,15 +153,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..6688250 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,164 @@ +#!/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. The version file decides: +# release the version rises; the title is `release ` +# (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 +# 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 [ "$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 + fi + echo "no-release pull request (version unchanged): merging it releases nothing" + out kind=no-release + return 0 + 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..2b0c136 --- /dev/null +++ b/scripts/test_release.sh @@ -0,0 +1,186 @@ +#!/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" "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" + 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 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 "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" +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..a7ff90f 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,18 +26,43 @@ jobs: workflows: uses: IronFlock/workflows/.github/workflows/workflows-lint.yml@v1 + # 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] + 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 new file mode 100644 index 0000000..7f20a25 --- /dev/null +++ b/templates/workflows/release.yml @@ -0,0 +1,53 @@ +# Caller template for the release of a container image. +# 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 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: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + image: + if: github.event_name != 'push' || startsWith(github.event.head_commit.message, 'release ') + uses: IronFlock/workflows/.github/workflows/release-image.yml@v1 + permissions: + 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 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: