also looking at this
fix(ci): unblock the three failing native lifecycle legs
#37
4 files
+130
-20
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | |
| 3 | 3 | import type { PackageResult } from '../packages/typescript/src/package' |
| 4 | 4 | import { createHash } from 'crypto' |
| 5 | import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs' | |
| 5 | import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, writeFileSync } from 'fs' | |
| 6 | 6 | import { join, resolve } from 'path' |
| 7 | 7 | import { packageApp } from '../packages/typescript/src/package' |
| 8 | 8 | import { macOSRollbackPlan } from './native-lifecycle-plan' |
| @@ -23,8 +23,56 @@ const workDir = join(reportDir, 'work') | ||
| 23 | 23 | const shouldInstall = process.argv.includes('--install') |
| 24 | 24 | const steps: Step[] = [] |
| 25 | 25 | |
| 26 | function 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 | */ | |
| 32 | function* 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 | */ | |
| 55 | function 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' } | |
| 28 | 76 | } |
| 29 | 77 | |
| 30 | 78 | async function command(name: string, argv: string[], expected?: string): Promise<string> { |
| @@ -161,14 +209,32 @@ async function main(): Promise<void> { | ||
| 161 | 209 | error = caught instanceof Error ? caught.stack || caught.message : String(caught) |
| 162 | 210 | } |
| 163 | 211 | |
| 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. | |
| 164 | 216 | const artifacts = packages |
| 165 | 217 | .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 | }) | |
| 172 | 238 | const report = { |
| 173 | 239 | schemaVersion: 1, |
| 174 | 240 | generatedAt: new Date().toISOString(), |