"Publish the versioned payload + Claude plugin as GitHub Release assets on every vX.Y.Z tag"
TASK-IMP-069: Publish the payload on every release
§1 - Description
Today the payload exists only as a gitignored local build; every consumer (Claude marketplace add, .plugin file pick, install.sh, rollout.sh) copies whatever a laptop last built. This task gives the payload one canonical, versioned, downloadable source: the GitHub Release for each vX.Y.Z tag.
Normative clauses:
- A script
tools/install/release-assets.sh <payload-dir> <out-dir>MUST produce, from a built payload:cyberos-payload-<ver>.tar.gz(deterministic: sorted paths, numeric owner 0:0, fixed mtime2000-01-01T00:00:00Z, gzip -n),cyberos-<ver>.plugin(byte-copy ofcyberos.plugin), stable-name aliasescyberos-payload.tar.gzandcyberos.plugin(byte-identical copies, soreleases/latest/download/<stable-name>always resolves), andSHA256SUMScovering all four.<ver>MUST be read from<payload-dir>/VERSION. release-assets.shMUST exit 10 without producing output when<payload>/VERSION, rootVERSION, and the intended tag do not all agree. The intended tag is$TAGwhen set (the release workflow sets it frominputs.tag || ref_name, covering workflow_dispatch whereGITHUB_REF_NAMEis the branch), else$GITHUB_REF_NAMEwhen set; neither set (local run) skips the tag leg. (Amended post-ship 2026-07-12: release #35 failed on dispatch because the script read only GITHUB_REF_NAME.).github/workflows/release.ymlMUST gain apayloadjob that, for everyv*tag: checks out, runsbuild.shinto a temp dir, runscheck-version-sync.sh(TASK-IMP-068) against it, runsrelease-assets.sh, and uploads the four files +SHA256SUMSto that tag's GitHub Release. DefaultGITHUB_TOKENwithcontents: writeMUST suffice; no new secrets.- The
payloadjob MUST fail when the pushed tag does not equalv$(cat VERSION)at that commit. bootstrap.shMUST support fetching the payload from a URL:CYBEROS_PAYLOAD_URLoverrides; default = the repo'sreleases/latest/download/cyberos-payload.tar.gz. It MUST downloadSHA256SUMSfrom the same location, verify the tarball's checksum before unpacking, and then runinstall.shfrom the unpacked payload. Verification failure MUST abort before any file lands in the target repo.rollout.shMUST accept, in place of a local payload dir,--from-release [tag]: download + verify once into a temp dir (per #5's mechanics), then proceed unchanged for every listed repo.tools/install/README.mdanddocs/deploy/RELEASE.mdMUST document the release-install paths for each channel: Claude Code (/plugin marketplace addon the unpacked payload), Claude desktop/Cowork (downloadcyberos.pluginfrom the release), curl bootstrap one-liner, and fleetrollout.sh --from-release. The "available once you host a tarball" placeholder MUST be replaced by the real URLs.
§2 - Why this design
GitHub Releases is the zero-infrastructure channel that matches the existing tag-driven release.yml and needs no hosting, keys, or registry. Deterministic tarballs mirror the memory module's export discipline and make the artifact reproducible from the tag alone. Stable-name aliases exist because GitHub's latest/download/ URL requires a constant asset name; versioned twins keep every historical release independently fetchable. Checksums ship next to the assets so bootstrap.sh can verify without a signing infrastructure (cosign-grade signing stays with TASK-PLUGIN-008's marketplace, which this task deliberately does not replace).
§3 - Contract
release-assets.sh <payload-dir> <out-dir>
exit 0 wrote 5 files into <out-dir> (2 versioned, 2 stable, SHA256SUMS)
exit 10 version disagreement (payload VERSION vs root VERSION vs $GITHUB_REF_NAME)
exit 2 payload missing/incomplete (no VERSION or no cyberos.plugin)
bootstrap.sh # unchanged local behavior when a payload dir is passed
CYBEROS_PAYLOAD_URL=<url> # tarball URL; SHA256SUMS fetched from its dirname
(no args, no local payload) -> latest-release default URL
rollout.sh --from-release [vX.Y.Z] <repo> [<repo>...]
release.yml addition (shape):
payload:
runs-on: ubuntu-latest
permissions: { contents: write }
steps:
- uses: actions/checkout@v4
- run: test "v$(cat VERSION)" = "$GITHUB_REF_NAME"
- run: bash tools/install/build.sh "$RUNNER_TEMP/payload"
- run: bash tools/install/check-version-sync.sh "$RUNNER_TEMP/payload"
- run: bash tools/install/release-assets.sh "$RUNNER_TEMP/payload" "$RUNNER_TEMP/assets"
- run: gh release upload "$GITHUB_REF_NAME" "$RUNNER_TEMP"/assets/* --clobber
env: { GH_TOKEN: "${{ github.token }}" }
§4 - Acceptance criteria
- Deterministic tarball (§1 #1) - running
release-assets.shtwice on the same payload yields byte-identicalcyberos-payload-<ver>.tar.gz(equal sha256). - All five files, both name forms (§1 #1) - output contains versioned + stable tarball and plugin names plus
SHA256SUMS; stable and versioned twins are byte-identical. - Checksums verify (§1 #1) -
sha256sum -c SHA256SUMSpasses in the out-dir; corrupting one byte of the tarball makes it fail. - Version triple-check (§1 #2) - payload 1.7.0 + root 1.7.0 +
GITHUB_REF_NAME=v1.6.0exits 10 and writes nothing. - Tag guard in CI (§1 #3, #4) - the
payloadjob's first step comparesv$(cat VERSION)to the tag; the job uploads exactly the five §1 #1 files with--clobber. - bootstrap URL flow (§1 #5) - with
CYBEROS_PAYLOAD_URL=file://<fixture>/cyberos-payload.tar.gzand a matchingSHA256SUMS, bootstrap downloads, verifies, unpacks, and runsinstall.shagainst the target repo (.cyberos/VERSIONappears). - bootstrap rejects a bad checksum (§1 #5) - with a tampered fixture tarball, bootstrap aborts before touching the target repo (no
.cyberos/created). - rollout from a release source (§1 #6) -
rollout.sh --from-releaseagainst the file:// fixture initializes two scratch repos from one download (fixture served once; second repo reuses the temp payload). - Docs list the four install paths with real URLs (§1 #7) - README and RELEASE.md contain the
releases/latest/download/URLs and the placeholder sentence is gone.
§5 - Verification
# tools/install/tests/test_release_assets.sh
# Fixtures: scratch payload built by build.sh into $TMP; file:// URLs exercise the
# download paths without network. Run: bash tools/install/tests/test_release_assets.sh
t01_deterministic_tarball() # AC 1
t02_five_files_two_name_forms() # AC 2
t03_sha256sums_roundtrip() # AC 3
t04_version_triple_check() # AC 4 (GITHUB_REF_NAME mismatch -> exit 10, empty out-dir)
t05_workflow_shape() # AC 5 (structural greps on release.yml: tag guard, upload step, --clobber)
t06_bootstrap_url_happy_path() # AC 6
t07_bootstrap_bad_checksum() # AC 7
t08_rollout_from_release() # AC 8
t09_docs_real_urls() # AC 9
§6 - Implementation skeleton
release-assets.sh: version guard; tar --sort=name --owner=0 --group=0 --mtime='2000-01-01 00:00:00Z' -cf - -C <payload> . | gzip -n; copies; sha256sum > SHA256SUMS. The upload step MUST be create-or-upload idempotent: gh release create "$GITHUB_REF_NAME" --verify-tag --notes-from-tag 2>/dev/null || true before gh release upload ... --clobber, so the payload job never races the installer jobs on release creation. bootstrap: curl -sfL both files, sha256sum -c --ignore-missing, tar -xzf into mktemp, exec install.sh. rollout: argument branch that materializes the payload then falls through to the existing loop.
§7 - Dependencies
Depends on TASK-IMP-068 (check-version-sync.sh is the pre-upload gate). Blocks TASK-IMP-070 (remote update check needs a published "latest" to compare against). Related: TASK-PLUGIN-008 / TASK-SKILL-201 stay the long-term product registries; TASK-APP-001's Ops tab can later add a "download latest release" affordance on top of this channel.
§8 - Example payloads
$ ls assets/
SHA256SUMS cyberos-1.7.0.plugin cyberos-payload-1.7.0.tar.gz cyberos-payload.tar.gz cyberos.plugin
$ curl -sfL https://github.com/cyberskill-official/cyberos/releases/latest/download/cyberos-payload.tar.gz -o p.tgz
§9 - Open questions
None blocking. Whether to ALSO publish on every VERSION bump (not just tags) is deferred: tags stay the human release act per version.yml's "auto version, manual release" model.
§10 - Failure modes inventory
- Tag pushed at a commit whose VERSION lags (bot bump not yet merged) - the tag guard (§1 #4) fails the job with a one-line explanation instead of publishing mismatched assets.
- Re-running a release (re-tag / workflow re-run) -
--clobbermakes uploads idempotent; determinism (#1) makes the re-uploaded bytes identical. - Partial upload (network drop mid-job) - assets are uploaded in one
gh release uploadinvocation; a failed job leaves the release withoutSHA256SUMSat worst, and bootstrap refuses to install anything it cannot verify. - GitHub API rate-limited during bootstrap - curl fails, bootstrap aborts cleanly with the URL in the error; no partial
.cyberos/. - A consumer pins
latestbut needs an old version - versioned asset names keep every release addressable; README documents the pinned form.
§11 - Implementation notes
Keep asset names exactly as specified; TASK-IMP-070 and future npm packaging key off them. The tarball packs the payload CONTENTS at archive root (unpack -> install.sh at top level), matching what bootstrap.sh expects from a local payload dir. Determinism flags (--sort, --owner, --mtime, gzip -n) are GNU tar/gzip semantics: the CI job runs on ubuntu; the test suite MUST skip the determinism case with a visible SKIP on non-GNU (macOS bsdtar) hosts rather than false-fail.
End of TASK-IMP-069.
Audit
TASK-IMP-069 audit
§1 - Verdict summary
Audited for spec correctness and for scope honesty against the two adjacent distribution tasks (TASK-PLUGIN-008, TASK-SKILL-201). Draft under-specified asset naming for latest/download, raced release creation, and assumed GNU tar everywhere. All resolved; traceability closes over t01-t09 in tools/install/tests/test_release_assets.sh.
§2 - Findings (all resolved)
ISS-001 latest/download needs stable asset names
GitHub's latest-download URL requires a constant filename; versioned-only names broke the one canonical URL the whole task exists to provide. Resolved: §1 #1 stable aliases + versioned twins, AC 2.
ISS-002 upload raced release creation
gh release upload fails when the release object does not exist yet (installer jobs create it concurrently). Resolved: §6 create-or-upload idempotent step (gh release create --verify-tag || true before upload).
ISS-003 determinism flags are GNU-only
tar --sort/--mtime and gzip -n false-fail on macOS bsdtar. Resolved: §11 pins CI to ubuntu and requires a visible SKIP (not a false pass or fail) for the determinism case on non-GNU hosts.
ISS-004 supersession ambiguity with the marketplace tasks
Per the operator's "newest wins" conflict rule, the audit checked whether this task displaces TASK-PLUGIN-008/TASK-SKILL-201: it does not (different systems). Resolved: source_decisions boundary statement + §2/§7 non-supersession wording, so no old task is closed by mistake.
ISS-005 checksums covered only the tarballs
The .plugin assets were outside SHA256SUMS in the first cut, leaving the desktop-install path unverifiable. Resolved: §1 #1 "covering all four", AC 3 round-trip.
ISS-006 untestable network paths
bootstrap/rollout ACs had no offline test strategy (TRACE-002 risk). Resolved: file:// fixtures specified in §5 (t06-t08), keeping the suite hermetic.
§3 - Resolution
All six findings addressed as cited; dependency direction (068 -> 069 -> 070) is acyclic and stated on both sides. Score = 10/10.
End of TASK-IMP-069 audit.
§10 - Post-implementation gates (2026-07-12, ship run)
- §10.4 coverage gate: PASS - t01-t09 green on fresh rerun; TASK-IMP-068 (10/10) and TASK-SKILL-116 (6/6) suites green as regression. Report: .workflow/TASK-IMP-069/coverage-and-review.md.
- TRACE-004 closure: PASS. awh/caf: N/A (declared); floor = bash -n + 25 green cases.
- HITL gate 1: APPROVED by Stephen Cheng 2026-07-12. HITL gate 2: ACCEPTED same date via explicit operator pre-authorization at the review gate; gates stayed green.
- First live proof arrives with the next v* tag (payload job publishes the assets).
TASK-IMP-069 shipped 2026-07-12.
§11 - Post-ship amendment (2026-07-12, release #35)
Field finding: on workflow_dispatch (the ONLY practical trigger, since bump commits carry [skip ci] and kill tag-push events), release-assets.sh read GITHUB_REF_NAME (= the branch) and refused with "tag main != v1.8.0". Fix: the script now honors TAG (set by the job from inputs.tag || ref_name) with GITHUB_REF_NAME as fallback; t04 gained dispatch-precedence and wrong-TAG sub-cases. §1 #2 amended accordingly. Root cause ([skip ci] on bump commits) is queued as a durable-fix task for the next batch.