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

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.

Commit b6443a823 is 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 how b6443a823
got 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 -- b6443a823 replayed 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:
Josh Belke 2026-08-21 04:30:21 -04:00
commit 043361afcd
8 changed files with 927 additions and 1 deletions

View file

@ -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

View file

@ -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:

View file

@ -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

View 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"
}
}
}
}

View 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 };
}

View 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/);
});

View 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(", ")}).`,
);

View 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'