Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 156 additions & 17 deletions .github/workflows/promote-docs-production.yml
Original file line number Diff line number Diff line change
@@ -1,63 +1,202 @@
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:
group: promote-docs-production
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
Expand Down
70 changes: 56 additions & 14 deletions docs/website/docs-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.
Expand Down Expand Up @@ -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 <commit>` 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.

Expand Down
Loading