ci(charts): enforce the Chart.yaml version bump instead of asking for it
Some checks failed
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Meridian Harness / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Meridian Harness / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Meridian Harness / Publish rolling release (push) Has been cancelled
Meridian Harness / Publish tagged release (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Isolated DB Gate (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Admin Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Server Cross-Compile (push) Has been cancelled
CI / Server Cross-Compile-1 (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Some checks failed
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Meridian Harness / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Meridian Harness / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Meridian Harness / Publish rolling release (push) Has been cancelled
Meridian Harness / Publish tagged release (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Isolated DB Gate (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Admin Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Server Cross-Compile (push) Has been cancelled
CI / Server Cross-Compile-1 (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
deploy/AGENTS.md has said it plainly for a long time: "Bump `Chart.yaml` `version` on any chart change. ArgoCD tracks chart versions; an unbumped chart deploys stale templates." Nothing checked it. helm lint does not, helm unittest does not, check-alert-runbooks does not, and the failure is both silent and remote -- the chart renders, the suite passes, CI is green, and the cluster keeps serving the previous templates. Commitb6443a823is the worked example. It added the entire meridian-relay.bus-reachability group plus MeridianBusPeersUnreachable to templates/prometheusrule.yaml and left Chart.yaml at 0.1.13, so a new alert was doubly unreachable: no promtool case proving it could fire, and a chart version that did not contain it. A human reading two files caught it three commits later (meridian-8s3s). The contract is differential -- "this changed without that changing" -- which is unusual here, because every other guard in scripts/ reads the current tree. Two diff-shaped placements were considered and rejected: - a merge-base diff inside `just check`. There is no trustworthy baseline. The root contract forbids branches, so work lands directly on a constantly-rebased `main`; a merge-base with origin/main is not a release boundary, and in a fresh or shallow checkout it does not exist at all. A guard that invents its baseline reports whatever the local ref happens to be. - a CI-only diff against the PR base. The baseline is real there, but it is only real on pull_request, and this repo pushes to main. It would also be invisible to `just ci` and to the pre-push hook, which is where this repo prefers to catch things -- and helm-chart.yml, the one workflow that already touches charts, is `paths`-filtered on pull_request, which is precisely the conditional gate that does not run when it matters. So the baseline is checked in instead, the same shape as scripts/n-minus-one-pins.json and .settings/stellar/snapshot.json: scripts/chart-version-manifest.json records, per chart, the sha256 of every file that renders into a cluster against the version they were last recorded at. Any edit to templates/, crds/, values*.yaml|json, Chart.yaml or Chart.lock moves a digest, and a moved digest without a higher `version` is a hard failure naming the file. No diff, no baseline branch, works in `just check` and in a shallow clone. Two normalizations make the guard say what it means. Chart.yaml is hashed with its top-level `version:` line removed, so a bump with no template change stays silent while an edit to `dependencies:` or `appVersion` still demands one; Chart.lock is hashed without `generated:`, which `helm dependency build` rewrites without changing what deploys. tests/, ci/, examples/ and README.md are deliberately outside the set -- none of them reach a cluster, and demanding a bump for a test-only edit teaches people to bump reflexively, which is the same failure wearing a hat. `--write` is not an escape hatch: it refuses to record a chart whose rendered files moved while `version` did not, printing the same message the check does. The only route to a green tree is the bump. Wired three ways, because a guard in one place is a guard someone skips: `just check` (via check-chart-version-bump), the ci.yml guards job, and a new lefthook pre-push entry -- before which a `deploy/charts/**`-only push matched no glob at all and ran zero local hooks, which is howb6443a823got out. scripts/test-chart-version-bump-guard.sh is the "prove it fires" half. It asserts the exit codes rather than the prose: refused with exit 1 on a template edit and on a values.yaml edit, `--write` refusing to launder either, exit 0 on a Chart.yaml-only bump and on a tests/ edit, every chart under deploy/charts/ actually present in the manifest, and -- when history is deep enough to reach it --b6443a823replayed against its own parent's baseline and refused for the unbumped 0.1.13. Covers all three charts: meridian@0.1.14, meridian-control-plane@0.1.1, meridian-push-gateway@0.1.0, 50 rendered files. No file under deploy/charts/ is touched by this commit. Refs: meridian-8s3s Signed-off-by: Joshua Belke <joshua@innovationhub-act.org>
This commit is contained in:
parent
610b9cac2e
commit
043361afcd
8 changed files with 927 additions and 1 deletions
15
.github/workflows/ci.yml
vendored
15
.github/workflows/ci.yml
vendored
|
|
@ -135,6 +135,21 @@ jobs:
|
|||
run: |
|
||||
node scripts/check-unwrap-budget.mjs
|
||||
node --test scripts/check-unwrap-budget-core.test.mjs
|
||||
- name: Chart version bump contract
|
||||
# ArgoCD tracks chart versions: a template edited without a Chart.yaml
|
||||
# `version` bump deploys the PREVIOUS templates, silently and remotely
|
||||
# (b6443a823 shipped an entire alert group that way at 0.1.13). Lives
|
||||
# here rather than in helm-chart.yml on purpose — that workflow is
|
||||
# `paths`-filtered on pull_request, and this repo pushes straight to
|
||||
# main, so it is exactly the conditional gate that does not run when it
|
||||
# matters. State-based against scripts/chart-version-manifest.json, so
|
||||
# it needs no diff and no baseline branch; unconditional and
|
||||
# self-contained (tracked chart files plus its own scratch copies).
|
||||
run: |
|
||||
node scripts/check-chart-version-bump.mjs
|
||||
node --test scripts/check-chart-version-bump-core.test.mjs
|
||||
bash scripts/test-chart-version-bump-guard.sh
|
||||
|
||||
- name: ReductStore Zenoh config guard
|
||||
# Two one-token omissions in one environment variable, each converting
|
||||
# the bytes tier into something DIAGRAM.md refuses by name — a second
|
||||
|
|
|
|||
16
Justfile
16
Justfile
|
|
@ -206,7 +206,7 @@ build-release:
|
|||
# `desktop-check` / `web-check` are biome plus the guard scripts — neither runs
|
||||
# `tsc`. Without the typecheck recipes here, this whole gate passes on a tree
|
||||
# that does not compile, and the first thing to notice is CI's `desktop-build`.
|
||||
check: fmt-check clippy desktop-check desktop-typecheck desktop-tauri-fmt-check desktop-tauri-clippy web-check web-typecheck mobile-check compose-check scoped-commit-check launcher-check check-docker-context check-git-hooks backup-restore-check git-pointer-repair-contract nip34-search-rollout-check reply-persistence-benchmark-contract capacity-contract-check check-kinds perf-check zenoh-check check-skills check-stellar check-gauntlet check-architecture-map check-alert-runbooks check-brand check-feature-specs check-mips check-unwrap-budget check-relay-e2e-inventory check-frozen-migrations check-legacy-namespaces check-relay-terminology check-npm-supply-chain check-external-copy check-reductstore-zenoh
|
||||
check: fmt-check clippy desktop-check desktop-typecheck desktop-tauri-fmt-check desktop-tauri-clippy web-check web-typecheck mobile-check compose-check scoped-commit-check launcher-check check-docker-context check-git-hooks backup-restore-check git-pointer-repair-contract nip34-search-rollout-check reply-persistence-benchmark-contract capacity-contract-check check-kinds perf-check zenoh-check check-skills check-stellar check-gauntlet check-architecture-map check-alert-runbooks check-chart-version-bump check-brand check-feature-specs check-mips check-unwrap-budget check-relay-e2e-inventory check-frozen-migrations check-legacy-namespaces check-relay-terminology check-npm-supply-chain check-external-copy check-reductstore-zenoh
|
||||
|
||||
# Guard run.sh's env layering and its process teardown.
|
||||
#
|
||||
|
|
@ -516,6 +516,20 @@ check-relay-e2e-inventory:
|
|||
node scripts/check-relay-e2e-inventory.mjs
|
||||
node --test scripts/check-relay-e2e-inventory-core.test.mjs
|
||||
|
||||
# ArgoCD tracks chart versions, so a template edited without a `Chart.yaml`
|
||||
# `version` bump deploys the PREVIOUS templates — silently, and remotely: the
|
||||
# chart renders, helm-unittest passes, CI is green. Commit b6443a823 added an
|
||||
# entire alert group at 0.1.13 exactly that way. The contract is differential
|
||||
# but this repo's guards run on a working tree with no trustworthy baseline
|
||||
# (`main` is pushed to directly and rebased, so a merge-base is not a release
|
||||
# boundary), so the baseline is checked in: scripts/chart-version-manifest.json
|
||||
# records each chart's rendered bytes against the version they were recorded at.
|
||||
# After a legitimate bump, `just check-chart-version-bump --write`.
|
||||
check-chart-version-bump *ARGS:
|
||||
node scripts/check-chart-version-bump.mjs {{ARGS}}
|
||||
node --test scripts/check-chart-version-bump-core.test.mjs
|
||||
bash scripts/test-chart-version-bump-guard.sh
|
||||
|
||||
# Applied migrations are immutable. Pin exact bytes to the attested schema-26
|
||||
# N-1 image so comments or branding edits cannot break SQLx rollout checksums.
|
||||
check-frozen-migrations:
|
||||
|
|
|
|||
10
lefthook.yml
10
lefthook.yml
|
|
@ -126,3 +126,13 @@ pre-push:
|
|||
mobile-test:
|
||||
glob: ["REMAPPING/meridian-mobile/**"]
|
||||
run: just mobile-test
|
||||
# A chart-only change matches no other glob above, so before this entry a
|
||||
# `deploy/charts/**` push ran ZERO local hooks. That is how b6443a823
|
||||
# reached main with an alert group added at Chart.yaml 0.1.13: ArgoCD tracks
|
||||
# chart versions, so the cluster kept the previous templates and nothing
|
||||
# anywhere said so. Cheap (node, no toolchain) and state-based, so it needs
|
||||
# no diff. `scripts/**` is in the glob because the manifest and the guard
|
||||
# itself live there.
|
||||
chart-version-bump:
|
||||
glob: ["deploy/charts/**", "scripts/chart-version-manifest.json", "scripts/check-chart-version-bump*.mjs", "scripts/test-chart-version-bump-guard.sh"]
|
||||
run: just check-chart-version-bump
|
||||
|
|
|
|||
69
scripts/chart-version-manifest.json
Normal file
69
scripts/chart-version-manifest.json
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
{
|
||||
"charts": {
|
||||
"meridian": {
|
||||
"version": "0.1.14",
|
||||
"files": {
|
||||
"Chart.lock": "50eb68b0b46af4d2133105553ff2321b057ce080984a7ffd52d8fd2eb19c8621",
|
||||
"Chart.yaml": "b77c4e5d55e276f77d8c5c34d24a25f512249fd57ff202dfd64d0dd8288c6ba8",
|
||||
"templates/NOTES.txt": "69296027b5bf2254652cd44d1a661ef7f3166db8f7c7940aa730479ca9dca68e",
|
||||
"templates/_helpers.tpl": "bcf8dcb298b676d14a27ade7da8f7c356d37184170f638f8be7856c2f6a5c97f",
|
||||
"templates/_validate.tpl": "00b975cc262a71ad103e0954ace1780908c533f72cdd3010255b9f48efd11154",
|
||||
"templates/deployment.yaml": "e83b065ca3a6ac6c646d66c6f70a2f5450a288ffa4c1ec8516a255313bdfa12b",
|
||||
"templates/extramanifests.yaml": "8a48acb3e2ce66b2f0ab0bba52447dee7889f3fcb93b57279a759c509330aa32",
|
||||
"templates/hpa.yaml": "e07e6f6d404394579333967120697bcd0c74e78ff9bf776429b4fe7a1e28f145",
|
||||
"templates/httproute.yaml": "44f172a61dd352264d08367af8d5211076316fd52d2fd053ad679b9077df693e",
|
||||
"templates/ingress.yaml": "85ed280e932aff1d51f852ab6595c20c8aa5cc20faba0112ea6690493f61d3f8",
|
||||
"templates/networkpolicy.yaml": "d38ae44a252b5cbbd6edd7d9f58dfbe723802bb3a1cb9f2424c4f92897db3924",
|
||||
"templates/pairing-relay.yaml": "d268043ad048eb1c6d16c09a67109ccd03fd8c3aaaaf93673a3dac02c60a7999",
|
||||
"templates/pdb.yaml": "d92e7533fac98f76318d1674c48f17a952ffb71199810a997504a36e4cd6f0d1",
|
||||
"templates/prometheusrule.yaml": "c8660d9b92118cf5afd205a5d5c5a4ec86edc070683a1ff1be0f513835615b16",
|
||||
"templates/pvc-git.yaml": "35e08190d86264bdb382995ba669d89026d720991ead1edf56e155056166ddbc",
|
||||
"templates/quickstart-minio-init.yaml": "bf9a9c4f66029ecaeeb932b5fdc760f4fbb38e42005bac4eff3ab4ba033c1c06",
|
||||
"templates/quickstart-minio.yaml": "c6eea36ba9910c0b45e8a527992f44a84314984a20735c21a42d5bb4a35d7061",
|
||||
"templates/secret-chart.yaml": "ae75aa19992fb3e7e3c0ce8124073599fc574f3c821bcd5d9328cc051cc58e7c",
|
||||
"templates/service.yaml": "e0b6060dd0aae42bec989cd36f4b66a24c271fa31a7f628c7ce87a436385dfa0",
|
||||
"templates/serviceaccount.yaml": "51a2666462df52cb76193a8f3b5b878aae0b7a2f65d9d44e719a9f958d55cbae",
|
||||
"templates/servicemonitor.yaml": "c67cad408687ff030c10ec7c3f36d2b3500f31d48d50a4eb469e77668191fa00",
|
||||
"values.schema.json": "117feed96aa6b117518272e44917bd05e542cef7e3cd60c423488cac42d9fb20",
|
||||
"values.yaml": "dedd4b5a873682b893da479cd492975fffd5277cbcc29d6309e1c51ca474bfe3"
|
||||
}
|
||||
},
|
||||
"meridian-control-plane": {
|
||||
"version": "0.1.1",
|
||||
"files": {
|
||||
"Chart.yaml": "838b41b449587ce5d93789b3df1cdac3010f47f6f197a3ab60dd6c8ef2b6d7c7",
|
||||
"templates/_helpers.tpl": "3dbde484418a124b517fc6d7857b2a87a4e52ace3443b3a71c44528fc87defd9",
|
||||
"templates/deployment.yaml": "66228891fc12551e08eca5b58899af7456071a9428bee068bc59ed752c41277d",
|
||||
"templates/hpa.yaml": "f7bc9785690b56d9889190cc22361f7cff8f94cabe3e183c372155f624b8eeb7",
|
||||
"templates/httproute.yaml": "4fe97a00c87c862998f95de4be832350899bdb4b278e89073ef83f43af9ae96b",
|
||||
"templates/migration-job.yaml": "4d97256928a813e1ab23b62437ca6c6f76a1faf7f1f26586b3f074bbeaec6fe2",
|
||||
"templates/networkpolicy.yaml": "1002c5e2511fcfae79b671e406420bce4c5be0bc63cde2d6ad882753c6b34ab4",
|
||||
"templates/pdb.yaml": "f98ff3e596b3404df532892c01e125c915a1f4f1fcd70c183c40e784113b64ac",
|
||||
"templates/podmonitor.yaml": "c87eaa765739ab4be8b0b68fd0f375cfca19b0fcd006172a5497a7511760c61a",
|
||||
"templates/prometheusrule.yaml": "0d556e637d4d3a615073e973a4b4288d2d3b43128b114065528ccacb52827689",
|
||||
"templates/service.yaml": "898efa6f6b0d2bc179a4aec0b0f5985e1b6e1e1648b7584db5238aad2a468d78",
|
||||
"values.schema.json": "b3298b304441a6f0b9cbadf05359583d8d3cd6f48aa953947dd2c6233e388090",
|
||||
"values.yaml": "642e2b5b016d447ff7f1629c645a1e7ba7b46750de74b0709df94a63e1581cf3"
|
||||
}
|
||||
},
|
||||
"meridian-push-gateway": {
|
||||
"version": "0.1.0",
|
||||
"files": {
|
||||
"Chart.yaml": "96f5399b8e21db785327751af3ab6eae4f035563d1090b9379c01477b079292c",
|
||||
"templates/_helpers.tpl": "58eed891df54ae2e09d0ac8a82295a3a4a7e7d2b9a72e8a35c35683039183967",
|
||||
"templates/deployment.yaml": "dd5159219041bbc57317899b438501f5860615c35d074aec661741cee47f0b63",
|
||||
"templates/httproute.yaml": "d40d10ec187fb6d8af6af38a30996168c76784ee797cec4e8c95b6c068d874e1",
|
||||
"templates/migration-job.yaml": "d9c6b4ad7f20a0cdcda547bf95d91f11291c4f5a1c1bc9f37538d2b317e1338a",
|
||||
"templates/migration-networkpolicy.yaml": "b26e6f5d1cd302f7f769c06c7e70b6b1cc2c29ba38300e9dd21d0e0be39912bd",
|
||||
"templates/networkpolicy.yaml": "be9dddc9acc9cd96ea3a21c5427c4aa4b9d0e4486f12a5b072d7a9cd7dbfa412",
|
||||
"templates/pdb.yaml": "ab93bc274e6063770ea858646c95c9e6cdd2b77056104c020388cbdb515a04c4",
|
||||
"templates/podmonitor.yaml": "3ce0cf3393b7262e98d82959b76de23cc45d4c1cb5ddd00566bbb130a1af1032",
|
||||
"templates/prometheusrule.yaml": "4b98c1be2df7cb84982c1a3bda860621f5408cfa554dc38f856d5dba30f984b1",
|
||||
"templates/service.yaml": "a591a76e69906625a140c388dd9b59dfaee9d42b9c51d4ec9ec2bdc7202a44bf",
|
||||
"values-production.yaml": "b3211496cfbd76041e1e5b3edc6a35d5db4b5e9a18f661caaf98b8e11682ec63",
|
||||
"values.schema.json": "a694a68124a3efccef43527ffc2df8a73083cf64019e7c28b8a4365a3e99b7ac",
|
||||
"values.yaml": "2a7be447028ca930f53bf06d32d86189ae22c3183524794eba033e5b1973c128"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
248
scripts/check-chart-version-bump-core.mjs
Normal file
248
scripts/check-chart-version-bump-core.mjs
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
/**
|
||||
* Chart version-bump contract — pure logic.
|
||||
*
|
||||
* deploy/AGENTS.md: "Bump `Chart.yaml` `version` on any chart change. ArgoCD
|
||||
* tracks chart versions; an unbumped chart deploys stale templates."
|
||||
*
|
||||
* That contract is *differential* ("changed without bumping") but this repo's
|
||||
* guards run against a working tree, where there is no trustworthy baseline to
|
||||
* diff against: `main` is pushed to directly and rebased constantly, so a
|
||||
* merge-base is not a release boundary. So the baseline is checked in instead —
|
||||
* `scripts/chart-version-manifest.json` records, per chart, the version at which
|
||||
* each rendered file's bytes were last recorded. The same shape as
|
||||
* `scripts/n-minus-one-pins.json` (frozen migrations) and
|
||||
* `.settings/stellar/snapshot.json`: a tracked snapshot, so accepting a change
|
||||
* is a reviewable diff rather than an invisible re-scan.
|
||||
*
|
||||
* Guarded = what Helm actually renders into a cluster: `templates/`, `crds/`,
|
||||
* every top-level `values*.yaml|json`, `Chart.yaml` and `Chart.lock`.
|
||||
* Not guarded: `tests/`, `ci/`, `examples/`, `README.md` — none of them reach a
|
||||
* cluster, and forcing a version bump for a test-only edit would train people
|
||||
* to bump reflexively, which is the same failure wearing the other hat.
|
||||
*
|
||||
* `Chart.yaml` is hashed with its top-level `version:` line REMOVED, so a
|
||||
* version-only bump changes no digest and the guard stays silent — while an
|
||||
* edit to `dependencies:` or `appVersion` still demands a bump. `Chart.lock` is
|
||||
* hashed without its `generated:` timestamp, which `helm dependency build`
|
||||
* rewrites without changing what deploys.
|
||||
*/
|
||||
|
||||
/** Files at the chart root whose bytes reach a cluster. */
|
||||
const GUARDED_ROOT_FILE =
|
||||
/^(?:values[^/]*\.(?:ya?ml|json)|Chart\.ya?ml|Chart\.lock)$/;
|
||||
|
||||
/** Subdirectories of a chart whose contents reach a cluster. */
|
||||
const GUARDED_DIRS = ["templates", "crds"];
|
||||
|
||||
/**
|
||||
* Is `relPath` (POSIX, relative to the chart directory) part of the rendered
|
||||
* artifact? `charts/` is excluded: it holds fetched subchart tarballs, which
|
||||
* `.gitignore` already drops and `Chart.lock` already pins.
|
||||
*/
|
||||
export function isGuardedChartPath(relPath) {
|
||||
if (relPath.startsWith(".")) return false;
|
||||
const slash = relPath.indexOf("/");
|
||||
if (slash === -1) return GUARDED_ROOT_FILE.test(relPath);
|
||||
const top = relPath.slice(0, slash);
|
||||
return GUARDED_DIRS.includes(top);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the top-level `version:` line from a Chart.yaml so that a bump alone
|
||||
* leaves the digest untouched. Anchored at column 0: subchart `version:` keys
|
||||
* under `dependencies:` are indented and stay in the digest, because changing
|
||||
* one changes what deploys.
|
||||
*/
|
||||
export function normalizeChartYaml(text) {
|
||||
return text
|
||||
.split("\n")
|
||||
.filter((line) => !/^version:/.test(line))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop `Chart.lock`'s `generated:` timestamp. `helm dependency build` rewrites
|
||||
* it on every run; the `digest:` and pinned subchart versions above it are the
|
||||
* part that decides what deploys, and they stay in the hash.
|
||||
*/
|
||||
export function normalizeChartLock(text) {
|
||||
return text
|
||||
.split("\n")
|
||||
.filter((line) => !/^generated:/.test(line))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/** Normalization applied before hashing, keyed by chart-relative path. */
|
||||
export function normalizeForDigest(relPath, text) {
|
||||
if (relPath === "Chart.yaml" || relPath === "Chart.yml") {
|
||||
return normalizeChartYaml(text);
|
||||
}
|
||||
if (relPath === "Chart.lock") return normalizeChartLock(text);
|
||||
return text;
|
||||
}
|
||||
|
||||
/** The top-level `version:` value of a Chart.yaml, or null. */
|
||||
export function parseChartVersion(text) {
|
||||
const match = text.match(/^version:\s*["']?([^"'#\s]+)["']?\s*(?:#.*)?$/m);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
const SEMVER =
|
||||
/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
|
||||
|
||||
function comparePrerelease(a, b) {
|
||||
if (a === undefined && b === undefined) return 0;
|
||||
// A version without a prerelease outranks one with (semver §11.3).
|
||||
if (a === undefined) return 1;
|
||||
if (b === undefined) return -1;
|
||||
const left = a.split(".");
|
||||
const right = b.split(".");
|
||||
for (let i = 0; i < Math.max(left.length, right.length); i += 1) {
|
||||
const l = left[i];
|
||||
const r = right[i];
|
||||
if (l === undefined) return -1;
|
||||
if (r === undefined) return 1;
|
||||
const lNum = /^\d+$/.test(l);
|
||||
const rNum = /^\d+$/.test(r);
|
||||
if (lNum && rNum) {
|
||||
if (Number(l) !== Number(r)) return Number(l) < Number(r) ? -1 : 1;
|
||||
} else if (lNum !== rNum) {
|
||||
return lNum ? -1 : 1;
|
||||
} else if (l !== r) {
|
||||
return l < r ? -1 : 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Semver compare. Throws on anything Helm would refuse to publish. */
|
||||
export function compareSemver(a, b) {
|
||||
const left = SEMVER.exec(a);
|
||||
const right = SEMVER.exec(b);
|
||||
if (!left) throw new Error(`chart version "${a}" is not semver`);
|
||||
if (!right) throw new Error(`chart version "${b}" is not semver`);
|
||||
for (let i = 1; i <= 3; i += 1) {
|
||||
const l = Number(left[i]);
|
||||
const r = Number(right[i]);
|
||||
if (l !== r) return l < r ? -1 : 1;
|
||||
}
|
||||
return comparePrerelease(left[4], right[4]);
|
||||
}
|
||||
|
||||
function diffFiles(recorded, actual) {
|
||||
const changed = [];
|
||||
const added = [];
|
||||
const removed = [];
|
||||
for (const [path, digest] of Object.entries(actual)) {
|
||||
if (!(path in recorded)) added.push(path);
|
||||
else if (recorded[path] !== digest) changed.push(path);
|
||||
}
|
||||
for (const path of Object.keys(recorded)) {
|
||||
if (!(path in actual)) removed.push(path);
|
||||
}
|
||||
return {
|
||||
changed: changed.sort(),
|
||||
added: added.sort(),
|
||||
removed: removed.sort(),
|
||||
any: changed.length + added.length + removed.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
function describe({ changed, added, removed }) {
|
||||
return [
|
||||
...changed.map((p) => `changed ${p}`),
|
||||
...added.map((p) => `added ${p}`),
|
||||
...removed.map((p) => `removed ${p}`),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} input
|
||||
* @param {Record<string, {version: string, files: Record<string,string>}>} input.charts
|
||||
* what is on disk now
|
||||
* @param {{charts: Record<string, {version: string, files: Record<string,string>}>}} input.manifest
|
||||
* the checked-in baseline
|
||||
* @returns {{violations: string[], staleness: string[], notes: string[], next: object}}
|
||||
* `violations` are contract breaches (rendered bytes moved without a version
|
||||
* bump). `staleness` are bookkeeping errors that `--write` resolves.
|
||||
* `notes` are non-fatal. `next` is the manifest `--write` should produce, and
|
||||
* is only safe to write when `violations` is empty.
|
||||
*/
|
||||
export function validateChartVersionBump({ charts, manifest }) {
|
||||
const violations = [];
|
||||
const staleness = [];
|
||||
const notes = [];
|
||||
const recordedCharts = manifest?.charts ?? {};
|
||||
const next = { charts: {} };
|
||||
|
||||
for (const name of Object.keys(recordedCharts).sort()) {
|
||||
if (!(name in charts)) {
|
||||
staleness.push(
|
||||
`${name}: recorded in the manifest but deploy/charts/${name} no longer has a Chart.yaml — run \`just check-chart-version-bump --write\` to drop it`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of Object.keys(charts).sort()) {
|
||||
const disk = charts[name];
|
||||
const recorded = recordedCharts[name];
|
||||
|
||||
if (!disk.version) {
|
||||
violations.push(`${name}: Chart.yaml has no top-level \`version:\``);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!recorded) {
|
||||
// A chart with no baseline has nothing to bump past; record it so the
|
||||
// NEXT edit is guarded. Still exit non-zero, or a new chart would slip in
|
||||
// permanently unguarded.
|
||||
staleness.push(
|
||||
`${name}: new chart at ${disk.version} is not in the manifest — run \`just check-chart-version-bump --write\` to record it`,
|
||||
);
|
||||
next.charts[name] = { version: disk.version, files: disk.files };
|
||||
continue;
|
||||
}
|
||||
|
||||
let cmp;
|
||||
try {
|
||||
cmp = compareSemver(disk.version, recorded.version);
|
||||
} catch (error) {
|
||||
violations.push(`${name}: ${error.message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const delta = diffFiles(recorded.files, disk.files);
|
||||
|
||||
if (!delta.any) {
|
||||
if (cmp < 0) {
|
||||
violations.push(
|
||||
`${name}: Chart.yaml version went backwards, ${recorded.version} -> ${disk.version}; published chart versions are immutable`,
|
||||
);
|
||||
next.charts[name] = recorded;
|
||||
continue;
|
||||
}
|
||||
if (cmp > 0) {
|
||||
notes.push(
|
||||
`${name}: version advanced ${recorded.version} -> ${disk.version} with no rendered-file change (allowed); \`--write\` records the new floor`,
|
||||
);
|
||||
}
|
||||
next.charts[name] = { version: disk.version, files: disk.files };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (cmp > 0) {
|
||||
staleness.push(
|
||||
`${name}: rendered files changed and version moved ${recorded.version} -> ${disk.version}, but the manifest still records ${recorded.version} — run \`just check-chart-version-bump --write\`\n ${describe(delta).join("\n ")}`,
|
||||
);
|
||||
next.charts[name] = { version: disk.version, files: disk.files };
|
||||
continue;
|
||||
}
|
||||
|
||||
violations.push(
|
||||
`${name}: rendered files changed but Chart.yaml version is still ${disk.version} (recorded at ${recorded.version})\n ${describe(delta).join("\n ")}\n Bump \`deploy/charts/${name}/Chart.yaml\` \`version\` above ${recorded.version}, then run \`just check-chart-version-bump --write\`.`,
|
||||
);
|
||||
next.charts[name] = recorded;
|
||||
}
|
||||
|
||||
return { violations, staleness, notes, next };
|
||||
}
|
||||
255
scripts/check-chart-version-bump-core.test.mjs
Normal file
255
scripts/check-chart-version-bump-core.test.mjs
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
compareSemver,
|
||||
isGuardedChartPath,
|
||||
normalizeChartLock,
|
||||
normalizeChartYaml,
|
||||
normalizeForDigest,
|
||||
parseChartVersion,
|
||||
validateChartVersionBump,
|
||||
} from "./check-chart-version-bump-core.mjs";
|
||||
|
||||
/** A one-chart baseline whose manifest and disk agree. */
|
||||
function fixture() {
|
||||
const files = {
|
||||
"Chart.yaml": "aaa",
|
||||
"values.yaml": "bbb",
|
||||
"values.schema.json": "ccc",
|
||||
"templates/deployment.yaml": "ddd",
|
||||
"templates/prometheusrule.yaml": "eee",
|
||||
};
|
||||
return {
|
||||
charts: { meridian: { version: "0.1.13", files: { ...files } } },
|
||||
manifest: {
|
||||
charts: { meridian: { version: "0.1.13", files: { ...files } } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("an untouched chart passes silently", () => {
|
||||
const result = validateChartVersionBump(fixture());
|
||||
assert.deepEqual(result.violations, []);
|
||||
assert.deepEqual(result.staleness, []);
|
||||
assert.deepEqual(result.notes, []);
|
||||
});
|
||||
|
||||
test("a template change without a version bump is a violation", () => {
|
||||
// This is commit b6443a823: an alert group added to prometheusrule.yaml with
|
||||
// Chart.yaml left at 0.1.13.
|
||||
const input = fixture();
|
||||
input.charts.meridian.files["templates/prometheusrule.yaml"] = "changed";
|
||||
const { violations, staleness } = validateChartVersionBump(input);
|
||||
assert.equal(violations.length, 1);
|
||||
assert.match(violations[0], /changed templates\/prometheusrule\.yaml/);
|
||||
assert.match(violations[0], /version is still 0\.1\.13/);
|
||||
assert.deepEqual(staleness, []);
|
||||
});
|
||||
|
||||
test("a violated chart is left at its recorded state in `next`", () => {
|
||||
const input = fixture();
|
||||
input.charts.meridian.files["templates/prometheusrule.yaml"] = "changed";
|
||||
const { next } = validateChartVersionBump(input);
|
||||
// --write must not be able to launder the violation into the manifest.
|
||||
assert.equal(
|
||||
next.charts.meridian.files["templates/prometheusrule.yaml"],
|
||||
"eee",
|
||||
);
|
||||
});
|
||||
|
||||
test("values.yaml and values.schema.json are guarded like templates", () => {
|
||||
for (const path of ["values.yaml", "values.schema.json"]) {
|
||||
const input = fixture();
|
||||
input.charts.meridian.files[path] = "changed";
|
||||
const { violations } = validateChartVersionBump(input);
|
||||
assert.equal(violations.length, 1, path);
|
||||
assert.match(
|
||||
violations[0],
|
||||
new RegExp(`changed ${path.replace(".", "\\.")}`),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("an added template without a bump is a violation", () => {
|
||||
const input = fixture();
|
||||
input.charts.meridian.files["templates/new.yaml"] = "fff";
|
||||
const { violations } = validateChartVersionBump(input);
|
||||
assert.equal(violations.length, 1);
|
||||
assert.match(violations[0], /added templates\/new\.yaml/);
|
||||
});
|
||||
|
||||
test("a removed template without a bump is a violation", () => {
|
||||
const input = fixture();
|
||||
delete input.charts.meridian.files["templates/prometheusrule.yaml"];
|
||||
const { violations } = validateChartVersionBump(input);
|
||||
assert.equal(violations.length, 1);
|
||||
assert.match(violations[0], /removed templates\/prometheusrule\.yaml/);
|
||||
});
|
||||
|
||||
test("a Chart.yaml-only version bump does not fire", () => {
|
||||
// Chart.yaml is hashed with its `version:` line removed, so the digest is
|
||||
// unchanged; a bump with no template change is legitimate.
|
||||
const input = fixture();
|
||||
input.charts.meridian.version = "0.1.14";
|
||||
const { violations, staleness, notes } = validateChartVersionBump(input);
|
||||
assert.deepEqual(violations, []);
|
||||
assert.deepEqual(staleness, []);
|
||||
assert.equal(notes.length, 1);
|
||||
assert.match(notes[0], /0\.1\.13 -> 0\.1\.14/);
|
||||
});
|
||||
|
||||
test("a template change WITH a bump is staleness, not a violation", () => {
|
||||
const input = fixture();
|
||||
input.charts.meridian.version = "0.1.14";
|
||||
input.charts.meridian.files["templates/prometheusrule.yaml"] = "changed";
|
||||
const { violations, staleness, next } = validateChartVersionBump(input);
|
||||
assert.deepEqual(violations, []);
|
||||
assert.equal(staleness.length, 1);
|
||||
assert.match(staleness[0], /--write/);
|
||||
assert.equal(next.charts.meridian.version, "0.1.14");
|
||||
assert.equal(
|
||||
next.charts.meridian.files["templates/prometheusrule.yaml"],
|
||||
"changed",
|
||||
);
|
||||
});
|
||||
|
||||
test("a version that moves backwards is a violation even with identical files", () => {
|
||||
const input = fixture();
|
||||
input.charts.meridian.version = "0.1.12";
|
||||
const { violations } = validateChartVersionBump(input);
|
||||
assert.equal(violations.length, 1);
|
||||
assert.match(violations[0], /backwards/);
|
||||
});
|
||||
|
||||
test("a chart missing from the manifest blocks until recorded", () => {
|
||||
const input = fixture();
|
||||
input.charts["meridian-push-gateway"] = { version: "0.1.0", files: {} };
|
||||
const { violations, staleness, next } = validateChartVersionBump(input);
|
||||
assert.deepEqual(violations, []);
|
||||
assert.equal(staleness.length, 1);
|
||||
assert.match(staleness[0], /new chart at 0\.1\.0/);
|
||||
assert.equal(next.charts["meridian-push-gateway"].version, "0.1.0");
|
||||
});
|
||||
|
||||
test("a chart deleted from disk blocks until the entry is dropped", () => {
|
||||
const input = fixture();
|
||||
input.manifest.charts.gone = { version: "0.1.0", files: {} };
|
||||
const { violations, staleness, next } = validateChartVersionBump(input);
|
||||
assert.deepEqual(violations, []);
|
||||
assert.equal(staleness.length, 1);
|
||||
assert.match(staleness[0], /no longer has a Chart\.yaml/);
|
||||
assert.equal("gone" in next.charts, false);
|
||||
});
|
||||
|
||||
test("a non-semver or missing version is a violation, not a crash", () => {
|
||||
const bad = fixture();
|
||||
bad.charts.meridian.version = "latest";
|
||||
assert.match(validateChartVersionBump(bad).violations[0], /not semver/);
|
||||
|
||||
const missing = fixture();
|
||||
missing.charts.meridian.version = null;
|
||||
assert.match(
|
||||
validateChartVersionBump(missing).violations[0],
|
||||
/no top-level `version:`/,
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizeChartYaml removes only the top-level version line", () => {
|
||||
const chart = [
|
||||
"apiVersion: v2",
|
||||
"name: meridian",
|
||||
"version: 0.1.13",
|
||||
"dependencies:",
|
||||
" - name: postgres",
|
||||
' version: "0.19.x"',
|
||||
"",
|
||||
].join("\n");
|
||||
const bumped = chart.replace("version: 0.1.13", "version: 0.1.14");
|
||||
assert.equal(normalizeChartYaml(chart), normalizeChartYaml(bumped));
|
||||
// A subchart pin change still moves the digest: it changes what deploys.
|
||||
const repinned = chart.replace('"0.19.x"', '"0.20.x"');
|
||||
assert.notEqual(normalizeChartYaml(chart), normalizeChartYaml(repinned));
|
||||
// So does appVersion, which is a different fact from the chart version.
|
||||
const app = `${chart}appVersion: "0.2.0"\n`;
|
||||
assert.notEqual(normalizeChartYaml(chart), normalizeChartYaml(app));
|
||||
});
|
||||
|
||||
test("normalizeChartLock drops the generated timestamp but keeps the digest", () => {
|
||||
const lock = [
|
||||
"dependencies:",
|
||||
"- name: postgres",
|
||||
" version: 0.19.5",
|
||||
"digest: sha256:abc",
|
||||
'generated: "2026-06-11T15:45:41.764379-04:00"',
|
||||
"",
|
||||
].join("\n");
|
||||
const rebuilt = lock.replace("2026-06-11", "2026-08-21");
|
||||
assert.equal(normalizeChartLock(lock), normalizeChartLock(rebuilt));
|
||||
const repinned = lock.replace("0.19.5", "0.19.6");
|
||||
assert.notEqual(normalizeChartLock(lock), normalizeChartLock(repinned));
|
||||
});
|
||||
|
||||
test("normalizeForDigest routes by filename and passes everything else through", () => {
|
||||
assert.equal(
|
||||
normalizeForDigest("templates/x.yaml", "version: 1\n"),
|
||||
"version: 1\n",
|
||||
);
|
||||
assert.equal(
|
||||
normalizeForDigest("Chart.yaml", "version: 1\nname: a\n"),
|
||||
"name: a\n",
|
||||
);
|
||||
assert.equal(
|
||||
normalizeForDigest("Chart.lock", 'generated: "x"\ndigest: d\n'),
|
||||
"digest: d\n",
|
||||
);
|
||||
});
|
||||
|
||||
test("parseChartVersion reads the top-level version, quoted or bare", () => {
|
||||
assert.equal(parseChartVersion("name: a\nversion: 0.1.13\n"), "0.1.13");
|
||||
assert.equal(parseChartVersion('version: "1.2.3"\n'), "1.2.3");
|
||||
assert.equal(parseChartVersion("version: 0.1.13 # bumped\n"), "0.1.13");
|
||||
assert.equal(parseChartVersion("name: a\n"), null);
|
||||
// An indented subchart version must not be mistaken for the chart's own.
|
||||
assert.equal(parseChartVersion("dependencies:\n - version: 9.9.9\n"), null);
|
||||
});
|
||||
|
||||
test("isGuardedChartPath covers what renders and excludes what does not", () => {
|
||||
for (const path of [
|
||||
"templates/deployment.yaml",
|
||||
"templates/NOTES.txt",
|
||||
"templates/_helpers.tpl",
|
||||
"crds/thing.yaml",
|
||||
"values.yaml",
|
||||
"values.schema.json",
|
||||
"values-production.yaml",
|
||||
"Chart.yaml",
|
||||
"Chart.lock",
|
||||
]) {
|
||||
assert.equal(isGuardedChartPath(path), true, path);
|
||||
}
|
||||
for (const path of [
|
||||
"tests/render_test.yaml",
|
||||
"tests/fixtures/ha-values.yaml",
|
||||
"ci/quickstart-values.yaml",
|
||||
"examples/argocd-app.yaml",
|
||||
"README.md",
|
||||
".helmignore",
|
||||
"charts/postgres-0.19.5.tgz",
|
||||
]) {
|
||||
assert.equal(isGuardedChartPath(path), false, path);
|
||||
}
|
||||
});
|
||||
|
||||
test("compareSemver orders releases and prereleases", () => {
|
||||
assert.equal(compareSemver("0.1.14", "0.1.13"), 1);
|
||||
assert.equal(compareSemver("0.1.13", "0.1.14"), -1);
|
||||
assert.equal(compareSemver("0.1.13", "0.1.13"), 0);
|
||||
assert.equal(compareSemver("0.2.0", "0.1.99"), 1);
|
||||
assert.equal(compareSemver("1.0.0", "0.99.99"), 1);
|
||||
assert.equal(compareSemver("1.0.0", "1.0.0-rc.1"), 1);
|
||||
assert.equal(compareSemver("1.0.0-rc.2", "1.0.0-rc.1"), 1);
|
||||
assert.equal(compareSemver("1.0.0-alpha", "1.0.0-beta"), -1);
|
||||
assert.equal(compareSemver("1.0.0-rc.1", "1.0.0-rc.1.1"), -1);
|
||||
assert.equal(compareSemver("1.0.0+build.9", "1.0.0+build.1"), 0);
|
||||
assert.throws(() => compareSemver("0.1", "0.1.0"), /not semver/);
|
||||
});
|
||||
129
scripts/check-chart-version-bump.mjs
Executable file
129
scripts/check-chart-version-bump.mjs
Executable file
|
|
@ -0,0 +1,129 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Wrapper for the chart version-bump contract. Logic lives in the core; this
|
||||
* walks `deploy/charts/<name>/` and reads/writes
|
||||
* `scripts/chart-version-manifest.json`.
|
||||
*
|
||||
* node scripts/check-chart-version-bump.mjs # verify
|
||||
* node scripts/check-chart-version-bump.mjs --write # record, after bumping
|
||||
*
|
||||
* `--write` is NOT an escape hatch: it refuses to record a chart whose rendered
|
||||
* files moved while `Chart.yaml`'s `version` did not, printing the same message
|
||||
* the check does. The only way to a green tree is the bump.
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import { join, relative, sep } from "node:path";
|
||||
import {
|
||||
isGuardedChartPath,
|
||||
normalizeForDigest,
|
||||
parseChartVersion,
|
||||
validateChartVersionBump,
|
||||
} from "./check-chart-version-bump-core.mjs";
|
||||
|
||||
const root = process.cwd();
|
||||
const chartsDir = join(root, "deploy", "charts");
|
||||
const manifestPath = join(root, "scripts", "chart-version-manifest.json");
|
||||
const write = process.argv.includes("--write");
|
||||
|
||||
function walk(dir, base, found) {
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return found;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = join(dir, entry.name);
|
||||
const rel = relative(base, full).split(sep).join("/");
|
||||
if (entry.isDirectory()) {
|
||||
// Prune below the first path segment the guard does not own, so the
|
||||
// fetched-subchart `charts/` tree is never walked.
|
||||
if (rel.indexOf("/") === -1 && !isGuardedChartPath(`${rel}/x`)) continue;
|
||||
walk(full, base, found);
|
||||
} else if (entry.isFile() && isGuardedChartPath(rel)) {
|
||||
found.push(rel);
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function readCharts() {
|
||||
const charts = {};
|
||||
if (!existsSync(chartsDir)) return charts;
|
||||
for (const entry of readdirSync(chartsDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const dir = join(chartsDir, entry.name);
|
||||
if (!existsSync(join(dir, "Chart.yaml"))) continue;
|
||||
const files = {};
|
||||
for (const rel of walk(dir, dir, []).sort()) {
|
||||
const text = readFileSync(join(dir, rel), "utf8");
|
||||
files[rel] = createHash("sha256")
|
||||
.update(normalizeForDigest(rel, text))
|
||||
.digest("hex");
|
||||
}
|
||||
charts[entry.name] = {
|
||||
version: parseChartVersion(readFileSync(join(dir, "Chart.yaml"), "utf8")),
|
||||
files,
|
||||
};
|
||||
}
|
||||
return charts;
|
||||
}
|
||||
|
||||
const manifest = existsSync(manifestPath)
|
||||
? JSON.parse(readFileSync(manifestPath, "utf8"))
|
||||
: { charts: {} };
|
||||
const charts = readCharts();
|
||||
const { violations, staleness, notes, next } = validateChartVersionBump({
|
||||
charts,
|
||||
manifest,
|
||||
});
|
||||
|
||||
const CONTRACT =
|
||||
'deploy/AGENTS.md: "Bump `Chart.yaml` `version` on any chart change. ArgoCD\n' +
|
||||
'tracks chart versions; an unbumped chart deploys stale templates."';
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error(
|
||||
`Chart version contract violated:\n${violations.map((v) => `- ${v}`).join("\n")}\n\n${CONTRACT}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (write) {
|
||||
const ordered = { charts: {} };
|
||||
for (const name of Object.keys(next.charts).sort()) {
|
||||
ordered.charts[name] = next.charts[name];
|
||||
}
|
||||
writeFileSync(manifestPath, `${JSON.stringify(ordered, null, 2)}\n`);
|
||||
console.log(
|
||||
`Wrote scripts/chart-version-manifest.json for ${Object.keys(ordered.charts).length} charts: ` +
|
||||
Object.entries(ordered.charts)
|
||||
.map(([name, chart]) => `${name}@${chart.version}`)
|
||||
.join(", "),
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (staleness.length > 0) {
|
||||
console.error(
|
||||
`scripts/chart-version-manifest.json no longer describes deploy/charts:\n${staleness
|
||||
.map((s) => `- ${s}`)
|
||||
.join("\n")}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
for (const note of notes) console.log(`note: ${note}`);
|
||||
const guarded = Object.values(charts).reduce(
|
||||
(n, chart) => n + Object.keys(chart.files).length,
|
||||
0,
|
||||
);
|
||||
console.log(
|
||||
`Chart versions hold: ${guarded} rendered files across ${Object.keys(charts).length} charts ` +
|
||||
`(${Object.entries(charts)
|
||||
.map(([name, chart]) => `${name}@${chart.version}`)
|
||||
.join(", ")}).`,
|
||||
);
|
||||
186
scripts/test-chart-version-bump-guard.sh
Executable file
186
scripts/test-chart-version-bump-guard.sh
Executable file
|
|
@ -0,0 +1,186 @@
|
|||
#!/usr/bin/env bash
|
||||
# Contract test for `just check-chart-version-bump`.
|
||||
#
|
||||
# The core's unit tests pin the decision logic. This pins what only the wrapper
|
||||
# can get wrong, and it is the part this repo keeps finding missing: a guard
|
||||
# nobody has watched fail.
|
||||
#
|
||||
# - it EXITS NON-ZERO when a rendered file moves without a `Chart.yaml`
|
||||
# `version` bump, and NAMES the file;
|
||||
# - `--write` REFUSES to record that state, so the manifest cannot be used to
|
||||
# launder the violation;
|
||||
# - it EXITS ZERO on a `Chart.yaml`-only version bump — a bump with no
|
||||
# template change is legitimate and must stay silent;
|
||||
# - it EXITS ZERO on a `tests/` edit, which never reaches a cluster;
|
||||
# - its scan REACHES EVERY chart under `deploy/charts/`. A chart that renders
|
||||
# but is absent from the manifest is unguarded for ever, which is exactly
|
||||
# the failure shape being closed here;
|
||||
# - and it reproduces the historical case, commit b6443a823, when git history
|
||||
# is deep enough to reach it.
|
||||
#
|
||||
# No Docker, no database, no network.
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
failures=0
|
||||
fail() {
|
||||
printf 'FAIL: %s\n' "$1" >&2
|
||||
failures=$((failures + 1))
|
||||
}
|
||||
pass() { printf 'ok: %s\n' "$1"; }
|
||||
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
# A scratch root the test can mutate: the real charts, the real manifest, the
|
||||
# real guard. Never edits the working tree — several agents share this checkout.
|
||||
scratch="$tmp/root"
|
||||
mkdir -p "$scratch/deploy" "$scratch/scripts"
|
||||
cp -R "$repo_root/deploy/charts" "$scratch/deploy/charts"
|
||||
cp "$repo_root/scripts/check-chart-version-bump.mjs" \
|
||||
"$repo_root/scripts/check-chart-version-bump-core.mjs" \
|
||||
"$repo_root/scripts/chart-version-manifest.json" "$scratch/scripts/"
|
||||
# Fetched subchart tarballs are gitignored and unguarded; drop them so a local
|
||||
# `helm dependency build` cannot change what this test sees.
|
||||
rm -rf "$scratch"/deploy/charts/*/charts
|
||||
|
||||
# Read the exit code, never the output: piping a gate into grep reports the
|
||||
# filter's status and a failed run looks clean.
|
||||
run_guard() {
|
||||
local code=0
|
||||
( cd "$scratch" && node scripts/check-chart-version-bump.mjs "$@" ) \
|
||||
>"$tmp/out" 2>&1 || code=$?
|
||||
return $code
|
||||
}
|
||||
|
||||
restore() {
|
||||
rm -rf "$scratch/deploy/charts"
|
||||
cp -R "$repo_root/deploy/charts" "$scratch/deploy/charts"
|
||||
rm -rf "$scratch"/deploy/charts/*/charts
|
||||
cp "$repo_root/scripts/chart-version-manifest.json" "$scratch/scripts/"
|
||||
}
|
||||
|
||||
expect_pass() {
|
||||
local what="$1" code=0
|
||||
run_guard || code=$?
|
||||
if [[ $code -ne 0 ]]; then
|
||||
fail "$what exited $code; it must be accepted: $(head -5 "$tmp/out")"
|
||||
else
|
||||
pass "$what accepted with exit 0"
|
||||
fi
|
||||
}
|
||||
|
||||
expect_fail() {
|
||||
local what="$1" needle="$2" code=0
|
||||
run_guard || code=$?
|
||||
if [[ $code -eq 0 ]]; then
|
||||
fail "$what was accepted (exit 0); it must be refused"
|
||||
return
|
||||
fi
|
||||
if [[ $code -ne 1 ]]; then
|
||||
fail "$what exited $code; the guard must exit 1 on a finding"
|
||||
return
|
||||
fi
|
||||
if ! grep -q "$needle" "$tmp/out"; then
|
||||
fail "$what was refused, but without naming '$needle': $(head -8 "$tmp/out")"
|
||||
return
|
||||
fi
|
||||
pass "$what refused with exit 1, naming '$needle'"
|
||||
}
|
||||
|
||||
# ── 1. The committed tree agrees with the committed manifest ────────────────
|
||||
expect_pass "the tree as committed"
|
||||
|
||||
# ── 2. Every chart that renders is actually covered ─────────────────────────
|
||||
for chart_yaml in "$repo_root"/deploy/charts/*/Chart.yaml; do
|
||||
name="$(basename "$(dirname "$chart_yaml")")"
|
||||
if ! node -e '
|
||||
const fs = require("node:fs");
|
||||
const m = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
|
||||
const chart = m.charts[process.argv[2]];
|
||||
if (!chart || Object.keys(chart.files).length === 0) process.exit(1);
|
||||
' "$repo_root/scripts/chart-version-manifest.json" "$name"; then
|
||||
fail "chart '$name' renders but has no files in the manifest — it is unguarded"
|
||||
else
|
||||
pass "chart '$name' is covered by the manifest"
|
||||
fi
|
||||
done
|
||||
|
||||
# ── 3. A template edit without a bump is refused, and names the file ────────
|
||||
printf '\n# guard probe\n' >> "$scratch/deploy/charts/meridian/templates/prometheusrule.yaml"
|
||||
expect_fail "a template edit with no version bump" "changed templates/prometheusrule.yaml"
|
||||
|
||||
# ── 4. --write must not be able to launder it ──────────────────────────────
|
||||
code=0
|
||||
run_guard --write || code=$?
|
||||
if [[ $code -eq 0 ]]; then
|
||||
fail "--write recorded a chart whose templates moved without a bump"
|
||||
elif ! diff -q "$scratch/scripts/chart-version-manifest.json" \
|
||||
"$repo_root/scripts/chart-version-manifest.json" >/dev/null; then
|
||||
fail "--write rewrote the manifest while refusing the change"
|
||||
else
|
||||
pass "--write refused the violation and left the manifest untouched"
|
||||
fi
|
||||
restore
|
||||
|
||||
# ── 5. values.yaml is guarded too ──────────────────────────────────────────
|
||||
printf '\n# guard probe\n' >> "$scratch/deploy/charts/meridian/values.yaml"
|
||||
expect_fail "a values.yaml edit with no version bump" "changed values.yaml"
|
||||
restore
|
||||
|
||||
# ── 6. A Chart.yaml-only bump must stay silent ─────────────────────────────
|
||||
node -e '
|
||||
const fs = require("node:fs");
|
||||
const path = process.argv[1];
|
||||
let done = false;
|
||||
const bumped = fs
|
||||
.readFileSync(path, "utf8")
|
||||
.split("\n")
|
||||
.map((line) =>
|
||||
!done && /^version:/.test(line) ? ((done = true), "version: 99.99.99") : line,
|
||||
)
|
||||
.join("\n");
|
||||
fs.writeFileSync(path, bumped);
|
||||
' "$scratch/deploy/charts/meridian/Chart.yaml"
|
||||
grep -q '^version: 99.99.99' "$scratch/deploy/charts/meridian/Chart.yaml" \
|
||||
|| fail "test setup: could not bump the scratch Chart.yaml"
|
||||
expect_pass "a Chart.yaml-only version bump"
|
||||
restore
|
||||
|
||||
# ── 7. tests/ never reaches a cluster, so it must not demand a bump ────────
|
||||
printf '\n# guard probe\n' >> "$scratch/deploy/charts/meridian/tests/render_test.yaml"
|
||||
expect_pass "a tests/ edit with no version bump"
|
||||
restore
|
||||
|
||||
# ── 8. The historical case: b6443a823 added an alert group at 0.1.13 ──────
|
||||
histcommit=b6443a823
|
||||
if git -C "$repo_root" cat-file -e "$histcommit^{commit}" 2>/dev/null \
|
||||
&& git -C "$repo_root" cat-file -e "$histcommit^^{commit}" 2>/dev/null; then
|
||||
hist="$tmp/hist"
|
||||
mkdir -p "$hist/scripts"
|
||||
cp "$repo_root/scripts/check-chart-version-bump.mjs" \
|
||||
"$repo_root/scripts/check-chart-version-bump-core.mjs" "$hist/scripts/"
|
||||
git -C "$repo_root" archive "$histcommit^" deploy/charts | tar -x -C "$hist"
|
||||
( cd "$hist" && node scripts/check-chart-version-bump.mjs --write ) >/dev/null 2>&1 \
|
||||
|| fail "could not record a baseline at $histcommit^"
|
||||
rm -rf "$hist/deploy"
|
||||
git -C "$repo_root" archive "$histcommit" deploy/charts | tar -x -C "$hist"
|
||||
code=0
|
||||
( cd "$hist" && node scripts/check-chart-version-bump.mjs ) >"$tmp/hist.out" 2>&1 || code=$?
|
||||
if [[ $code -ne 1 ]]; then
|
||||
fail "$histcommit exited $code against its own parent's baseline; it must exit 1"
|
||||
elif ! grep -q 'version is still 0.1.13' "$tmp/hist.out"; then
|
||||
fail "$histcommit was refused, but not for the unbumped 0.1.13: $(head -8 "$tmp/hist.out")"
|
||||
else
|
||||
pass "$histcommit (the case that shipped) is refused with exit 1"
|
||||
fi
|
||||
else
|
||||
printf 'skip: %s is not in this checkout (shallow clone); history case not replayed\n' "$histcommit"
|
||||
fi
|
||||
|
||||
if [[ $failures -gt 0 ]]; then
|
||||
printf '\n%d chart version-bump guard contract(s) failed\n' "$failures" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '\nchart version-bump guard contract holds\n'
|
||||
Loading…
Add table
Add a link
Reference in a new issue