The 2026-09-17 purge removed six live credentials that had sat in this repo
for weeks, several in .md prose. Nothing blocked that class of commit, so a
warning in a stream nobody reads was the only signal. This adds a guard that
fails the build instead of warning.
Guard
- scripts/secret-scan.sh: bash + coreutils + grep/sed/awk + git only (the Gitea
Actions runner executes job steps inside the runner container — BusyBox grep,
no node/python). Modes: --tree (git-tracked, default), --path DIR (no git),
--staged (pre-commit), --diff REF. Exit 1 on a finding, 2 on config error.
- scripts/secret-patterns.tsv: checked-in pattern list — sk-, sk-or-v1-,
sk_live_, literal Bearer tokens, PVEAPIToken=, raw Authorization values, PEM
private-key blocks, prose credential lines, and password/api_key/secret/token
assignments carrying a literal value. Prose is scanned exactly like code.
- scripts/secret-allowlist.tsv: one entry per deliberate synthetic example, each
with a reason. A missing reason is a hard error (fail closed). The 2026-09-17
purge's `«vault: ...»` placeholders are listed explicitly rather than filtered
by a general "vault"/"synthetic" rule, so a new occurrence still needs a
reviewed, reasoned entry.
- A small inert-value classifier drops env refs, paths, dotted code access,
variable names and right-truncated redactions; it does not know the words
"synthetic"/"example", so a fabrication is always an explicit exception.
- Findings are printed with the credential masked; a scan never echoes a full
secret into the log.
Wiring
- .gitea/workflows/pr-pipeline.yaml lint job: explicit "Committed-credential
scan" step plus the self-test. A finding fails the required
`pr-pipeline / lint` context, which the merge gate depends on.
- scripts/prose-lint.sh (the local gate): a "Secret scan" section, so
`bash scripts/prose-lint.sh` before pushing is equivalent to CI.
Tests
- tests/test_secret_scan.sh: 20 cases. Plants pattern-matching fixtures in temp
trees (outside every allowlisted path) and asserts the guard FAILS, including
the --staged commit-time path; asserts the tree is quiet; asserts allowlisted
text at an unlisted path still fails (path-explicit, not word-based); asserts
a reasonless allowlist entry exits 2.
Verified: guard run against 8245716^ (the pre-fix revision, before the purge)
fails on the real OpenRouter/LiteLLM/Zulip/Proxmox/Stirling credentials; guard
run over the current tree is clean.
The pr-pipeline workflow filtered both push and pull_request on
paths (**.prose.md, scripts/**.sh, **.yaml, **.yml). A PR whose diff
touched none of those paths — e.g. PR #77, deliverables/-only —
produced no Gitea Actions run at all, so validate/lint/ai-review and
the merge gate were silently skipped.
Remove the paths filter from both triggers and record in the file
that the trigger is intentionally unfiltered. No job, step, needs,
if or command is changed.
The validate job runs with bash -e -o pipefail. `echo "$FM" | grep -q '^name:'`
lets grep exit on first match, which can SIGPIPE the echo; pipefail then reports
the pipeline non-zero and the || branch raises a false "Missing name/description".
The flagged file set varied run to run (and included files untouched by the PR)
while a fresh clone of the same commit passes the identical check. Reproduced:
the old form failed 3 of 5 local runs under the same shell flags, the herestring
form passed 5 of 5. Use herestrings so no pipe can be broken.
The Gitea Actions runner cannot resolve the internal hostname
git.sysloggh.net during checkout. Using the direct IP address
resolves the DNS resolution issue in CI pipeline steps.
- Replaced actions/checkout@v4 with native git clone (no Node.js needed)
- Fixed FAILED counter: increment instead of resetting to 1
- Fixed exit condition: -gt 0 instead of -eq 1 (caught >1 violation)
- Test-violations.prose.md still present — pipeline should now catch it
- Removed actions/checkout@v4 (requires Node.js, unavailable on runner)
- Gitea act runner provides repo checkout automatically
- Added 'enforcement' to valid kind list for hermes-key-enforcement
- All steps are now pure shell — zero external dependencies
Previously the pipeline only triggered on pull_request events.
Direct pushes to master bypassed all validation (auth, lint, ai-review).
Now push to master also triggers the pipeline.