SCM And Pull Requests
SCM behavior splits across stack commit syncing, runtime SCM credential generation, webhook ingestion, and asynchronous pull-request refresh through a durable event inbox.
SCM Integration Discovery
SCM integrations are organization-scoped. GitHub App integrations are matched against repository URLs by regex pattern and priority, in internal/backend/services/scm_integration_service.go:
- integrations sorted by ascending priority
- first regex that matches wins
- invalid regex entries skipped with a warning
- no match is valid and means “treat as public repository”
Stack Commit Sync
SyncStackCommit asks the matching SCM integration for the latest commit on the stack branch and writes that snapshot to the stack. This is what makes a stack “commit-synced” and therefore eligible for deployment creation.
Runtime SCM Credentials
At run accept time the credentials provider mints SCM credentials for cloning. GitHub App integrations produce installation tokens; with no matching provider, SCMCredentials.Type is none (treated as public).
The provider also returns SCMConfig.GitConfig — git configuration the runner writes verbatim into a run-owned file. For GitHub that is an http.<base>.extraHeader entry that lends the same installation token to tofu init as an Authorization header, so a stack can pull a private module without a stored PAT (and without the token reaching the run log). See runner_runtime_credentials.md.
Pull Request Refresh
PR refresh is asynchronous, processed by a worker reading the durable scm_events inbox.
POST /api/v1/orgs/{org_id}/stacks/{stack_id}/pull-requests/refreshcallsPullRequestService.EnqueueRefreshByStack, which writes apr_refresh_requestedevent and returns202 Acceptedwith the event id.GET /api/v1/orgs/{org_id}/stacks/{stack_id}/pull-requests/refreshreturns the active refresh event (status / attempts / last error) so the UI can poll. Returns nil when idle.- The worker processes the event in a write transaction: snapshots stack config, discovers the matching SCM integration, refreshes or reuses a shared repo/branch PR snapshot, materializes stack memberships, and upserts PR rows. Failures move the event to
failed/deadper the inbox retry policy.
Refresh rules:
- a PR matched to a stack remains until it closes
- new PRs must match the stack’s working directory
- root-directory stacks match all files
- stacks sharing the same integration, repo URL, and branch reuse a shared snapshot — refreshing one updates PR memberships for all of them
- the worker rejects the refresh if the target stack’s repo / branch / working directory changed mid-refresh
Webhook Ingestion
Providers push repository activity to GantryCD through a per-integration webhook endpoint:
POST /api/v1/orgs/{org_id}/scm-integrations/{integration_id}/webhook
The endpoint is org-scoped and integration-scoped by design. GantryCD runs one
GitHub App per organization, so each integration has its own URL; the org_id
in the path means an enqueued event can only ever fan out to that org’s stacks.
The route is unauthenticated — there is no user session behind a provider
delivery — and the per-integration webhook secret is the authentication.
Ingestion does the minimum on the hot path and never calls the SCM API:
- Load the integration scoped to
(org_id, integration_id); an integration in another org is reported as404, never as a cross-org existence oracle. Provider.ParseWebhookverifies the signature (GitHub: HMAC-SHA256 of the raw body againstauth_config.webhook_secret, constant-time) and normalizes the payload. A signature mismatch returnsscm.ErrWebhookSignatureInvalid→ HTTP401.- Each normalized event is enqueued into
scm_events(statuspending) in one write transaction, then apg_notifywakes the worker immediately.
The endpoint always enqueues — it never pre-filters against the stack table.
An event that matches no stack is a cheap worker no-op; never dropping at the
door means a delivery is always retryable. Exact-delivery dedup uses the
provider’s delivery id (GitHub X-GitHub-Delivery) as external_event_id, so a
provider retrying the same delivery is absorbed by ON CONFLICT DO NOTHING.
GitHub event normalization:
X-GitHub-Event | condition | normalized event |
|---|---|---|
push | branch ref, not deleted | push_branch_changed |
push | branch deletion | ignored |
push | tag ref | ignored |
pull_request | action opened / reopened / synchronize / edited | pr_changed |
pull_request | action closed (including merged) | pr_closed |
pull_request | any other action | ignored |
pull_request_review | action submitted / dismissed | pr_changed (re-evaluate the plan gate) |
pull_request_review | any other action (e.g. edited) | ignored |
check_suite | action requested, rerequested, or completed, with same-repo PRs | pr_changed per associated PR (re-evaluate the plan gate) |
check_suite | any other action, or no same-repo PRs (e.g. fork) | ignored |
ping / unknown | — | ignored (verified, no-op) |
pull_request_review and check_suite exist only to re-trigger
promotion-gate evaluation when a PR’s approval or CI
state changes with no new push — both normalize to pr_changed, the same path a
pull_request synchronize takes, so the worker re-runs the branch sync and
re-evaluates the gate against fresh SCM status. A check_suite fans out to one
pr_changed per associated PR, each with a distinct external_event_id
(<delivery-id>-pr<number>) so inbox dedup keys them apart.
A verified delivery that normalizes to nothing still returns 202 — the sender
should not retry it.
The webhook URL is surfaced on the SCM integration API response (webhook_url)
when GANTRYCD_BACKEND_PUBLIC_BASE_URL is configured, so an admin can copy it
into the GitHub App’s webhook settings alongside the matching webhook_secret.
The GitHub App must be subscribed to the push and pull_request events; stacks
using promotion gates additionally need pull_request_review and check_suite
(the latter also requires the checks: read permission).
Push vs. pull-request triggers
Pushes sync stacks; pull-request activity refreshes PRs. The two never overlap, so a single user action never does duplicate work:
- A push to a stack’s configured branch → sync. The worker resolves stacks
with
ListByRepositoryURLAndBranch(org, repo, pushedBranch), then keeps only the stacks whose working directory the push actually touched — it diffs the push’sbefore...afterrange (Provider.ListChangedFiles) and applies the same working-directory rule as PR matching (stackMatchesChangedFiles; a root/stack always matches). For each surviving stack it updates the commit SHA and creates a normal stack-lane deployment (planmode,origin = push), shown in the stack’s deployments view. A stack the push did not touch is skipped entirely — no commit update, no deployment. Three cases fall back to syncing every matching stack because the delta can’t be evaluated: a push with nobefore(a branch’s first push); a diff whose file list hits GitHub’s per-comparison cap (len(files) >= CompareFilesLimit); and a comparison response too large to read (the compare endpoint embeds a per-file patch, so the body is read against a raised cap and a body over it is reported as truncated). A compare API failure (network, auth, rate limit) is not swallowed — it fails the event (logged, retried per the inbox policy), so a transient SCM outage produces no deployments rather than wrong ones. An empty diff matches nothing, so the push syncs no stack (not even a root/stack). - A push to any other branch → no-op. A PR’s head/feature branch, a newly
created branch, or a branch deletion matches no stack and does nothing. PR
refreshes are driven only by
pull_requestevents, so a commit pushed to a PR (which GitHub delivers as both apushto the head branch and apull_request.synchronize) is refreshed exactly once. - A pull-request event → refresh. opened / reopened / edited / synchronized
(
pr_changed) and closed or merged (pr_closed) all rebuild the open-PR projection for the PR’s base branch (the same path aspr_refresh_requested): PRs no longer open are pruned, and any PR whose head SHA moved gets aplan_unlocked, non-stack-lane deployment (origin = pull_request). A PR is matched to a stack by the same working-directory rule the push path uses, and fails safe the same way: if the PR’s changed-file list is incomplete because it is too large (!ChangedFilesComplete, e.g. the list hitPullRequestFilesLimit), it is treated as touching every path and associates with every stack on the branch. These PR previews never take the stack lock. - A PR merge → sync and refresh. GitHub delivers a merge as two events: a
pull_request.closed(merged = true) that refreshes and prunes the merged PR, and apushto the base branch — the merge commit — that syncs the stack via the first rule. No special-casing and no duplicate deployment.
A PR whose head SHA did not change across a refresh keeps its existing
plan_unlocked deployment and does not re-plan (resolvePRDeploymentID). So
updating the configured branch triggers a new plan in the deployments view, while
PR activity — whether delivered as a push to a head branch or a pull_request
event — stays scoped to the PR.
Promoting a pull request
A plan_unlocked PR preview can never apply. To act on it, a user promotes
the PR (POST .../pull-requests/{number}/promote, gated by deploy). This
creates a brand-new standard plan-mode, stack-lane deployment that runs at the
PR’s head commit and follows the normal plan → confirmation → apply flow.
Only a PR whose latest preview plan succeeded can be promoted: the service
checks that the deployment referenced by the PR row’s latest_deployment_id is
finished (for the read-only plan_unlocked workflow, finished means the plan
run succeeded — there is no apply), and otherwise returns a 409. The UI hides the
Promote button in every non-finished state to match.
The promoted deployment carries origin = promoted_pull_request — a distinct
origin from the read-only pull_request preview, so it stays out of the PR-plan
machinery (it does not repoint the PR’s latest_deployment_id, is not
subject to the retry-staleness guard, and shows in the deployments timeline like
a normal deployment). It snapshots the PR title, url, and body onto the
deployment (origin_pr_title/url/body) so the deployments view can show a
“Promoted from PR #N” banner with a link and description even after the PR closes
and its pull_requests row is pruned. Promote is keyed on the PR (always the
current head) and debounced against a recent lock-acquiring deployment at the
same commit (read-only previews at that commit are ignored).
Cleanup
PR rows are cleared when a stack’s repo configuration changes in-place. Stack deletion / rename relies on schema-level cascade behavior.
See also: