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.