diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 3fbd2bd..07aa30c 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -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: @@ -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: @@ -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 }} diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 21e3b9b..f57a802 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -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: @@ -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 v` 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: diff --git a/README.md b/README.md index 1f2ced5..d78beb9 100644 --- a/README.md +++ b/README.md @@ -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 ` 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 @@ -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 v`): 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 @@ -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 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 diff --git a/scripts/release.sh b/scripts/release.sh index 6688250..8904379 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -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" diff --git a/scripts/test_release.sh b/scripts/test_release.sh index 2b0c136..2347e5b 100755 --- a/scripts/test_release.sh +++ b/scripts/test_release.sh @@ -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 diff --git a/templates/workflows/ci-go.yml b/templates/workflows/ci-go.yml index a7ff90f..0bbb01a 100644 --- a/templates/workflows/ci-go.yml +++ b/templates/workflows/ci-go.yml @@ -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 diff --git a/templates/workflows/release.yml b/templates/workflows/release.yml index 7f20a25..f71a5bf 100644 --- a/templates/workflows/release.yml +++ b/templates/workflows/release.yml @@ -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