diff --git a/.githooks/pre-push b/.githooks/pre-push index f3f50c0..026ea80 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -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 " " 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 `..` 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" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d8f62e..6ca812b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 @@ -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 @@ -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 }}" \ @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0ded64e..60342e0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,9 +22,13 @@ on: branches: - staging - main - # MUST stay in sync with ci.yml's push-trigger paths-ignore list — a - # divergence lets a push trigger Release without a matching CI run, - # which then times out the preflight SHA poll below (PR #297 review). + # ci.yml has NO push paths-ignore any more: it runs CI on EVERY push to + # main/staging (ci.yml:31-36, #182). That is a strict superset of the pushes + # that reach Release here, so any push that starts a release is guaranteed a + # matching CI run for the preflight SHA poll below to find — regardless of + # what this list ignores. This paths-ignore therefore only spares docs-only + # pushes from kicking off a release at all (PR #297 review; premise updated + # for #182, which removed ci.yml's paths-ignore). paths-ignore: - '**.md' - 'docs/**' @@ -148,12 +152,16 @@ jobs: exit 0 fi if [ "$RUN_CONCLUSION" = "cancelled" ]; then - # ci.yml runs with cancel-in-progress: true, so a rapid - # follow-up push to the same branch cancels this SHA's CI - # run. That's not a CI failure for this SHA — it means a - # newer push superseded it, so fail fast instead of - # reporting a generic non-success error. - echo "::error::CI run for $SHA was cancelled — superseded by a newer push; this release attempt is stale, the newer push will release instead." + # A cancelled CI run is not a success for this SHA, so fail + # fast rather than reporting a generic non-success error. + # NOTE (premise updated for #182): ci.yml does NOT cancel + # in-progress runs on main/staging — cancel-in-progress is + # false for exactly these release branches (ci.yml:48), each + # push gets its own concurrency group. So a cancellation here + # is no longer necessarily "a newer push superseded it"; it may + # have been cancelled by other means (e.g. a manual cancel). + # Either way the run is stale — do not release on it. + echo "::error::CI run for $SHA was cancelled — this release attempt is stale (a newer push, if any, will release instead)." exit 1 fi echo "::error::CI run for commit $SHA on branch '$BRANCH' completed with conclusion '$RUN_CONCLUSION'." @@ -355,6 +363,10 @@ jobs: if git ls-files --error-unmatch dist/VERSION 2>/dev/null; then echo "$NEW" > dist/VERSION fi + # Keep the Helm chart tag/appVersion and frontend package.json in + # lockstep so the chart never pins an image tag the release doesn't + # publish (#177 Blocker 2). + bash scripts/sync-version-artifacts.sh --write "$NEW" - name: Update CHANGELOG.md run: | @@ -413,8 +425,10 @@ jobs: NEW="${{ needs.preflight.outputs.new_version }}" BUMP="${{ needs.preflight.outputs.bump_type }}" - # Stage VERSION, dist/VERSION (if tracked), CHANGELOG + # Stage VERSION, dist/VERSION (if tracked), CHANGELOG, and the + # version-bearing artifacts synced above (#177 Blocker 2). git add VERSION CHANGELOG.md + git add helm/bnk-forge/values.yaml helm/bnk-forge/Chart.yaml frontend-v2/package.json git ls-files --error-unmatch dist/VERSION 2>/dev/null && git add dist/VERSION || true git commit -m "release: v${NEW} [skip ci] @@ -515,6 +529,7 @@ jobs: if git ls-files --error-unmatch dist/VERSION 2>/dev/null; then echo "${{ needs.preflight.outputs.new_version }}" > dist/VERSION fi + bash scripts/sync-version-artifacts.sh --write "${{ needs.preflight.outputs.new_version }}" - name: Update changelog run: | @@ -548,6 +563,7 @@ jobs: - name: Commit and tag run: | git add VERSION CHANGELOG.md + git add helm/bnk-forge/values.yaml helm/bnk-forge/Chart.yaml frontend-v2/package.json git ls-files --error-unmatch dist/VERSION 2>/dev/null && git add dist/VERSION || true git commit -m "release: v${{ needs.preflight.outputs.new_version }} [skip ci] diff --git a/.github/workflows/secret-baseline.yml b/.github/workflows/secret-baseline.yml new file mode 100644 index 0000000..bdb01fc --- /dev/null +++ b/.github/workflows/secret-baseline.yml @@ -0,0 +1,39 @@ +# ╔══════════════════════════════════════════════════════════════════════════╗ +# ║ Secret Scan — Full-History Baseline ║ +# ║ ║ +# ║ bonnyr-f5 #182 r3 (Major): the per-PR/push gate in ci.yml scans only the ║ +# ║ COMMIT RANGE of each change. Anything already in history before that gate ║ +# ║ landed — or a secret that slips in via a path the range scan misses — is ║ +# ║ never re-examined. This workflow runs gitleaks over ALL reachable history ║ +# ║ on a weekly schedule and on demand, so the whole repo stays monitored. ║ +# ║ ║ +# ║ It reuses scripts/secret-scan.sh (the SAME scan + assertion backstop as ║ +# ║ CI and `make secret-scan`), with RANGE="" meaning "full history". The ║ +# ║ gate still FAILS on a git error or a 0-commit non-scan, so a broken ║ +# ║ baseline is caught, not silently green. ║ +# ╚══════════════════════════════════════════════════════════════════════════╝ + +name: Secret Scan Baseline + +on: + schedule: + # Mondays 06:17 UTC — weekly full-history sweep (off the hour to avoid the + # scheduler's top-of-hour congestion). + - cron: "17 6 * * 1" + workflow_dispatch: {} + +permissions: + contents: read + +jobs: + baseline: + name: "Full-history gitleaks baseline" + runs-on: ubuntu-latest + env: + RANGE: "" # explicit empty => scan all reachable history + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 # the whole history is the point + - name: gitleaks (full history) + run: make secret-scan diff --git a/.gitleaks.toml b/.gitleaks.toml index d6b977b..bbe27e2 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -34,15 +34,16 @@ regexes = [ # "signature_here"; used to test _looks_like_jwt(). '''eyJhbGciOiJIUzI1NiJ9\.eyJzdWIiOiJ0ZXN0In0\.signature_here''', ] + +[[rules]] +id = "private-key" +# bonnyr-f5 #182: scope the fixture private keys to the private-key rule only, so +# a real AWS/GitHub/generic secret hidden in these same files is still caught -- +# a top-level [allowlist].paths would have disabled EVERY rule for them. The +# .pyc/__pycache__ blanket entries were dropped (zero tracked files). +[rules.allowlist] paths = [ - # Throwaway RSA/Ed25519 keypairs generated solely to exercise paramiko key - # parsing. They authenticate nothing — no corresponding public key is - # deployed anywhere. See the note in the file header. '''backend/tests/unit/test_paramiko_utils\.py''', - # PEM headers wrapped around placeholder bodies, not key material: - # test_agent_host_candidates.py -> "MIIEowIBAAKCAQEA000000..." - # test_routes_project_secrets.py -> "fake" - # test_infrastructure_access_service.py-> "MIIEowIBAAKCAQEAuTestKeyMaterial" '''backend/tests/component/test_agent_host_candidates\.py''', '''backend/tests/integration/test_routes_project_secrets\.py''', '''backend/tests/unit/test_infrastructure_access_service\.py''', diff --git a/AGENTS.md b/AGENTS.md index c2289cd..972aa5b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,5 +82,28 @@ Strong success criteria let you loop independently. Weak criteria ("make it work **These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes. +## Commit conventions + +Conventional Commits (`type: subject`, optional body, `BREAKING CHANGE:` footer for a +major). One repo-specific trap worth stating outright: + +- **Never write a CI-control marker as literal text anywhere in a commit message — + subject *or* body — even when quoting it in prose.** GitHub scans the whole message, + so a `[skip ci]` / `[ci skip]` sitting in a sentence suppresses the run for that + commit. This has bitten us twice, most recently on a shell-script change where the + gates that got skipped (ShellCheck, Script Self-Tests, Secret Scan) were exactly the + ones that mattered. Refer to it indirectly instead: "CI suppressed", "the skip-CI + marker", or split it across backticks. The release job's *deliberate* skip is the + only legitimate use, and it lands on the subject line where the release loop reads it. + This is now **enforced**, not just documented: the `commit-lint` CI gate and the + `.githooks/pre-push` hook both run `scripts/lint-commit-markers.sh`, which fails a + push/PR whose commit range carries any CI-control marker (bonnyr-f5 #182 r3, #166: + documentation is not enforcement). +- **Declare a major bump with a real `BREAKING CHANGE: ` footer**, not a + bold `**BREAKING CHANGE**` heading or a bare colon-less line. `compute_version_bump.sh` + majors on the phrase, so a prose line that *looks* like a footer ships a spurious major + release; `commit-lint` rejects the line-start prose forms while allowing the plain + footer. + --- diff --git a/Makefile b/Makefile index 75528fe..22a0fa4 100644 --- a/Makefile +++ b/Makefile @@ -464,7 +464,82 @@ test-upgrade: shellcheck: @echo "" @echo "=== ShellCheck: linting shell scripts ===" - @shellcheck --severity=warning upgrade.sh scripts/*.sh vm-bnk-forge/*.sh vm-bnk-forge/lib/*.sh + @# bonnyr-f5 #182: drive from git ls-files so the WHOLE corpus is gated + @# (the hardcoded globs missed 14 tracked scripts incl. dist/install.sh). + @# bonnyr-f5 #182 r2: include the (extensionless) git hooks, and fail on an + @# EMPTY list -- `xargs shellcheck` with no files exits 0 on BSD (blind). + @files="$$(git ls-files '*.sh' .githooks/pre-commit .githooks/pre-push 2>/dev/null)"; \ + n=$$(printf '%s\n' "$$files" | grep -c .); \ + [ "$$n" -ge 1 ] || { echo "::error::shellcheck found no files to lint"; exit 1; }; \ + printf '%s\n' "$$files" | xargs shellcheck --severity=warning + +# ── CI-parity gates (bonnyr-f5 #182 r3, Major) ────────────────────────────── +# The four gates ci.yml added were not runnable locally: `make pre-push` ran +# none of them and `make shellcheck` had no dependents, yet ci.yml's header +# claims `make pre-push` == CI. #166: "a local gate that does not run the CI +# command is not a gate." These targets ARE the CI command (ci.yml calls the +# same `make` targets / same scripts), and `pre-push` now depends on `ci-gates`. +.PHONY: ci-gates version-check secret-scan commit-lint script-selftests + +# Helm chart tag/appVersion + frontend package.json must equal VERSION. +version-check: + @echo "" + @echo "=== Version artifacts consistency (sync-version-artifacts.sh --check) ===" + @bash scripts/sync-version-artifacts.sh --check + +# gitleaks range-aware secret scan + assertion backstop (single source of truth, +# shared with ci.yml's secret-scan job and the scheduled baseline workflow). +# Honours RANGE from the environment; unset => scan since the upstream merge-base. +secret-scan: + @echo "" + @echo "=== Secret scan (gitleaks) ===" + @bash scripts/secret-scan.sh + +# Commit-message marker enforcement (shared with ci.yml's commit-lint job and +# .githooks/pre-push). Honours RANGE; unset => @{upstream}..HEAD. +commit-lint: + @echo "" + @echo "=== Commit message marker lint ===" + @bash scripts/lint-commit-markers.sh + +# The paired self-test harnesses ci.yml's script-selftests job runs. +# ci.yml's compute step has FOUR anti-vacuity assertions and this target must +# mirror ALL of them, or a broken harness passes locally while CI goes red +# (bonnyr-f5 #182 r4/r5, Major-3: a local gate that diverges from the CI command +# is not a gate). The four (in ci.yml order): +# 1. non-zero exit -> the harness itself errored +# 2. a "FAIL:" line (rc still 0) -> an assertion failed but exit was swallowed +# 3. NO "PASS:" line -> the guard was silenced / renamed: green with +# zero assertions actually run +# 4. NO "=== END SELF-TEST ===" -> the harness exited early (deleted END marker +# or an early `exit 0`) with assertions unrun +# r5 landed 3+4 here; r4 had only 1+2, so the "silenced guard" and "early exit" +# harness-break modes were CI-red but `make`-GREEN. +script-selftests: + @echo "" + @echo "=== Script self-tests ===" + @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 printf '%s\n' "$$out" | grep -qE '(^|[[:space:]])FAIL:'; then \ + echo "::error::compute_version_bump self-test reported FAIL: but exited 0"; exit 1; \ + fi; \ + if ! printf '%s\n' "$$out" | grep -qE '(^|[[:space:]])PASS:'; then \ + echo "::error::self-test produced no PASS lines -- the harness did not run"; exit 1; \ + fi; \ + if ! printf '%s\n' "$$out" | grep -qE '=== END SELF-TEST ==='; then \ + echo "::error::self-test did not reach its END marker -- it exited early with assertions unrun"; exit 1; \ + fi + @if grep -q -- '--self-test' scripts/extract-breaking-changes.sh; then \ + bash scripts/extract-breaking-changes.sh --self-test; \ + else \ + echo " (extract-breaking-changes.sh has no --self-test yet; it lands with #179)"; \ + fi + +# Aggregate: every CI gate that is not already covered by quick-check/tests. +ci-gates: shellcheck version-check commit-lint script-selftests secret-scan + @echo "" + @echo "=== CI-parity gates passed ===" # Convenience: start/stop/restart all (platform-aware) up: ensure-artifact-network @@ -694,8 +769,10 @@ quick-check: lint typecheck-backend openapi-types-check check-migrations # ── Pre-push (~90s parallel): mirrors ALL CI jobs ─────────────────────────── # Run once before git push. Runs test suites in parallel for speed. -# Prerequisite: quick-check runs first (sequential), then tests fan out. -pre-push: quick-check +# Prerequisite: quick-check runs first (sequential), then the CI-parity gates +# (shellcheck / version-check / commit-lint / script-selftests / secret-scan -- +# bonnyr-f5 #182 r3, so `make pre-push` genuinely == CI), then tests fan out. +pre-push: quick-check ci-gates @echo "" @echo "=== Running all test suites in parallel... ===" @failed=""; \ diff --git a/frontend-v2/package.json b/frontend-v2/package.json index 4e25c0c..7282abe 100644 --- a/frontend-v2/package.json +++ b/frontend-v2/package.json @@ -1,7 +1,7 @@ { "name": "frontend-v2", "private": true, - "version": "2.12.0", + "version": "3.1.6", "type": "module", "sideEffects": [ "*.css" diff --git a/helm/bnk-forge/Chart.yaml b/helm/bnk-forge/Chart.yaml index 950b44a..40db248 100644 --- a/helm/bnk-forge/Chart.yaml +++ b/helm/bnk-forge/Chart.yaml @@ -3,7 +3,7 @@ name: bnk-forge description: BNK-Forge — F5 BNK lifecycle / deployment platform (api, workers, beat, frontend, proxy, mcp) type: application version: 0.1.0 -appVersion: "3.0.1" +appVersion: "3.1.6" home: https://github.com/f5devcentral/bnk-forge maintainers: - name: BNK Forge Maintainers diff --git a/helm/bnk-forge/values.yaml b/helm/bnk-forge/values.yaml index bb88060..97b654d 100644 --- a/helm/bnk-forge/values.yaml +++ b/helm/bnk-forge/values.yaml @@ -16,7 +16,7 @@ global: image: pullPolicy: IfNotPresent - tag: "3.0.1" + tag: "3.1.6" # Generated/explicit secrets. If left empty, helm generates random values on # first install and reuses them on upgrade (lookup-based). diff --git a/scripts/get_dpu_pwd.sh b/scripts/get_dpu_pwd.sh index 779352a..d6f0271 100644 --- a/scripts/get_dpu_pwd.sh +++ b/scripts/get_dpu_pwd.sh @@ -1,3 +1,4 @@ +#!/usr/bin/env bash docker compose exec -it backend python -c " from database import SessionLocal from models.bare_metal import BareMetalHost diff --git a/scripts/ibm_cloud_bnk_forge.sh b/scripts/ibm_cloud_bnk_forge.sh index 39d4ff5..e781fa8 100644 --- a/scripts/ibm_cloud_bnk_forge.sh +++ b/scripts/ibm_cloud_bnk_forge.sh @@ -343,7 +343,7 @@ done docker compose up -d # 9. Wait for backend health then drop a ready marker -for i in $(seq 1 60); do +for _ in $(seq 1 60); do curl -sf http://localhost:8000/api/system/health >/dev/null 2>&1 && break || sleep 5 done touch /opt/bnk-forge/.bnk-forge-ready @@ -650,7 +650,7 @@ VM_ID="$(echo "${INST_JSON}" | jq -r '.id')" [ -n "${VM_ID}" ] && [ "${VM_ID}" != "null" ] || die "Instance creation failed." log "Waiting for the VSI to reach 'running'..." -for i in $(seq 1 60); do +for _ in $(seq 1 60); do ST="$(ibmcloud is instance "${VM_ID}" --output json | jq -r '.status')" [ "${ST}" = "running" ] && break [ "${ST}" = "failed" ] && die "Instance entered 'failed' state." @@ -684,7 +684,7 @@ log "Floating IP: ${FIP}" URL="https://${FIP}" log "Installing bnk-forge on the VSI (this can take 5–10 minutes)..." READY=0 -for i in $(seq 1 90); do +for _ in $(seq 1 90); do CODE="$(curl -sk -o /dev/null -w '%{http_code}' --connect-timeout 5 "${URL}/api/system/health" 2>/dev/null || true)" if [ "${CODE}" = "200" ]; then READY=1; break; fi sleep 10 diff --git a/scripts/lint-commit-markers.sh b/scripts/lint-commit-markers.sh new file mode 100644 index 0000000..442ddf7 --- /dev/null +++ b/scripts/lint-commit-markers.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +# +# Enforce the AGENTS.md "Commit conventions" rule -- documentation is not +# enforcement (#166; bonnyr-f5 #182 r3). Shared by the ci.yml `commit-lint` job, +# `make commit-lint`, and .githooks/pre-push, so a local run == CI. +# +# FAILS a commit-message range on any of: +# +# 1. A CI-control marker anywhere in subject or body. GitHub scans the whole +# message, so one of these sitting even in prose SUPPRESSES the workflow run +# for that commit -- and the gates that get skipped (ShellCheck, Secret Scan, +# Script Self-Tests) are exactly the ones that matter. Bit us on #179/#181. +# The `skip-checks: true` commit-check trailer is caught too (bonnyr-f5 #182 +# r4): it is GitHub's documented way to suppress ALL required checks and is +# not a bracketed token, so the fixed-string list alone would miss it. +# +# 2. A line that STARTS a major-version-bump declaration as prose rather than a +# real Conventional Commits footer. compute_version_bump.sh bumps major on +# any `\bBREAKING[[:space:] -]+CHANGE\b`, so a bold "**BREAKING CHANGE**" +# heading, a bullet "- BREAKING CHANGE", a block-quoted "> BREAKING CHANGE", +# an indented one, or a bare colon-less line all spuriously ship a major +# release. A PROPER footer -- a line of the exact canonical form +# `BREAKING CHANGE: ` (or `BREAKING-CHANGE: `), column 0, no +# markdown, single separator -- is the intended, documented, self-tested +# mechanism and is ALLOWED. (Mid-line prose mentions are a separate, +# pre-existing greediness in the detector itself, owned by the +# version-tooling PRs #179/#180; this gate does not touch them.) +# +# EXEMPT: the release bot's own commits (subject `^release: `). release.yml's +# promotion commits are of the form "release: vX.Y.Z [skip ci]" -- that marker +# is DELIBERATE (release.yml's loop-guard filters them so a release push does not +# re-trigger a release). Linting them would turn the staging->main promotion +# range red -> the CI Gate fails -> release.yml's preflight refuses that SHA -> +# main never releases again (bonnyr-f5 #182 r4, INV-4/INV-28: a gate that forbids +# a token must exempt the machine identity told to emit it). The exemption is +# scoped to the exact release-bot subject prefix, so a HUMAN quoting a marker in +# any other commit is still caught. +# +# EXEMPT (2nd machine identity, bonnyr-f5 #182 r5, BLOCKER-1): GitHub's own +# squash-merge composer -- committer `GitHub `, single +# parent. Its body is machine-composed from the PR description and the commit is +# already merged (the new tip of staging/main), so it is unamendable and already +# past the pre-merge gate. On a push to staging/main the range `before..tip` +# scans this squash tip; without the exemption a BREAKING-CHANGE bullet or a +# quoted marker in the summarised body reddens the push's ci-gate and release.yml +# then refuses to release that SHA. Human commits never carry this committer +# identity, so they are still fully linted in their own PR. +# +# RANGE (env): "base..head" to scan. If unset/empty, defaults to +# @{upstream}..HEAD, else just the tip commit. Never scans all history (old +# release-bot commits legitimately carry the deliberate skip marker). An +# explicitly-set RANGE that does not resolve is a HARD failure -- we never +# silently fall back to scanning the tip while claiming we scanned the range +# (bonnyr-f5 #182 r4; matches secret-scan.sh's fail-closed behaviour). +set -uo pipefail + +# Resolve the commit list without ever falling back to full history, and fail +# closed when an explicit RANGE is unresolvable. +if [ -n "${RANGE:-}" ]; then + if ! commits="$(git rev-list "$RANGE" 2>/dev/null)"; then + echo "::error::commit-lint: RANGE '$RANGE' is not a resolvable revision range -- the scan did not run" + exit 1 + fi +elif upstream="$(git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null)"; then + commits="$(git rev-list "${upstream}..HEAD" 2>/dev/null || true)" + [ -z "$commits" ] && commits="$(git rev-list -1 HEAD)" +else + commits="$(git rev-list -1 HEAD)" +fi + +# CI-control markers (matched case-insensitively, as fixed strings). +markers=('[skip ci]' '[ci skip]' '[no ci]' '[skip actions]' '[actions skip]') + +fail=0 +n=0 +while IFS= read -r sha; do + [ -z "$sha" ] && continue + n=$((n + 1)) + msg="$(git log -1 --format='%B' "$sha")" + subject="$(git log -1 --format='%s' "$sha")" + + # Release-bot commits are exempt (see header): machine identity told to emit + # the marker. A human quoting a marker in any non-release commit is still hit. + if grep -qE '^release: ' <<< "$subject"; then + echo "commit-lint: commit $sha ($subject) is a release-bot commit -- exempt" + continue + fi + + # GitHub's squash-merge composer is the SECOND machine identity on the + # promotion path (bonnyr-f5 #182 r5, BLOCKER-1 / INV-28). When a PR is + # squash-merged, GitHub composes the resulting commit's BODY from the PR + # description under the identity `GitHub ` with a single + # parent. That commit is (a) UNAMENDABLE -- its body is machine-composed, and + # (b) ALREADY MERGED -- it is the new tip of staging/main, so re-linting it + # serves no pre-merge purpose (the human commits it summarises were linted in + # their own PR). On a push to staging/main the range is `before..tip`, so the + # just-merged squash tip IS scanned; a stray line-start "BREAKING CHANGE" or a + # marker quoted from the summarised PR body then turns the push's ci-gate red + # and release.yml's preflight refuses that SHA -- the pipeline stops releasing. + # Exempting this machine identity (mirroring the `^release: ` exemption) means + # the gate never judges already-merged, machine-composed history, while every + # HUMAN-authored commit -- which never carries this committer identity -- is + # still linted in its own PR. This is an identity check on the committer, not a + # spoofable subject allowlist. Scoped to single-parent commits so a genuine + # non-squash merge is not blanket-exempted. + committer="$(git log -1 --format='%cn <%ce>' "$sha")" + nparents="$(git log -1 --format='%p' "$sha" | wc -w)" + if [ "$committer" = "GitHub " ] && [ "$nparents" -eq 1 ]; then + echo "commit-lint: commit $sha ($subject) is a GitHub-composed squash commit (already-merged machine identity) -- exempt" + continue + fi + + for m in "${markers[@]}"; do + if grep -iqF -- "$m" <<< "$msg"; then + echo "::error::commit $sha ($subject): message contains CI-control marker \"$m\" -- it would suppress the workflow run. Refer to it indirectly (e.g. \"the skip-CI marker\") or split it across backticks." + fail=1 + fi + done + + # GitHub's documented commit-check trailer suppresses ALL required checks; it + # is a key:value trailer, not a bracketed token, so the fixed-string list above + # would miss it. + if grep -iqE '^[[:space:]]*skip-checks:[[:space:]]*true\b' <<< "$msg"; then + echo "::error::commit $sha ($subject): message carries the 'skip-checks: true' trailer -- it suppresses all required checks. Remove it or refer to it indirectly." + fail=1 + fi + + # A line that OPENS with a major-bump declaration -- in any of the shapes a + # human writes (bare, bold, bulleted, block-quoted, indented) -- spuriously + # majors a release, because the detector fires on the token anywhere. + while IFS= read -r line; do + # Peel a leading run of markdown/quote/whitespace/bold so we judge the line + # by the shape a human wrote, not just a bare column-0 token. + stripped="$(sed -E 's/^[[:space:]]*([>*+-][[:space:]]*)*//' <<< "$line")" + if grep -qE '^BREAKING[[:space:] -]+CHANGE' <<< "$stripped"; then + # ...allowed ONLY as the exact canonical footer at column 0: no leading + # prefix, no markdown, a single separator, real ": ". + if grep -qE '^BREAKING[ -]CHANGE: .' <<< "$line"; then + continue + fi + echo "::error::commit $sha ($subject): line \"$line\" starts a BREAKING CHANGE declaration that is not a plain Conventional Commits footer -- it spuriously triggers a major release. Use a real footer 'BREAKING CHANGE: ' at column 0 or reword (e.g. lowercase 'breaking-change')." + fail=1 + fi + done <<< "$msg" +done <<< "$commits" + +echo "commit-lint: scanned $n commit(s) in range '${RANGE:-}'" +if [ "$fail" -ne 0 ]; then + echo "::error::commit-lint failed -- see markers above." + exit 1 +fi +echo "commit-lint: OK" diff --git a/scripts/secret-scan.sh b/scripts/secret-scan.sh new file mode 100644 index 0000000..6fd0002 --- /dev/null +++ b/scripts/secret-scan.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# +# Single source of truth for the gitleaks secret scan + its assertion backstop. +# +# Called by BOTH .github/workflows/ci.yml (the secret-scan job and the scheduled +# baseline job) AND `make secret-scan` / `make pre-push`, so a local run is +# byte-identical to CI -- #166: "a local gate that does not run the CI command is +# not a gate", and ci.yml's header claims `make pre-push` == CI. +# +# The gate must be able to tell "clean scan" from "did not run". gitleaks exits 0 +# on a bad revision range OR a git dubious-ownership refusal, printing +# "ERR [git] ..." + "0 commits scanned" -- a silently blind green gate +# (bonnyr-f5 #182 r3 BLOCKER). So we capture the output and FAIL on: any +# "ERR [git]" line, a missing "commits scanned" line, 0 commits for a non-empty +# range, or a non-zero gitleaks exit (leaks found). +# +# RANGE selection: +# * If the RANGE env var is SET (even to empty), it is used verbatim -- empty +# means "scan all reachable history" (the scheduled baseline + first push). +# CI computes it from the triggering event. +# * If RANGE is UNSET, a local default is computed: everything since HEAD +# diverged from its upstream tracking branch, falling back to full history. +set -uo pipefail + +# gitleaks v8.30.1, pinned by digest so a re-tag cannot change what runs +# (bonnyr-f5 #182 r3 nit). Update the version comment when bumping the digest. +IMAGE="ghcr.io/gitleaks/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f" # v8.30.1 + +repo_dir="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" + +# Resolve the range (see header). ${RANGE+set} distinguishes unset from empty. +if [ -n "${RANGE+set}" ]; then + range="$RANGE" +else + range="" + if upstream="$(git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null)"; then + if base="$(git merge-base "$upstream" HEAD 2>/dev/null)"; then + range="${base}..HEAD" + fi + fi +fi + +echo "gitleaks scanning range: ${range:-} (repo: $repo_dir)" + +# safe.directory whitelists the mount so git 2.35.2+ does not refuse it for +# dubious ownership (the image runs as root; the checkout is owned by another +# uid). GIT_CONFIG_* needs no writable HOME, unlike `git config --global`. +# --max-archive-depth 2: without it gitleaks defaults to 0 and NEVER looks inside +# tracked archives, so a secret shipped in a tarball is invisible (bonnyr-f5 #182 +# r3 Major). Depth 2 covers e.g. a key inside a .tar.gz inside a .zip. +out="$(docker run --rm \ + -e GIT_CONFIG_COUNT=1 -e GIT_CONFIG_KEY_0=safe.directory -e GIT_CONFIG_VALUE_0=/repo \ + -v "$repo_dir:/repo:ro" -w /repo "$IMAGE" detect \ + --source=/repo --config=/repo/.gitleaks.toml --redact --verbose \ + --max-archive-depth 2 \ + ${range:+--log-opts="$range"} 2>&1)" +rc=$? +printf '%s\n' "$out" + +# Strip ANSI so parsing is robust whether or not gitleaks colourises. +clean="$(printf '%s\n' "$out" | sed -E 's/\x1b\[[0-9;]*m//g')" + +# 1) Any git error (bad range, dubious ownership) means the scan never saw the +# repo/range -- fail even though gitleaks exited 0. +if grep -qE 'ERR \[git\]' <<< "$clean"; then + echo "::error::gitleaks hit a git error (bad revision range or dubious ownership) -- the scan did not run" + exit 1 +fi + +# 2) Positive evidence the scan ran: gitleaks always prints " commits scanned" +# in git mode. No such line == silence == must not pass. +scanned="$(grep -oE '[0-9]+ commits scanned' <<< "$clean" | grep -oE '^[0-9]+' | tail -n1)" +echo "gitleaks reported commits scanned: ${scanned:-}" +if [ -z "$scanned" ]; then + echo "::error::gitleaks printed no 'commits scanned' line -- no evidence the scan ran" + exit 1 +fi + +# 3) A non-empty range that scanned 0 commits scanned NOTHING. +if [ -n "$range" ] && [ "$scanned" -eq 0 ]; then + echo "::error::gitleaks scanned 0 commits for range $range -- the gate would have passed blind" + exit 1 +fi + +# 4) Real leaks make gitleaks exit non-zero -- that must still fail here. +if [ "$rc" -ne 0 ]; then + echo "::error::gitleaks exited $rc (leaks found or scan error)" + exit "$rc" +fi + +echo "secret-scan: OK" diff --git a/scripts/sync-version-artifacts.sh b/scripts/sync-version-artifacts.sh new file mode 100644 index 0000000..f787b67 --- /dev/null +++ b/scripts/sync-version-artifacts.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Keep every version-bearing artifact in lockstep with VERSION. +# +# The release job bumps VERSION but historically nothing else, so the Helm chart +# pinned an image tag the release never publishes (:3.0.1) -> ImagePullBackOff, +# and frontend package.json drifted (PR #177 review, Blocker 2). This is the one +# place that writes them, and the same code checks them in CI so drift can't +# reappear silently. +# +# Usage: +# sync-version-artifacts.sh --write # set all artifacts to +# sync-version-artifacts.sh --check # verify all == VERSION; exit 1 if not +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +VALUES="$ROOT/helm/bnk-forge/values.yaml" +CHART="$ROOT/helm/bnk-forge/Chart.yaml" +PKG="$ROOT/frontend-v2/package.json" + +# Read each artifact's current version. +_helm_tag() { grep -m1 -E '^ tag: ' "$VALUES" | sed -E 's/^ tag: "?([^"]*)"?.*/\1/'; } +_appversion() { grep -m1 -E '^appVersion:' "$CHART" | sed -E 's/^appVersion: "?([^"]*)"?.*/\1/'; } +_pkg_version() { grep -m1 -E '^ "version":' "$PKG" | sed -E 's/^ "version": "([^"]*)".*/\1/'; } + +case "${1:-}" in + --write) + V="${2:?usage: sync-version-artifacts.sh --write }" + # The global image tag (2-space indent) — per-service tags are 4-space and + # fall back to it; postgres/redis tags are external and left alone. + sed -i -E "s|^ tag: .*| tag: \"${V}\"|" "$VALUES" + sed -i -E "s|^appVersion: .*|appVersion: \"${V}\"|" "$CHART" + sed -i -E "s|^ \"version\": \"[^\"]*\"| \"version\": \"${V}\"|" "$PKG" + # Fail closed: a sed whose pattern matched nothing no-ops silently, and the + # caller commits the unchanged file [skip ci] believing it synced (#180 + # review). Re-read each artifact with the same helpers --check trusts and + # confirm it actually took ${V}. + rc=0 + for pair in "helm image.tag:$(_helm_tag)" "Chart appVersion:$(_appversion)" "frontend package.json:$(_pkg_version)"; do + name="${pair%%:*}"; got="${pair#*:}" + if [ "$got" != "$V" ]; then + echo "::error::--write did not take on $name: it is '$got', expected '$V' (the sed pattern matched nothing — the artifact's format changed)" >&2 + rc=1 + fi + done + [ "$rc" -eq 0 ] || exit 1 + echo "synced helm tag, appVersion, frontend package.json -> ${V}" + ;; + --check) + EXPECTED="$(cat "$ROOT/VERSION")" + rc=0 + for pair in "helm image.tag:$(_helm_tag)" "Chart appVersion:$(_appversion)" "frontend package.json:$(_pkg_version)"; do + name="${pair%%:*}"; got="${pair#*:}" + if [ "$got" = "$EXPECTED" ]; then + echo " OK $name = $got" + else + echo "::error::$name is '$got' but VERSION is '$EXPECTED' — the release publishes only :\${VERSION}, so a mismatch means ImagePullBackOff / drift. Run scripts/sync-version-artifacts.sh --write $EXPECTED" + rc=1 + fi + done + exit "$rc" + ;; + *) + echo "usage: sync-version-artifacts.sh --write | --check" >&2 + exit 2 + ;; +esac