Skip to content
GantryCD

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/refresh calls PullRequestService.EnqueueRefreshByStack, which writes a pr_refresh_requested event and returns 202 Accepted with the event id.
  • GET /api/v1/orgs/{org_id}/stacks/{stack_id}/pull-requests/refresh returns 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/dead per 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:

  1. Load the integration scoped to (org_id, integration_id); an integration in another org is reported as 404, never as a cross-org existence oracle.
  2. Provider.ParseWebhook verifies the signature (GitHub: HMAC-SHA256 of the raw body against auth_config.webhook_secret, constant-time) and normalizes the payload. A signature mismatch returns scm.ErrWebhookSignatureInvalid → HTTP 401.
  3. Each normalized event is enqueued into scm_events (status pending) in one write transaction, then a pg_notify wakes 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-Eventconditionnormalized event
pushbranch ref, not deletedpush_branch_changed
pushbranch deletionignored
pushtag refignored
pull_requestaction opened / reopened / synchronize / editedpr_changed
pull_requestaction closed (including merged)pr_closed
pull_requestany other actionignored
pull_request_reviewaction submitted / dismissedpr_changed (re-evaluate the plan gate)
pull_request_reviewany other action (e.g. edited)ignored
check_suiteaction requested, rerequested, or completed, with same-repo PRspr_changed per associated PR (re-evaluate the plan gate)
check_suiteany other action, or no same-repo PRs (e.g. fork)ignored
ping / unknownignored (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’s before...after range (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 (plan mode, 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 no before (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_request events, so a commit pushed to a PR (which GitHub delivers as both a push to the head branch and a pull_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 as pr_refresh_requested): PRs no longer open are pruned, and any PR whose head SHA moved gets a plan_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 hit PullRequestFilesLimit), 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 a push to 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: