Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
0308e6b
fix(tests): isolate auth logging and GitHub fixture traffic
osterman Sep 30, 2026
85cffaa
test(viewport): allow cleared live frames in terminal capture
osterman Sep 30, 2026
9199dde
test(scheduler): allow CI scheduling delays in harness deadlines
osterman Sep 30, 2026
2672d42
fix(tests): isolate source flags and drain captured output
osterman Sep 30, 2026
5ebf509
test(terraform): isolate planfile fixtures across processes
osterman Sep 30, 2026
76dd557
test(terraform): copy only immutable planfile fixture inputs
osterman Sep 30, 2026
480a608
fix(ci): allow cold Go setup in acceptance summaries
osterman Sep 30, 2026
cf0c4cc
fix(tests): resolve toolchain releases through local fixtures
osterman Sep 30, 2026
9c89c36
fix(ci): permit Go fallback downloads in hardened jobs
osterman Sep 30, 2026
6184e27
docs(prd): add PRD for native aws/cloudformation component type
osterman Aug 24, 2026
c2cb001
fix(docs): correct editorconfig indentation in CloudFormation PRD
osterman Aug 24, 2026
0797f64
docs(skills): add gh-stack skill for GitHub PR-stack workflow gotchas
osterman Aug 24, 2026
6d143ca
docs(skills): add gotcha #3 — trust GitHub's mergeable/conflict repor…
osterman Aug 27, 2026
43fd92e
docs: fix stale/inaccurate CFN PRD and gh-stack skill content
osterman Aug 27, 2026
d6f1de3
docs(cloudformation): fix CodeRabbit findings on PRD and gh-stack saf…
osterman Sep 8, 2026
75008c0
fix(cloudformation): address CodeRabbit findings on PRD and gh-stack …
osterman Sep 10, 2026
c037c9b
fix(cloudformation): address second-pass CodeRabbit findings on place…
osterman Sep 10, 2026
aa4e4fe
docs(cloudformation): clarify preview and protection requirements
osterman Sep 30, 2026
7758509
docs(cloudformation): clarify macro capabilities for changesets
osterman Sep 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 177 additions & 0 deletions .claude/skills/gh-stack/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
---
name: gh-stack
description: "Split large work (e.g. a multi-phase PRD) into a sequence of reviewable, dependent PRs using GitHub's gh stack CLI (github/gh-stack) in this repo: gh stack init/add/submit/checkout/rebase/merge, plus three repo-specific gotchas that cause commits to land on the wrong branch or a GitHub-reported conflict to get wrongly dismissed as a false positive. Invoke before starting stacked-branch work, or when a gh stack command fails unexpectedly (e.g. an unrelated file failing pre-commit), or when GitHub reports a PR conflict."
metadata:
copyright: Copyright Cloud Posse, LLC 2026
version: "1.0.0"
---

# GitHub PR Stacks (`gh stack`)

Not to be confused with **`atmos-stacks`** (Atmos's own stack-config subsystem) or **`atmos-git`**
(Atmos's git integration feature). This skill is about splitting *your own implementation work* —
e.g. a large PRD with multiple rollout phases — into a chain of small, sequentially-based PRs using
GitHub's [`gh stack`](https://github.com/github/gh-stack) CLI extension
(`gh extension install github/gh-stack`; Conductor's cloud workspaces have it preinstalled, local
workspaces need the extension installed once).

Use this when a task is too large for one PR — each layer becomes its own branch, based on the layer
below it, each with its own PR, reviewed and mergeable independently while still expressing "this
depends on that."

## Core commands

| Command | What it does |
|---|---|
| `gh stack init` | Initializes a stack with the current branch as layer 1 (based on the trunk, usually `main`) |
| `gh stack add <branch>` | Creates and checks out a new branch on top of the current top of the stack |
| `gh stack submit` | Pushes every branch and creates/updates every PR in the stack (4 sequential steps: push branches, create PRs, update base branches, create/update the stack object — not atomic; a mid-run failure can leave some branches pushed with no PR yet, or a partially-updated stack. Rerunning is safe — it picks up wherever it left off) |
| `gh stack view` | Shows the full stack (branches, PR numbers, merge-readiness) |
| `gh stack checkout [<stack-number> / <pr-number> / <pr-url> / <branch>]` | Resolves an identifier, fetching a remote stack when needed; no argument opens the available-stack picker |
| `gh stack switch` | Opens the branch picker for the current stack; accepts no identifier |
| `gh stack rebase` / `gh stack sync` | Fetches trunk, cascades a rebase across every branch in the stack |
| `gh stack merge` | Merges one or more ready PRs in the stack (doesn't require merging the whole stack at once) |

## Three gotchas that will bite you in this repo

### 1. Switching stack layers does NOT clear your staged changes

`gh stack checkout` resolves stack numbers, PR numbers, PR URLs, and locally tracked branch names.
It can fetch a remote stack and set up local tracking; with no argument it opens the available-stack
picker. `gh stack switch` only opens the branch picker for the current stack: it does not accept an
identifier, so use `gh stack checkout <branch>`, not `gh stack switch <branch>`. Once either command
resolves *which* branch to land on, the actual switch goes through git's own checkout, which does not
reset the index or the working tree — it only touches files that differ between the two branches. If you have
staged **or unstaged** changes for **files that are identical on both branches**, those changes
silently ride along to whatever branch you land on next.

This is exactly how a real incident happened: branch-1 changes were `git add`ed, the commit failed
(see gotcha #2), the session ran `gh stack checkout <parent-branch>` to fix something unrelated, and
those still-staged branch-1 files rode along and got swept into the parent's commit.

**Rule: before switching stack layers, get to a genuinely clean state first** — commit what you have.
`git restore --staged .` alone is not enough: it unstages, but leaves any working-tree modifications
in place, which still ride along the same way staged changes do. If some changes truly aren't ready
to commit, they still need to be fully out of the working tree before switching — but getting there
must never mean silently discarding uncommitted work. **Never discard changes (`git checkout -- .`,
`git clean -fd`, `git reset --hard`, etc.) without the user's explicit, per-change approval** — this
matches this repo's Git Safety Protocol. Preserve the work first, then switch:

- Commit it, even as a throwaway WIP commit on the current branch (`git commit -m 'WIP: <tag>'`) —
with the user's approval, `git reset --mixed HEAD~1` restores it as unstaged changes once you're
back on this layer. Use this only for your own unpublished WIP commit at the branch tip.
- Or copy the dirty files out to a temporary worktree, or a patch file written safely: create a
private temp directory (`dir=$(mktemp -d)`), exclusively create the patch file inside it
(`patch=$(mktemp "$dir/XXXXXX.patch")` — avoids following a pre-existing symlink at a predictable
shared `/tmp/<tag>.patch` path), and capture with `git diff HEAD > "$patch"` — `HEAD`, not a bare
`git diff`, so both staged and unstaged tracked changes are captured (a bare `git diff` only
compares the working tree against the index and silently drops anything already staged); untracked
files still need separate handling (copy them by hand — don't `git stash -u`, per this repo's
no-stash convention). Reapply after switching back, then remove the temp directory.

Only if neither is viable and the changes are genuinely disposable, ask the user for explicit approval
before discarding — never remove uncommitted work by default. Never switch layers mid-edit with dirty
state for work you haven't finished placing.

**After every stack checkout, verify before you commit:**
```bash
git log --oneline -3 # confirm you're on the branch/commit you expect
git status --short # confirm only the files you intend to touch are dirty/staged
git diff --cached --stat # right before committing — confirm the staged set matches your intent
```

### 2. The EditorConfig hook's affected set differs from your staged diff

`atmos-validate-editorconfig` runs on every commit (`always_run: true`), but its
`atmos validate --affected` command normally selects committed changes since the merge base,
unstaged changes, and untracked files, subject to the hook's exclusions. It does not collect the
staged diff separately: a staged-only path can be missed unless another part of that affected set
also includes it. Selected EditorConfig rule changes trigger a full scan instead.

On a stacked branch, lower-layer changes can appear in that committed range: **a commit on layer 2
can fail because of a formatting issue in a file that only layer 1 touched**, even though your
layer-2 commit never touches that file. A passing hook is not proof that every staged file was checked.

If a commit fails on a file you didn't stage and don't recognize as part of your change, check
whether it's inherited from a lower stack layer before assuming your own change broke something.
Fix it **on the layer that owns the file** (usually the lowest layer that introduced or touched it),
commit it there, then bring the fix forward to upper layers (see the fast-forward trick below) —
don't let an upper layer's commit accidentally absorb an unrelated lower-layer fix (see gotcha #1).

## Bringing a lower-layer fix forward without disturbing a dirty upper layer

If the upper layer has **zero commits of its own yet** (fresh from `gh stack add`, still just staged/
unstaged changes in the working tree) and the layer below it gets a new commit, you don't need
`gh stack rebase` (which requires a clean index) and you don't need to stash (a stray stash is easy to
forget and this repo's convention is to avoid `git stash` — commit or fast-forward instead):

```bash
git merge --ff-only <parent-layer-latest-commit>
```

This fast-forwards the branch pointer and merges in the parent's new commit's file changes without
touching your working tree's dirty files, as long as those dirty files don't conflict with what the
fast-forward brings in (they won't, if the parent commit only touched files you haven't touched).

Once the upper layer has real commits of its own, use `gh stack rebase`/`gh stack sync` instead — but
only from a genuinely clean worktree first. `github/gh-stack` requires zero uncommitted changes,
staged or not, for these commands, and does not stash them automatically. Unstaging alone (`git
restore --staged .`) is not enough — it clears the index but leaves working-tree modifications in
place, and those still block the command. Commit what you have or safely move it out of the working
tree first (per gotcha #1), not just unstage it. Once clean, `gh stack rebase`/`gh stack sync` is the
tool built for cascading a real rebase across every branch in the stack.

### 3. Trust GitHub's `mergeable`/conflict report over a local pairwise branch comparison

When GitHub shows a PR as having conflicts (`gh pr view <num> --json mergeable` returns
`CONFLICTING`, or the PR page shows "This branch has conflicts that must be resolved" with a
specific file list), **that is ground truth — do not "disprove" it with local git commands and
conclude it's a stale/false positive.** A real incident: `git merge-tree --write-tree
origin/<parent> origin/<child>` succeeded cleanly (exit 0, no conflicts) and `git merge-base
--is-ancestor` confirmed a strict fast-forward relationship between the two branch tips — which
led to concluding GitHub's report was wrong. It wasn't. `gh stack rebase` immediately reproduced
the exact same conflict, in the exact same files GitHub had listed.

**Why the local check was misleading, not GitHub:** a pairwise `merge-tree` between two branches'
*current* committed tips only proves those two exact commits merge cleanly *as they are right now*.
It says nothing about what happens once the stack's lower layers get rebased onto the current tip
of `main` — which is exactly what GitHub's own mergeability check (and `gh stack rebase`) account
for, and what actually determines whether the stack can merge. If `main` has moved forward since
the stack was built, a real conflict can exist between "phase N rebased onto phase N-1 rebased onto
current main" even though "phase N vs phase N-1's original commits" shows none. Check whether
`main` has advanced past the stack's base with `git merge-base --is-ancestor origin/main
origin/<bottom-layer-branch>` before trusting a clean pairwise diff as proof of anything — if it's
not an ancestor, `main` has moved and the pairwise check is not asking the right question.

**Rule:** the moment GitHub reports a conflict, go straight to `gh stack rebase` — it reproduces
the real, current mergeability question (does each layer still rebase cleanly onto its rebased
parent and the current tip of `main`) and resolves it interactively in the same step. Don't spend
time trying to locally falsify the report first, and **don't reach for `gh stack sync` as a
"read-only" probe** — sync is not read-only: per its own `--help`, it fetches, reconciles the
remote stack with your local one, cascade-rebases, and then **pushes every branch atomically**
(`--force-with-lease --atomic`) and syncs PR state on GitHub. Running it just to "see what
conflicts" can force-push branches and mutate remote PR/stack state as a side effect — only run
it when you actually intend those effects (e.g. after resolving conflicts and wanting a full
fetch+rebase+push+PR-sync in one step). `gh stack rebase` alone reproduces the conflict locally
without pushing anything; use it for diagnosis, and again to actually resolve. If `gh stack
rebase` reports zero conflicts, only then is it reasonable to treat GitHub's cached state as stale
and worth a recheck after a short wait (GitHub's own mergeability computation can lag a
`gh stack submit` push by a few seconds, but it resolves in well under a minute).

When resolving the conflicts `gh stack rebase` surfaces, first check whether the two sides are
actually independent, then combine them — don't reflexively keep both. In this repo, stacked layers
usually add independent, coexisting things to the same shared file (e.g. two different component
types each adding their own `case` in the same switch statement, or their own field in the same
struct); for those, prefer **keeping both sides** over picking one — a conflict here is usually not
“which change is correct,” it's “both changes are correct and need to be merged.” But when the two
sides touch the same case, field, or logic in incompatible ways, keeping both blindly produces
duplicate cases or contradictory logic — resolve the conflict semantically instead, picking the
correct combined behavior rather than concatenating both raw hunks, and re-run the affected checks
afterward.

## PR labeling across a stack

Per the `pull-request` skill's semver-label rule: **the label is per-PR, not per-feature.** If a
five-layer stack adds one user-visible feature, only the final layer that wires it up gets
`minor`/`major` — the foundation/plumbing layers underneath it get `no-release`. Don't label every
layer in a stack the same way just because they're part of one larger effort.
2 changes: 2 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@ jobs:
gocloud.dev:443
filippo.io:443
sigs.k8s.io:443
raw.githubusercontent.com:443
dl.google.com:443

- name: Checkout code
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
Expand Down
13 changes: 13 additions & 0 deletions .github/workflows/native-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ jobs:
storage.googleapis.com:443
google.golang.org:443
modernc.org:443
raw.githubusercontent.com:443
golang.org:443
go.dev:443
dl.google.com:443

- name: Check out code
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
Expand Down Expand Up @@ -171,6 +175,9 @@ jobs:
raw.githubusercontent.com:443
tuf-repo-cdn.sigstore.dev:443
rekor.sigstore.dev:443
golang.org:443
go.dev:443
dl.google.com:443

- name: Check out code
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
Expand Down Expand Up @@ -254,6 +261,9 @@ jobs:
raw.githubusercontent.com:443
tuf-repo-cdn.sigstore.dev:443
rekor.sigstore.dev:443
golang.org:443
go.dev:443
dl.google.com:443

- name: Check out code
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
Expand Down Expand Up @@ -330,6 +340,9 @@ jobs:
raw.githubusercontent.com:443
tuf-repo-cdn.sigstore.dev:443
rekor.sigstore.dev:443
golang.org:443
go.dev:443
dl.google.com:443

- name: Check out code
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/planfile-artifacts-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ jobs:
tuf-repo-cdn.sigstore.dev:443
rekor.sigstore.dev:443
registry.opentofu.org:443
golang.org:443
go.dev:443
dl.google.com:443

- name: Checkout
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
Expand Down Expand Up @@ -125,6 +128,9 @@ jobs:
tuf-repo-cdn.sigstore.dev:443
rekor.sigstore.dev:443
registry.opentofu.org:443
golang.org:443
go.dev:443
dl.google.com:443

- name: Checkout
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/planfile-verify-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ jobs:
tuf-repo-cdn.sigstore.dev:443
rekor.sigstore.dev:443
registry.opentofu.org:443
golang.org:443
go.dev:443
dl.google.com:443
- name: Checkout
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
Expand Down Expand Up @@ -133,6 +136,9 @@ jobs:
tuf-repo-cdn.sigstore.dev:443
rekor.sigstore.dev:443
registry.opentofu.org:443
golang.org:443
go.dev:443
dl.google.com:443
- name: Checkout
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
Expand Down Expand Up @@ -183,6 +189,9 @@ jobs:
tuf-repo-cdn.sigstore.dev:443
rekor.sigstore.dev:443
registry.opentofu.org:443
golang.org:443
go.dev:443
dl.google.com:443
- name: Checkout
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/rerun-infra-failures.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ jobs:
storage.googleapis.com:443
google.golang.org:443
modernc.org:443
raw.githubusercontent.com:443
go.dev:443
dl.google.com:443

- name: Check out code into the Go module directory
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/setup-go-cache-warmup.yml
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,9 @@ jobs:
ocsp.usertrust.com:80
ocsp.digicert.com:80
*.pool.ntp.org:123
raw.githubusercontent.com:443
golang.org:443
go.dev:443

- name: Check out code into the Go module directory
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
Expand Down
22 changes: 22 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,9 @@ jobs:
ocsp.usertrust.com:80
ocsp.digicert.com:80
*.pool.ntp.org:123
golang.org:443
go.dev:443
dl.google.com:443

- name: Announce build target
if: ${{ ! ( matrix.target == 'windows' && github.event.pull_request.draft ) }}
Expand Down Expand Up @@ -425,6 +428,9 @@ jobs:
ocsp.usertrust.com:80
ocsp.digicert.com:80
*.pool.ntp.org:123
golang.org:443
go.dev:443
dl.google.com:443

- name: Check out code into the Go module directory
if: ${{ ! ( matrix.flavor.target == 'windows' && github.event.pull_request.draft ) }}
Expand Down Expand Up @@ -659,6 +665,9 @@ jobs:
ocsp.usertrust.com:80
ocsp.digicert.com:80
*.pool.ntp.org:123
golang.org:443
go.dev:443
dl.google.com:443

- name: Check out code into the Go module directory
if: ${{ ! ( matrix.flavor.target == 'windows' && github.event.pull_request.draft ) }}
Expand Down Expand Up @@ -1068,7 +1077,10 @@ jobs:
egress-policy: block
allowed-endpoints: >
api.github.com:443
dl.google.com:443
github.com:443
go.dev:443
raw.githubusercontent.com:443
release-assets.githubusercontent.com:443
golang.org:443
proxy.golang.org:443
Expand Down Expand Up @@ -1436,6 +1448,8 @@ jobs:
storage.googleapis.com:443
tuf-repo-cdn.sigstore.dev:443
us.i.posthog.com:443
golang.org:443
go.dev:443

- name: Check out code into the Go module directory
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
Expand Down Expand Up @@ -1756,6 +1770,10 @@ jobs:
ocsp.usertrust.com:80
ocsp.digicert.com:80
*.pool.ntp.org:123
raw.githubusercontent.com:443
golang.org:443
go.dev:443
dl.google.com:443

- name: Check out code into the Go module directory
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
Expand Down Expand Up @@ -2216,6 +2234,10 @@ jobs:
release-assets.githubusercontent.com:443
storage.googleapis.com:443
us.i.posthog.com:443
raw.githubusercontent.com:443
golang.org:443
go.dev:443
dl.google.com:443

- name: Check out code into the Go module directory
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
Expand Down
Loading
Loading