https://github.com/siner308/staqd
Stacked PR merge queue powered by GitHub Actions and PR comments. No CLI, no SaaS.
https://github.com/siner308/staqd
automation code-review developer-tools git-rebase github-actions merge-queue pull-requests stacked-prs
Last synced: 3 months ago
JSON representation
Stacked PR merge queue powered by GitHub Actions and PR comments. No CLI, no SaaS.
- Host: GitHub
- URL: https://github.com/siner308/staqd
- Owner: siner308
- License: mit
- Created: 2026-02-11T00:00:04.000Z (4 months ago)
- Default Branch: main
- Last Pushed: 2026-03-03T08:35:18.000Z (4 months ago)
- Last Synced: 2026-03-03T11:21:36.577Z (4 months ago)
- Topics: automation, code-review, developer-tools, git-rebase, github-actions, merge-queue, pull-requests, stacked-prs
- Language: JavaScript
- Homepage: https://siner308.github.io/staqd
- Size: 1.24 MB
- Stars: 3
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
Staqd
**/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:
```yaml
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:
```yaml
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`:
```yaml
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`:
```yaml
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:
```yaml
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
```bash
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
```bash
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:
```bash
# PR #1 body — child is PR #2
gh api repos/{owner}/{repo}/pulls/1 --method PATCH --input - <<'EOF'
{"body": "Add auth module\n\n"}
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"}
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
```html
```
Only list **direct children**. `merge-all` recursively follows each child's metadata.
**Linear stack** (A → B → C):
```
PR #1 body:
PR #2 body:
PR #3 body: (none)
```
**Tree stack** (X and Y branch from base):
```
PR #10 body:
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
```
```
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 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
```mermaid
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)
```
## 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 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/` | 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?
```yaml
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`:
> ```yaml
> 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