From f86bca6639967a9584b52b10d8baf7cda83e1813 Mon Sep 17 00:00:00 2001 From: Ivan Garcia Sainz-Aja Date: Thu, 16 Jul 2026 23:07:10 +0200 Subject: [PATCH] chore: hardened release pipeline --- .github/workflows/release-from-notes.yml | 126 +++++++++++++++++++---- docs/release-security.md | 68 ++++++++---- 2 files changed, 153 insertions(+), 41 deletions(-) diff --git a/.github/workflows/release-from-notes.yml b/.github/workflows/release-from-notes.yml index ff5c1f7..a555fe1 100644 --- a/.github/workflows/release-from-notes.yml +++ b/.github/workflows/release-from-notes.yml @@ -204,22 +204,35 @@ jobs: } >> "${GITHUB_STEP_SUMMARY}" # --------------------------------------------------------------------------- - # prepare-release: the ONLY job with contents:write before publication. - # Bumps the version in build.gradle.kts with sed (NO Gradle execution, so no - # repository-controlled code runs in this job at all), creates the release - # commit + next development commit, tags, and pushes atomically to main. + # prepare-release: the ONLY job with contents:write/pull-requests:write before + # publication. Bumps the version in build.gradle.kts with sed (NO Gradle + # execution, so no repository-controlled code runs in this job at all), + # creates the release commit + next development commit on a throwaway + # release/ branch, and lands them on main via a PR (auto-merged, + # not pushed directly). + # + # Why a PR instead of a direct push: main's ruleset requires changes to go + # through a pull request. A direct push would need a bypass actor added to + # that ruleset -- but ruleset bypass is granted to an IDENTITY (e.g. "GitHub + # Actions"), not to this one job. Any other workflow in the repo capable of + # authenticating as that identity would inherit the same bypass, which is a + # far bigger hole than this job's own blast radius. Merging a PR needs no + # bypass at all: it is the standard, already-allowed path, so this design + # adds no new privileged identity to the repository. + # # Receives NO Central/npm/signing secrets; the GITHUB_TOKEN exists only in - # the push step. Fails (non-fast-forward) if main moved since dispatch. + # the push/PR steps. A genuine conflict (something else changed + # build.gradle.kts concurrently) fails the merge instead of silently + # resolving it. # --------------------------------------------------------------------------- prepare-release: name: Create release commit and tag runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 20 needs: validate permissions: - # push release commit + tag to main (requires ruleset bypass for the - # GitHub Actions app, see docs/release-security.md#repository-rules) - contents: write + contents: write # push the release branch and the tag + pull-requests: write # open and auto-merge the release PR outputs: release_commit_sha: ${{ steps.commit.outputs.release_commit_sha }} steps: @@ -230,6 +243,16 @@ jobs: fetch-depth: 0 persist-credentials: false + - name: Refuse a stale release branch + env: + VERSION: ${{ needs.validate.outputs.version }} + run: | + set -euo pipefail + if git ls-remote --exit-code --heads origin "release/${VERSION}" >/dev/null 2>&1; then + echo "::error::Branch release/${VERSION} already exists on origin (leftover from a previous run?). Delete it and re-run." + exit 1 + fi + - name: Set release version and create release commit and tag id: commit env: @@ -263,6 +286,8 @@ jobs: set_version "${VERSION}" git commit -am "chore(release): release ${VERSION}" + # Tag object is created locally now (pointing at this exact commit) + # but only pushed once the PR below has actually merged into main. git tag -a "${TAG}" -m "Release ${VERSION}" echo "release_commit_sha=$(git rev-parse HEAD)" >> "${GITHUB_OUTPUT}" @@ -280,30 +305,89 @@ jobs: git commit -am "chore(release): prepare next development version ${DEV_VERSION}" # The token is only present in this step's env, never persisted to disk - # or exposed to any build tool. The non-fast-forward push doubles as the - # "fail if main moved during the release" policy. - - name: Push release commits and tag to main + # or exposed to any build tool. + - name: Push release branch env: GITHUB_TOKEN: ${{ github.token }} - RELEASE_SHA: ${{ needs.validate.outputs.release_sha }} - TAG: ${{ needs.validate.outputs.tag }} + VERSION: ${{ needs.validate.outputs.version }} run: | set -euo pipefail - git fetch origin main - if [[ "$(git rev-parse origin/main)" != "${RELEASE_SHA}" ]]; then - echo "::error::main moved while the release was running (expected ${RELEASE_SHA}). Start a new release run." - exit 1 - fi + ASKPASS="$(mktemp)" + trap 'rm -f "${ASKPASS}"' EXIT + printf '#!/bin/sh\nprintf %%s "${GITHUB_TOKEN}"\n' > "${ASKPASS}" + chmod 700 "${ASKPASS}" + + GIT_ASKPASS="${ASKPASS}" git push \ + "https://x-access-token@github.com/${GITHUB_REPOSITORY}.git" \ + "HEAD:refs/heads/release/${VERSION}" + + - name: Open and auto-merge release PR + id: pr + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + VERSION: ${{ needs.validate.outputs.version }} + TAG: ${{ needs.validate.outputs.tag }} + run: | + set -euo pipefail + PR_URL="$(gh pr create --base main --head "release/${VERSION}" \ + --title "chore(release): ${VERSION}" \ + --body "Automated release commit and next-development version bump for ${TAG}. This PR is required because main is protected; once merged, the release continues automatically.")" + PR_NUMBER="${PR_URL##*/}" + echo "pr_number=${PR_NUMBER}" >> "${GITHUB_OUTPUT}" + gh pr merge "${PR_NUMBER}" --merge --auto --delete-branch + + # Waits for GitHub's native auto-merge to land the PR (e.g. once required + # status checks pass). Fails fast on a real conflict or on the PR being + # closed unmerged, instead of spinning for the full timeout. + - name: Wait for release PR to merge + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PR_NUMBER: ${{ steps.pr.outputs.pr_number }} + run: | + set -euo pipefail + ELAPSED=0 + TIMEOUT=900 + while true; do + STATE="$(gh pr view "${PR_NUMBER}" --json state --jq '.state')" + if [[ "${STATE}" == "MERGED" ]]; then + break + fi + if [[ "${STATE}" == "CLOSED" ]]; then + echo "::error::Release PR #${PR_NUMBER} was closed without merging." + exit 1 + fi + MERGEABLE="$(gh pr view "${PR_NUMBER}" --json mergeable --jq '.mergeable')" + if [[ "${MERGEABLE}" == "CONFLICTING" ]]; then + echo "::error::Release PR #${PR_NUMBER} conflicts with main (something else changed build.gradle.kts concurrently). Resolve manually and re-run the release." + exit 1 + fi + if (( ELAPSED >= TIMEOUT )); then + echo "::error::Timed out waiting for release PR #${PR_NUMBER} to merge (state=${STATE}). Check required status checks / branch protection." + exit 1 + fi + sleep 15 + ELAPSED=$((ELAPSED + 15)) + done + # Only pushed after the commit above is confirmed to be reachable from + # main via the merge, so the tag can never point at unmerged content. + - name: Push release tag + env: + GITHUB_TOKEN: ${{ github.token }} + TAG: ${{ needs.validate.outputs.tag }} + run: | + set -euo pipefail ASKPASS="$(mktemp)" trap 'rm -f "${ASKPASS}"' EXIT printf '#!/bin/sh\nprintf %%s "${GITHUB_TOKEN}"\n' > "${ASKPASS}" chmod 700 "${ASKPASS}" - GIT_ASKPASS="${ASKPASS}" git push --atomic \ + GIT_ASKPASS="${ASKPASS}" git push \ "https://x-access-token@github.com/${GITHUB_REPOSITORY}.git" \ - "HEAD:refs/heads/main" "refs/tags/${TAG}" + "refs/tags/${TAG}" # --------------------------------------------------------------------------- # build-and-test: full Gradle build from the immutable release commit with NO diff --git a/docs/release-security.md b/docs/release-security.md index 816f5c2..db6e571 100644 --- a/docs/release-security.md +++ b/docs/release-security.md @@ -70,14 +70,28 @@ token. The two privileged jobs check out no repository code: the only code running while credentials are available is the audited shell inline in the workflow file (itself protected on `main`). -## Trusted commit capture and the "main moved" policy - -Identical to zenwave-sdk: `github.sha` is resolved once at dispatch; the run -refuses non-`main` dispatch, verifies reachability from `origin/main`, and -threads the SHA (and derived release commit) through every job. **Policy: fail -if `main` moved** — `prepare-release` re-checks `origin/main` before its -atomic, non-fast-forward push; if you pushed mid-release, the run fails before -any tag or commit exists. +## Trusted commit capture and landing the release commit on main + +`github.sha` is resolved once at dispatch; the run refuses non-`main` +dispatch, verifies reachability from `origin/main`, and threads the SHA (and +derived release commit) through every job. + +`prepare-release` does **not** push directly to `main`. It pushes the release +commit and the next-SNAPSHOT commit to a throwaway `release/` +branch, opens a PR into `main`, enables GitHub's native auto-merge, and waits +for the PR to actually merge before pushing the tag. This is deliberate: +`main`'s ruleset requires a pull request, and the only way to push directly +despite that would be adding a bypass actor to the ruleset. Ruleset bypass is +granted to an **identity** (e.g. "GitHub Actions"), not to this one job — +every other workflow in the repository capable of authenticating as that +identity would inherit the same bypass, which is a strictly larger hole than +this job's own blast radius. Merging a PR needs no bypass at all: it is the +standard, already-allowed path, so this design adds no new privileged +identity to the repository. A genuine conflict (something else changed +`build.gradle.kts` concurrently) fails the wait step outright instead of +silently resolving; the run also refuses to start if a stale +`release/` branch is still sitting on origin from a previous failed +attempt. ## Release commit and tag @@ -86,10 +100,11 @@ The version bump is a deterministic `sed` replacement of the single top-level invocation, so the only job with a write token before publication executes no repository-controlled code at all. The job fails if the file does not contain exactly one version line, if the result does not match the expected line, or -if anything other than `build.gradle.kts` changed. Both commits (release + -next SNAPSHOT) and the annotated tag `vX.Y.Z` are pushed atomically. The -privileged jobs later re-verify tag → release-commit → reachable-from-main via -the GitHub API. +if anything other than `build.gradle.kts` changed. The annotated tag `vX.Y.Z` +is created locally right after the release commit but is only pushed after +the release PR has actually merged, so it can never point at unmerged +content. The privileged jobs later re-verify tag → release-commit → +reachable-from-main via the GitHub API. ## Artifact trust boundary @@ -203,16 +218,23 @@ required reviewers. ### Repository rules -Same as zenwave-sdk: - - **`main` ruleset**: require PR (approvals 0 while solo — you cannot approve your own PRs; enable code-owner review once a second maintainer exists), - require status checks, block force pushes and deletion, enforce for admins, - bypass **only** for the GitHub Actions app (so `prepare-release` can push - the release commits), Settings → Actions → Workflow permissions set to - **read-only** default. -- **Tag ruleset `v*`**: creation only via the bypass actor; updates and - deletions blocked for everyone (immutable tags). + block force pushes and deletion, enforce for admins, Settings → Actions → + Workflow permissions set to **read-only** default. **No bypass actor is + configured or needed** — `prepare-release` lands its commits through a + real, auto-merged PR (see above), not a direct push, so there is no + privileged identity to grant. If you also require status checks on `main`, + make sure at least one check actually runs on `pull_request` events (this + repo's CI currently only triggers on `push`/`workflow_dispatch`), otherwise + the release PR's auto-merge will wait until `prepare-release`'s own + 15-minute timeout and fail. +- **Tag ruleset `v*`**: block updates and deletions for everyone (immutable + tags). Tag **creation** does not need to be restricted to a bypass actor: + by the time `prepare-release` pushes a tag, the commit it points to has + already gone through the required-PR merge into `main`, so an unrestricted + `git push origin refs/tags/vX.Y.Z` from the default token grants no extra + power — it can only ever label a commit that already passed review. - **CODEOWNERS** (`.github/CODEOWNERS`) covers `.github/`, Gradle build files and wrapper, `kotlin-js-store/` (Kotlin/JS yarn lockfile), `nodejs-test-project/`, `release-notes/` and the release docs. Ineffective @@ -231,6 +253,12 @@ Identical model to zenwave-sdk: can never rebuild a different commit under the same version. "Re-run all jobs" after `prepare-release` succeeded fails fast in `validate` (tag exists): intentional duplicate protection. Artifacts are retained 14 days. +- if `prepare-release` itself fails **after** pushing the `release/` + branch (e.g. the merge-wait step timed out because required status checks + never reported), do not just re-run the job — it will refuse to start + because that branch still exists on origin. Check the open release PR + first: merge it manually if it is just stalled, or close the PR and delete + the branch if you want to abort, then re-run. ## Snapshot publication