From c873e5004e1af133370d73b656176c786dcd3001 Mon Sep 17 00:00:00 2001 From: Bruno Campana <7632562+BrunoCampana@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:53:17 -0300 Subject: [PATCH] infra: pin docs promotion to an explicit, pre-validated commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The commit to promote is now a required `workflow_dispatch` input. The job used to resolve `origin/main` at run time, which is *after* the environment approval, so anything merged during the (unbounded) approval wait was promoted to production without ever having been verified on staging. Naming the commit pins the promotion to the state the operator actually inspected. - Validation moved into a separate ungated `preflight` job. An environment gate pauses a job before its first step runs, so a single-job workflow can only discover a bad request *after* a reviewer has already been paged. Preflight rejects an unknown commit, a commit not contained in `main`, a commit behind `docs-production`, and divergence that makes the fast-forward impossible — all while the run is still unattended. It also publishes the resolved SHA and the exact list of commits being promoted to the run summary, which is what the approver reads before deciding. - Requiring containment in `main` is a security boundary, not a convenience: without it the dispatch input would be a path to deploy unreviewed code straight to the production site. - The gated job re-asserts containment and fast-forwardability against the branch as it stands after the wait, and explicitly refuses a target already contained in `docs-production`. `git merge --ff-only ` reports "Already up to date" and exits 0, so without that check a branch that advanced past the target would produce a green run that pushed nothing while logging a successful promotion. - Promoting the commit `docs-production` already points at is a no-op: preflight skips the gated job entirely, so no reviewer is paged. - `run-name` now carries the target commit, making it visible in the run title before the approval decision. - Least privilege: top-level permissions dropped to `contents: read`, with `contents: write` only on the job that pushes; preflight checks out with `persist-credentials: false` so it cannot push at all. Both jobs declare `timeout-minutes`. - `docs/website/docs-workflow.md` updated to describe the input, the two jobs, and the full set of failure conditions. --- .github/workflows/promote-docs-production.yml | 173 ++++++++++++++++-- docs/website/docs-workflow.md | 70 +++++-- 2 files changed, 212 insertions(+), 31 deletions(-) diff --git a/.github/workflows/promote-docs-production.yml b/.github/workflows/promote-docs-production.yml index 18a0755c8f..47841c1c02 100644 --- a/.github/workflows/promote-docs-production.yml +++ b/.github/workflows/promote-docs-production.yml @@ -1,20 +1,38 @@ name: Promote docs to production -# Manually promote the reviewed docs on `main` to the `docs-production` branch, +run-name: Promote docs-production to ${{ inputs.commit }} + +# Manually promote a reviewed commit of `main` to the `docs-production` branch, # which the hosting provider (Sevalla) watches to deploy the production docs site. # -# This workflow ONLY fast-forwards docs-production to the current main commit: +# The commit to promote is chosen at dispatch time, so the promoted state is the +# one the operator inspected on staging — not whatever `main` happens to point at +# when the environment approval finally lands. +# +# Two jobs, on purpose: +# - `preflight` runs ungated and rejects every bad request up front, so a +# reviewer is only ever asked to approve a promotion that will succeed; +# - `promote` is gated on the `docs-production` environment and does nothing +# but fast-forward the branch and push it. +# +# This workflow ONLY fast-forwards docs-production to the requested commit: # - it never runs automatically on a merge to main, # - it never opens a PR, # - it never creates a new commit on docs-production, -# - it fails (does not merge) if docs-production has diverged from main. +# - it refuses any commit that is not already contained in main, +# - it fails (does not merge) if the fast-forward is not possible. # See docs/website/docs-workflow.md ("Production (manual promotion)") for the model. on: workflow_dispatch: + inputs: + commit: + description: Commit to promote — any revision already merged to main (full SHA recommended) + required: true + type: string permissions: - contents: write + contents: read # Never run two promotions at once; do not cancel an in-flight promotion. concurrency: @@ -22,42 +40,163 @@ concurrency: cancel-in-progress: false jobs: + preflight: + name: Validate the requested commit + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + target_sha: ${{ steps.validate.outputs.target_sha }} + promote: ${{ steps.validate.outputs.promote }} + steps: + - name: Checkout docs-production + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # 6.0.2 + with: + ref: docs-production + # Full history so the fast-forward check can see the relationship + # between docs-production and main. + fetch-depth: 0 + # This job only inspects history; it must never be able to push. + persist-credentials: false + + - name: Validate the requested commit + id: validate + shell: bash + env: + TARGET: ${{ inputs.commit }} + run: | + set -euo pipefail + + git fetch --no-tags origin main docs-production + + if ! target_sha="$(git rev-parse --quiet --verify "${TARGET}^{commit}")"; then + echo "::error::'${TARGET}' is not a commit known to this repository." + exit 1 + fi + + main_sha="$(git rev-parse origin/main)" + production_sha="$(git rev-parse origin/docs-production)" + + { + echo "### Promotion preflight" + echo + echo "| | |" + echo "|---|---|" + echo "| requested | \`${TARGET}\` |" + echo "| resolved | \`${target_sha}\` |" + echo "| docs-production | \`${production_sha}\` |" + echo "| main | \`${main_sha}\` |" + } >> "$GITHUB_STEP_SUMMARY" + + # Only a commit already reviewed and merged to main may reach production. + # Without this check the dispatch input would be a path to deploy + # arbitrary unreviewed code straight to the production site. + if ! git merge-base --is-ancestor "$target_sha" "$main_sha"; then + echo "::error::${target_sha} is not contained in main; only a commit already merged to main can be promoted." + exit 1 + fi + + if [ "$target_sha" = "$production_sha" ]; then + echo "promote=false" >> "$GITHUB_OUTPUT" + echo "::notice::docs-production is already at ${target_sha}; nothing to promote." + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "docs-production is already at this commit. Nothing to promote, no approval needed." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + # A commit behind docs-production would make `git merge --ff-only` + # report "Already up to date" and push nothing — a rollback attempt + # that silently succeeds. Reject it explicitly instead. + if git merge-base --is-ancestor "$target_sha" "$production_sha"; then + echo "::error::${target_sha} is behind docs-production; this workflow only moves the branch forward. Roll back deliberately, outside this promotion." + exit 1 + fi + + # Anything left that is not a descendant of docs-production means the + # branches have diverged. Catch it here rather than after the approval: + # divergence must be repaired in a separate, deliberate step. + if ! git merge-base --is-ancestor "$production_sha" "$target_sha"; then + echo "::error::docs-production has diverged from main; fast-forward promotion is not possible. Realign the branches before promoting." + exit 1 + fi + + echo "target_sha=${target_sha}" >> "$GITHUB_OUTPUT" + echo "promote=true" >> "$GITHUB_OUTPUT" + + { + echo "" + echo "Commits to promote:" + echo "" + echo '```' + git log --oneline "${production_sha}..${target_sha}" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + promote: - name: Fast-forward docs-production to main + name: Fast-forward docs-production to the requested commit + needs: preflight + if: needs.preflight.outputs.promote == 'true' runs-on: ubuntu-latest + timeout-minutes: 10 # Gate the promotion: the job pauses for approval by a required reviewer # (configured on the docs-production environment) before it can push. + # Every check has already run in `preflight`, so an approval here is only + # ever requested for a promotion that is known to be valid. environment: docs-production + permissions: + contents: write steps: - name: Checkout docs-production uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # 6.0.2 with: ref: docs-production - # Full history so the fast-forward check can see the relationship - # between docs-production and main. fetch-depth: 0 persist-credentials: true # docs-production only accepts updates through this workflow. The push # runs as the GitHub Actions app (GITHUB_TOKEN), the sole bypass # identity on the docs-production ruleset (github-ops). - - name: Fast-forward docs-production to origin/main + - name: Fast-forward docs-production and push shell: bash + env: + TARGET_SHA: ${{ needs.preflight.outputs.target_sha }} run: | set -euo pipefail git fetch --no-tags origin main docs-production - echo "main = $(git rev-parse --short origin/main)" - echo "docs-production = $(git rev-parse --short origin/docs-production)" - echo "commits to promote:" - git log --oneline origin/docs-production..origin/main + # Re-assert the security invariant after the approval wait: preflight + # validated the target, but the gate holds for an unbounded time. + if ! git merge-base --is-ancestor "$TARGET_SHA" origin/main; then + echo "::error::${TARGET_SHA} is no longer contained in main; refusing to promote." + exit 1 + fi + + production_sha="$(git rev-parse origin/docs-production)" + + echo "promoting = ${TARGET_SHA}" + echo "docs-production = ${production_sha}" + + if [ "$TARGET_SHA" = "$production_sha" ]; then + echo "::notice::docs-production is already at ${TARGET_SHA}; nothing to push." + exit 0 + fi + + # `git merge --ff-only` reports "Already up to date" and exits 0 when + # the target is contained in HEAD, so a docs-production that advanced + # past the target while the approval was pending would end in a green + # run that pushed nothing. Fail instead of reporting a promotion that + # did not happen. + if git merge-base --is-ancestor "$TARGET_SHA" "$production_sha"; then + echo "::error::docs-production advanced past ${TARGET_SHA} while the approval was pending (it is now at ${production_sha}); nothing was pushed. Dispatch a new promotion for the commit you want." + exit 1 + fi - # --ff-only: promote ONLY when docs-production can fast-forward to main. - # If the branches have diverged, this fails instead of creating a merge - # commit or resolving conflicts — divergence must be repaired in a - # separate, deliberate step (out of scope for this promotion). - if ! git merge --ff-only origin/main; then + # --ff-only: promote ONLY when docs-production can fast-forward to the + # target. Preflight proved it could, and the two checks above rule out + # the branch having moved forward — so a failure here means + # docs-production received a commit that is not on main. That is real + # divergence, and it must be repaired deliberately. + if ! git merge --ff-only "$TARGET_SHA"; then echo "::error::docs-production has diverged from main; fast-forward promotion is not possible. Realign the branches before promoting." exit 1 fi diff --git a/docs/website/docs-workflow.md b/docs/website/docs-workflow.md index 8a3be38507..2ec0dfcfcd 100644 --- a/docs/website/docs-workflow.md +++ b/docs/website/docs-workflow.md @@ -326,17 +326,22 @@ hosting provider's build the same way. ### Production (manual promotion) ``` -Staging is verified and ready +Staging is verified and ready at a known commit │ ▼ -Manually run the "Promote docs to production" workflow (workflow_dispatch) +Manually run the "Promote docs to production" workflow (workflow_dispatch), +passing that commit as the `commit` input │ ▼ -Job pauses for docs-production environment approval +Preflight job (ungated) validates the commit and publishes the +commit list to the run summary + │ (fails here if the commit is not on main, or the ff is not possible) + ▼ +Promote job pauses for docs-production environment approval │ (required reviewer: qvac-internal-release) ▼ -Workflow fast-forwards docs-production to origin/main (--ff-only) - │ (fails if docs-production has diverged from main) +Workflow fast-forwards docs-production to the commit (--ff-only) + │ ▼ Push to docs-production (GitHub Actions app / GITHUB_TOKEN) │ @@ -350,11 +355,29 @@ Hosting provider builds the static site and deploys to production Production is promoted by manually running the **Promote docs to production** workflow (`.github/workflows/promote-docs-production.yml`), never by merging a PR into `docs-production`. The workflow advances -`docs-production` to the current `main` commit using **fast-forward-only** -semantics: if the branches have diverged it fails instead of creating a -merge commit, so `docs-production` stays a pure pointer into `main`'s -history. A `docs-production` environment required-reviewer gate pauses the -job until a `qvac-internal-release` member approves. +`docs-production` to the commit given in the required `commit` input, +using **fast-forward-only** semantics: if the branches have diverged it +fails instead of creating a merge commit, so `docs-production` stays a +pure pointer into `main`'s history. A `docs-production` environment +required-reviewer gate pauses the job until a `qvac-internal-release` +member approves. + +The target is an explicit input rather than "whatever `main` points at" +because the approval gate introduces an unbounded delay between dispatch +and push. Resolving `main` after the approval would promote commits that +landed while the run waited — commits nobody verified on staging. Naming +the commit pins the promotion to the state the operator actually +inspected, and makes the run self-documenting: the target appears in the +run title, so the approver sees what they are approving. + +The validation runs in a separate ungated `preflight` job for the same +reason. An environment gate pauses a job before any of its steps run, so +a single-job workflow can only discover a bad input *after* someone has +been asked to approve it. Splitting the work means every rejectable +condition fails while the run is still unattended, and the reviewer is +only ever paged for a promotion that is known to be valid. Preflight also +writes the resolved SHA and the exact list of commits being promoted to +the run summary, which is what the reviewer reads before approving. The person promoting is responsible for confirming staging is healthy and that the docs PR Checks have passed on `main` before running the workflow. @@ -398,13 +421,32 @@ The API summary `index.mdx` lives at `content/docs/reference/api/` and is commit **Triggers:** Manual `workflow_dispatch` only. It never runs automatically on a merge to `main`. +**Inputs:** + +| Input | Required | Description | +|---|---|---| +| `commit` | Yes | The commit to promote. Any revision already merged to `main` (a full SHA is recommended; the resolved SHA is echoed in the log). | + **What it does:** -- Pauses for approval on the `docs-production` environment (`qvac-internal-release` required reviewers) + +`preflight` (ungated, read-only checkout): +- Resolves `commit` and verifies it is contained in `origin/main` +- Rejects a commit behind `docs-production`, and divergence that makes the fast-forward impossible +- Writes the resolved SHA and the list of commits being promoted to the run summary +- Skips the promotion entirely (no approval requested) when `docs-production` is already at the requested commit + +`promote` (gated on the `docs-production` environment, `qvac-internal-release` required reviewers): - Checks out `docs-production` (full history) using `GITHUB_TOKEN` — the GitHub Actions app is the sole bypass identity on the `docs-production` ruleset -- Fetches `origin/main` and runs `git merge --ff-only origin/main` -- Pushes the fast-forwarded `docs-production`, which the hosting provider picks up to deploy production +- Re-asserts the checks against the current branch state, since the approval wait is unbounded +- Runs `git merge --ff-only ` and pushes the fast-forwarded `docs-production`, which the hosting provider picks up to deploy production + +**Fails when:** +- `commit` is not a commit this repository knows +- `commit` is not contained in `main` — production only ever receives reviewed, already-merged code +- `commit` is behind `docs-production` — the workflow only moves the branch forward; a rollback is a deliberate operation performed outside it +- `docs-production` has diverged from `main` (the `--ff-only` merge is rejected) -**Fails when:** `docs-production` has diverged from `main` (the `--ff-only` merge is rejected). This is intentional — divergence must be repaired deliberately, not resolved by an automatic merge commit. The workflow never opens a PR and never creates a new commit on `docs-production`. +Divergence must be repaired deliberately, not resolved by an automatic merge commit. The workflow never opens a PR and never creates a new commit on `docs-production`. Promoting the commit `docs-production` already points at is a no-op that exits cleanly. **Purpose:** Give the docs owner a single, deliberate button to promote the reviewed `main` state to production once the SDK package is (about to be) published, without ever letting `docs-production` drift from `main`'s history.