Skip to content
GantryCD

Promotion Requirements

Each stack can declare its own conditions, evaluated against SCM status, for two pull-request actions: running a PR’s read-only preview plan (“plan”) and promoting that PR into an apply-capable deployment. A stack that declares nothing behaves exactly as before — plan and promote are ungated.

The canonical example: stack X plans only with ≥1 approving review and promotes only with ≥2 approvals and CI passing; stack Y requires nothing.

Model

Promotion requirements are a per-stack config blob stored in stacks.promotion_requirements (nullable jsonb; NULL = no requirements). The wire/JSON shape is two gates, each a list of typed conditions:

{
  "plan":    [ { "type": "approvals", "min": 1 } ],
  "promote": [ { "type": "approvals", "min": 2 }, { "type": "ci_checks", "checks": ["build", "e2e"] } ]
}

The domain types live in pkg/domain/promotion_requirements.go:

  • Requirement — a small interface (Type(), Validate(), Evaluate(PRStatus) RequirementResult). Concrete types are decoded from JSON by a registry keyed on the type discriminator, so adding a condition kind is a code change with no schema migration.
  • PRStatus — the provider-neutral SCM snapshot a requirement evaluates against: the approving-review count and the head commit’s per-check CI state (Checks map). Fields are added as new requirement types need them; outstanding “changes requested” reviews are not currently captured, so approvals counts approvals only.
  • PromotionRequirements / EvaluateGate — the per-gate list plus the pure evaluator that runs each requirement and reports Allowed + the per-requirement met/reason results.

Built-in requirement types

typeparamsmet when
approvalsmin (int ≥ 1)the PR has at least min distinct approving reviews
ci_checkschecks ([]string)every named check reports success on the head commit

ci_checks names match a CI check-run name (Checks API) or a commit-status context (legacy Status API). A named check that is missing or still running holds the gate — it isn’t “passing yet” — mirroring GitHub branch protection’s required status checks. Check runs that complete as SKIPPED/NEUTRAL count as success (they didn’t fail). There is deliberately no “all checks pass” mode: a stack lists exactly the checks it depends on, so an unrelated or newly-added check never silently starts gating.

Adding a new requirement type

  1. Implement domain.Requirement as a small value type with JSON-tagged params.
  2. Register a decoder for its type in the init() block of promotion_requirements.go.
  3. If it needs a signal not already on PRStatus, add the field and populate it in the SCM read (below).

SCM status is fetched on demand

There is no stored projection of approvals/CI — status is read from the SCM at gate-evaluation time via scm.Provider.GetPullRequestStatus (GitHub implementation: a single GraphQL query for reviewDecision, approving reviews, and the head commit’s statusCheckRollup). A gate with no requirements never makes the call. SCMIntegrationService.PullRequestStatus resolves the repository’s integration and provider; services/promotion_gate.go evaluates a stack’s gate against the result.

Where the gates run

  • Plan gateSCMEventService.resolvePRDeploymentID. When a stack gates the plan, every open PR’s status is pre-fetched outside the materialization write transaction (fetchPlanGateStatus) and the gate is evaluated on every refresh. The gate is two-way: an unmet PR gets no preview plan (the PR row records no deployment), and a PR that was previously planned but later stops meeting the requirements — an approval is dismissed, a required check regresses — has its parked preview retired. If the gate re-opens, a fresh preview is created. Preview confirmation also re-reads the live gate before creating a run. Because status is fetched on demand, a change that arrives with no new push must re-trigger evaluation. Two webhook deliveries are admitted as PR-refresh events for this (both normalize to pr_changed, re-running the branch sync and re-evaluating the gate):
    • pull_request_review — an approval/dismissal flips an approvals requirement.
    • check_suite (actions requested, rerequested, and completed) — CI starting or finishing flips a ci_checks requirement. The payload’s check_suite.pull_requests[] carries each same-repo PR’s number and base ref, so one delivery fans out to one refresh per PR. Operator action required: the GitHub App must be subscribed to the check_suite event and hold checks: read. Fork PRs arrive with an empty pull_requests list and are not re-triggered, and CI that reports only via the legacy commit-status API (no Checks API) carries no PR linkage in its status webhook, so those re-evaluate on the next push or review instead. The statusCheckRollup read already aggregates legacy statuses — only the auto re-trigger is Checks-API-only.
  • Promote gateDeploymentService.PromotePullRequest evaluates the gate before opening the write transaction (mirroring preflightCloudProviders); unmet requirements return a domain.RequirementsNotMetError, mapped to HTTP 409 with the unmet reasons.
  • DisplayGET /api/v1/orgs/{org}/stacks/{stack}/pull-requests/{number}/promotion-status evaluates both gates on demand and returns per-requirement met/reason detail, powering the web UI (disabled Promote button with a reason, “plan held” surfacing).

Apply confirmation is not SCM-gated. Preview confirmation is: it re-evaluates the Plan gate because permission to run a preview must not override a newly dismissed approval or regressed check.

See also

  • docs/reference/deployment_state_machine.md — the plan/promote/apply lifecycle these gates sit in front of.
  • docs/reference/scm_and_pull_requests.md — webhook ingestion and the PR refresh path the plan gate hooks into.