A minimal, standalone demonstration of a fully autonomous issue → draft PR → review → fix → merge pipeline built on gh-aw (GitHub Agentic Workflows).
GH-AW, like the underlying raw Github Actions is a very flexible system, this demonstrates one possible pipeline.
Rather than forking this whole repo (see "Adapting to your project" below), you can
install it as a gh-aw package: gh aw add cgwalters/gh-agentic-workflows@v0.1.0 pulls in
drafter.md, review.md, and fix.md, compiling them fresh against your repo, and
copies merge.yml and install-labels.yml over verbatim. The installed .md files
arrive hardcoded to this repo's bot identity, so before anything will actually trigger you
still need to:
- Edit the
bots:/trusted-users:fields in the three installed.mdfiles to reference your own GitHub App's bot slug instead ofcgwaltersbot[bot]. - Register your own GitHub App and configure
GH_AW_APP_CLIENT_ID/GH_AW_APP_PRIVATE_KEY. - Run the
install-labels.ymlworkflow (orscripts/install-labels.js) to create the required labels.
See "Repository setup checklist" below for the full details on each of these steps.
Four stages, each a separate workflow:
issue labeled pull_request label: agent/fixme label: agent/lgtm
'agent/code' opened/synchronize (pull_request labeled) (pull_request labeled)
│ │ │ │
▼ ▼ ▼ ▼
drafter.md ──opens PR──▶ review.md ──labels──▶ fix.md ──pushes──▶ (back to review.md)
(gh-aw agent) (gh-aw agent) (gh-aw agent)
│
└──labels 'agent/lgtm'──▶ merge.yml
(plain Actions workflow)
drafter.md— triggers when an issue is labeledagent/code. Reads the issue, implements the change, validates it with whatever the repo provides, and opens a draft pull request on anagent/*branch via gh-aw'screate-pull-requestsafe-output.review.md— triggers onpull_request: [opened, synchronize]foragent/*branches. Reviews the diff, posts aCOMMENTreview with concrete feedback, and applies exactly one ofagent/fixme(needs work) oragent/lgtm(ready to merge), removing the other if present.fix.md— triggers on theagent/fixmelabel. Consumes the label (removes it so it can't retrigger itself), reads the reviewer's feedback from the PR's reviews, pushes a fix commit to the same branch viapush-to-pull-request-branch, and stops. Pushing a new commit firesreview.mdagain, closing the loop. An iteration cap stops the loop after 2 automated fix attempts and posts a PR comment asking a human to take over instead of silently doing nothing — see "Troubleshooting and operations" below.merge.yml— triggers on theagent/lgtmlabel. A deliberately plain, non-gh-aw Actions workflow: merging is mechanical once the reviewer has already made the judgment call, so no LLM is involved. Marks the draft PR ready, squash-merges it, and deletes the branch.
A few things here are non-obvious and were hard-won getting this to actually work:
github-app:is safe at thesafe-outputs:root — that's specific to how gh-aw handles App auth. gh-aw mints the App installation token only inside the trusted safe-outputs job (after the untrusted agent job has already finished), so declaringgithub-app:once at thesafe-outputs:root applies to every handler below it without ever reaching the agent job's sandbox. This is not generally true of a raw token string: the earlier version of this repo used a PAT (GH_AW_CI_TRIGGER_TOKENviagithub-token: ...), which had to be nested under each individual safe-output key (e.g.add-labels: github-token: ...) instead of the root, precisely because a root-level plaingithub-token:leaks into the untrusted agent job's environment.github-app:doesn't have that problem, so it belongs at the root.label_command:plus a custom top-levelif:drops the label match. gh-aw'slabel_command:trigger is the natural-looking choice for "run when label X is applied," but combining it with a custom top-levelif:(needed here to also check theagent/*branch prefix) silently drops the trigger's own label-name condition — the workflow ends up firing on any label change.fix.mdworks around this with a plainpull_request: types: [labeled]trigger and an explicitif: github.event.label.name == 'agent/fixme'check instead.- Draft PRs need an explicit "ready" step.
drafter.mdopens PRs as drafts, andgh pr mergerefuses drafts outright.merge.ymlcallsgh pr readybeforegh pr merge. - Plain workflows don't get gh-aw's fork guard for free. gh-aw workflows come with
an automatic fork-origin guard; a plain Actions workflow like
merge.ymldoes not. If your repository could ever receive fork pull requests — which matters even for private repos, since GitHub still hands secrets to fork-originatedpull_requestruns — add an explicit check yourself, e.g.github.event.pull_request.head.repo.id == github.repository_id.merge.ymlin this repo already includes it; keep it if you adapt the file. - A GitHub App bot actor always fails gh-aw's default role check. gh-aw's
pre_activationjob gates every run onon.roles(defaultadmin,maintainer,write), checked viaGET /repos/{owner}/{repo}/collaborators/{username}/permission. A GitHub App installation isn't a collaborator in that ACL model — its access comes from the installation grant, not collaborator status — so that endpoint always reportsnonefor an App bot actor, and the check always fails. Sincereview.mdandfix.mdtrigger onpull_requestevents that can be authored or labeled by the shared App bot itself (drafter.md's PR, or fix.md's own labeling via review.md'sadd-labels), both needon.bots: ["cgwaltersbot[bot]"]to bypass the role check for that specific identity. The failure mode is deceptive: thepre_activationjob reports success (it did its job, correctly gating the run off) while everything downstream silently never runs — the overall run conclusion looks fine, so you have to check job-level status, not run-level status, to notice it. - Pin the gh-aw compiler and the agent's model. gh-aw's CLI auto-upgrades by default,
and an upgrade once shipped a compiler whose AI-credits pricing table had fallen out of
sync with Anthropic's current model line — agents started failing with errors like
<model> has no AI credits pricing. The fix: pin the extension to a known-good version (gh extension install github/gh-aw --pin v0.81.6, asci.ymldoes) and setengine.modelexplicitly in each.mdfile's frontmatter to an exact dated model ID (e.g.claude-sonnet-4-5-20250929) instead of a floating alias, then recompile. If workflows that were working suddenly start failing with a pricing-lookup error, this is almost certainly why. - A per-workflow
agent/*-workinglabel is added and removed via frontmatterjobs:, not safe-outputs. Each workflow carries its own working label on the issue/PR for the duration of a run —agent/draft-working(drafter.md),agent/review-working(review.md),agent/fix-working(fix.md) — using two custom jobs declared directly in each.mdfile'sjobs:block:add_working_label(depends onpre_activation, which gh-aw's compiler automatically threads intoactivation's own dependencies, soagenttransitively waits for it) adds the label, andremove_working_labelremoves it, gated onneeds.pre_activation.outputs.activated == 'true'so a run only clears the label it actually set (not another workflow's, and not one it never added because its own activation gate failed). The labels are per-workflow rather than shared:review.mdandfix.mdboth used to add/remove a singleagent/workinglabel on the same PR, and a run whose real work was skipped could still unconditionally strip the other workflow's still-in-progress signal — confirmed live, wherefix.md's cleanup removed the label 11 seconds afterreview.mdset it, whilereview.md's agent job kept running several more minutes. safe-outputs handlers only run when the agent job succeeds, which can't guarantee cleanup on failure or timeout — a plain job withif: always()(further gated as above) can.
drafter.md and fix.md both default to gh-aw's request_review policy for protected
files (README.md, the workflow definitions themselves, .github/aw/actions-lock.json,
etc.): the PR still gets opened, but with a mandatory REQUEST_CHANGES review blocking
merge until a human looks at those specific files. This is what issue-driven runs hit if
the requested change happens to touch, say, README.md — see e.g.
#19.
Sometimes you know a task legitimately needs to touch protected files — renaming a
label consistently across README.md and the .md/.lock.yml workflow sources is a
good example. For that, both files' protected-files.policy is a GitHub Actions
expression string
rather than a bare literal:
protected-files:
policy: ${{ case(contains(github.event.issue.labels.*.name, 'agent/workflow-edits-allowed'), 'allowed', 'request_review') }}Note this uses case() rather than the more common cond && 'allowed' || 'request_review'
ternary idiom: gh-aw's compiler JSON-encodes this policy string with Go's default
HTML-escaping, which turns a literal && into \u0026\u0026 inside the heredoc-embedded
config blob. GitHub Actions' expression parser can't parse that, so the whole compiled
workflow gets rejected as invalid on push — see
the fix for #21.
case() avoids &, <, and > entirely, so it survives serialization intact.
Applying the agent/workflow-edits-allowed label to the issue — before or alongside
agent/code — pre-authorizes that specific drafter.md run to write protected files
without the review gate; fix.md checks the same label on the pull request instead, so
either the drafter PR needs to carry it too (a human can add it after the fact) for
follow-up fix commits to get the same treatment. Crucially, this is a human decision made
before the agent runs, not something the agent can grant itself: the label has to
already be present on the triggering issue/PR when the event fires, so a prompt-injected
or misbehaving agent can't unlock this on its own mid-run. The expression falls back to
request_review whenever the label is absent, which keeps that the default for every
ordinary run.
Separately from the protected-files policy above, GitHub enforces a hard server-side rule:
a GitHub App-authenticated push that touches anything under .github/workflows/ is
rejected outright unless the token minting the push was granted the workflows
permission — even if the App installation itself has that permission enabled. gh-aw's
create-github-app-token step only requests contents/issues/pull-requests by
default, so any run that legitimately edits workflow files (like the label rename above)
fails at the git push step with refusing to allow a GitHub App to create or update workflow ... without \workflows` permission`, and falls back to filing an issue instead
of opening a PR — see #21.
Both drafter.md and fix.md request the extra scope explicitly via safe-outputs.github-app.permissions:
github-app:
client-id: ${{ vars.GH_AW_APP_CLIENT_ID }}
private-key: ${{ secrets.GH_AW_APP_PRIVATE_KEY }}
permissions:
workflows: writegh-aw filters GitHub content an agent reads by integrity: on a public repo, anything
not authored by an OWNER/MEMBER/COLLABORATOR (or a first-party bot like Dependabot)
defaults to below the approved threshold and is silently dropped before the agent sees
it — see gh-aw's integrity filtering docs.
Pull requests get a pass (any non-fork PR on a public repo counts as approved
regardless of author), but plain issues don't — including the fallback issues our own
cgwaltersbot[bot] files when a safe-output push is rejected (e.g.
#26).
That means relabeling one of those fallback issues to retry a task doesn't work: the
agent can see the label but gets a filtered, empty view of the issue body describing
what to actually do, and correctly no-ops. All three workflows list the bot in
tools.github.trusted-users so its own output is treated as trusted input:
tools:
github:
min-integrity: approved
trusted-users: ["cgwaltersbot[bot]"]The right way to retry a rejected task is still to fix the label order (see above) or apply the bundle manually on the original issue/PR — relabeling the fallback issue is now at least readable by the agent, but it was never the intended recovery path.
-
Create seven labels:
agent/code,agent/fixme,agent/lgtm,agent/draft-working,agent/review-working,agent/fix-working,agent/workflow-edits-allowed(see "Letting the agent edit protected files" above). The threeagent/*-workinglabels just need to exist; their color is cosmetic (see "a per-workflowagent/*-workinglabel is added and removed via frontmatterjobs:" above).The easiest way is to run the included install script via the Install Labels workflow in the Actions tab, or manually via:
gh label create "agent/code" --color 0E8A16 \ --description "Triggers the autonomous drafter agent" gh label create "agent/fixme" --color D93F0B \ --description "Reviewer agent found issues that need fixing" gh label create "agent/lgtm" --color 0E8A16 \ --description "Reviewer agent approved; ready to auto-merge" gh label create "agent/draft-working" --color FBCA04 \ --description "The drafter agent is actively working on this issue" gh label create "agent/review-working" --color FBCA04 \ --description "The review agent is actively working on this PR" gh label create "agent/fix-working" --color FBCA04 \ --description "The fix agent is actively working on this PR" gh label create "agent/workflow-edits-allowed" --color 5319E7 \ --description "Pre-authorizes agent runs to edit protected files without the request_review gate"
See
scripts/README.mdfor more installation options. -
Register a GitHub App to act as the pipeline's bot identity (this must be a real App, not the default
GITHUB_TOKEN— see "GITHUB_TOKEN doesn't retrigger workflows" above):- Go to Settings → Developer settings → GitHub Apps → New GitHub App.
- Grant repository permissions: Contents (Read & Write), Issues (Read & Write), Pull
requests (Read & Write), Workflows (Read & Write). Metadata (Read) is auto-granted.
Workflows is easy to miss since nothing in this repo's own
permissions:blocks needs it — it's only required becausedrafter.md/fix.mdpush commits that touch.github/workflows/*(see "The App token also needs an explicitworkflowspermission" above). - Set the webhook to inactive — it isn't needed, since this App is only used to mint API tokens for Actions, not to receive events.
- Set installability to "Only on this account".
- After creating it, capture the Client ID (not the numeric App ID) from the App's General settings page.
- Generate and download a private key from the same page.
- Install the App on this specific repository.
- Store the credentials on the repo:
gh variable set GH_AW_APP_CLIENT_ID --body "<client-id>"(a non-secret repo variable) andgh secret set GH_AW_APP_PRIVATE_KEY < path/to/key.pem(a secret).
(The App used for this specific demo instance is
cgwaltersbot; the steps above are written generically so you can register your own App if adapting this repo.) -
Add
bots: ["<your-app-slug>[bot]"]to theon:blocks ofreview.mdandfix.md(notdrafter.md— see "Design notes and gotchas" above), matching your App's actual bot slug. Without this, both workflows'pre_activationjob will always reject the shared App bot's role check and silently no-op forever. -
Add the same
<your-app-slug>[bot]name totools.github.trusted-usersin all three.mdfiles (see "Trusting the pipeline's own bot" above), so agents can read content the bot itself files, like fallback issues. -
Make sure
gh aw compileruns cleanly against the.mdfiles (see below) — rungh aw compile drafter review fix --approveafter any workflow edit. Pin the gh-aw extension version when you do (see "Design notes and gotchas" above). -
If validating your project needs network access beyond gh-aw's engine defaults — e.g.
cargo test/cargo checkreaching a crate registry, or any other compiled language's package manager — add anetwork:block todrafter.md's andfix.md's frontmatter. gh-aw's agent jobs run behind an egress firewall whose defaults don't include most non-npm package registries; without this, an agent's own build/test commands can silently stall or fail (they may still open a PR anyway, having punted verification to CI). See gh-aw's network documentation for the exact syntax, e.g.:network: allowed: - defaults - rust # or your ecosystem's identifier, or explicit domains
How to tell if a PR is stuck. fix.md's iteration cap (3, i.e. 2 automated fix
attempts) is enforced by counting commits on the PR via gh pr view --json commits. When
that cap is hit, fix.md removes agent/fixme (so it can't retrigger) and posts a PR
comment explaining that automated fixing has stopped, but does not apply any label. A
PR that has neither agent/fixme nor agent/lgtm, but does have a bot comment about the
iteration cap, is stuck and waiting on a human — it is not "in progress," and nothing
will move it forward on its own.
What not to do. Toggling the originating issue's agent/code label off and back on
does not resume work on the stuck PR — drafter.md triggers on that label and will
open a brand-new, separate PR for the same issue, leaving the original stuck PR untouched
and orphaned. Don't do this; it just produces a duplicate.
How to actually rescue a stuck PR. Because the iteration cap is a running commit
count on the branch that only ever grows, re-applying agent/fixme to a capped PR does
not give the loop a clean extra attempt: fix.md will immediately see the same (or
higher) commit count, hit the cap again, and post another comment without ever touching
the code. The reliable options are:
- Push a fix commit to the branch yourself, then apply
agent/lgtmdirectly once you're confident it's ready — this bypassesfix.md/review.mdentirely and goes straight tomerge.yml. - Or just close the PR if it's not worth pursuing further.
Note that the first option isn't just the preferred way to get a fresh judgment on a
manually-pushed commit — it's the only way. review.md (like fix.md) refuses to
activate unless the actor that triggered its synchronize/labeled event matches the
PR's original author, even for a repo admin; this is deliberate (it stops a human from
smuggling commits onto a bot-authored branch to inherit the bot's min-integrity: approved/trusted-users treatment — see "Trusting the pipeline's own bot" above). So a
human push to an agent/* branch will never cause review.md to re-review it, no matter
who pushes it or what role they have; merge.yml has no such check (it's a plain
mechanical workflow gated only by the agent/lgtm label, not an agent job), which is
exactly why applying that label by hand is the supported rescue path, not a workaround.
(If you really want the automated fix loop itself to run again rather than finishing by
hand, you'd need to reduce the branch's commit count back below the cap first, e.g. by
squashing the existing commits, before re-applying agent/fixme — at that point you're
almost as far along as just finishing the fix yourself, so this is rarely worth it.)
Verifying the pipeline end to end. tests/e2e.sh scripts the manual procedure for
exercising the full loop against a live repository: it files a fresh issue, then polls
the real GitHub API and reports each stage — PR opened, review posted, agent/fixme/
agent/lgtm applied, fix commit pushed, re-review, merge — as it happens.
./tests/e2e.sh --repo cgwalters/gh-agentic-workflows --scenario needs-fix
This is not a unit test: it burns real Anthropic API credits against the live
workflows, and a full needs-fix round trip (drafter, review, fix, re-review, merge) has
been observed to take 20 minutes or more. It's a manual tool for a human — or an agent, on
explicit request — to run deliberately; it is not wired into any Actions trigger. See
./tests/e2e.sh --help for the full set of flags and the clean scenario (no deliberate
gap, expected to reach agent/lgtm on the first review).
Copy .github/workflows/{drafter,review,fix}.md (+ their compiled .lock.yml
counterparts) and merge.yml.
Safe to edit: the prompt bodies under each --- frontmatter block — the task
description, validation instructions, and review criteria are all plain English and
should be tailored to your repo's conventions and tooling.
Leave alone unless you know what you're changing: the on:/if: triggers, the
safe-outputs: blocks and their token scoping, and the agent/fixme/agent/lgtm/
agent/code label names. If you do rename a label, update it consistently across
review.md, fix.md, and merge.yml — the pipeline depends on all three agreeing on
the same names.
After editing any .md file, recompile with:
gh aw compile drafter review fix --approve
The pipeline lives entirely in
.github/workflows/drafter.md,
review.md, fix.md, and
merge.yml — see "How it works" above for what each does.
The matching *.lock.yml files are gh-aw's compiled output, checked in as generated
artifacts.
This is a sibling demo to cgwalters/merge-queue-like-bors: same spirit of solving a real GitHub Actions automation gap with a minimal, well-documented, standalone workflow instead of reaching for a heavyweight external tool.
It builds directly on gh-aw (GitHub Agentic
Workflows), which provides the underlying agent execution model, sandboxing, and
safe-outputs used by drafter.md, review.md, and fix.md.
It's also an exploration of the same category of problem that fullsend addresses — autonomous triage/implement/review/merge pipelines for Git-hosted projects — using a much smaller surface area: a few gh-aw Markdown workflows and one plain Actions workflow, rather than a dedicated platform. It's not a replacement for fullsend; it's a worked example of how far you can get with the primitives GitHub already gives you, and the platform quirks you run into along the way.
This is a demo/reference implementation, not a hardened product. There's no dashboard, no multi-repo support, and the iteration cap and validation steps are intentionally simple.
This repository was built and tested with OpenCode-coordinated agents: one drafting the workflows, another reviewing the first's work, and the pipeline itself then exercised end to end against real issues and PRs. It's a fittingly self-referential demo — the artifact is a pipeline of AI agents drafting, reviewing, and fixing code, and it was built the same way.
Licensed under either of Apache License, Version 2.0 or MIT license at your option.