ci(release): gate main_head push to main behind manual approval (#2622) (#3045)

This commit is contained in:
Steven Enamakel's Droid
2026-05-30 20:16:39 -07:00
committed by GitHub
parent b92d61c3ad
commit 8677eb643c
2 changed files with 72 additions and 1 deletions
+40
View File
@@ -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<version>` 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 }}
+32 -1
View File
@@ -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<version>` 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<version>` 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<version>-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 <tag>`) 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.