omakit 0.6.8 → 0.6.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/tools/blocks/commit.json +2 -2
- package/tools/lab/guest.mjs +1 -1
- package/tools/lab/host.mjs +2 -2
- package/tools/lab/inspect.mjs +170 -31
- package/tools/lab/paths.mjs +1 -1
- package/tools/lab/pin.json +10 -14
- package/tools/lab/pin.mjs +78 -13
- package/tools/lab/prune.mjs +10 -7
- package/tools/lab/release.mjs +178 -0
- package/tools/lab/report.mjs +78 -18
- package/tools/lab/run.mjs +27 -9
- package/tools/lab/setup.mjs +301 -68
- package/tools/lab/verify.mjs +149 -20
- package/tools/marketplace/README.md +11 -10
- package/tools/marketplace/cli.mjs +36 -8
- package/tools/marketplace/doctor.mjs +128 -23
- package/tools/marketplace/github.mjs +11 -5
- package/tools/marketplace/options.mjs +1 -1
- package/tools/marketplace/pin.mjs +79 -14
- package/tools/marketplace/preflight.mjs +2 -2
- package/tools/marketplace/registry.mjs +31 -49
- package/tools/marketplace/review-cost.mjs +2 -3
- package/tools/marketplace/submit.mjs +2 -2
- package/tools/marketplace/usage.mjs +19 -16
- package/tools/marketplace/watch.mjs +2 -3
package/tools/lab/setup.mjs
CHANGED
|
@@ -6,16 +6,19 @@
|
|
|
6
6
|
// for automation, and a pipe without it refuses. The plan is computed
|
|
7
7
|
// first and reads only; the disclosure prints it whole; then, in order:
|
|
8
8
|
//
|
|
9
|
+
// 0. The newest release, found by release.mjs before the plan is made:
|
|
10
|
+
// the newest at or above the floor whose ISO, checksum and signature
|
|
11
|
+
// are published, signed by the key omakit ships. A setup whose host
|
|
12
|
+
// cannot build is refused before that read.
|
|
9
13
|
// 1. The ISO, into $XDG_CACHE_HOME/omakit/lab/downloads/<sha256>/: a
|
|
10
|
-
// literal GET of the
|
|
11
|
-
// range, or a copy of a local file named with --from.
|
|
12
|
-
// "latest": the URL, the byte count, the digest and the signer are
|
|
13
|
-
// the pin's.
|
|
14
|
+
// literal GET of the release's versioned URL to a .part file, resumed
|
|
15
|
+
// by byte range, or a copy of a local file named with --from.
|
|
14
16
|
// 2. Verification, of a downloaded file and of a file already there
|
|
15
|
-
// alike: the byte count
|
|
16
|
-
// sidecar
|
|
17
|
-
//
|
|
18
|
-
//
|
|
17
|
+
// alike: the byte count and the SHA-256 against what the release
|
|
18
|
+
// publishes, the sidecar fetched now against the digest read at the
|
|
19
|
+
// start, the detached signature against the packaged key at the
|
|
20
|
+
// pinned fingerprint. A mismatch fails closed: the file is left as
|
|
21
|
+
// .part, nothing is recorded, nothing boots it.
|
|
19
22
|
// 3. The base, built by the pinned omarchy-iso toolchain's console
|
|
20
23
|
// driver from the verified ISO, in a staging directory under the lab
|
|
21
24
|
// (the driver derives its base directory from its own location, so
|
|
@@ -23,7 +26,9 @@
|
|
|
23
26
|
// checkout: docs/history/2026-09-18-lab-inventory.md P6, P19), then
|
|
24
27
|
// booted once by the lab's own driver to read the installed
|
|
25
28
|
// omarchy package, hashed, made read-only, given its manifest, and
|
|
26
|
-
// promoted with one rename.
|
|
29
|
+
// promoted with one rename. The base it replaces, and the downloads
|
|
30
|
+
// of older releases, are removed only after that: until the new base
|
|
31
|
+
// verifies, a run uses the old one.
|
|
27
32
|
//
|
|
28
33
|
// Never fetched: the toolchain. It is a git checkout at a pinned commit
|
|
29
34
|
// with the packaged patch applied, and the one command that makes it is
|
|
@@ -31,14 +36,15 @@
|
|
|
31
36
|
// harness against the pin.
|
|
32
37
|
|
|
33
38
|
import { spawn, spawnSync } from "node:child_process"
|
|
34
|
-
import { chmodSync, closeSync, createWriteStream, existsSync, fsyncSync, openSync, readdirSync, statSync, writeFileSync } from "node:fs"
|
|
39
|
+
import { chmodSync, closeSync, createWriteStream, existsSync, fsyncSync, openSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"
|
|
35
40
|
import { join, resolve } from "node:path"
|
|
36
41
|
import { getStream, GitHubError } from "../marketplace/github.mjs"
|
|
37
|
-
import { LAB_DIR, bytesBoth, durationWords, labPin } from "./pin.mjs"
|
|
42
|
+
import { LAB_DIR, bytesBoth, compareVersions, durationWords, guestIsRelease, labPin, releaseOf, withRelease } from "./pin.mjs"
|
|
38
43
|
import { allocatedBytes, copyIntoLab, inLab, labDir, labLayout, moveIntoLab, readJson, removeFromLab, stampNow, writeJson } from "./paths.mjs"
|
|
39
44
|
import { BUILD_COMMANDS, VERIFY_COMMANDS, freeBytesAt, probeCommands, probeRunHost } from "./host.mjs"
|
|
40
|
-
import { judgeRelease, packagedKey, sha256File } from "./verify.mjs"
|
|
41
|
-
import {
|
|
45
|
+
import { judgeRelease, packagedKey, sha256File, signatureIssuers, signedByPinned } from "./verify.mjs"
|
|
46
|
+
import { sidecarDigest, smallBody } from "./release.mjs"
|
|
47
|
+
import { BASE_FILES, baseRelease, buildGuests, downloadDir, downloadEntry, inspectBase, inspectDownload, inspectToolchain, toolchainCommand } from "./inspect.mjs"
|
|
42
48
|
import { LabError, acquireLock, freePort, releaseLock, withGuest } from "./run.mjs"
|
|
43
49
|
import { WEIGH_LISTED, listedPlugin } from "./suites.mjs"
|
|
44
50
|
import { marketplacePinDir } from "../marketplace/pin.mjs"
|
|
@@ -62,66 +68,186 @@ export function recordToolchain({ dir, env = process.env, pin = labPin() }) {
|
|
|
62
68
|
return { dir: checkout, sha256 }
|
|
63
69
|
}
|
|
64
70
|
|
|
71
|
+
/** The newest release, not looked up yet: what the first, network-free plan is made with. */
|
|
72
|
+
export const NOT_ASKED = Object.freeze({ checked: false, code: "not-asked", reason: "the newest release was not looked up" })
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The release a local file names when the release list cannot be read:
|
|
76
|
+
* `omarchy-<version>.iso`, with its `.sha256` and `.sig` beside it, at or
|
|
77
|
+
* above the floor, signed (by the packet's own word) by the key omakit
|
|
78
|
+
* ships. Verification still hashes it and runs gpg before anything boots
|
|
79
|
+
* it; this only names what it claims to be. `{ release }` or `{ blocker }`.
|
|
80
|
+
*/
|
|
81
|
+
export function localRelease(pin, file) {
|
|
82
|
+
const source = resolve(file)
|
|
83
|
+
const name = source.split("/").at(-1).match(/^omarchy-(\d+\.\d+\.\d+)\.iso$/)?.[1]
|
|
84
|
+
const command = "omakit lab setup --from <omarchy-X.Y.Z.iso> with its .sha256 and .sig beside it, or with the network, omakit lab setup"
|
|
85
|
+
if (!existsSync(source)) return { blocker: { what: "the file named by --from", cost: `${source} is not there`, command } }
|
|
86
|
+
if (!name) return { blocker: { what: "the file named by --from", cost: `${source} is not named omarchy-<version>.iso, so it names no release; without the network there is nothing else to name one`, command } }
|
|
87
|
+
if (compareVersions(name, pin.releases.floor) < 0) return { blocker: { what: "the file named by --from", cost: `Omarchy ${name} is older than ${pin.releases.floor}, the oldest release the lab takes`, command } }
|
|
88
|
+
let sha256 = null
|
|
89
|
+
try {
|
|
90
|
+
sha256 = sidecarDigest(readFileSync(`${source}.sha256`, "utf8"), source.split("/").at(-1))
|
|
91
|
+
} catch {
|
|
92
|
+
sha256 = null
|
|
93
|
+
}
|
|
94
|
+
if (!sha256) return { blocker: { what: "the checksum beside --from", cost: `${source}.sha256 is missing or does not name the file with a SHA-256`, command } }
|
|
95
|
+
let signers = null
|
|
96
|
+
try {
|
|
97
|
+
signers = signatureIssuers(readFileSync(`${source}.sig`))
|
|
98
|
+
} catch {
|
|
99
|
+
return { blocker: { what: "the signature beside --from", cost: `${source}.sig is not there; nothing boots a release whose signature was not checked`, command } }
|
|
100
|
+
}
|
|
101
|
+
if (signedByPinned(signers, pin.releases.signingFingerprint) === false) return { blocker: { what: "the signature beside --from", cost: `${source}.sig is by ${signers.map((signer) => signer.fingerprint || `key ${signer.keyId}`).join(" and ")}, not the key omakit ships (${pin.releases.signingFingerprint})`, command: "omakit upgrade" } }
|
|
102
|
+
return { release: releaseOf(pin, { name, bytes: statSync(source).size, sha256 }) }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Download directories a new base supersedes, removed once it verifies:
|
|
107
|
+
* every one but the release's own, except a newer release's. Never going
|
|
108
|
+
* back a release holds for the downloads too: with no base, a newer ISO
|
|
109
|
+
* verified by an earlier setup stays when this one builds an older
|
|
110
|
+
* release (its checksum was being republished when the search ran).
|
|
111
|
+
*/
|
|
112
|
+
function supersededDownloads(layout, release) {
|
|
113
|
+
if (!existsSync(layout.downloads)) return []
|
|
114
|
+
return readdirSync(layout.downloads)
|
|
115
|
+
.filter((digest) => /^[0-9a-f]{64}$/.test(digest) && digest !== release.sha256)
|
|
116
|
+
.map((digest) => ({ digest, relative: `downloads/${digest}`, name: downloadEntry(join(layout.downloads, digest), digest).name, bytes: allocatedBytes(join(layout.downloads, digest)) }))
|
|
117
|
+
.filter((entry) => !entry.name || compareVersions(entry.name, release.name) <= 0)
|
|
118
|
+
}
|
|
119
|
+
|
|
65
120
|
/**
|
|
66
121
|
* The plan, reading only: what setup would do on this host, with every
|
|
67
122
|
* size and the destination. `steps` is what the consent covers.
|
|
123
|
+
*
|
|
124
|
+
* `newest` is release.mjs's check. With NOT_ASKED the plan holds only what
|
|
125
|
+
* no release changes (the commands verification needs, and, when there is
|
|
126
|
+
* no usable base, what a build needs), so a setup that cannot start says
|
|
127
|
+
* so without a network read. With the newest release found, the plan
|
|
128
|
+
* prepares it; with the lookup failed, a usable base on disk is left as it
|
|
129
|
+
* is and the plan says the newest was not checked, and with none there,
|
|
130
|
+
* that is the blocker.
|
|
68
131
|
*/
|
|
69
|
-
export function planSetup({ env = process.env, pin = labPin(), from = null, plugins = false, repoRoot } = {}) {
|
|
132
|
+
export function planSetup({ env = process.env, pin = labPin(), newest = NOT_ASKED, from = null, plugins = false, repoRoot } = {}) {
|
|
70
133
|
const layout = labLayout(env)
|
|
71
|
-
const download = inspectDownload(layout, pin)
|
|
72
|
-
const base = inspectBase(layout, pin)
|
|
73
134
|
const toolchain = inspectToolchain(layout, pin)
|
|
74
135
|
const free = freeBytesAt(layout.cache)
|
|
75
136
|
const steps = []
|
|
76
137
|
const blockers = []
|
|
138
|
+
const buildBlockers = (withBuild) => {
|
|
139
|
+
for (const line of probeRunHost({ pin, env })) if (line.state !== "ok") blockers.push({ what: line.name, cost: line.reason, command: line.remedy })
|
|
140
|
+
if (!withBuild) return
|
|
141
|
+
for (const line of probeCommands(BUILD_COMMANDS, { env })) if (line.state !== "ok") blockers.push({ what: line.name, cost: line.reason, command: line.remedy })
|
|
142
|
+
if (toolchain.state !== "ready") blockers.push({ what: "the toolchain", cost: toolchain.reason, command: toolchain.state === "unpatched" ? `git -C ${toolchain.dir} apply ${join(LAB_DIR, pin.toolchain.patch)} && omakit lab setup --toolchain ${toolchain.dir}` : toolchainCommand(pin, join(layout.cache, "toolchain/omarchy-iso")) })
|
|
143
|
+
}
|
|
77
144
|
for (const line of probeCommands(VERIFY_COMMANDS, { env })) if (line.state !== "ok") blockers.push({ what: line.name, cost: line.reason, command: line.remedy })
|
|
145
|
+
// The base against its own release: whether a run can use what is there.
|
|
146
|
+
const own = baseRelease(layout, pin)
|
|
147
|
+
const current = inspectBase(layout, withRelease(pin, own))
|
|
148
|
+
const usable = current.state === "ready"
|
|
149
|
+
// The listed plugins are asked for whatever the base does: a base kept
|
|
150
|
+
// as it is (the newest not looked up, or one newer than the newest) is
|
|
151
|
+
// no reason to leave `prove weigh-evidence` without them.
|
|
152
|
+
let pluginsPlanned = false
|
|
153
|
+
const addPlugins = () => {
|
|
154
|
+
if (!plugins || pluginsPlanned) return
|
|
155
|
+
pluginsPlanned = true
|
|
156
|
+
const pinDir = marketplacePinDir(repoRoot)
|
|
157
|
+
const wanted = []
|
|
158
|
+
for (const id of WEIGH_LISTED) {
|
|
159
|
+
const listing = listedPlugin(pinDir, id)
|
|
160
|
+
if (!listing.ok) blockers.push({ what: `${id} in the pinned catalog`, cost: listing.reason, command: "omakit pin" })
|
|
161
|
+
else wanted.push({ id, repo: listing.repo, commit: listing.commit, to: join(layout.plugins, id) })
|
|
162
|
+
}
|
|
163
|
+
if (wanted.length) steps.push({ kind: "plugins", plugins: wanted, to: layout.plugins, bytes: pin.measured.pluginsBytes || null })
|
|
164
|
+
}
|
|
165
|
+
const done = (release, extra = {}) => ({ layout, pin: withRelease(pin, release), newest, download: null, base: current, toolchain, free, steps, blockers, needed: 0, afterBytes: 0, ...extra })
|
|
166
|
+
if (!newest.checked && newest.code === "not-asked") {
|
|
167
|
+
// Whatever the newest release turns out to be, a host with no usable
|
|
168
|
+
// base builds one: those blockers are known before the network.
|
|
169
|
+
if (!usable) buildBlockers(stagedBases(layout).length === 0)
|
|
170
|
+
return done(own, { asked: false })
|
|
171
|
+
}
|
|
172
|
+
let release = newest.checked ? newest.release : null
|
|
173
|
+
if (from && newest.checked) {
|
|
174
|
+
const named = resolve(from).split("/").at(-1).match(/^omarchy-(\d+\.\d+\.\d+)\.iso$/)?.[1]
|
|
175
|
+
if (named && named !== release.name) blockers.push({ what: "the file named by --from", cost: `${resolve(from)} is Omarchy ${named}; the newest release is ${release.name}, and setup prepares the newest`, command: `omakit lab setup --from <path to ${release.fileName}>, or without --from to download it` })
|
|
176
|
+
} else if (from) {
|
|
177
|
+
const local = localRelease(pin, from)
|
|
178
|
+
if (local.blocker) blockers.push(local.blocker)
|
|
179
|
+
release = local.release || null
|
|
180
|
+
}
|
|
181
|
+
if (!release) {
|
|
182
|
+
// The newest could not be looked up. A usable base stays as it is and
|
|
183
|
+
// the plan says what was not checked; with none, that is the blocker.
|
|
184
|
+
if (usable) {
|
|
185
|
+
addPlugins()
|
|
186
|
+
return done(own, { stale: { base: own.name, reason: newest.reason, code: newest.code } })
|
|
187
|
+
}
|
|
188
|
+
if (!from) blockers.push({ what: "the newest Omarchy release", cost: `it could not be looked up: ${newest.reason}`, command: "connect to the network and run omakit lab setup again, or omakit lab setup --from <omarchy-X.Y.Z.iso> with its .sha256 and .sig beside it" })
|
|
189
|
+
return done(null)
|
|
190
|
+
}
|
|
191
|
+
pin = withRelease(pin, release)
|
|
192
|
+
const download = inspectDownload(layout, pin)
|
|
193
|
+
const base = inspectBase(layout, pin)
|
|
194
|
+
// A good base newer than the release in hand is kept: the lab never goes
|
|
195
|
+
// back a release on its own (inspect.mjs, `ahead`), and nothing older is
|
|
196
|
+
// fetched or built over it.
|
|
197
|
+
if (base.state === "ahead") {
|
|
198
|
+
addPlugins()
|
|
199
|
+
return done(release, { download, base, ahead: { base: base.manifest.release.name, release: release.name, from: newest.checked ? "newest" : "from" } })
|
|
200
|
+
}
|
|
78
201
|
if (!download.verified) {
|
|
79
202
|
if (from) {
|
|
80
203
|
const source = resolve(from)
|
|
81
|
-
if (!existsSync(source)) blockers.push({ what: `the file named by --from`, cost: `${source} is not there`, command:
|
|
82
|
-
else steps.push({ kind: "import", from: source, bytes: statSync(source).size, to: join(download.dir,
|
|
204
|
+
if (!existsSync(source)) blockers.push({ what: `the file named by --from`, cost: `${source} is not there`, command: `omakit lab setup --from <path to ${release.fileName}>` })
|
|
205
|
+
else steps.push({ kind: "import", from: source, bytes: statSync(source).size, to: join(download.dir, release.fileName), sidecars: { checksum: existsSync(`${source}.sha256`) ? `${source}.sha256` : null, signature: existsSync(`${source}.sig`) ? `${source}.sig` : null } })
|
|
83
206
|
} else if (download.present) {
|
|
84
207
|
steps.push({ kind: "verify", file: download.iso, bytes: download.bytes })
|
|
85
208
|
} else {
|
|
86
|
-
steps.push({ kind: "download", url:
|
|
209
|
+
steps.push({ kind: "download", url: release.isoUrl, bytes: release.bytes, resumeFrom: download.partial || 0, to: join(download.dir, release.fileName) })
|
|
87
210
|
}
|
|
88
|
-
steps.push({ kind: "sidecars", bytes: 203, urls: [
|
|
211
|
+
steps.push({ kind: "sidecars", bytes: 203, urls: [release.checksumUrl, release.signatureUrl], to: download.dir })
|
|
89
212
|
}
|
|
90
213
|
if (base.state !== "ready") {
|
|
91
|
-
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
const staged = stagedBases(layout)
|
|
214
|
+
// A base the toolchain built for this release but a verification boot
|
|
215
|
+
// never promoted (an interrupted or failed setup) is verified and
|
|
216
|
+
// promoted, not rebuilt: six minutes and six gigabytes are not spent
|
|
217
|
+
// twice. One built from another release is left for prune.
|
|
218
|
+
const staged = stagedBases(layout, release)
|
|
219
|
+
buildBlockers(staged.length === 0)
|
|
220
|
+
const replacing = base.state === "missing" ? null : base
|
|
221
|
+
const superseded = supersededDownloads(layout, release)
|
|
96
222
|
if (staged.length) {
|
|
97
|
-
steps.push({ kind: "promote", staged: staged.at(-1), to: layout.base, replacing
|
|
223
|
+
steps.push({ kind: "promote", staged: staged.at(-1), to: layout.base, replacing, superseded })
|
|
98
224
|
} else {
|
|
99
|
-
|
|
100
|
-
if (toolchain.state !== "ready") blockers.push({ what: "the toolchain", cost: toolchain.reason, command: toolchain.state === "unpatched" ? `git -C ${toolchain.dir} apply ${join(LAB_DIR, pin.toolchain.patch)} && omakit lab setup --toolchain ${toolchain.dir}` : toolchainCommand(pin, join(layout.cache, "toolchain/omarchy-iso")) })
|
|
101
|
-
steps.push({ kind: "build", toolchain: toolchain.harness, to: layout.base, replacing: base.state === "missing" ? null : base, bytes: pin.measured.baseDirectoryBytes, milliseconds: pin.measured.buildMilliseconds })
|
|
225
|
+
steps.push({ kind: "build", toolchain: toolchain.harness, to: layout.base, replacing, superseded, bytes: pin.measured.baseDirectoryBytes, milliseconds: pin.measured.buildMilliseconds })
|
|
102
226
|
}
|
|
103
227
|
}
|
|
104
|
-
|
|
105
|
-
const pinDir = marketplacePinDir(repoRoot)
|
|
106
|
-
const wanted = []
|
|
107
|
-
for (const id of WEIGH_LISTED) {
|
|
108
|
-
const listing = listedPlugin(pinDir, id)
|
|
109
|
-
if (!listing.ok) blockers.push({ what: `${id} in the pinned catalog`, cost: listing.reason, command: "omakit pin" })
|
|
110
|
-
else wanted.push({ id, repo: listing.repo, commit: listing.commit, to: join(layout.plugins, id) })
|
|
111
|
-
}
|
|
112
|
-
if (wanted.length) steps.push({ kind: "plugins", plugins: wanted, to: layout.plugins, bytes: pin.measured.pluginsBytes || null })
|
|
113
|
-
}
|
|
228
|
+
addPlugins()
|
|
114
229
|
const needed = steps.reduce((sum, step) => sum + (step.kind === "download" ? step.bytes - (step.resumeFrom || 0) : step.kind === "import" ? step.bytes : step.kind === "build" ? step.bytes : 0), 0)
|
|
115
230
|
if (steps.length && free.bytes < needed) blockers.push({ what: "disk", cost: `${bytesBoth(free.bytes)} free at ${free.path}; this setup needs ${bytesBoth(needed)}`, command: "omakit lab prune" })
|
|
116
|
-
|
|
231
|
+
// Afterwards: this ISO and a base the size the measured release's was.
|
|
232
|
+
return { layout, pin, newest, download, base, toolchain, free, steps, blockers, needed, afterBytes: release.bytes + pin.measured.baseDirectoryBytes }
|
|
117
233
|
}
|
|
118
234
|
|
|
119
|
-
/**
|
|
120
|
-
|
|
235
|
+
/**
|
|
236
|
+
* Staged bases awaiting verification: `staging/base-*` with the four files
|
|
237
|
+
* and no manifest, oldest first. With a release, only the ones built from
|
|
238
|
+
* its ISO: the build record names the ISO it was built from, and a base of
|
|
239
|
+
* another release would never pass the verification boot's guest check.
|
|
240
|
+
*/
|
|
241
|
+
export function stagedBases(layout, release = null) {
|
|
121
242
|
if (!existsSync(layout.staging)) return []
|
|
122
243
|
return readdirSync(layout.staging).filter((name) => /^base-\d{8}-\d{6}$/.test(name)).sort()
|
|
123
244
|
.map((name) => join(layout.staging, name))
|
|
124
245
|
.filter((dir) => [BASE_FILES.disk, BASE_FILES.vars, BASE_FILES.key, BASE_FILES.publicKey].every((file) => existsSync(join(dir, file))) && !existsSync(join(dir, BASE_FILES.manifest)))
|
|
246
|
+
.filter((dir) => {
|
|
247
|
+
if (!release) return true
|
|
248
|
+
const record = readJson(join(dir, "build", "lab-build.json"))
|
|
249
|
+
return record?.release?.sha256 === release.sha256 || String(record?.iso || "").includes(`/downloads/${release.sha256}/`)
|
|
250
|
+
})
|
|
125
251
|
}
|
|
126
252
|
|
|
127
253
|
/** The lines of the disclosure, exactly as the contract in docs/LAB.md shows them; the consent question follows them. */
|
|
@@ -132,24 +258,48 @@ export function disclosureLines(plan) {
|
|
|
132
258
|
const imported = plan.steps.find((step) => step.kind === "import")
|
|
133
259
|
const build = plan.steps.find((step) => step.kind === "build")
|
|
134
260
|
const plugins = plan.steps.find((step) => step.kind === "plugins")
|
|
135
|
-
|
|
261
|
+
// Plugins alone: the base stays as it is, and the lines say why.
|
|
262
|
+
if (!plan.steps.some((step) => ["download", "import", "verify", "sidecars", "build", "promote"].includes(step.kind))) {
|
|
263
|
+
const baseName = plan.base?.manifest?.release?.name
|
|
264
|
+
lines.push(["Omarchy", plan.stale
|
|
265
|
+
? `the base there, Omarchy ${plan.stale.base}, left as it is: the newest release could not be looked up (${plan.stale.reason})`
|
|
266
|
+
: plan.ahead
|
|
267
|
+
? `the base there, Omarchy ${plan.ahead.base}, kept: newer than ${plan.ahead.release}`
|
|
268
|
+
: `the base there, Omarchy ${baseName || pin.release?.name}, ${plan.newest?.checked ? "the newest release" : "left as it is"}`])
|
|
269
|
+
if (plugins) lines.push(["plugins", `${plugins.plugins.length} listed plugins at their validated commits, shallow, into ${plugins.to}`])
|
|
270
|
+
lines.push(["store", plan.layout.cache])
|
|
271
|
+
lines.push(["on disk", `${bytesBoth(plan.free.bytes)} free now`])
|
|
272
|
+
return lines
|
|
273
|
+
}
|
|
274
|
+
const found = plan.newest?.checked
|
|
275
|
+
? `the newest published (${plan.newest.tag}${plan.newest.publishedAt ? `, ${plan.newest.publishedAt.slice(0, 10)}` : ""}, found now in ${plan.newest.list.replace(/^https:\/\/api\.github\.com\/repos\/([^/]+\/[^/]+)\/releases.*$/, "github.com/$1")})`
|
|
276
|
+
: `from the file named by --from; the newest could not be looked up (${plan.newest?.reason || "not asked"})`
|
|
277
|
+
lines.push(["Omarchy", `release ${pin.release.name}, ${found}; the guest will run omarchy ${pin.release.name}`])
|
|
278
|
+
for (const skipped of plan.newest?.skipped || []) lines.push(["newer", `${skipped.name} is tagged and passed over: ${skipped.reason}`])
|
|
136
279
|
if (download) lines.push(["download", `${bytesBoth(download.bytes - download.resumeFrom)}${download.resumeFrom ? ` (resuming at ${download.resumeFrom.toLocaleString("en-US")} of ${download.bytes.toLocaleString("en-US")} B)` : ""}`], ["from", download.url])
|
|
137
280
|
else if (imported) lines.push(["download", "0 B: the file named by --from is copied and verified"], ["from", imported.from])
|
|
138
281
|
else lines.push(["download", "0 B: the ISO on disk is verified"])
|
|
139
|
-
lines.push(["verify", `
|
|
282
|
+
lines.push(["verify", `SHA-256 ${pin.release.sha256} as published beside it, and the Omarchy signature ${pin.release.signingFingerprint}, the key omakit ships`])
|
|
140
283
|
lines.push(["store", plan.layout.cache])
|
|
141
284
|
const promote = plan.steps.find((step) => step.kind === "promote")
|
|
285
|
+
const replacing = (step, what) => {
|
|
286
|
+
const parts = []
|
|
287
|
+
if (step.replacing) parts.push(`the ${step.replacing.state} base there${step.replacing.manifest ? ` (Omarchy ${step.replacing.manifest.release.name}, ${bytesBoth(step.replacing.allocatedBytes)})` : ` (${bytesBoth(step.replacing.allocatedBytes)})`}`)
|
|
288
|
+
const older = step.superseded || []
|
|
289
|
+
if (older.length) parts.push(`${older.length === 1 ? "one older download" : `${older.length} older downloads`}${older.some((entry) => entry.name) ? ` (${older.map((entry) => entry.name || entry.digest.slice(0, 12)).join(", ")})` : ""}, ${bytesBoth(older.reduce((sum, entry) => sum + entry.bytes, 0))}`)
|
|
290
|
+
if (parts.length) lines.push(["replacing", `${parts.join(", and ")}, removed after ${what} verifies; until then a run uses what is there`])
|
|
291
|
+
}
|
|
142
292
|
if (build) {
|
|
143
|
-
lines.push(["build", `${durationWords(build.milliseconds)} on the reference host (M14); download excluded`])
|
|
144
|
-
|
|
293
|
+
lines.push(["build", `${durationWords(build.milliseconds)} measured with Omarchy ${pin.measured.release} on the reference host (M14); download excluded`])
|
|
294
|
+
replacing(build, "the new base")
|
|
145
295
|
}
|
|
146
296
|
if (promote) {
|
|
147
297
|
lines.push(["build", `none: the base the toolchain built at ${promote.staged} is booted once, verified and promoted`])
|
|
148
|
-
|
|
298
|
+
replacing(promote, "the staged base")
|
|
149
299
|
}
|
|
150
300
|
if (plugins) lines.push(["plugins", `${plugins.plugins.length} listed plugins at their validated commits, shallow, into ${plugins.to}`])
|
|
151
301
|
lines.push(["afterwards", "verified ISO, one immutable base, and manifests"])
|
|
152
|
-
lines.push(["on disk",
|
|
302
|
+
lines.push(["on disk", `about ${bytesBoth(plan.afterBytes)}: this ISO and a base the size Omarchy ${pin.measured.release}'s measured (M14), before evidence; ${bytesBoth(plan.free.bytes)} free now`])
|
|
153
303
|
return lines
|
|
154
304
|
}
|
|
155
305
|
|
|
@@ -157,8 +307,8 @@ export function disclosureLines(plan) {
|
|
|
157
307
|
export const CONSENT_QUESTION = "Acquire and build this verified base now?"
|
|
158
308
|
|
|
159
309
|
/**
|
|
160
|
-
* A resumable literal GET of the
|
|
161
|
-
* count is checked against the
|
|
310
|
+
* A resumable literal GET of the release's ISO to `<to>.part`. The byte
|
|
311
|
+
* count is checked against the release's as it arrives and at the end; a
|
|
162
312
|
* server that answers 200 to a range request restarts the file.
|
|
163
313
|
*/
|
|
164
314
|
export async function downloadRelease({ url, to, bytes, onProgress = () => {}, signal, fetchStream = getStream }) {
|
|
@@ -172,6 +322,7 @@ export async function downloadRelease({ url, to, bytes, onProgress = () => {}, s
|
|
|
172
322
|
try {
|
|
173
323
|
response = await fetchStream(url, { rangeFrom: have, signal })
|
|
174
324
|
} catch (error) {
|
|
325
|
+
if (error?.code === "interrupted") throw new LabError("interrupted", "interrupted; the partial download stays and resumes next time", { remedy: "omakit lab setup: the download resumes where it stopped" })
|
|
175
326
|
if (error instanceof GitHubError) throw new LabError(error.code, error.message, { remedy: "check the network, then run `omakit lab setup` again; the download resumes" })
|
|
176
327
|
throw error
|
|
177
328
|
}
|
|
@@ -179,32 +330,51 @@ export async function downloadRelease({ url, to, bytes, onProgress = () => {}, s
|
|
|
179
330
|
const expected = append ? bytes - have : bytes
|
|
180
331
|
const length = Number(response.headers.get("content-length"))
|
|
181
332
|
if (Number.isFinite(length) && length > 0 && length !== expected) {
|
|
182
|
-
throw new LabError("size-mismatch", `${url} announces ${length.toLocaleString("en-US")} B where
|
|
333
|
+
throw new LabError("size-mismatch", `${url} announces ${length.toLocaleString("en-US")} B where ${expected.toLocaleString("en-US")} were expected; the object at the versioned URL changed since the release was read`, { remedy: "omakit lab setup: the release is read again" })
|
|
183
334
|
}
|
|
184
335
|
const out = createWriteStream(part, { flags: append ? "a" : "w", mode: 0o600 })
|
|
185
336
|
let written = append ? have : 0
|
|
186
337
|
const started = Date.now()
|
|
338
|
+
const interrupted = () => new LabError("interrupted", "interrupted; the partial download stays and resumes next time", { remedy: "omakit lab setup: the download resumes where it stopped" })
|
|
187
339
|
try {
|
|
188
340
|
for await (const chunk of response.body) {
|
|
189
|
-
if (signal?.aborted) throw
|
|
341
|
+
if (signal?.aborted) throw interrupted()
|
|
190
342
|
written += chunk.length
|
|
191
|
-
if (written > bytes) throw new LabError("size-mismatch", `${url} sent more than the
|
|
343
|
+
if (written > bytes) throw new LabError("size-mismatch", `${url} sent more than the ${bytes.toLocaleString("en-US")} B it announced`)
|
|
192
344
|
if (!out.write(chunk)) await new Promise((resolvePromise) => out.once("drain", resolvePromise))
|
|
193
345
|
onProgress(written, bytes, Date.now() - started)
|
|
194
346
|
}
|
|
347
|
+
} catch (error) {
|
|
348
|
+
// An abort while the body streams ends the loop with the abort's own
|
|
349
|
+
// reason ("SIGINT", a bare string), not with the check above: it is an
|
|
350
|
+
// interrupt, said as one, with the exit status the signal gives.
|
|
351
|
+
if (signal?.aborted && !(error instanceof LabError)) throw interrupted()
|
|
352
|
+
throw error
|
|
195
353
|
} finally {
|
|
196
354
|
await new Promise((resolvePromise) => out.end(resolvePromise))
|
|
197
355
|
}
|
|
198
|
-
if (written !== bytes) throw new LabError("size-mismatch", `${url} ended at ${written.toLocaleString("en-US")} of
|
|
356
|
+
if (written !== bytes) throw new LabError("size-mismatch", `${url} ended at ${written.toLocaleString("en-US")} of ${bytes.toLocaleString("en-US")} B; run setup again to resume`)
|
|
199
357
|
return { part, bytes: written, milliseconds: Date.now() - started }
|
|
200
358
|
}
|
|
201
359
|
|
|
202
360
|
/** The two sidecars, small, to the download directory; the signature is required, the checksum compared. */
|
|
203
361
|
async function fetchSidecars({ pin, dir, layout, fetchStream = getStream, signal }) {
|
|
204
362
|
for (const [url, name] of [[pin.release.checksumUrl, `${pin.release.fileName}.sha256`], [pin.release.signatureUrl, `${pin.release.fileName}.sig`]]) {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
363
|
+
let response
|
|
364
|
+
try {
|
|
365
|
+
response = await fetchStream(url, { signal })
|
|
366
|
+
} catch (error) {
|
|
367
|
+
if (error?.code === "interrupted") throw new LabError("interrupted", "interrupted while fetching the checksum and signature; what was downloaded stays", { remedy: "omakit lab setup: it picks up where it stopped" })
|
|
368
|
+
if (error instanceof GitHubError) throw new LabError(error.code, error.message, { remedy: "check the network, then run `omakit lab setup` again" })
|
|
369
|
+
throw error
|
|
370
|
+
}
|
|
371
|
+
let body
|
|
372
|
+
try {
|
|
373
|
+
body = await smallBody(response, url, "sidecar-mismatch")
|
|
374
|
+
} catch (error) {
|
|
375
|
+
if (signal?.aborted && !(error instanceof LabError)) throw new LabError("interrupted", "interrupted while fetching the checksum and signature; what was downloaded stays", { remedy: "omakit lab setup: it picks up where it stopped" })
|
|
376
|
+
throw error
|
|
377
|
+
}
|
|
208
378
|
writeFileSync(inLab(layout.cache, `downloads/${pin.release.sha256}/${name}`), body, { mode: 0o600 })
|
|
209
379
|
}
|
|
210
380
|
}
|
|
@@ -216,16 +386,50 @@ async function verifyAndPromote({ candidate, to, pin, layout, stagingRoot, onPro
|
|
|
216
386
|
const checksum = `${to}.sha256`
|
|
217
387
|
const judged = judgeRelease({ file: candidate, signature: existsSync(signature) ? signature : null, checksum: existsSync(checksum) ? checksum : null, pin, stagingRoot, onProgress })
|
|
218
388
|
if (!judged.ok) {
|
|
219
|
-
throw new LabError("iso-mismatch", `the file at ${candidate} is not
|
|
389
|
+
throw new LabError("iso-mismatch", `the file at ${candidate} is not Omarchy ${pin.release.name} as published: ${judged.reason}; it stays where it is and nothing will boot it`, { remedy: "remove it with `omakit lab prune`, then `omakit lab setup` again" })
|
|
220
390
|
}
|
|
221
|
-
if (judged.sidecarMatch === false) throw new LabError("sidecar-mismatch", `the published
|
|
391
|
+
if (judged.sidecarMatch === false) throw new LabError("sidecar-mismatch", `the checksum published now names ${judged.sidecarSha256}, and ${pin.release.sha256} when setup read the release: Omarchy republished ${pin.release.fileName} while setup ran, and this file is the earlier one`, { remedy: "omakit lab setup: it reads the release again and fetches what is published now" })
|
|
222
392
|
if (candidate !== to) await moveIntoLab(layout.cache, candidate, `downloads/${pin.release.sha256}/${pin.release.fileName}`)
|
|
223
393
|
chmodSync(to, 0o444)
|
|
224
394
|
const st = statSync(to)
|
|
225
|
-
writeJson(layout.cache, `downloads/${pin.release.sha256}/verified.json`, { schema: 1, sha256: judged.sha256, bytes: st.size, mtimeMs: st.mtimeMs, fingerprint: judged.signature.fingerprint, signer: judged.signature.detail, sidecarSha256: judged.sidecarSha256, verifiedAt: new Date().toISOString() })
|
|
395
|
+
writeJson(layout.cache, `downloads/${pin.release.sha256}/verified.json`, { schema: 1, name: pin.release.name, fileName: pin.release.fileName, sha256: judged.sha256, bytes: st.size, mtimeMs: st.mtimeMs, fingerprint: judged.signature.fingerprint, signer: judged.signature.detail, sidecarSha256: judged.sidecarSha256, verifiedAt: new Date().toISOString() })
|
|
226
396
|
return { file: to, judged, dir }
|
|
227
397
|
}
|
|
228
398
|
|
|
399
|
+
/**
|
|
400
|
+
* Stop what a build left running under `stagingDir` (inspect.mjs
|
|
401
|
+
* buildGuests): SIGTERM, which QEMU takes as a clean shutdown, then SIGKILL
|
|
402
|
+
* after fifteen seconds. Runs after the driver exits for any reason, an
|
|
403
|
+
* interrupt included, so no guest outlives the setup that started it.
|
|
404
|
+
*/
|
|
405
|
+
export async function stopBuildGuests(stagingDir, onLine = () => {}) {
|
|
406
|
+
for (const guest of buildGuests(stagingDir)) {
|
|
407
|
+
try {
|
|
408
|
+
process.kill(guest.pid, "SIGTERM")
|
|
409
|
+
} catch {
|
|
410
|
+
continue
|
|
411
|
+
}
|
|
412
|
+
const deadline = Date.now() + 15_000
|
|
413
|
+
let alive = true
|
|
414
|
+
while (alive && Date.now() < deadline) {
|
|
415
|
+
await new Promise((resolvePromise) => setTimeout(resolvePromise, 250))
|
|
416
|
+
try {
|
|
417
|
+
process.kill(guest.pid, 0)
|
|
418
|
+
} catch {
|
|
419
|
+
alive = false
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (alive) {
|
|
423
|
+
try {
|
|
424
|
+
process.kill(guest.pid, "SIGKILL")
|
|
425
|
+
} catch {
|
|
426
|
+
// Gone between the last check and this one.
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
onLine({ state: "info", text: `stopped the build's QEMU (pid ${guest.pid}), which the driver left running` })
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
229
433
|
/**
|
|
230
434
|
* Build the base with the toolchain's console driver, verify it by
|
|
231
435
|
* booting it, and promote it. The driver runs from a copy under staging,
|
|
@@ -269,6 +473,7 @@ async function buildBase({ pin, layout, toolchainHarness, iso, onPhase, onLine,
|
|
|
269
473
|
})
|
|
270
474
|
})
|
|
271
475
|
const buildMilliseconds = Date.now() - started
|
|
476
|
+
await stopBuildGuests(stagingDir, onLine)
|
|
272
477
|
if (status !== 0) throw new LabError(signal?.aborted ? "interrupted" : "build-failed", `the toolchain's install driver exited ${status === null ? "on a signal" : status} after ${durationWords(buildMilliseconds)}; its log is ${join(stagingDir, "build.log")}, and nothing was promoted`, { remedy: "read the log, then `omakit lab prune` to remove the staging directory and `omakit lab setup` to build again" })
|
|
273
478
|
const built = join(stagingDir, "test-runs", pin.release.fileName.replace(/\.iso$/, ""))
|
|
274
479
|
for (const name of ["base.qcow2", "OVMF_VARS.4m.fd", "id_ed25519", "id_ed25519.pub"]) {
|
|
@@ -285,7 +490,7 @@ async function buildBase({ pin, layout, toolchainHarness, iso, onPhase, onLine,
|
|
|
285
490
|
removeFromLab(layout.cache, `staging/${buildId}`)
|
|
286
491
|
// The measured duration travels with the staged base, so a promotion
|
|
287
492
|
// after an interrupted setup still records it.
|
|
288
|
-
writeJson(layout.cache, `staging/${baseId}/build/lab-build.json`, { buildMilliseconds, harnessSha256: sha256, iso, builtAt: new Date().toISOString() })
|
|
493
|
+
writeJson(layout.cache, `staging/${baseId}/build/lab-build.json`, { buildMilliseconds, harnessSha256: sha256, iso, release: { name: pin.release.name, sha256: pin.release.sha256 }, builtAt: new Date().toISOString() })
|
|
289
494
|
return { staged, baseId, buildMilliseconds, harnessSha256: sha256, origin: `built ${new Date().toISOString()} by the pinned toolchain harness ${sha256.slice(0, 12)} from the verified ISO` }
|
|
290
495
|
}
|
|
291
496
|
|
|
@@ -294,8 +499,8 @@ async function verifyAndPromoteBase({ staged, baseId, pin, layout, buildMillisec
|
|
|
294
499
|
onPhase("booting the staged base once to read the installed omarchy package")
|
|
295
500
|
const booted = await withGuest({ baseDir: staged, stagingName: `${baseId}-verify`, logFile: join(staged, "verify-boot.log"), layout, pin, onPhase, signal }, async () => null)
|
|
296
501
|
const version = booted.identity?.version
|
|
297
|
-
if (version
|
|
298
|
-
throw new LabError("guest-mismatch", `the staged base runs omarchy ${version || "unknown"} (${booted.identity?.omarchyPackage || "no package read"})
|
|
502
|
+
if (!guestIsRelease(version, pin.release)) {
|
|
503
|
+
throw new LabError("guest-mismatch", `the staged base runs omarchy ${version || "unknown"} (${booted.identity?.omarchyPackage || "no package read"}), not Omarchy ${pin.release.name}'s package; the base stays in staging and is not promoted`, { remedy: "`omakit lab prune` removes it; an ISO that installs another release's package is not used as that release" })
|
|
299
504
|
}
|
|
300
505
|
if (booted.identity.linked) throw new LabError("guest-mismatch", `the staged base is dev-linked to ${booted.identity.omarchyPath}; a base runs the installed package`)
|
|
301
506
|
onPhase("hashing the base disk and the firmware template")
|
|
@@ -310,7 +515,7 @@ async function verifyAndPromoteBase({ staged, baseId, pin, layout, buildMillisec
|
|
|
310
515
|
const manifest = {
|
|
311
516
|
schema: 1,
|
|
312
517
|
state: "ready",
|
|
313
|
-
release: { name: pin.release.name, sha256: pin.release.sha256, bytes: pin.release.bytes, isoUrl: pin.release.isoUrl,
|
|
518
|
+
release: { name: pin.release.name, sha256: pin.release.sha256, bytes: pin.release.bytes, isoUrl: pin.release.isoUrl, iso },
|
|
314
519
|
signature: { fingerprint: pin.release.signingFingerprint },
|
|
315
520
|
guest: { version, omarchyPackage: booted.identity.omarchyPackage, kernel: booted.identity.kernel, hostname: booted.identity.hostname, readBy: "pacman -Q omarchy over SSH in the verification boot" },
|
|
316
521
|
disk: { file: BASE_FILES.disk, bytes: diskHash.bytes, sha256: diskHash.sha256, virtualBytes: qemuInfo?.["virtual-size"] ?? null, allocatedBytes: allocatedBytes(disk), format: qemuInfo?.format ?? null },
|
|
@@ -359,6 +564,23 @@ function fetchPlugins({ plugins, layout, onPhase }) {
|
|
|
359
564
|
return fetched
|
|
360
565
|
}
|
|
361
566
|
|
|
567
|
+
/**
|
|
568
|
+
* After a new base is promoted: the downloads of older releases the plan
|
|
569
|
+
* named, each checked again against the release's own digest so the one
|
|
570
|
+
* just verified is never among them. Nothing is removed before the
|
|
571
|
+
* promotion, so a build that fails leaves the old release usable.
|
|
572
|
+
*/
|
|
573
|
+
export function removeSuperseded({ layout, pin, step, onLine = () => {} }) {
|
|
574
|
+
const removed = []
|
|
575
|
+
for (const entry of step.superseded || []) {
|
|
576
|
+
if (entry.digest === pin.release.sha256 || !existsSync(join(layout.cache, entry.relative))) continue
|
|
577
|
+
removeFromLab(layout.cache, entry.relative)
|
|
578
|
+
removed.push(entry)
|
|
579
|
+
}
|
|
580
|
+
if (removed.length) onLine({ state: "pass", text: `removed ${removed.length === 1 ? "the older download" : `${removed.length} older downloads`} (${removed.map((entry) => entry.name || entry.digest.slice(0, 12)).join(", ")}), ${bytesBoth(removed.reduce((sum, entry) => sum + entry.bytes, 0))}` })
|
|
581
|
+
return removed
|
|
582
|
+
}
|
|
583
|
+
|
|
362
584
|
/**
|
|
363
585
|
* Execute a plan after consent. `consented` is the caller's statement
|
|
364
586
|
* that the disclosure was shown and answered yes, or that --yes was
|
|
@@ -374,9 +596,18 @@ export async function setupLab({ plan, consented, onPhase = () => {}, onLine = (
|
|
|
374
596
|
await acquireLock(layout, { runId: `setup-${stampNow()}`, pid: process.pid, qmpSocket: null, startedAt: new Date().toISOString() })
|
|
375
597
|
const lockId = readJson(join(layout.lock, "holder.json"))?.runId
|
|
376
598
|
try {
|
|
377
|
-
|
|
599
|
+
// With the lock held, no other setup is building, so a build QEMU still
|
|
600
|
+
// running under staging belongs to one that was killed before it could
|
|
601
|
+
// stop it (SIGKILL, a crash): an orphan, stopped here before anything else.
|
|
602
|
+
if (existsSync(layout.staging)) {
|
|
603
|
+
for (const name of readdirSync(layout.staging).filter((entry) => /^build-\d{8}-\d{6}$/.test(entry)).sort()) await stopBuildGuests(join(layout.staging, name), onLine)
|
|
604
|
+
}
|
|
605
|
+
// A download directory only for a plan that fetches or imports: a
|
|
606
|
+
// plugins-only plan over a kept base leaves none behind for a release
|
|
607
|
+
// it never fetched.
|
|
608
|
+
if (plan.steps.some((step) => ["download", "import", "sidecars"].includes(step.kind))) labDir(layout.cache, "downloads", pin.release.sha256)
|
|
378
609
|
labDir(layout.cache, "staging")
|
|
379
|
-
const target = join(downloadDir(layout, pin), pin.release.fileName)
|
|
610
|
+
const target = pin.release ? join(downloadDir(layout, pin), pin.release.fileName) : null
|
|
380
611
|
for (const step of plan.steps) {
|
|
381
612
|
if (signal?.aborted) throw new LabError("interrupted", "interrupted; what was verified stays verified, what was not stays unverified")
|
|
382
613
|
if (step.kind === "download") {
|
|
@@ -410,14 +641,16 @@ export async function setupLab({ plan, consented, onPhase = () => {}, onLine = (
|
|
|
410
641
|
const built = await buildBase({ pin, layout, toolchainHarness: step.toolchain, iso: target, onPhase, onLine, signal, repoRoot })
|
|
411
642
|
onLine({ state: "pass", text: `the toolchain built a base in ${durationWords(built.buildMilliseconds)}` })
|
|
412
643
|
const promoted = await verifyAndPromoteBase({ ...built, pin, layout, iso: target, onPhase, signal })
|
|
413
|
-
done.push({ kind: "build", milliseconds: built.buildMilliseconds, dir: promoted.dir, guest: promoted.manifest.guest, diskSha256: promoted.manifest.disk.sha256, allocatedBytes: promoted.manifest.disk.allocatedBytes })
|
|
644
|
+
done.push({ kind: "build", milliseconds: built.buildMilliseconds, dir: promoted.dir, guest: promoted.manifest.guest, diskSha256: promoted.manifest.disk.sha256, allocatedBytes: promoted.manifest.disk.allocatedBytes, release: pin.release.name })
|
|
414
645
|
onLine({ state: "pass", text: `promoted ${promoted.dir}: guest omarchy ${promoted.manifest.guest.version}, disk sha256 ${promoted.manifest.disk.sha256}` })
|
|
646
|
+
done.push({ kind: "superseded", removed: removeSuperseded({ layout, pin, step, onLine }) })
|
|
415
647
|
} else if (step.kind === "promote") {
|
|
416
648
|
const baseId = step.staged.split("/").at(-1)
|
|
417
649
|
const buildRecord = readJson(join(step.staged, "build", "lab-build.json"))
|
|
418
650
|
const promoted = await verifyAndPromoteBase({ staged: step.staged, baseId, pin, layout, buildMilliseconds: buildRecord?.buildMilliseconds ?? null, origin: `built ${buildRecord?.builtAt || "earlier"} by the pinned toolchain harness ${(buildRecord?.harnessSha256 || pin.toolchain.patchedHarnessSha256).slice(0, 12)} from the verified ISO, promoted ${new Date().toISOString()} after a verification boot`, harnessSha256: buildRecord?.harnessSha256 || pin.toolchain.patchedHarnessSha256, iso: target, onPhase, signal })
|
|
419
|
-
done.push({ kind: "build", milliseconds: buildRecord?.buildMilliseconds ?? 0, dir: promoted.dir, guest: promoted.manifest.guest, diskSha256: promoted.manifest.disk.sha256, allocatedBytes: promoted.manifest.disk.allocatedBytes })
|
|
651
|
+
done.push({ kind: "build", milliseconds: buildRecord?.buildMilliseconds ?? 0, dir: promoted.dir, guest: promoted.manifest.guest, diskSha256: promoted.manifest.disk.sha256, allocatedBytes: promoted.manifest.disk.allocatedBytes, release: pin.release.name })
|
|
420
652
|
onLine({ state: "pass", text: `promoted ${promoted.dir}: guest omarchy ${promoted.manifest.guest.version}, disk sha256 ${promoted.manifest.disk.sha256}` })
|
|
653
|
+
done.push({ kind: "superseded", removed: removeSuperseded({ layout, pin, step, onLine }) })
|
|
421
654
|
} else if (step.kind === "plugins") {
|
|
422
655
|
const fetched = fetchPlugins({ plugins: step.plugins, layout, onPhase })
|
|
423
656
|
done.push({ kind: "plugins", fetched })
|