- Stack: TypeScript ESM (NodeNext,
strict+noUncheckedIndexedAccess+exactOptionalPropertyTypes), Node 22+, pnpm. Vitest +@vitest/coverage-v8. ESLint (strict-type-checked + unicorn) + Prettier. VitePress for docs. Changesets for releases. - Two surfaces: a CLI (
ghaar) and a composite GitHub Action. Both share the same domain core undersrc/core/. - Domain: scan GitHub Actions workflow run annotations across the latest run per workflow, fingerprint them stably, file/update/reopen/auto-close issues based on history. The auto-close decision is guarded by miss counter + age window + (default) source-run-success.
- Fingerprint: sha256(
workflowPath \0 annotationPath \0 normalizedMessage). Line numbers are intentionally NOT part of the preimage. - Persistence: all state lives in HTML-comment markers in the issue body (
annot-id,annot-managed-by,annot-state). No external datastore. dist/is gitignored, npm-shipped: the action runs vianpx -y -p github-actions-annotations-reporter@<version> ghaar, so it pulls the published package (whosefiles: ["dist", …]shipsdist/). Locally and in CI we rebuild viapnpm buildwhenever we needdist/cli.js. Don't commitdist/.
These came out of past sessions where things slipped through to CI. Follow them every time; they're cheap on the local machine and expensive when CI catches them.
Run all four, in this order, and fix anything red before committing:
pnpm format:check # prettier — easy to miss because `pnpm lint` does NOT cover it
pnpm lint
pnpm typecheck
pnpm test # or pnpm test:coverage when the change touches src/pnpm format:check is separate from pnpm lint and easy to forget when you've only edited markdown/docs. CI runs both — so do you.
If the change touched src/cli.ts (flags, descriptions, defaults), also run pnpm docs:gen-cli to regenerate docs/reference/cli.md, and commit the regenerated file alongside the source change. CI doesn't gate on this regen, so a miss ships silently. Run node dist/cli.js --help or pnpm dev -- --help as a final sanity-check on the prose the user actually sees.
When tests or lint fail mid-task, fix and re-run; do not commit "WIP" or "skip CI" unless the user explicitly asks for it. A commit that fails CI wastes a full matrix run.
For any work that's more than a one-line change:
- Call
TaskCreatefor each discrete step before you start coding. - Mark
in_progresswhen you pick it up. - Mark
completedimmediately when it's done — don't batch. - If you discover work mid-task, create new tasks rather than silently expanding scope.
This is what the user sees as progress; without it, the session feels opaque.
Changesets exist for changes the user (= the npm/Action consumer) will notice or care about. Examples that warrant an entry: new flags, behavior changes, bug fixes visible from the outside, breaking renames. Examples that do not warrant an entry, and should never end up in CHANGELOG.md: process / workflow / CLAUDE.md updates, internal refactors that preserve public behavior, test-only changes, lockfile bumps, dev-dependency upgrades, CI tweaks, formatting passes. When unsure, ask: "would this change appear in the changelog of any other npm package shipping the same fix?" — if not, no changeset.
When a change does warrant a changeset, always ask the user before adding or modifying one via AskUserQuestion, with the proposed entry body in the option preview. This is the same shape as commit confirmation:
- Question:
"Add (or extend) a changeset entry for this change?" - One option
Approvewhosepreviewis the full proposed Markdown — frontmatter ('github-actions-annotations-reporter': major|minor|patch) plus the body. - One option
Alter— when chosen, ask what to change, then re-issue. - One option
Skip— explicitly choose to ship the change with no changeset entry (the right call for non-user-visible work). - One option
Cancel— back out without committing the calling change either.
Don't create a .changeset/*.md file from Bash before this prompt has been approved.
Treat every diff as if you're reviewing it for a PR with strict standards. Four lenses:
- Off-by-one, null handling, untested edge cases.
- Cross-platform paths (Windows uses
\; we display POSIX). Usepath.relative+toPosixPath(seesrc/utils/paths.ts) for any path shown to a human or test. - Race conditions / shared mutable state across async paths.
- Partial-failure surfacing: a single annotation fetch erroring on one job should not blow up the whole scan — the collector should keep going and report what it could fetch.
- Deterministic tests: snapshot fixtures must not depend on
process.cwd()or wall-clock time. Pass an explicitnow: Dateto time-sensitive code (the reconciler / auto-close policy already accept this).
- Don't pull in heavy dependencies for trivial helpers; the project's existing utilities (
toPosixPath,globToRegex,normalizeMessage) cover most cases. - GitHub API calls go through
src/core/github/client.ts. Tests mock at the Octokit boundary viatests/helpers/fake-octokit.ts— not at the HTTP layer. Preserve that pattern when adding new fetchers. - Persistent state lives in the issue body via HTML comments (
annot-id,annot-state). Don't introduce a side-channel cache, KV store, or workflow artifact — the in-body markers are the source of truth.
This tool reads GitHub data, writes issues, and runs inside Actions runners with workflow-provided tokens. Every change must be evaluated against these:
- No shell. Use
execFile/spawndirectly with an args array — neverexec, neverspawn(cmd, { shell: true })with non-constant input. - No command injection via configuration. Workflow names, glob patterns, label names, and regex patterns are all attacker-controllable in malicious repos. Never interpolate them into a shell command, env var name, URL position, or filesystem path.
- No prototype pollution. When parsing user input (configs, issue bodies, annotations), prefer
Mapover plain objects, or useObject.create(null). NeverObject.assigna parsed object onto a config object. - No regex DoS. Any new regex applied to user input must be linear-time. The
wontfix.commentPatternis the highest-risk surface — it's compiled from user config and run against arbitrary issue comments. Caps are enforced insrc/core/wontfix-detector.ts:MAX_PATTERN_LENGTH = 1000(oversized patterns are rejected, treated as non-match) andMAX_COMMENT_LENGTH = 64 * 1024(the comment is truncated before regex evaluation, bounding worst-case cost). Invalid regex is fail-safe (no match). Preserve all three guarantees when changing the detector. - Credentials hygiene. GitHub tokens come through
GITHUB_TOKEN/GH_TOKENenv vars orgh auth token. Never log, never write to disk, never pass beyond the in-memoryOctokitinstance. TheresolveAuthchain (src/core/auth.ts) is the only legitimate token-handling code. - Supply chain. When bumping a dependency, check the GitHub Dependabot tab on the remote afterward — moderate advisories should land as
pnpm.overridesentries before merging. action.ymlcomposite-action security. All inputs reach the bash dispatcher throughenv:blocks (never inline${{ }}inrun:), then onto anargs=()array so npx receives them as separate argv entries without shell re-parsing. Theversioninput is allowlist-validated (^[A-Za-z0-9][A-Za-z0-9._+-]*$) before reachingnpx— this prevents tarball/git-URL/alias forms that would resolve to arbitrary code.- Workflow injection in our own CI. The project's own
.github/workflows/*.ymlmust not interpolate${{ github.event.* }}intorun:commands directly — useenv:blocks with quoted shell variables. See the GitHub Security blog post on workflow injection.
If you find a security issue while making an unrelated change, flag it to the user immediately. Don't silently fix and move on; the user wants to know.
The single most-flagged class of issue in our PR reviews is doc/code drift — prose that described a previous version of the implementation. Anything visible at a user-facing contract boundary lives in multiple files, and a code change that doesn't carry the matching prose along is incomplete. When you touch a CLI flag, an Action input/output, an exit code, the JSON report schema, or the issue body format, walk every place the contract is named and update them in the same commit:
- The implementation itself —
src/for the CLI, therun:script inaction.ymlfor the Action. - The matching
description:field inaction.yml(inputs:/outputs:). - The matching row in
README.mdtables and prose. - The matching paragraph(s) in
docs/guide/— usuallyuse-as-action.md,quickstart.md,how-it-works.md, orconfig-file.md. - The CLI's own self-documentation. A change in
src/cli.tspropagates into three additional surfaces:- commander's
--helpoutput, which the CLI emits at runtime. Sanity-check withpnpm dev -- --helpornode dist/cli.js --helpafter any flag, description, or default change — a stray typo here ships verbatim to every user. docs/reference/cli.md, auto-generated from commander viapnpm docs:gen-cli. Regenerate after any change tosrc/cli.ts; commit the regenerated file. CI doesn't check that the generated reference is up-to-date, so a missed regen rides quietly into a release.- Runnable example snippets in
README.mdanddocs/guide/quickstart.md. These drift silently when flags rename or new ones appear; re-read them whenever you touch the corresponding option.
- commander's
- The JSON report schema in
docs/reference/json-output.mdwheneverJsonReport/SerializedActioninsrc/io/output/json.tschanges shape. BumpschemaVersionfor breaking changes. - The issue body layout in
docs/reference/issue-format.mdwhenever the markers insrc/io/issue-body.tschange. - Any open
.changeset/*.mdentry that bundles this change.
Before committing a contract-touching change, grep -n the identifier (flag name, input name, output name, behavior phrase) across the repo. If it's named in three files but updated in only one, you have doc drift in flight. CI will not catch this; the next reviewer will.
Doc surfaces drift in both directions. Code can move while prose goes stale (code → docs drift); docs can describe a feature the code doesn't yet ship (docs → code drift). When you add a feature, walk the docs that already describe it and reconcile. When you remove one, walk the docs that still describe it.
- Preimage:
sha256(workflowPath \0 annotationPath \0 normalizeMessage(message)). The literal byte sequence isworkflow path, NUL byte,annotation path, NUL byte, normalized message. normalizeMessagecollapses: trailing whitespace per line, CRLF→LF, leading/trailing blank lines. Same logical message across CRLF/LF or trailing-space differences hashes the same.- Line numbers are not in the preimage. A benign refactor that shifts a deprecation from line 10 to line 200 must hash the same. Tests guard this — see
tests/unit/core/fingerprint.test.ts. - Workflow path matters. Two workflows emitting the same annotation message produce two distinct fingerprints. This is intentional: a notice that hits both
ci.ymlandrelease.ymlis two separate things to track.
| Code | When |
|---|---|
0 |
The pipeline ran. By default, finding/filing/updating issues exits 0. |
1 |
An error during the run (auth failure, repo resolution failure, GitHub API failure). |
2 |
--fail-on-new was set and the run created at least one new issue. |
Don't change these without bumping the schema in docs/reference/cli.md and adding a changeset.
picocolorsfor color (no chalk; we keep the dep tree tiny).cli-table3for the action table; border chars are explicit so the table is consistent across terminals.- Display paths are always POSIX-normalized via
src/utils/paths.ts::toPosixPath. Absolute paths used forfscalls stay native. - The
reportsummary line uses bare color helpers (pc.green,pc.yellow,pc.red) — keep severity → color stable so users can scan output without a key.
- Unit tests in
tests/unit/; mirrorssrc/. - Mock at the Octokit boundary via
makeFakeOctokitintests/helpers/fake-octokit.ts— not at the HTTP layer. New API calls should be added to the fake before they're added to the production wrapper. - Time-sensitive code (reconciler, auto-close policy) accepts an injected
now: Date. Use a fixed date in tests, notnew Date(). - Coverage thresholds: lines/functions/statements at 90 %, branches at 85 % (vitest 4 counts branches more granularly than v2 did). The
cli.tsandcommands/*.tsfiles are excluded from coverage — they are thin glue exercised end-to-end via the pipeline tests. - Use
c8 ignoresparingly — only for genuine system-boundary code (thedefaultGhAuthTokensubprocess, the CLI'sparseAsyncinvocation).
- Logical, atomic commits. One concern per commit.
- Conventional commit prefixes:
feat,fix,chore,docs,test,refactor. Add!for breaking (feat(cli)!:). - Body explains why, not what — the diff already says what.
- Never
--no-verify, never--forcewithout explicit user authorization.
Even in auto/yolo mode, any action that's visible to others or hard to fully undo must be confirmed by the user via AskUserQuestion, with the full content of the action rendered in an option preview. Plain Y/N harness prompts via permissions.ask are explicitly removed — the rich preview is the whole point.
The rule applies to:
git commit— preview is the commit message you're about to use.git push— preview is the list of commits leaving the local machine.gh pr create— preview is the PR title plus the full body.gh pr edit— preview is the resulting title/body (the new state, not the diff).gh pr comment,gh pr review— preview is the comment / review body.- Replying to a PR review comment or discussion thread — preview is the reply body. When responding to multiple inline comments from the same review, batch all the replies into a single prompt whose preview lists every reply with its target file:line header.
- Resolving / unresolving a PR review thread —
Approve/Cancel. Batch with the matching replies. gh pr merge,gh pr close,gh pr reopen— preview is a short statement of which PR changes state and how. NoAlteroption.gh issue create/edit/comment/close/reopen— same shape as PR.- Adding or modifying a
.changeset/*.mdfile — see the "Changesets" section above. - Anything posting to a third-party service (Slack, gist, paste, registry).
Shape of the prompt:
| Action kind | Option set | Preview content |
|---|---|---|
| Commit | Approve / Alter the message / Cancel |
Full commit message. |
| Push | Approve / Cancel |
git log @{upstream}..HEAD --oneline --decorate. |
| PR create | Approve / Alter title / Alter body / Cancel |
<title>\n\n<full body>. |
| PR edit / comment / review | Approve / Alter the body / Cancel |
The new title+body or comment body. |
| PR review reply (batch) | Approve / Alter / Cancel |
Every reply body in the batch, each prefixed with ── <file>:<line> ──. |
| PR / issue state change | Approve / Cancel |
One-line statement (#42 → closed). |
| Thread resolve (batch) | Approve / Cancel |
List of <file>:<line> threads being resolved. |
| Changeset add / edit | Approve / Alter / Skip / Cancel |
Full proposed .changeset/*.md content. |
When the user picks Alter ..., follow up by asking what to change, then re-issue the same question with the revised preview. Never execute the underlying command before this prompt has been approved.
- Machine-dependent snapshots. Always pin
cwdandnow: Datein renderer / pipeline tests. path.relativeon Windows yields backslashes. Normalize withtoPosixPathbefore display/test assertions.spawnof.cmdshims on Windows fails withoutshell: true. Restrictshell: trueto a branch where every arg is a static test string.- Vitest 4 dropped
coverage.all. Don't reintroduce it. - pnpm 11 requires explicit build approval. See
pnpm-workspace.yaml'sallowBuildsentry. process.stdin.isTTYis ambient. Tests that depend on it pass under vitest but fail underpnpm test:watchfrom a real terminal. ForceisTTYexplicitly withObject.defineProperty(process.stdin, 'isTTY', { configurable: true, value: <bool> })+ atry/finallyrestore.- Composite Action input defaults are literal strings.
default: ${{ github.token }}is the literal string, not the token. We use empty defaults + an expression fallback in theenv:block — seeaction.yml'sGITHUB_TOKENhandling. set -euo pipefail+ pipe + downstream tool that may exit non-zero. A failing CLI incli | tee outkills the script before subsequent commands run. Wrap withset +e→cli | tee out→captured=${PIPESTATUS[0]}→set -e, write outputs, end withexit "$captured".jqitself fails on empty/unparseable JSON.// 0inside the filter doesn't cover this —jqexits non-zero before the filter even runs. Combine// 0with2>/dev/null || echo 0outside to stay robust under both modes.- Fixed
$RUNNER_TEMP/<name>.jsoncollides under multi-use. Two invocations of the same composite action in one job overwrite each other's reports. Usemktemp "$RUNNER_TEMP/<name>.XXXXXXXX.json"and surface the per-invocation path as the output.action.ymlalready does this. extra=($VAR)enables pathname expansion in bash. Globs in$VARexpand against the workspace before reaching the next stage. Useread -r -a extra <<<"$VAR"instead, then guard(( ${#extra[@]} > 0 ))because whitespace-only input parses to a zero-length array and a bare--flagwith no values silently eats the next argument.- npm package specs accept more than semver.
npm install foo@<spec>parses<spec>against a wide grammar — tarball URLs, git URLs, file paths, alias forms (npm:other-pkg@...) — all of which override the package name and execute arbitrary code. Validate any user-controlled<spec>against a tight allowlist (e.g.^[A-Za-z0-9][A-Za-z0-9._+-]*$) before passing tonpx.action.ymlalready does this. - VitePress and
${{ ... }}. Vue interpolates double-curly mustaches even inside markdown. Don't write literal${{ github.token }}in prose or table cells — use a workaround likeworkflow \github.token`or wrap in`. Fenced code blocks are safe (auto v-pre). closed_byisnullfor issues closed by GitHub Apps or via the API. ThegetClosingCommentfallback (any comment ≤closed_at) handles this. Don't tighten the predicate without considering the fallback case.- CI environment leaks into tests. GitHub Actions runners auto-set
GITHUB_REPOSITORY,GITHUB_TOKEN,GITHUB_ACTIONS,RUNNER_TEMP,process.stderr.isTTY, etc. Tests that read these (directly or via the code under test) pass locally and fail in CI, or vice versa. Stub explicitly withvi.stubEnv(name, '')+vi.unstubAllEnvs()in a try/finally — seetests/unit/core/pipeline.test.ts"throws when the repository cannot be determined". The CLI/command glue (src/cli.ts,src/commands/*.ts) is excluded from coverage so it never has to be exercised under these conditions in unit tests.
pnpm dev # run the CLI from source
pnpm dev -- scan --dry-run --json
pnpm test # full suite
pnpm test:coverage # with thresholds
pnpm build # produce dist/cli.js
pnpm docs:dev # local docs preview
pnpm docs:gen-cli # regenerate the CLI reference from commander
pnpm docs:build # build the VitePress site (also runs docs:gen-cli)
pnpm changeset # add an entry (only when user approves)Ask. The user prefers a short clarifying question over a guess-and-revert cycle. But also: keep questions decisive (multiple-choice over open-ended) and respect their time — don't ask three when one would do.