/stakt/ — like "stacked"
Stacked PR merge queue powered by GitHub Actions and PR comments.
Add a workflow file for each of the two events:
.github/workflows/staqd-auto-detect.yml — runs on pull request events:
name: Staqd Auto Detect
on:
pull_request:
types: [opened, edited, closed]
permissions:
pull-requests: write
issues: write
jobs:
staqd:
uses: siner308/staqd/.github/workflows/staqd-auto-detect.yml@v1.github/workflows/staqd-command.yml — runs on PR comment commands:
name: Staqd Command
on:
issue_comment:
types: [created]
permissions:
contents: write
pull-requests: write
issues: write
checks: read
jobs:
staqd:
uses: siner308/staqd/.github/workflows/staqd-command.yml@v1That's it. Each workflow triggers only on its relevant event, keeping PR checks clean and focused.
.github/workflows/staqd-auto-detect.yml:
name: Staqd Auto Detect
on:
pull_request:
types: [opened, edited, closed]
permissions:
pull-requests: write
issues: write
jobs:
staqd:
uses: siner308/staqd/.github/workflows/staqd-auto-detect.yml@v1
with:
app-id: ${{ vars.STAQD_APP_ID }}
secrets:
app-private-key: ${{ secrets.STAQD_APP_PRIVATE_KEY }}.github/workflows/staqd-command.yml:
name: Staqd Command
on:
issue_comment:
types: [created]
permissions:
contents: write
pull-requests: write
issues: write
checks: read
jobs:
staqd:
uses: siner308/staqd/.github/workflows/staqd-command.yml@v1
with:
app-id: ${{ vars.STAQD_APP_ID }}
secrets:
app-private-key: ${{ secrets.STAQD_APP_PRIVATE_KEY }}Deprecation: The single-file
staqd.ymlis deprecated. Use the two-file setup above.
staqd-auto-detect.yml
| Input | Description | Required | Default |
|---|---|---|---|
app-id |
GitHub App ID. Used with app-private-key to generate an installation token. |
No | |
runs-on |
Runner label for all jobs | No | ubuntu-latest |
staqd-command.yml
| Input | Description | Required | Default |
|---|---|---|---|
app-id |
GitHub App ID. Used with app-private-key to generate an installation token. |
No | |
runs-on |
Runner label for all jobs | No | ubuntu-latest |
timeout-minutes |
Timeout for command job | No | 30 |
| Secret | Description | Required |
|---|---|---|
app-private-key |
GitHub App private key. Used with app-id input. |
No |
Why a GitHub App?
GITHUB_TOKENpushes don't trigger other workflows (GitHub security policy). If your CI needs to run after a restack force-push, use a GitHub App.
How to create a GitHub App
- Go to GitHub Settings → Developer settings → GitHub Apps → New GitHub App
- Fill in:
- App name: e.g.,
staqd-bot - Homepage URL: your repo URL
- Webhook: uncheck "Active" (not needed)
- App name: e.g.,
- Set Repository permissions:
- Contents: Read & write
- Pull requests: Read & write
- Issues: Read & write
- Checks: Read
- Click Create GitHub App
- Note the App ID from the app's settings page
- Click Generate a private key and download the
.pemfile - Click Install App and install it on your repository
- In your repo, go to Settings → Secrets and variables → Actions:
- Add variable:
STAQD_APP_ID= your App ID - Add secret:
STAQD_APP_PRIVATE_KEY= contents of the.pemfile
- Add variable:
For full control over per-job runners, permissions, and custom steps, use the composite action directly:
name: Staqd
on:
pull_request:
types: [opened, edited, closed]
issue_comment:
types: [created]
jobs:
# Guide comment — no concurrency needed
guide:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
pull-requests: write
issues: write
steps:
- uses: siner308/staqd@v1
# Resolve base branch for concurrency group
resolve-base:
if: >-
github.event_name == 'issue_comment'
&& github.event.issue.pull_request
&& (startsWith(github.event.comment.body, 'stack ') || startsWith(github.event.comment.body, 'st '))
runs-on: ubuntu-latest
permissions:
pull-requests: read
outputs:
base-ref: ${{ steps.get.outputs.base-ref }}
steps:
- id: get
uses: actions/github-script@v7
with:
script: |
const { data: pr } = await github.rest.pulls.get({
...context.repo,
pull_number: context.payload.issue.number,
});
core.setOutput('base-ref', pr.base.ref);
# Execute command — serialized per base branch
command:
needs: resolve-base
concurrency:
group: staqd-${{ needs.resolve-base.outputs.base-ref }}
cancel-in-progress: false
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: write
pull-requests: write
issues: write
checks: read
steps:
- uses: siner308/staqd@v1| Input | Description | Required | Default |
|---|---|---|---|
token |
GitHub token for API calls and git operations. Use a PAT or GitHub App token to enable CI auto-trigger after force push. | No | github.token |
app-id |
GitHub App ID. Used with app-private-key to generate an installation token. |
No | |
app-private-key |
GitHub App private key. Used with app-id to generate an installation token. |
No |
Comment on a PR to trigger:
| Command | Action |
|---|---|
stack merge (st merge) |
Auto-discover stack, merge this PR, restack child branches, and delete the merged branch |
stack merge-all (st merge-all) |
Auto-discover stack, merge the entire stack in order, deleting each branch after merge (all PRs must be approved) |
stack merge-all --force (st merge-all --force) |
Same as merge-all but skips the approval check |
stack restack (st restack) |
Restack entire stack recursively |
stack discover (st discover) |
Auto-discover stack tree from base branches and update metadata |
stack help (st help) |
Show usage |
Tip: All commands support the short alias
st— e.g.,st mergeinstead ofstack merge.
git checkout main
git checkout -b feat-auth
# ... make changes, push ...
git checkout feat-auth
git checkout -b feat-auth-ui
# ... make changes, push ...
git checkout feat-auth-ui
git checkout -b feat-auth-tests
# ... make changes, push ...gh pr create --base main --head feat-auth --title "feat: add auth module" # → PR #1
gh pr create --base feat-auth --head feat-auth-ui --title "feat: add auth UI" # → PR #2
gh pr create --base feat-auth-ui --head feat-auth-tests --title "test: add auth tests" # → PR #3Option A: Auto-discover (recommended)
Comment on the root PR:
st discover
Staqd scans all open PRs by base branch relationships, builds the tree, and updates every PR's metadata automatically. It also detects if any children need restacking:
### Stack Discovered
Found 3 PR(s) in the stack:
- #1 (`feat-auth`)
- #2 (`feat-auth-ui`) ⚠️
- #3 (`feat-auth-tests`)
All PR metadata has been updated.
> ⚠️ Some PRs are out of date with their parent branch.
> Run `st restack` on the parent PR to rebase.
Option B: Manual metadata
Each parent PR's body needs an HTML comment listing its direct children:
# PR #1 body — child is PR #2
gh api repos/{owner}/{repo}/pulls/1 --method PATCH --input - <<'EOF'
{"body": "Add auth module\n\n<!-- stack-rebase:{\"children\":[{\"branch\":\"feat-auth-ui\",\"pr\":2}]} -->"}
EOF
# PR #2 body — child is PR #3
gh api repos/{owner}/{repo}/pulls/2 --method PATCH --input - <<'EOF'
{"body": "Add auth UI\n\n<!-- stack-rebase:{\"children\":[{\"branch\":\"feat-auth-tests\",\"pr\":3}]} -->"}
EOF
# PR #3 — no children, no metadata neededNote: Use
gh apiinstead ofgh pr edit --bodybecause some shells strip HTML comments.
# During development — sync children after pushing to parent
Comment on PR #1: "stack restack"
# After review — merge the entire stack at once
Comment on PR #1: "stack merge-all"
<!-- stack-rebase:{"children":[{"branch":"<branch>","pr":<number>}]} -->Only list direct children. merge-all recursively follows each child's metadata.
Linear stack (A → B → C):
PR #1 body: <!-- stack-rebase:{"children":[{"branch":"B","pr":2}]} -->
PR #2 body: <!-- stack-rebase:{"children":[{"branch":"C","pr":3}]} -->
PR #3 body: (none)
Tree stack (X and Y branch from base):
PR #10 body: <!-- stack-rebase:{"children":[{"branch":"X","pr":11},{"branch":"Y","pr":12}]} -->
PR #11 body: (none)
PR #12 body: (none)
Tree children are siblings — each rebases independently onto the same parent.
Everything reduces to one git command:
git rebase --onto <new_base> <old_parent_tip_sha> <child_branch>
Before (feat-1 squash-merged into main):
main: A───B───CD' (C+D squashed into new commit)
feat-2: C───D───E───F (still contains original C, D)
After:
git rebase --onto main <SHA_of_D> feat-2
main: A───B───CD'
\
feat-2: E'───F' (C, D removed; only E, F rebased)
The skip SHA (old_parent_tip_sha) comes from GitHub's PR API, which preserves head.sha even after merge. No database needed.
sequenceDiagram
participant Dev as Developer
participant PR as PR Comment
participant GA as GitHub Actions
participant Git as Git / GitHub API
Dev->>PR: stack merge
PR->>GA: Trigger workflow
GA->>Git: Merge PR (squash)
GA->>Git: git fetch origin
GA->>Git: git rebase --onto main skip_sha child
GA->>Git: git push --force-with-lease
GA->>PR: Post result comment
Note over Dev,Git: merge-all repeats this for each child (DFS)
When a conflict occurs, the bot posts manual fix commands:
Restack: Action Needed
| Branch | PR | Status |
|--------|-----|--------|
| feat-auth-ui | #2 | Restacked |
| feat-auth-tests | #3 | Conflict |
Manual restack commands:
git fetch origin
git rebase --onto origin/feat-auth-ui <skip_sha> feat-auth-tests
# resolve conflicts
git push origin feat-auth-tests --force-with-lease
Why HTML comments in PR body?
- No metadata files polluting the codebase
- Readable/writable via GitHub API — no separate storage
- Invisible when rendered — no noise for reviewers
- PR body is deterministic (one per PR); comments checked as fallback
Why only direct children (not all descendants)?
- Enables tree-shaped stacks where one parent has multiple children
- All children are siblings — each rebases independently (no chaining)
merge-allrecursively traverses each child's metadata (DFS)
Why different --onto targets for restack vs merge?
| Scenario | --onto target |
Reason |
|---|---|---|
stack restack (pre-merge) |
origin/<parent_branch> |
Must include parent's new commits |
stack merge (post-merge) |
origin/main |
Parent already merged into main |
stack merge-all |
origin/main |
Each PR merges into main sequentially |
Why --force-with-lease?
Rejects the push if the remote ref changed since last fetch. Prevents overwriting commits pushed by others.
Why retry merge API instead of polling checks API?
- Check suite names vary by repo — would require configuration
- Merge API already reports "CI still pending" as an error
- Retries every 30s, up to 20 times (~10 min)
Why per-base-branch concurrency?
concurrency:
group: staqd-${{ needs.resolve-base.outputs.base-ref }}
cancel-in-progress: falseCommands targeting the same base branch (e.g. main) are serialized to prevent race conditions and force-push conflicts. Independent stacks targeting different base branches run in parallel. cancel-in-progress: false creates a FIFO queue per base branch.
| Staqd | Graphite | ghstack | |
|---|---|---|---|
| Installation | None (workflow file) | CLI + account | CLI |
| Stack storage | PR body HTML comments | .graphite_info + server |
Commit metadata |
| Restack trigger | PR comment | gt restack CLI |
ghstack CLI |
| Conflict resolution | Async (Actions → local fix → push) | Sync (terminal) | Sync (terminal) |
| Tree stacks | Supported (siblings) | Supported (DAG) | Linear only |
| Merge queue | Comment-based | Dedicated UI | None |
| External dependency | None | SaaS | None |
When a parent PR is merged through the GitHub UI (instead of st merge), Staqd automatically:
- Updates each child PR's base branch to the parent's base (e.g.,
main) - Posts a notification comment on each child PR
This prevents child PRs from becoming orphaned with a deleted base branch. The child PR may still need rebasing — run st restack on the child if needed.
Requires
closedin your workflow'spull_request.types:pull_request: types: [opened, edited, closed]
CI doesn't run after restack push:
GITHUB_TOKEN pushes don't trigger other workflows. Set up a GitHub App or PAT.
merge-all stops at a conflict:
The result table shows the merge order and which PRs failed. Fix the conflict locally, push, then run st merge on the failed PRs in the order shown (e.g., Fix the issue and run \st merge` in order: #3 → #5`).
merge-all CI timeout:
Default wait is ~10 min (30s × 20 retries). Increase tryMerge retry count for slower CI.
PR diff looks wrong after restack:
The PR's base branch may not have been updated. stack restack handles this automatically, but you can manually change the base in PR settings.
Second PR in merge-all always fails initially:
If branch protection has required checks, CI may not exist yet right after restack. tryMerge retries until CI is created and passes.
MIT