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 thetypediscriminator, 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 (Checksmap). Fields are added as new requirement types need them; outstanding “changes requested” reviews are not currently captured, soapprovalscounts approvals only.PromotionRequirements/EvaluateGate— the per-gate list plus the pure evaluator that runs each requirement and reportsAllowed+ the per-requirement met/reason results.
Built-in requirement types
type | params | met when |
|---|---|---|
approvals | min (int ≥ 1) | the PR has at least min distinct approving reviews |
ci_checks | checks ([]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
- Implement
domain.Requirementas a small value type with JSON-tagged params. - Register a decoder for its
typein theinit()block ofpromotion_requirements.go. - 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 gate —
SCMEventService.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 topr_changed, re-running the branch sync and re-evaluating the gate):pull_request_review— an approval/dismissal flips anapprovalsrequirement.check_suite(actionsrequested,rerequested, andcompleted) — CI starting or finishing flips aci_checksrequirement. The payload’scheck_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 thecheck_suiteevent and holdchecks: read. Fork PRs arrive with an emptypull_requestslist and are not re-triggered, and CI that reports only via the legacy commit-status API (no Checks API) carries no PR linkage in itsstatuswebhook, so those re-evaluate on the next push or review instead. ThestatusCheckRollupread already aggregates legacy statuses — only the auto re-trigger is Checks-API-only.
- Promote gate —
DeploymentService.PromotePullRequestevaluates the gate before opening the write transaction (mirroringpreflightCloudProviders); unmet requirements return adomain.RequirementsNotMetError, mapped to HTTP 409 with the unmet reasons. - Display —
GET /api/v1/orgs/{org}/stacks/{stack}/pull-requests/{number}/promotion-statusevaluates 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.