Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions .github/workflows/release-tag.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,13 @@
# pull request it does nothing; on a re-run whose tag is already on this
# commit it passes.
#
# The tag ruleset admits only the IronFlock release GitHub App, so the tag is
# written with an App token. The job runs in the caller's `release`
# environment (deployments from main only), which holds the variable
# RELEASE_APP_CLIENT_ID and the secret RELEASE_APP_PRIVATE_KEY.
#
# Callers grant
# permissions: { contents: write }
# permissions: { contents: read }
name: release-tag

on:
Expand Down Expand Up @@ -41,8 +46,9 @@ jobs:
tag:
runs-on: ubuntu-24.04
timeout-minutes: 5
environment: release
permissions:
contents: write
contents: read
outputs:
tag: ${{ steps.version.outputs.tag }}
steps:
Expand Down Expand Up @@ -75,10 +81,19 @@ jobs:
COMMIT_TITLE: ${{ github.event.head_commit.message }}
SHA: ${{ github.sha }}
run: '"$HARNESS/scripts/release.sh" mode'
- name: release App token (contents of this repository)
id: app
if: steps.version.outputs.mode == 'release'
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.RELEASE_APP_CLIENT_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
repositories: ${{ github.event.repository.name }}
permission-contents: write
- name: git tag ${{ steps.version.outputs.tag }}
if: steps.version.outputs.mode == 'release'
env:
GH_TOKEN: ${{ github.token }}
GH_TOKEN: ${{ steps.app.outputs.token }}
REPO: ${{ github.repository }}
TAG: ${{ steps.version.outputs.tag }}
SHA: ${{ github.sha }}
Expand Down
9 changes: 7 additions & 2 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ on:
type: string
default: ""
image-on:
description: Events that build and scan the image. Public repositories scan on pull_request too.
description: Events that build and scan the image. Public repositories and release pull requests scan on pull_request too.
type: string
default: push,schedule,workflow_dispatch
vex-file:
Expand Down Expand Up @@ -493,9 +493,14 @@ jobs:
retention-days: 90

image:
# Pull requests of private repositories skip the build (Actions minutes),
# except release pull requests: their title `release <name> v<x>` is held
# to a rising version by release-intent.yml, so the image that the merge
# publishes is scanned before the merge.
if: >-
inputs.image && (contains(inputs.image-on, github.event_name) ||
(github.event_name == 'pull_request' && !github.event.repository.private))
(github.event_name == 'pull_request' && (!github.event.repository.private ||
startsWith(github.event.pull_request.title, 'release '))))
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
Expand Down
17 changes: 13 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ The inputs of each workflow are documented in its `on.workflow_call` block.
| [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` | 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` |
| [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"; written with a token of the release GitHub App from the caller's `release` environment |
| [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
Expand Down Expand Up @@ -79,6 +79,11 @@ repository-local `integration` job next to the gate.
- semgrep runs the IronFlock rules plus `semgrep-rulesets` (default `p/default`).
Go repositories, whose `just lint` runs gosec, set `semgrep-rulesets: ""` so each
finding is reported (and annotated) once.
- The container image (`image: true`) is built and scanned on the events in
`image-on`, and on every pull request of a public repository. Private
repositories skip the build on pull requests to save Actions minutes, except
on release pull requests (title `release <name> v<x>`): the image that the
merge publishes is scanned before the merge.

Every run writes a summary table to the job summary and keeps the raw reports
as artifacts (`security-findings-*`, `security-sbom`) for 90 days. A workflow that
Expand Down Expand Up @@ -127,9 +132,13 @@ the next release PR; the unreleased version stays a gap in the history.
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`. 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.
pushes (and version tags) through `release-image.yml`. Only the IronFlock
release GitHub App may create `v*` tags (bypass actor of the repository's
`release tags v*` tag ruleset; the built-in GitHub Actions app cannot be one).
`release-tag.yml` takes its token in the repository's `release` environment:
deployments from `main` only, variable `RELEASE_APP_CLIENT_ID`, secret
`RELEASE_APP_PRIVATE_KEY`; the token covers the contents of that one
repository. Callers grant `contents: read` only.

## Exceptions

Expand Down
6 changes: 4 additions & 2 deletions scripts/release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,11 @@ intent() {
[ -n "$head" ] || { echo "::error::$VERSION_FILE has no version at $HEAD_SHA"; return 1; }

if [ "$head" = "$base" ]; then
# The squash merge carries this title, and release.yml starts on a merge
# whose message starts with 'release ' (GitHub compares case-insensitively).
# 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"
if [[ "$(printf '%s' "$TITLE" | tr '[:upper:]' '[:lower:]')" == "release "* ]]; then
echo "::error::the title starts with '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"
Expand Down
2 changes: 2 additions & 0 deletions scripts/test_release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ for vf in VERSION package.json; do
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"
check "$vf: any title starting with release, without a bump" 1 intent "$r" "$vf" "$base" "$same" "Release notes cleanup"
check "$vf: no-release title mentioning a release later" 0 intent "$r" "$vf" "$base" "$same" "docs: explain the release flow"

git -C "$r" checkout -q "$base"
setv "$r" "$vf" 1.2.4
Expand Down
6 changes: 2 additions & 4 deletions templates/workflows/ci-go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,13 @@ jobs:

# 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.
# main.
release-build:
needs: [intent]
if: needs.intent.outputs.kind == 'release'
uses: ./.github/workflows/release.yml
permissions:
contents: write
contents: read
id-token: write

# The one required check of the main-branch ruleset: green only when every
Expand Down
2 changes: 1 addition & 1 deletion templates/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,6 @@ jobs:
if: github.event_name == 'push' && startsWith(github.event.head_commit.message, 'release ')
uses: IronFlock/workflows/.github/workflows/release-tag.yml@v1
permissions:
contents: write
contents: read
with:
version-file: VERSION
Loading