"Payload-version drift gate - CI and git hooks fail when any dist/plugin stamp differs from VERSION"
TASK-IMP-068: Payload-version drift gate
§1 - Description
The root VERSION file is the single platform version, auto-bumped in CI by version.yml. The distributable payload (dist/cyberos) is stamped from VERSION by tools/install/build.sh, but only when a human runs the build. Nothing compares the two, so the payload and every installed plugin silently lag (observed: 1.2.0 vs 1.7.0). This task adds the missing comparison and makes it enforceable in CI and locally.
Normative clauses:
- A script
tools/install/check-version-sync.sh <payload-dir>MUST compare rootVERSIONagainst every stamped artifact in the payload:<payload>/VERSION,<payload>/plugin/.claude-plugin/plugin.json.version,<payload>/.claude-plugin/marketplace.jsonmetadata.version,<payload>/mcp/package.json.version,<payload>/manifest.yamlcyberos_version, and theplugin.jsonsealed inside<payload>/cyberos.plugin(read viaunzip -p, no extraction to disk). It MUST exit 0 when all six match, exit 10 on any mismatch printing one line per drifted artifact in the formDRIFT <relative-path>: <found> != <expected>, and exit 2 when rootVERSIONis missing or notX.Y.Zsemver. - A workflow
.github/workflows/payload-gate.ymlMUST run on push and pull_request tomainwhen any oftools/install/**,modules/skill/**,modules/cuo/**, orVERSIONchanges. It MUST build the payload into a temporary directory withbuild.sh <tmpdir>and runcheck-version-sync.sh <tmpdir>; a non-zero exit from either MUST fail the workflow. build.shMUST exit non-zero with an explicit error when rootVERSIONis missing or notX.Y.Zsemver. The current silent fallback (|| echo 0.0.0) MUST be removed; a payload stamped0.0.0MUST be impossible to produce.- A
.githooks/pre-commithook MUST be added (the repo'score.hooksPathis.githooks, so hooks placed only in the pre-commit framework never fire for contributors who skippedpre-commit install). When staged paths match the existing trigger list in.pre-commit-hooks/cyberos-payload-build.sh(modules/cuo/ | modules/skill/ | tools/install/ | VERSION), the hook MUST invoke that script to refreshdist/cyberosand then runcheck-version-sync.sh dist/cyberos. Rebuild or check failure MUST abort the commit; a clean run MUST NOT block it. Non-matching commits MUST be a no-op. docs/deploy/RELEASE.mdMUST describe the invariant as enforced (CI gate + wired hook), replacing the current aspirational claim that the pre-commit hook keeps the payload matched.- The CI gate MUST NOT require network access beyond checkout and MUST complete its build+check steps in under 3 minutes (the build is file copies plus sed).
- Because the bot's bump commit carries
[skip ci](sopayload-gate.ymlnever sees it),version.ymlMUST run the build+check inline in its own job, immediately aftercyberos-version.mjs --applyand before pushing - the bump and the proof that a payload builds clean at the new version land together.
§2 - Why this design
The stamping logic in build.sh is already correct and single-source; duplicating version propagation elsewhere would create a second drift surface. The gate therefore re-uses the real build and only adds a read-only comparator. Building into a temp dir in CI keeps dist/ gitignored (operator decision) while still proving that a build at this commit yields stamps equal to VERSION. The comparator is a standalone script so the CI gate, the git hook, TASK-IMP-069's release job, and the desktop Ops tab (TASK-APP-001) all share one implementation.
§3 - Contract
check-version-sync.sh
usage: check-version-sync.sh [payload-dir] # default: <repo>/dist/cyberos
exit 0 in sync (prints "sync OK <version> across 6 artifacts")
exit 10 drift (one "DRIFT <path>: <found> != <expected>" line per artifact)
exit 2 root VERSION missing/invalid, payload dir missing, or artifact unreadable
Artifact readers: VERSION = trimmed file; JSON fields via node -p (node is already a build dependency); manifest.yaml via grep of the cyberos_version: line; sealed plugin.json via unzip -p <payload>/cyberos.plugin .claude-plugin/plugin.json.
payload-gate.yml
name: payload-gate
on:
push: { branches: [main], paths: [tools/install/**, modules/skill/**, modules/cuo/**, VERSION] }
pull_request: { paths: [tools/install/**, modules/skill/**, modules/cuo/**, VERSION] }
jobs:
build-and-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: bash tools/install/build.sh "$RUNNER_TEMP/payload"
- run: bash tools/install/check-version-sync.sh "$RUNNER_TEMP/payload"
.githooks/pre-commit
Delegates to .pre-commit-hooks/cyberos-payload-build.sh (trigger-list match) then check-version-sync.sh dist/cyberos; both under set -euo pipefail so failure aborts the commit.
§4 - Acceptance criteria
- Sync passes on a fresh build (§1 #1) - immediately after
build.sh,check-version-sync.shexits 0 and reports the version and artifact count. - Each artifact is individually guarded (§1 #1) - tampering any one of the six stamps (payload VERSION, plugin.json, marketplace.json, mcp/package.json, manifest.yaml, sealed plugin.json) makes the check exit 10 and name exactly that artifact.
- The sealed bundle is checked without extraction (§1 #1) - re-zipping
cyberos.pluginwith a stale inner plugin.json while all on-disk files match still exits 10. - Invalid VERSION cannot stamp (§1 #3) - with
VERSIONcontainingbanana,build.shexits non-zero and writes no payload; withVERSIONdeleted, same. - No 0.0.0 escape hatch remains (§1 #3) -
grep -n "0\.0\.0" tools/install/build.shshows only the placeholder-substitution seds, no fallback echo. - CI gate is wired (§1 #2, #6) -
payload-gate.ymlexists with the four path filters on both push and pull_request, parses as valid YAML, and its steps are exactly build-into-temp + check. - Hook fires on trigger paths (§1 #4) - in a fixture repo with
core.hooksPath=.githooks, committing a change undermodules/skill/runs the rebuild and check; committing a change underdocs/runs neither. - Hook failure blocks the commit (§1 #4) - with
build.shforced to fail, the commit aborts with the build error visible. - RELEASE.md tells the truth (§1 #5) - the doc names
payload-gate.ymland.githooks/pre-commitas the enforcement points; the old aspirational sentence is gone. - Bump commits are self-proving (§1 #7) -
version.ymlcontains the inline build+check steps between apply and push; removing them makes t10's structural assertion fail.
§5 - Verification
# tools/install/tests/test_check_version_sync.sh
# Harness: builds a scratch payload into $TMP/payload from a scratch VERSION file,
# then mutates artifacts one at a time. Run: bash tools/install/tests/test_check_version_sync.sh
t01_fresh_build_syncs() # AC 1
t02_each_artifact_guarded() # AC 2 (loop over the 6 artifacts, expect exit 10 + the right DRIFT line)
t03_sealed_zip_checked() # AC 3 (zip -j a stale plugin.json into cyberos.plugin)
t04_invalid_version_refused() # AC 4 (VERSION=banana and VERSION absent -> build.sh non-zero)
t05_no_fallback_left() # AC 5 (static grep assertion)
t06_workflow_shape() # AC 6 (node yaml-less structural greps: name, both triggers, 4 path filters)
t07_hook_trigger_matrix() # AC 7 (git init fixture, core.hooksPath=.githooks, two commits)
t08_hook_blocks_on_failure() # AC 8 (PATH-shadowed failing build.sh -> commit exits non-zero)
t09_release_md_updated() # AC 9 (grep RELEASE.md for payload-gate.yml + .githooks/pre-commit)
t10_version_yml_inline_check() # AC 10 (structural greps: build.sh + check-version-sync.sh steps between apply and push)
All nine cases green = §5 pass. The suite is plain bash with set -euo pipefail, no framework, runnable in CI and locally.
§6 - Implementation skeleton
check-version-sync.sh: read+validate root VERSION; declare the six readers; accumulate drift lines; print and exit per contract. build.sh diff: replace cyver="$(tr -d ' \n\r' < "$repo/VERSION" 2>/dev/null || echo 0.0.0)" with an existence+regex guard that errors out. Hook: 15-line bash wrapper as in §3.
§7 - Dependencies
None upstream. Blocks TASK-IMP-069 (the release publisher reuses check-version-sync.sh as its pre-upload gate). TASK-APP-001's desktop Ops tab can surface the same check verbatim.
§8 - Example payloads
$ bash tools/install/check-version-sync.sh dist/cyberos
DRIFT dist/cyberos/VERSION: 1.2.0 != 1.7.0
DRIFT dist/cyberos/plugin/.claude-plugin/plugin.json: 1.2.0 != 1.7.0
DRIFT dist/cyberos/cyberos.plugin!.claude-plugin/plugin.json: 1.2.0 != 1.7.0
$ echo $?
10
§9 - Open questions
None blocking. Whether payload-gate becomes a required check in the branch ruleset is an operator toggle after one week of green runs.
§10 - Failure modes inventory
- CI runner lacks
zip/unzip-build.shalready requires zip; the gate installs nothing, so the check script MUST fail with exit 2 and a "unzip missing" message rather than a false pass. Covered by t02 harness precondition. - Commit bypasses the hook (
--no-verify) - accepted;payload-gate.ymlis the backstop on push/PR. - Bot bump commit is invisible to path-triggered workflows (
[skip ci]) - closed by §1 #7: the bump job itself proves the build before pushing, so no commit lands on main with an unprovable payload state. - Interrupted local build leaves a half-written dist - the check reads six artifacts; any missing file is exit 2 (unreadable), never a false 0.
- manifest.yaml format change breaks the grep reader - t02 pins the reader against the generated manifest; a format change fails the suite at the same commit that changes the generator.
§11 - Implementation notes
Keep the job name payload-gate / build-and-check stable so it can be added to the ruleset's required checks. The check script must not import from build.sh (read-only comparator; zero side effects). Reuse in TASK-IMP-069 and TASK-APP-001 is by invocation, not by copy.
Post-ship amendment (2026-07-12, TASK-IMP-071): §1 #7's premise - bump commits carry [skip ci] so payload-gate never sees them - is retired; bump commits are now plain chore(release): and payload-gate runs on them too. The inline proof in version.yml stays as belt-and-suspenders.
End of TASK-IMP-068.
Audit
TASK-IMP-068 audit
§1 - Verdict summary
Spec-correctness audit against the engineering-spec@1 rule set (structure, BCP-14 clause quality, §1->§4->§5 traceability, failure-mode honesty). Draft was structurally complete but carried two real design holes (sealed-bundle blind spot, [skip ci] bump blindness) and one dead mechanism (pre-commit framework hook). All resolved by revision; TRACE-001/002/003 close: every §1 clause is cited by >= 1 AC, every AC by >= 1 named test in tools/install/tests/test_check_version_sync.sh (listed in new_files).
§2 - Findings (all resolved)
ISS-001 sealed bundle escaped the artifact set
The check originally compared five on-disk stamps; the plugin.json sealed inside cyberos.plugin - the artifact users actually install - could stay stale. Resolved: §1 #1 adds the unzip -p check, AC 3 + t03 cover a tampered-zip-only case.
ISS-002 build.sh 0.0.0 fallback contradicted the invariant
|| echo 0.0.0 lets a broken VERSION stamp a plausible-looking payload. Resolved: §1 #3 removes it normatively; AC 4/5 pin both the behavior and the absence of the fallback.
ISS-003 hook specified for a framework that is not wired
First cut hung the local guard on the pre-commit framework; the repo's core.hooksPath=.githooks bypasses it. Resolved: §1 #4 targets .githooks/pre-commit directly; AC 7/8 test firing and blocking.
ISS-004 bump commits were invisible to the gate
§10 claimed the path filter catches the bot bump; [skip ci] on that commit skips all triggered workflows. Resolved: §1 #7 moves the proof inline into version.yml's own job (build+check between apply and push), AC 10 + t10 added, modified_files gains version.yml, §10 #3 corrected.
ISS-005 TRACE-003 gap
§5 initially referenced tests not present in new_files. Resolved: test file added to new_files; t01-t10 map 1:1 onto AC 1-10.
ISS-006 unbounded performance claim
"Fast gate" lacked a mechanism (QA-007 class). Resolved: §1 #6 grounds the 3-minute bound in the no-network, file-ops-only build.
§3 - Resolution
All six findings addressed in the task body as cited. Clause set is closed, ACs are individually falsifiable, failure modes name their mitigations. Score = 10/10.
End of TASK-IMP-068 audit.
§10 - Post-implementation gates (2026-07-12, ship run)
- §10.4 coverage gate: PASS - suite t01-t10 green on fresh testing-phase rerun (tests_failed=0, files_below_90pct=[], ecm_rows_uncovered=[]); full report at docs/tasks/.workflow/TASK-IMP-068/coverage-gate.md.
- TRACE-004 closure: PASS - every §1 clause's cited test passed (mapping table in the coverage artefact).
- §10.5 awh gate: N/A - module
improvementhas no sealed goldenset (declared, not fabricated). - §10.6 caf gate: N/A - no modules/improvement/audit-profile.yaml; deterministic floor run instead (bash -n clean on all touched scripts + full suite green).
- HITL gate 1 (reviewing -> ready_to_test): APPROVED by Stephen Cheng, 2026-07-12 (review packet docs/tasks/.workflow/TASK-IMP-068/code-review.md).
- HITL gate 2 (testing -> done): ACCEPTED by Stephen Cheng, 2026-07-12 - recorded up front as an explicit operator pre-authorization ("approve review + pre-authorize done if gates stay green"), gates stayed green; equivalent to memory.status_overridden with reason "operator pre-authorized final acceptance at review gate".
- Live enforcement proof: .githooks/pre-commit fired during the implementation commits, rebuilt dist/cyberos, and reported
sync OK 1.7.1 across 6 artifacts.
TASK-IMP-068 shipped 2026-07-12.