CircleCI Field Guide
Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Back to homepage
Edit page

GitHub Stacked Pull Requests

GitHub Stacked PRs (public preview) rebase remaining branches when a lower PR in the stack merges, then force-push them. GitHub documents that this retriggers CI on every remaining PR.

CircleCI treats those events as normal push / pull_request.synchronize pipelines. A 5-deep stack can therefore fan out into extra pipelines on every bottom-up merge.

This recipe uses dynamic config so a cheap setup job inspects the PR before expensive work runs. The decision matches GitHub’s guidance for optimizing CI on stacked PRs.

Decision table

Situation Run expensive CI?
No open PR / default-branch push Yes
Unstacked PR Yes
Stacked + lowest unmerged (stack.base.ref equals the PR’s base.ref) Yes
Stacked + top of stack (position == size) Yes
Stacked + mid-stack (typical cascade rebase) No
GitHub API lookup failed Yes (fail open)
Always Post a cheap required check
flowchart TD
  pushNode["Push or PR synchronize"]
  setupNode["Setup job: GET GitHub PR"]
  decide{"Stacked and mid-stack?"}
  expensive["expensive-tests"]
  skipJob["skip-expensive no-op"]
  requiredCheck["required-check no-op"]
  pushNode --> setupNode --> decide
  decide -->|"no: unstacked, lowest, or top"| expensive --> requiredCheck
  decide -->|"yes: cascade rebase"| skipJob --> requiredCheck

What not to do

[skip ci] is not a stack workaround. GitHub rebase commits do not include that trailer, and skipping the whole pipeline leaves required GitHub status checks pending, so the stack cannot merge.

Technical workaround

A setup workflow analyzes the PR, then continuation starts the real jobs.

How it runs:

  1. The setup job calls the GitHub REST API and reads stack metadata.
  2. Continuation parameters set run_expensive from the decision table above.
  3. Mid-stack cascade rebases skip the expensive suite and run a no-op instead.
  4. A cheap required-check fans in from either path so branch protection still sees a passing status.

Prerequisites

  • Dynamic config enabled: Project Settings > Advanced > Enable dynamic config using setup workflows. See the dynamic configuration overview.
  • A context that provides GITHUB_TOKEN (needed for private repos and for stack metadata on the Pulls API). The sample attaches a context named github-api; rename it to match the project.
  • The continuation orb (circleci/continuation@2.0.0).
  • The analyzer copied into the repo as scripts/analyze_stacked_pr.py.

Setup Workflow (config.yml)

The setup job calls GitHub, writes continuation parameters, and starts .circleci/continue.yml.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
version: 2.1

# Setup config: inspect GitHub PR stack metadata, then continue.
#
# The setup job GETs the PR from GitHub, then continues with run_expensive
# booleans. Requires dynamic config (Project Settings > Advanced) and a
# context that provides GITHUB_TOKEN (sample name: github-api).

setup: true

orbs:
  continuation: circleci/continuation@2.0.0

jobs:
  analyze-pr:
    docker:
      - image: cimg/base:current
    resource_class: small
    steps:
      - checkout
      - run:
          name: Analyze GitHub PR / stack before continuing
          command: python3 scripts/analyze_stacked_pr.py
      - continuation/continue:
          configuration_path: .circleci/continue.yml
          parameters: pipeline-parameters.json

workflows:
  analyze-pr:
    jobs:
      - analyze-pr:
          context: github-api

Analyzer (scripts/analyze_stacked_pr.py)

The setup job runs this file at scripts/analyze_stacked_pr.py. It GETs the open PR for CIRCLE_BRANCH, reads stack, and writes pipeline-parameters.json.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
#!/usr/bin/env python3
"""Inspect the GitHub PR (including stack metadata) before continuation.

The setup job calls the GitHub REST API and writes continuation parameters.

Decision (matches GitHub's stacked-PR CI guidance):
  - Not a PR / default branch / unstacked PR -> run expensive CI
  - Stacked + lowest unmerged (targets the stack base, e.g. main) -> run expensive
  - Stacked + top of stack (full stacked diff) -> run expensive
  - Stacked + mid-stack (typical cascade rebase of remaining PRs) -> skip expensive

Always continue with run_required_check=true so a cheap GitHub status is posted.
API failures fail open (run expensive) so a lookup error never silently skips CI.
"""

from __future__ import annotations

import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request

GITHUB_API = "https://api.github.com"
PARAMS_PATH = os.environ.get("STACK_PARAMS_PATH", "pipeline-parameters.json")


def log(message: str) -> None:
    print(message, flush=True)


def github_headers() -> dict[str, str]:
    # GITHUB_TOKEN comes from a project context; omit Authorization on public repos.
    headers = {
        "Accept": "application/vnd.github+json",
        "X-GitHub-Api-Version": "2022-11-28",
        "User-Agent": "circleci-stacked-pr-analyzer",
    }
    token = (
        os.environ.get("GITHUB_TOKEN", "").strip()
        or os.environ.get("github_token", "").strip()
    )
    if token:
        headers["Authorization"] = f"Bearer {token}"
    return headers


def github_get(url: str) -> dict | list:
    request = urllib.request.Request(url, headers=github_headers())
    try:
        with urllib.request.urlopen(request, timeout=30) as response:
            return json.load(response)
    except urllib.error.HTTPError as exc:
        body = exc.read().decode("utf-8", errors="replace")
        raise RuntimeError(f"GitHub API {exc.code} for {url}: {body[:500]}") from exc


def write_params(params: dict) -> None:
    with open(PARAMS_PATH, "w", encoding="utf-8") as handle:
        json.dump(params, handle)
    log(f"Wrote {PARAMS_PATH}: {json.dumps(params)}")


def sanitize(value: str) -> str:
    return " ".join(value.replace('"', "'").split())[:180]


def result(
    *,
    run_expensive: bool,
    reason: str,
    pr_number: str = "",
    pr_url: str = "",
    stack_position: str = "",
    stack_size: str = "",
) -> dict:
    return {
        "run_expensive": run_expensive,
        "run_required_check": True,
        "pr_number": pr_number,
        "pr_url": pr_url,
        "stack_position": stack_position,
        "stack_size": stack_size,
        "decision_reason": sanitize(reason),
    }


def pr_number_from_url(url: str) -> str:
    return url.rstrip("/").rsplit("/", 1)[-1]


def find_pr(owner: str, repo: str, branch: str) -> dict | None:
    # Prefer listing by head branch; fall back to CIRCLE_PULL_REQUEST(S).
    head = urllib.parse.quote(f"{owner}:{branch}")
    listed = github_get(
        f"{GITHUB_API}/repos/{owner}/{repo}/pulls?head={head}&state=open&per_page=10"
    )
    if isinstance(listed, list) and listed:
        if len(listed) > 1:
            log(f"Multiple open PRs for {owner}:{branch}; using #{listed[0]['number']}")
        return listed[0]

    pull_url = os.environ.get("CIRCLE_PULL_REQUEST", "").strip()
    if not pull_url:
        pulls = os.environ.get("CIRCLE_PULL_REQUESTS", "").strip()
        if pulls:
            pull_url = pulls.split(",")[0].strip()
    if not pull_url:
        return None

    number = pr_number_from_url(pull_url)
    return github_get(f"{GITHUB_API}/repos/{owner}/{repo}/pulls/{number}")


def decide_from_pr(pr: dict) -> dict:
    # Unstacked, lowest-unmerged, or top of stack -> expensive CI; mid-stack -> skip.
    number = str(pr.get("number", ""))
    html_url = pr.get("html_url", "")
    base_ref = (pr.get("base") or {}).get("ref", "")
    stack = pr.get("stack")

    if not stack:
        return result(
            run_expensive=True,
            reason="unstacked PR; run expensive CI",
            pr_number=number,
            pr_url=html_url,
        )

    position = stack.get("position")
    size = stack.get("size")
    stack_base = (stack.get("base") or {}).get("ref", "")
    lowest_unmerged = bool(stack_base) and stack_base == base_ref
    top = position is not None and size is not None and position == size
    run_expensive = lowest_unmerged or top

    if lowest_unmerged and top:
        reason = "stacked PR; single remaining layer (lowest unmerged and top)"
    elif lowest_unmerged:
        reason = "stacked PR; lowest unmerged (targets stack base)"
    elif top:
        reason = "stacked PR; top of stack (full stacked diff)"
    else:
        reason = "stacked PR; mid-stack (typical cascade rebase); skip expensive CI"

    return result(
        run_expensive=run_expensive,
        reason=reason,
        pr_number=number,
        pr_url=html_url,
        stack_position="" if position is None else str(position),
        stack_size="" if size is None else str(size),
    )


def main() -> int:
    owner = os.environ.get("CIRCLE_PROJECT_USERNAME", "")
    repo = os.environ.get("CIRCLE_PROJECT_REPONAME", "")
    branch = os.environ.get("CIRCLE_BRANCH", "")
    log(f"Analyzing branch={branch} repo={owner}/{repo}")

    if not owner or not repo:
        # Fail open: never skip CI because project env vars were missing.
        write_params(
            result(run_expensive=True, reason="missing CIRCLE_PROJECT_* ; fail open")
        )
        return 0

    try:
        pr = find_pr(owner, repo, branch)
    except Exception as exc:  # noqa: BLE001 — fail open on any lookup error
        log(f"PR lookup failed ({exc}); failing open")
        write_params(
            result(run_expensive=True, reason=f"GitHub API lookup failed; fail open: {exc}")
        )
        return 0

    if not pr:
        write_params(
            result(
                run_expensive=True,
                reason="no open PR for this branch (default branch or non-PR push)",
            )
        )
        return 0

    number = pr.get("number")
    if number:
        try:
            pr = github_get(f"{GITHUB_API}/repos/{owner}/{repo}/pulls/{number}")
        except Exception as exc:  # noqa: BLE001
            log(f"Full PR fetch failed ({exc}); using list payload")

    params = decide_from_pr(pr)
    log(params["decision_reason"])
    write_params(params)
    return 0


if __name__ == "__main__":
    sys.exit(main())

Continue Workflow (continue.yml)

expensive-tests runs only when analysis says so. skip-expensive is a no-op so required-check can fan in from either path via flexible requires (success or not_run). Branch protection should point at required-check, not expensive-tests.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
version: 2.1

# Continuation config. Parameters come from scripts/analyze_stacked_pr.py.
#
# expensive-tests runs when run_expensive is true; otherwise skip-expensive
# (no-op) runs. required-check is a no-op that fans in from either path via
# flexible requires (success or not_run). Branch protection should use
# required-check, not expensive-tests.

parameters:
  run_expensive:
    type: boolean
    default: true
  run_required_check:
    type: boolean
    default: true
  pr_number:
    type: string
    default: ""
  pr_url:
    type: string
    default: ""
  stack_position:
    type: string
    default: ""
  stack_size:
    type: string
    default: ""
  decision_reason:
    type: string
    default: ""

jobs:
  expensive-tests:
    docker:
      - image: cimg/base:current
    resource_class: small
    steps:
      - checkout
      - run:
          name: Stand-in for the expensive test suite
          command: |
            echo "Running expensive CI for << pipeline.parameters.decision_reason >>"
            echo "pr: << pipeline.parameters.pr_url >> (#<< pipeline.parameters.pr_number >>)"
            echo "stack: << pipeline.parameters.stack_position >> / << pipeline.parameters.stack_size >>"
            sleep 5
            echo "expensive tests passed"

  skip-expensive:
    type: no-op

  required-check:
    type: no-op

workflows:
  ci:
    jobs:
      - expensive-tests:
          filters: pipeline.parameters.run_expensive
      - skip-expensive:
          filters: not pipeline.parameters.run_expensive
      - required-check:
          requires:
            - expensive-tests:
                - success
                - not_run
            - skip-expensive:
                - success
                - not_run

The expensive-tests job body is a stand-in for the real suite. The filters and the required-check fan-in should stay as shown.

GitHub App pipelines

The same setup job works for GitHub App pipelines.