Skip to content

siner308/staqd

Repository files navigation

Staqd

Staqd

License: MIT GitHub release

/stakt/ — like "stacked"

Stacked PR merge queue powered by GitHub Actions and PR comments.

Usage

Basic

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@v1

That's it. Each workflow triggers only on its relevant event, keeping PR checks clean and focused.

With GitHub App (recommended for CI auto-trigger)

.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.yml is deprecated. Use the two-file setup above.

Reusable Workflow Inputs

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

Reusable Workflow Secrets

Secret Description Required
app-private-key GitHub App private key. Used with app-id input. No

Why a GitHub App? GITHUB_TOKEN pushes 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
  1. Go to GitHub Settings → Developer settings → GitHub Apps → New GitHub App
  2. Fill in:
    • App name: e.g., staqd-bot
    • Homepage URL: your repo URL
    • Webhook: uncheck "Active" (not needed)
  3. Set Repository permissions:
    • Contents: Read & write
    • Pull requests: Read & write
    • Issues: Read & write
    • Checks: Read
  4. Click Create GitHub App
  5. Note the App ID from the app's settings page
  6. Click Generate a private key and download the .pem file
  7. Click Install App and install it on your repository
  8. 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 .pem file

Advanced: Direct Composite Action

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

Composite Action Inputs

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

Commands

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 merge instead of stack merge.

Setting Up a Stack

1. Create stacked branches

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 ...

2. Create PRs with correct base branches

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 #3

3. Add stack metadata

Option 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 needed

Note: Use gh api instead of gh pr edit --body because some shells strip HTML comments.

4. Use commands

# 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"

Metadata Format

<!-- 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.

How It Works

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.

Workflow

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)
Loading

Handling Conflicts

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

Design Decisions

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-all recursively 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: false

Commands 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.

Alternatives

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

Auto-update on Manual Merge

When a parent PR is merged through the GitHub UI (instead of st merge), Staqd automatically:

  1. Updates each child PR's base branch to the parent's base (e.g., main)
  2. 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 closed in your workflow's pull_request.types:

pull_request:
  types: [opened, edited, closed]

Troubleshooting

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.

License

MIT

About

Stacked PR merge queue powered by GitHub Actions and PR comments. No CLI, no SaaS.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors