From 8677eb643cbdf3c248dd11d68e8d35a7a5a7eb73 Mon Sep 17 00:00:00 2001 From: Steven Enamakel's Droid Date: Sat, 30 May 2026 20:16:39 -0700 Subject: [PATCH] ci(release): gate main_head push to main behind manual approval (#2622) (#3045) --- .github/workflows/release-production.yml | 40 ++++++++++++++++++++++++ gitbooks/developing/release-policy.md | 33 ++++++++++++++++++- 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-production.yml b/.github/workflows/release-production.yml index 256e704ac..195c021ca 100644 --- a/.github/workflows/release-production.yml +++ b/.github/workflows/release-production.yml @@ -81,6 +81,35 @@ concurrency: # `.github/workflows/build-desktop.yml` and is shared with release-staging.yml. # --------------------------------------------------------------------------- jobs: + # ========================================================================= + # Phase 0: Manual approval gate (main_head only). + # + # The `main_head` path bumps the version, COMMITS to `main`, and pushes that + # commit + tag using a GitHub App token that bypasses branch protection + # (prepare-build → "Commit, push and tag (main_head)"). If that App private + # key (secrets.XGITHUB_APP_PRIVATE_KEY) ever leaked, an attacker could push + # arbitrary commits to the default branch — CWE-250. This job parks the run + # on the `Release-Approval` environment (configured with required reviewers + # in repo settings) so a human must explicitly approve before prepare-build + # is allowed to run its push step. A rejected/cancelled approval leaves + # prepare-build skipped, so nothing is ever pushed. + # + # Scoped to `main_head` only: the `staging_tag` promotion path pushes just an + # immutable `v` tag at an already-validated staging commit — it does + # NOT push a commit to `main` — so it stays gated only by the existing + # `Production` environment and is intentionally skipped here (no regression). + # ========================================================================= + review-approval: + name: Manual approval gate (main_head) + runs-on: ubuntu-latest + environment: Release-Approval + if: inputs.release_source == 'main_head' + steps: + - name: Record approval + run: | + echo "[release-production] main_head push to main approved by a required reviewer." + echo "Proceeding to version bump + commit/tag push on prepare-build." + # ========================================================================= # Phase 1: Resolve build ref and (for main_head) bump version + create tag # ========================================================================= @@ -88,6 +117,17 @@ jobs: name: Prepare build context runs-on: ubuntu-latest environment: Production + needs: [review-approval] + # The push-to-main step lives inside this job (main_head path), so gating + # prepare-build on review-approval gates the push. `!cancelled()` (not + # `always()`) so a cancelled run never pushes; the success/skipped guard + # lets the `staging_tag` path through (review-approval is skipped there) + # while requiring an explicit approval success for `main_head`. A rejected + # approval yields result == 'failure', failing the guard → no push. + if: >- + !cancelled() + && (needs.review-approval.result == 'success' + || needs.review-approval.result == 'skipped') outputs: version: ${{ steps.resolve.outputs.version }} tag: ${{ steps.resolve.outputs.tag }} diff --git a/gitbooks/developing/release-policy.md b/gitbooks/developing/release-policy.md index 4b0d99803..688f11108 100644 --- a/gitbooks/developing/release-policy.md +++ b/gitbooks/developing/release-policy.md @@ -68,9 +68,11 @@ There is no separate `staging` branch, staging cuts and production promotions bo ### Hotfix from `main` HEAD 1. Run **Release Production** via `workflow_dispatch` with `release_source = main_head` and the desired `release_type` (`patch` / `minor` / `major`). -2. The workflow runs the legacy bump-and-tag path: bump on `main`, commit `chore(release): vX.Y.Z`, push, tag `vX.Y.Z`, build. +2. The run first parks on the **`review-approval`** job (`environment: Release-Approval`); a [required reviewer](#release-app-token-approval-gate-and-rotation) must approve before anything is pushed. Only after approval does `prepare-build` run the bump-and-tag path: bump on `main`, commit `chore(release): vX.Y.Z`, push, tag `vX.Y.Z`, build. 3. Use this only when a production-only fix needs to ship without going through staging. +> The `staging_tag` promotion path is **not** gated by `review-approval` — it pushes only an immutable `v` tag at an already-validated staging commit, never a commit to `main`. It remains gated by the existing `Production` environment. + ### Tag policy and rollback - **Naming.** Staging tags use the SemVer pre-release suffix `-staging` (`v1.2.4-staging`) so they sort *before* the matching production tag. Promotion to production drops the suffix verbatim; the version embedded in the bundled installer is identical between the two tags. @@ -78,3 +80,32 @@ There is no separate `staging` branch, staging cuts and production promotions bo - **Rollback (production).** A failed build matrix triggers `cleanup-failed-release`, which deletes both the draft GitHub Release and the `v` tag. The staging tag it was promoted from is left untouched and can be re-promoted after fixing. - **Rollback (staging).** A failed staging build deletes the `v-staging` tag. The bump commit on `main` is left in place; the next staging cut continues from the new patch number rather than re-using it (we accept a small “gap” in patch numbers over racing with concurrent merges). - **Who can delete tags.** Same write-access as `main`. Workflow-driven cleanup deletes run with the workflow's token via `actions/github-script` (the GitHub App token is only used by `prepare-build` for the bump commit + tag push); manual deletes (`git push --delete origin `) require equivalent maintainer permissions. + +## Release App token: approval gate and rotation + +The `main_head` path of `release-production.yml` bumps the version, **commits to `main`**, and pushes that commit + tag with a GitHub App token (`secrets.XGITHUB_APP_ID` / `secrets.XGITHUB_APP_PRIVATE_KEY`) that **bypasses branch protection**. A leaked private key (via a log, a compromised action, or a misconfigured runner) would let an attacker push arbitrary commits to the default branch — [CWE-250: Execution with Unnecessary Privileges](https://cwe.mitre.org/data/definitions/250.html). Two controls bound that blast radius. + +### Manual approval gate + +The `review-approval` job runs **before** `prepare-build` and parks the run on the **`Release-Approval`** GitHub environment, so a human must approve before any push happens. It is scoped to `release_source == 'main_head'`; the `staging_tag` promotion path skips it (it pushes only a tag, never a commit to `main`). + +One-time setup (repo **Settings → Environments**): + +1. Create an environment named **`Release-Approval`** (exact name — the workflow references it verbatim). +2. Under **Deployment protection rules**, enable **Required reviewers** and add the maintainers allowed to authorize a production push to `main`. A reviewer **cannot** approve their own run unless _Prevent self-review_ is left off; for a release gate, keeping a second approver is preferred. +3. (Optional) Set a short **wait timer** of 0 — the gate is a human decision, not a delay. + +When a `main_head` run starts it shows **"Waiting"** on the `review-approval` job; an approver opens the run and clicks **Review deployments → Approve**. Rejecting (or cancelling) leaves `prepare-build` skipped, so nothing is pushed. + +### Quarterly key rotation + +Rotate `XGITHUB_APP_PRIVATE_KEY` **every quarter** (and immediately on any suspected exposure). Schedule: end of **Mar / Jun / Sep / Dec**. + +1. In the GitHub **App settings** (Org → Settings → Developer settings → GitHub Apps → the release App), under **Private keys** click **Generate a private key**. Download the new `.pem`. +2. Update the repo secret: **Settings → Secrets and variables → Actions → `XGITHUB_APP_PRIVATE_KEY`** → paste the full new key (including the `-----BEGIN/END-----` lines). `XGITHUB_APP_ID` is unchanged. +3. Trigger a low-risk verification run (e.g. **Release (Staging)**) and confirm the **Generate GitHub App token** step succeeds and the push authenticates. + Do not use `main_head` for this check unless you intentionally want to cut a real bump commit and production tag — even with `create_release = false`, `prepare-build` still bumps the version, commits to `main`, and pushes the release tag. +4. Back in **App settings → Private keys**, **delete the old key** so only the freshly-issued one remains valid. +5. Record the rotation date (PR description, ops log, or `docs/OPERATIONS.md`) so the next quarter's owner can see when it last happened. + +Rotating invalidates any copy of the old key that may have leaked, capping the exposure window at one quarter.