Two gaps, both found because something went wrong quietly.
tools/github/file-issue.sh
gh issue create does not touch the project board — the issue exists, carries its labels, and is invisible in the Kanban, which reads exactly like a ticket nobody filed. Measured 2026-08-24: eight issues filed as a scripted batch (#57–#64) all reached the board; #66, filed as a one-off minutes later, did not, and surfaced only because someone went looking.
Three things it does that a two-command snippet would not:
Resolves the project, Status field and option ids by name, every run. Caching them is the obvious optimisation and the wrong one — a renamed or reordered column would then have this writing a stale id with no error anywhere.
Reads the item back. A mutation returning 200 says the request was accepted, not that the board shows what was asked for. This has to be GraphQL: the fields array REST returns on a project item carries Title and nothing else, so a REST-only check reports every Status as unset.
Exits 3, loudly, with the issue number on its own line when the issue was created but the board step failed. That combination is the failure being prevented; it must never be the quiet path.
Verified end-to-end by filing #68 with it, then confirming board placement independently. Validation paths exercised: missing title, both body forms, unreadable body file, non-numeric project, unknown status (lists the real options), and --dry-run canonicalising "ready for development" → Ready for Development.
shellcheck in CI
Shell was the other language here with nothing checking it — four scripts, one of them the CI entry point. It now runs in the Static analysis job (already a required check, so no ruleset change) over git ls-files '*.sh', so a script added later is covered without editing the workflow.
Full severity, info included. The two findings it raises today are both the tool being wrong, so both get a targeted disable with its reason rather than a lowered severity:
run-e2e.sh's on_signal reported as never invoked — it is installed as the INT and TERM trap eleven lines below.
The $names in file-issue.sh's queries are GraphQL variables that must not expand; expanding them would send the shell's idea of $owner to the API instead of declaring a parameter.
A blanket --severity=warning would have hidden both, and the next real finding with them.
The gradle step gains if: !cancelled() so a shellcheck failure cannot cost the ktlint/detekt/lint lists — the same reason it already passes --continue.
Not covered, deliberately
shellcheck here reads .sh files, not the inline run: blocks in the workflows, where a good deal of this repo's bash lives. actionlint does read them and finds one pre-existing info issue in build.yml. Wiring it in means pinning a container digest — every action here is pinned by SHA and actionlint's usual installer is a curl | bash off a moving branch. Filed separately rather than bolted on.
Closes nothing; CLAUDE.md carries both as standing instructions.
Two gaps, both found because something went wrong quietly.
## `tools/github/file-issue.sh`
`gh issue create` does not touch the project board — the issue exists, carries its labels, and is invisible in the Kanban, which reads exactly like a ticket nobody filed. **Measured 2026-08-24:** eight issues filed as a scripted batch (#57–#64) all reached the board; #66, filed as a one-off minutes later, did not, and surfaced only because someone went looking.
Three things it does that a two-command snippet would not:
- **Resolves the project, Status field and option ids by name, every run.** Caching them is the obvious optimisation and the wrong one — a renamed or reordered column would then have this writing a stale id with no error anywhere.
- **Reads the item back.** A mutation returning 200 says the request was accepted, not that the board shows what was asked for. This has to be GraphQL: the `fields` array REST returns on a project item carries Title and nothing else, so a REST-only check reports every Status as unset.
- **Exits 3, loudly, with the issue number on its own line** when the issue was created but the board step failed. That combination is the failure being prevented; it must never be the quiet path.
Verified end-to-end by filing #68 with it, then confirming board placement independently. Validation paths exercised: missing title, both body forms, unreadable body file, non-numeric project, unknown status (lists the real options), and `--dry-run` canonicalising `"ready for development"` → `Ready for Development`.
## shellcheck in CI
Shell was the other language here with nothing checking it — four scripts, one of them the CI entry point. It now runs in the **Static analysis** job (already a required check, so no ruleset change) over `git ls-files '*.sh'`, so a script added later is covered without editing the workflow.
Full severity, `info` included. The two findings it raises today are **both the tool being wrong**, so both get a targeted `disable` with its reason rather than a lowered severity:
- `run-e2e.sh`'s `on_signal` reported as never invoked — it is installed as the INT and TERM trap eleven lines below.
- The `$names` in `file-issue.sh`'s queries are GraphQL variables that must not expand; expanding them would send the shell's idea of `$owner` to the API instead of declaring a parameter.
A blanket `--severity=warning` would have hidden both, and the next real finding with them.
The gradle step gains `if: !cancelled()` so a shellcheck failure cannot cost the ktlint/detekt/lint lists — the same reason it already passes `--continue`.
## Not covered, deliberately
shellcheck here reads `.sh` files, **not the inline `run:` blocks** in the workflows, where a good deal of this repo's bash lives. `actionlint` does read them and finds one pre-existing `info` issue in `build.yml`. Wiring it in means pinning a container digest — every action here is pinned by SHA and actionlint's usual installer is a `curl | bash` off a moving branch. Filed separately rather than bolted on.
Closes nothing; `CLAUDE.md` carries both as standing instructions.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Two gaps, both found because something went wrong quietly.
tools/github/file-issue.shgh issue createdoes not touch the project board — the issue exists, carries its labels, and is invisible in the Kanban, which reads exactly like a ticket nobody filed. Measured 2026-08-24: eight issues filed as a scripted batch (#57–#64) all reached the board; #66, filed as a one-off minutes later, did not, and surfaced only because someone went looking.Three things it does that a two-command snippet would not:
fieldsarray REST returns on a project item carries Title and nothing else, so a REST-only check reports every Status as unset.Verified end-to-end by filing #68 with it, then confirming board placement independently. Validation paths exercised: missing title, both body forms, unreadable body file, non-numeric project, unknown status (lists the real options), and
--dry-runcanonicalising"ready for development"→Ready for Development.shellcheck in CI
Shell was the other language here with nothing checking it — four scripts, one of them the CI entry point. It now runs in the Static analysis job (already a required check, so no ruleset change) over
git ls-files '*.sh', so a script added later is covered without editing the workflow.Full severity,
infoincluded. The two findings it raises today are both the tool being wrong, so both get a targeteddisablewith its reason rather than a lowered severity:run-e2e.sh'son_signalreported as never invoked — it is installed as the INT and TERM trap eleven lines below.$namesinfile-issue.sh's queries are GraphQL variables that must not expand; expanding them would send the shell's idea of$ownerto the API instead of declaring a parameter.A blanket
--severity=warningwould have hidden both, and the next real finding with them.The gradle step gains
if: !cancelled()so a shellcheck failure cannot cost the ktlint/detekt/lint lists — the same reason it already passes--continue.Not covered, deliberately
shellcheck here reads
.shfiles, not the inlinerun:blocks in the workflows, where a good deal of this repo's bash lives.actionlintdoes read them and finds one pre-existinginfoissue inbuild.yml. Wiring it in means pinning a container digest — every action here is pinned by SHA and actionlint's usual installer is acurl | bashoff a moving branch. Filed separately rather than bolted on.Closes nothing;
CLAUDE.mdcarries both as standing instructions.