ReviewOS

also looking at this

craft-native/craft

fix(ci): unblock the three failing native lifecycle legs

#37
Merged glennmichael123 wants to merge fix/native-lifecycle-and-release into main
4 files +130 -20
scripts/native-lifecycle.tsmodified+75-9
Changes to scripts/native-lifecycle.ts
@@ -2,7 +2,7 @@
22
33import type { PackageResult } from '../packages/typescript/src/package'
44import { createHash } from 'crypto'
5import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'
5import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, writeFileSync } from 'fs'
66import { join, resolve } from 'path'
77import { packageApp } from '../packages/typescript/src/package'
88import { macOSRollbackPlan } from './native-lifecycle-plan'
@@ -23,8 +23,56 @@ const workDir = join(reportDir, 'work')
2323const shouldInstall = process.argv.includes('--install')
2424const steps: Step[] = []
2525
26function sha256(path: string): string {
27 return createHash('sha256').update(readFileSync(path)).digest('hex')
26/**
27 * Every file inside a bundle, deepest-last, in a stable order.
28 *
29 * Sorted at each level so the walk does not depend on directory order, which
30 * differs between filesystems and would make the digest below unreproducible.
31 */
32function* bundleEntries(dir: string, prefix = ''): Generator<{ relativePath: string, absolutePath: string }> {
33 for (const name of readdirSync(dir).sort()) {
34 const absolutePath = join(dir, name)
35 const relativePath = prefix ? `${prefix}/${name}` : name
36 if (lstatSync(absolutePath).isDirectory()) yield* bundleEntries(absolutePath, relativePath)
37 else yield { relativePath, absolutePath }
38 }
39}
40
41/**
42 * SHA-256 of a packaged artifact.
43 *
44 * Most artifacts are single files. A macOS `.app` is a directory, and reading
45 * one as a file throws `EISDIR` — which is what took down both macOS legs of
46 * this workflow, after every packaging, install, update and rollback step had
47 * already passed. The report was never written and the evidence upload then
48 * failed too, so a fully successful lifecycle run reported nothing at all.
49 *
50 * Bundles are hashed as a tree instead: each file's path, then its mode bit or
51 * symlink target, then its contents. The path goes in as well as the contents
52 * so that moving a file within the bundle changes the digest. `report.json`
53 * says which of the two kinds each digest is, since they are not comparable.
54 */
55function sha256(path: string): { sha256: string, kind: 'file' | 'tree' } {
56 if (!lstatSync(path).isDirectory())
57 return { sha256: createHash('sha256').update(readFileSync(path)).digest('hex'), kind: 'file' }
58
59 const hash = createHash('sha256')
60 for (const { relativePath, absolutePath } of bundleEntries(path)) {
61 const stats = lstatSync(absolutePath)
62 hash.update(relativePath)
63 if (stats.isSymbolicLink()) {
64 // Read the link rather than follow it: bundles are full of internal
65 // symlinks (Versions/Current and friends) and following them would both
66 // double-count and, in the wrong bundle, loop.
67 hash.update('\u0002')
68 hash.update(readlinkSync(absolutePath))
69 }
70 else {
71 hash.update(stats.mode & 0o111 ? '\u0001' : '\u0000')
72 hash.update(readFileSync(absolutePath))
73 }
74 }
75 return { sha256: hash.digest('hex'), kind: 'tree' }
2876}
2977
3078async function command(name: string, argv: string[], expected?: string): Promise<string> {
@@ -161,14 +209,32 @@ async function main(): Promise<void> {
161209 error = caught instanceof Error ? caught.stack || caught.message : String(caught)
162210 }
163211
212 // Hashing runs after the try/catch above, so anything it throws used to
213 // escape `main` before `report.json` was written — losing the record of
214 // every step that had already passed. Failures are recorded per artifact
215 // and still fail the run, but the evidence survives them.
164216 const artifacts = packages
165217 .filter(result => result.success && result.outputPath && existsSync(result.outputPath))
166 .map(result => ({
167 platform: result.platform,
168 format: result.format,
169 path: result.outputPath!.replace(`${root}/`, ''),
170 sha256: sha256(result.outputPath!),
171 }))
218 .map((result) => {
219 const artifact = {
220 platform: result.platform,
221 format: result.format,
222 path: result.outputPath!.replace(`${root}/`, ''),
223 sha256: null as string | null,
224 sha256Kind: null as 'file' | 'tree' | null,
225 error: undefined as string | undefined,
226 }
227 try {
228 const digest = sha256(result.outputPath!)
229 artifact.sha256 = digest.sha256
230 artifact.sha256Kind = digest.kind
231 }
232 catch (caught) {
233 artifact.error = caught instanceof Error ? caught.message : String(caught)
234 error ||= `failed to hash ${result.format} artifact ${artifact.path}: ${artifact.error}`
235 }
236 return artifact
237 })
172238 const report = {
173239 schemaVersion: 1,
174240 generatedAt: new Date().toISOString(),