Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
18bd737
fix: keep Helm chart tag, appVersion, and package.json in lockstep wi…
jgruber Aug 20, 2026
64a37f9
fix: generate mcp-password instead of shipping "changeme" in the publ…
jgruber Aug 20, 2026
2b01fb4
fix: sync --write fails closed; drop the half-fix MCP secret (defer t…
jgruber Aug 20, 2026
99a286f
docs(AGENTS): never put a CI-control marker in a commit message body
jgruber Aug 20, 2026
ca6fe89
ci: wire the gitleaks, shellcheck, and script self-test gates
jgruber Aug 20, 2026
d51d18a
review fix: don't allowlist frontend-v2/dist; pin the gitleaks image
jgruber Aug 20, 2026
1c271fe
fix: catalogue the 13 private-key false positives the gitleaks gate s…
jgruber Aug 20, 2026
06af757
review fix: pin gitleaks to v8.30.1 (precise private-key rule); read-…
jgruber Aug 20, 2026
b12384b
fix: secret-scan every change; make the self-test job able to fail
jgruber Aug 20, 2026
f13d2fc
fix: stop releases being starved by cancelled CI; scope gitleaks; gat…
jgruber Aug 21, 2026
cc9a99a
fix: gitleaks scans the commit range; per-SHA concurrency; gate rejec…
jgruber Aug 21, 2026
83982af
ci: assert the BREAKING CHANGE detector is identical across both scripts
jgruber Aug 21, 2026
d357e40
fix: make the secret-scan gate fail when it scanned nothing
jgruber Aug 21, 2026
cf64faf
ci: close the remaining secret-scan / gate / parity gaps from #182 r3
jgruber Aug 21, 2026
46d4ec7
ci: parity-check the detector FUNCTIONS, not just a regex
jgruber Aug 21, 2026
fb1e793
fix: exempt the release bot from commit-lint so promotion can't deadlock
jgruber Aug 21, 2026
95da587
fix: exempt GitHub's squash composer from commit-lint; complete self-…
jgruber Aug 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .githooks/pre-push
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,49 @@ if [ $? -ne 0 ]; then
exit 1
fi

echo ""

# ─── Commit-message marker lint (bonnyr-f5 #182 r3) ───────────────────────────
# Fail fast, before the heavy suite, if any commit about to be pushed carries a
# CI-control marker (which would suppress the workflow run) or a spurious major-
# bump prose line. Same script the ci.yml commit-lint gate runs, so local == CI.
#
# RANGE from the pre-push stdin protocol (bonnyr-f5 #182 r5, Minor). git feeds
# this hook one "<local ref> <local sha> <remote ref> <remote sha>" line per ref
# being pushed. Scanning the script's default `@{upstream}..HEAD` misses every
# non-tip commit when the branch has no upstream yet (a FIRST push) -- exactly
# when a bad commit is most likely to slip in. Deriving `<remote sha>..<local
# sha>` from stdin scans precisely the commits this push introduces. A new remote
# branch reports an all-zero remote sha (no merge-base to diff against); there we
# fall back to the script's own default rather than scanning all of history.
# Deletions (all-zero local sha) contribute no commits. When stdin is empty (the
# hook run by hand, not by git) we leave RANGE unset so the script default runs.
zero="0000000000000000000000000000000000000000"
prepush_range=""
while read -r _localref localsha _remoteref remotesha; do
[ -z "${localsha:-}" ] && continue
[ "$localsha" = "$zero" ] && continue # branch deletion: nothing to lint
if [ "${remotesha:-$zero}" = "$zero" ]; then
prepush_range="__DEFAULT__" # new branch: no base -> script default
break
fi
prepush_range="${remotesha}..${localsha}" # normal update: exactly the pushed commits
break
done

echo "=== Commit message marker lint (pre-push) ==="
if [ -n "$prepush_range" ] && [ "$prepush_range" != "__DEFAULT__" ]; then
lint_status() { RANGE="$prepush_range" bash scripts/lint-commit-markers.sh; }
else
lint_status() { bash scripts/lint-commit-markers.sh; }
fi
if ! lint_status; then
echo ""
echo "PUSH BLOCKED: a commit message carries a CI-control / spurious-major marker."
echo "Reword it (see AGENTS.md 'Commit conventions') and try again."
exit 1
fi

echo ""
echo "========================================="
echo " Pre-push: Running local checks"
Expand Down
249 changes: 227 additions & 22 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,33 +24,28 @@ name: CI
on:
pull_request:
branches: [main, staging, develop]
paths-ignore:
- '**.md'
- 'docs/**'
- '.agent/**'
- '.opencode/**'
- 'LICENSE'
- '.gitignore'
- '.trivyignore'
- 'USER_GUIDE.md'
# No workflow-level paths-ignore: secret scanning (gitleaks) and the CI Gate
# must see EVERY change, doc-only PRs included — a secret lands in a .md as
# easily as in code, and this is a public repo (#182 review). Expensive jobs
# still skip on irrelevant paths via the per-job `changes` filter below; path
# filtering lives there (one source of truth), not at the trigger.
push:
# main (deploy trigger) + staging (release-automation preflight needs a
# push-triggered CI run to match by SHA — see release.yml preflight);
# develop skipped.
# develop skipped. No paths-ignore, same reason as above.
branches: [main, staging]
paths-ignore:
- '**.md'
- 'docs/**'
- '.agent/**'
- '.opencode/**'
- 'LICENSE'
- '.gitignore'
- '.trivyignore'
- 'USER_GUIDE.md'

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
# bonnyr-f5 #182 r2: on main/staging give every push its OWN group (append the
# SHA) so a later docs-only push can't cancel -- even as a PENDING run -- the CI
# run a release polls by SHA. Feature branches keep the per-ref group so rapid
# pushes still supersede each other and save minutes.
group: ci-${{ github.ref }}${{ (github.ref_name == 'main' || github.ref_name == 'staging') && github.sha || '' }}
# bonnyr-f5 #182: never cancel an in-flight CI run on the release branches --
# release.yml preflight polls that exact run by SHA, so a docs-only push (which
# triggers CI but not Release) would otherwise cancel it and strand the earlier
# commit's release. Feature branches still cancel to save minutes.
cancel-in-progress: ${{ github.ref_name != 'main' && github.ref_name != 'staging' }}

permissions:
contents: read
Expand Down Expand Up @@ -126,6 +121,183 @@ jobs:
- name: Run lint
run: make lint-backend

version-consistency:
name: "P1 · Version Consistency"
needs: changes
if: always()
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Assert version-bearing artifacts agree with VERSION
# Helm chart tag/appVersion and frontend package.json must equal VERSION,
# or the release (which publishes only :${VERSION}) yields ImagePullBackOff
# / silent drift (#177 Blocker 2). Goes through `make version-check` so
# this job and `make pre-push` run the identical command (#182 r3).
run: make version-check

shellcheck:
name: "P1 · ShellCheck"
needs: changes
if: always()
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Install shellcheck
run: sudo apt-get update && sudo apt-get install -y shellcheck
- name: Run shellcheck
run: make shellcheck

secret-scan:
name: "P1 · Secret Scan (gitleaks)"
needs: changes
if: always()
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0 # full history so the range scan sees add-then-remove
- name: gitleaks
run: |
# bonnyr-f5 #182 r2: scan the PR/push COMMIT RANGE in git mode, not the
# working tree. --no-git misses a secret added then REMOVED within the
# branch, which stays fetchable forever from a public clone -- the main
# thing a public repo needs a history-aware scan for.
#
# All of the scan + assertion logic (the r3 BLOCKER fix, the
# dubious-ownership safe.directory fix, the archive-depth fix, and the
# digest pin) lives in scripts/secret-scan.sh so `make secret-scan` and
# this job run byte-identical commands (#166 / ci.yml header: local==CI).
# We only compute the range from the event here and hand it to the
# script; RANGE is exported (even when empty => full history).
if [ "${{ github.event_name }}" = "pull_request" ]; then
RANGE="${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}"
elif [ -n "${{ github.event.before }}" ] && [ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]; then
RANGE="${{ github.event.before }}..${{ github.sha }}"
else
RANGE="" # first push / no base — scan all reachable history
fi
export RANGE
make secret-scan

commit-lint:
name: "P1 · Commit Message Lint"
needs: changes
if: always()
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0 # need the whole PR range of commit messages
- name: Lint commit messages for CI-control / spurious-bump markers
run: |
# bonnyr-f5 #182 r3 (Minor -> enforcement): the AGENTS.md rule against
# CI-control markers in commit messages was documentation only, and
# "documentation is not enforcement" (#166). This gate FAILS a PR/push
# whose commit range carries a marker (which would suppress CI for that
# commit -- the #179/#181 case) or an accidental line-start BREAKING
# CHANGE prose that spuriously majors a release. The .githooks/pre-push
# hook runs the SAME script locally so it is caught before push too.
if [ "${{ github.event_name }}" = "pull_request" ]; then
RANGE="${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}"
elif [ -n "${{ github.event.before }}" ] && [ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]; then
RANGE="${{ github.event.before }}..${{ github.sha }}"
else
RANGE="" # first push / no base — script scans just the tip commit
fi
export RANGE
make commit-lint

script-selftests:
name: "P1 · Script Self-Tests"
needs: changes
if: always()
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: compute_version_bump SELF_TEST
run: |
# Fail on a non-zero exit OR a FAIL: line. The harness historically
# printed FAIL: but still exited 0, so trusting the exit code alone
# made this job unable to catch a broken self-test until the exit-code
# fix landed (#182 review). Checking both decouples the two.
set +e
out="$(SELF_TEST=1 bash scripts/compute_version_bump.sh 2>&1)"; rc=$?
echo "$out"
if [ "$rc" -ne 0 ]; then
echo "::error::compute_version_bump self-test exited $rc"; exit "$rc"
fi
if grep -qE '(^|[[:space:]])FAIL:' <<< "$out"; then
echo "::error::compute_version_bump self-test reported FAIL: but exited 0"; exit 1
fi
# bonnyr-f5 #182: silence must not pass -- require positive evidence the
# harness actually ran (renaming its SELF_TEST guard produced empty
# output + rc=0, i.e. green with zero assertions).
# bonnyr-f5 #182 r2: require the END marker AND >=1 PASS. The marker
# prints only after the LAST assertion, so an early exit (the #179 shape,
# 5 of 6 unrun) is caught without hardcoding a per-branch test count.
if ! grep -qE '(^|[[:space:]])PASS:' <<< "$out"; then
echo "::error::self-test produced no PASS lines -- the harness did not run"; exit 1
fi
if ! grep -qE '=== END SELF-TEST ===' <<< "$out"; then
echo "::error::self-test did not reach its END marker -- it exited early with assertions unrun"; exit 1
fi
- name: BREAKING CHANGE detector parity + extractor self-test
run: |
# bonnyr-f5 #179 r3 (cross-PR / INV-15): the BREAKING CHANGE detector
# MUST be byte-identical between the two scripts -- if they drift, a
# major bump ships with empty notes (or a note ships with no bump). This
# asserts identity in code, replacing the "MUST stay identical" comment.
#
# The extraction is version-agnostic on purpose: it pulls the regex out
# of whichever `grep -qE '...BREAKING...CHANGE...'` detector each script
# uses, so it enforces parity whether the tree is pre- or post-#179
# (both scripts use the SAME form as each other in either state). That
# keeps this gate meaningful on this PR today AND on the merged stack.
set -euo pipefail
E=scripts/extract-breaking-changes.sh
C=scripts/compute_version_bump.sh
# Adaptive check. #179 factors detection into _is_breaking_subject and
# _is_breaking_body FUNCTIONS (the body one is a paragraph-aware awk, not
# a single grep). When those functions exist, diff their full bodies --
# that guards the awk detector too, and answers bonnyr-f5 #179 r4's nit
# that compute had no function to diff. On the pre-#179 tree (inline
# greps, no functions) fall back to extracting the detector regex, so the
# gate stays meaningful on this PR before the stack merges.
_fn() { sed -n "/^$2()/,/^}/p" "$1"; } # print a function definition
_regex() {
grep -oE "grep -qE '[^']*BREAKING[^']*CHANGE[^']*'" "$1" \
| sed -E "s/^grep -qE '//; s/'\$//" | sort -u
}
if grep -q '^_is_breaking_body()' "$E" && grep -q '^_is_breaking_body()' "$C"; then
for fn in _is_breaking_subject _is_breaking_body; do
if [ "$(_fn "$E" "$fn")" != "$(_fn "$C" "$fn")" ]; then
echo "::error::INV-15 violated: $fn differs between the two scripts"
diff <(_fn "$E" "$fn") <(_fn "$C" "$fn") || true
exit 1
fi
done
echo "INV-15 OK: _is_breaking_subject + _is_breaking_body are byte-identical functions in both scripts"
else
ex="$(_regex "$E")"; cv="$(_regex "$C")"
if [ -z "$ex" ] || [ -z "$cv" ]; then
echo "::error::could not extract a BREAKING CHANGE detector from one of the scripts (extract='$ex' compute='$cv')"; exit 1
fi
if [ "$ex" != "$cv" ]; then
echo "::error::INV-15 violated: the BREAKING CHANGE detector regex differs between the two scripts"
echo " extract: $ex"; echo " compute: $cv"; exit 1
fi
echo "INV-15 OK (pre-#179 tree): detector regex identical across both scripts -> $ex"
fi
# Run the extractor's own self-test once #179's anchored extractor (which
# adds --self-test) is in the tree. Until #179 merges to staging, this
# PR's base carries the older extractor; warn LOUDLY (not a silent skip)
# so the pending activation is visible in the log.
if grep -q -- '--self-test' scripts/extract-breaking-changes.sh; then
bash scripts/extract-breaking-changes.sh --self-test
else
echo "::warning::extract-breaking-changes.sh has no --self-test yet (it lands with #179); the parity gate above is still enforced this run"
fi

lint-frontend:
name: "P1 · Lint Frontend"
needs: changes
Expand Down Expand Up @@ -1063,6 +1235,11 @@ jobs:
- changes
# Phase 1
- lint-backend
- version-consistency
- shellcheck
- secret-scan
- commit-lint
- script-selftests
- lint-frontend
- typecheck-backend
- openapi-check
Expand Down Expand Up @@ -1094,8 +1271,32 @@ jobs:

# Collect all job results (skipped jobs are OK — they were filtered by path)
failed=false

# bonnyr-f5 #182 r3 (Major): the aggregator must verify the change-
# detection job itself SUCCEEDED. If `changes` fails/cancels, ~21 of the
# gates below resolve to `skipped` (their `needs: changes` was never
# satisfied), the old loop accepted skipped as success, and the required
# check printed "CI Gate PASSED" while nothing had actually run. Same
# class as the secret-scan blocker: cannot distinguish "passed" from
# "never evaluated". A non-success `changes` fails the gate outright.
changes_result="${{ needs.changes.result }}"
echo "changes (change-detection): $changes_result"
if [ "$changes_result" != "success" ]; then
echo "::error::change-detection job did not succeed ($changes_result) -- every downstream gate was skipped, so the gate cannot certify anything. Failing."
failed=true
fi

# bonnyr-f5 #182 r2/r3: these gates run `if: always()` on every change, so
# `skipped` for them means a future path-filter silently disabled the
# check. Treat skipped as a failure for exactly these.
ALWAYS_RUN="version-consistency shellcheck secret-scan commit-lint script-selftests"
for job in \
"lint-backend:${{ needs.lint-backend.result }}" \
"version-consistency:${{ needs.version-consistency.result }}" \
"shellcheck:${{ needs.shellcheck.result }}" \
"secret-scan:${{ needs.secret-scan.result }}" \
"commit-lint:${{ needs.commit-lint.result }}" \
"script-selftests:${{ needs.script-selftests.result }}" \
"lint-frontend:${{ needs.lint-frontend.result }}" \
"typecheck-backend:${{ needs.typecheck-backend.result }}" \
"openapi-check:${{ needs.openapi-check.result }}" \
Expand All @@ -1119,10 +1320,14 @@ jobs:
; do
name="${job%%:*}"
result="${job##*:}"
# 'success' and 'skipped' are both acceptable
# 'success' and 'skipped' are acceptable, EXCEPT skipped for an
# always-run gate, which means the check silently didn't execute.
if [ "$result" = "failure" ] || [ "$result" = "cancelled" ]; then
echo "::error::$name: $result"
failed=true
elif [ "$result" = "skipped" ] && case " $ALWAYS_RUN " in *" $name "*) true;; *) false;; esac; then
echo "::error::$name was SKIPPED but is an always-run gate — the check never executed"
failed=true
else
echo "$name: $result"
fi
Expand Down
Loading
Loading