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
.github/workflows/native-lifecycle.ymlmodified+29-9
Changes to .github/workflows/native-lifecycle.yml
@@ -30,18 +30,19 @@ jobs:
3030 fail-fast: false
3131 matrix:
3232 include:
33 # `name` is the whole identity of a leg: it is the evidence label the
34 # script writes under, the artifact name, and the path the upload
35 # reads back. It used to be two fields, `name` and `report`, and they
36 # disagreed on Windows — the script wrote windows-x64/ while the
37 # upload looked in win32-x64/ and failed with "No files were found".
3338 - os: macos-15
3439 name: darwin-arm64
35 report: darwin-arm64
3640 - os: macos-15-intel
3741 name: darwin-x64
38 report: darwin-x64
3942 - os: ubuntu-24.04
4043 name: linux-x64
41 report: linux-x64
4244 - os: windows-2025
4345 name: windows-x64
44 report: win32-x64
4546 runs-on: ${{ matrix.os }}
4647 timeout-minutes: 30
4748 env:
@@ -60,9 +61,28 @@ jobs:
6061 if: runner.os == 'Windows'
6162 shell: pwsh
6263 run: |
63 choco install wixtoolset --version=3.14.1 --yes --no-progress
64 $bin = Get-ChildItem "${env:ProgramFiles(x86)}\WiX Toolset v3*\bin" -Directory | Select-Object -First 1
65 if (-not $bin) { throw 'WiX Toolset bin directory not found' }
64 # The windows-2025 image already ships WiX v3 — 3.14.1.20250415 as of
65 # this writing — and `choco install --version=3.14.1` is a *downgrade*
66 # against it, which choco refuses:
67 #
68 # A newer version of wixtoolset (v3.14.1.20250415) is already installed.
69 # Chocolatey installed 0/1 packages. 1 packages failed.
70 #
71 # So the step failed before it ever looked for the toolset it needed,
72 # and every Windows lifecycle run died at setup. Look first, install
73 # only when it is genuinely absent, and take whatever v3 the image
74 # provides rather than pinning under it.
75 $wixBin = {
76 Get-ChildItem "${env:ProgramFiles(x86)}\WiX Toolset v3*\bin" -Directory -ErrorAction SilentlyContinue |
77 Sort-Object Name -Descending | Select-Object -First 1
78 }
79 $bin = & $wixBin
80 if (-not $bin) {
81 choco install wixtoolset --version=3.14.1 --yes --no-progress
82 $bin = & $wixBin
83 }
84 if (-not $bin) { throw 'WiX Toolset v3 bin directory not found' }
85 Write-Host "Using WiX at $($bin.FullName)"
6686 $bin.FullName | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
6787
6888 - name: Test packaging API
renamed throughout · 9 hidden lines
Changes to .github/workflows/native-lifecycle.yml
@@ -77,7 +97,7 @@ jobs: renamed throughout
7797 with:
7898 name: native-lifecycle-${{ matrix.name }}
7999 path: |
80 artifacts/native-lifecycle/${{ matrix.report }}/report.json
81 artifacts/native-lifecycle/${{ matrix.report }}/work/packages-*/**
100 artifacts/native-lifecycle/${{ matrix.name }}/report.json
101 artifacts/native-lifecycle/${{ matrix.name }}/work/packages-*/**
82102 if-no-files-found: error
83103 retention-days: 90
packages/typescript/src/package.test.tsmodified+11-1
Changes to packages/typescript/src/package.test.ts
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from 'bun:test'
22import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
33import { tmpdir } from 'node:os'
44import { join } from 'node:path'
5import { codesignArguments, dmgCapacityMegabytes, dmgCreateArguments, formatPackagingCommandError, macOSInfoPlist, notarytoolArguments, packageApp, productbuildArguments, renderWixSource, shouldRetryHdiutil, windowsArchitecture, windowsExecutableName } from './package'
5import { candleArguments, codesignArguments, dmgCapacityMegabytes, dmgCreateArguments, formatPackagingCommandError, macOSInfoPlist, notarytoolArguments, packageApp, productbuildArguments, renderWixSource, shouldRetryHdiutil, windowsArchitecture, windowsExecutableName } from './package'
66
77describe('Windows MSI packaging', () => {
88 it('renders a deterministic major-upgrade installer without shell interpolation', () => {
@@ -29,6 +29,16 @@ describe('Windows MSI packaging', () => {
2929 expect(() => windowsExecutableName('CON')).toThrow('Reserved Windows application name')
3030 })
3131
32 it('compiles for the architecture the source declares', () => {
33 // Without -arch, candle defaults to x86 and quietly produces a 32-bit
34 // installer around a 64-bit payload — while the source it just compiled
35 // says Platform="x64", ProgramFiles64Folder and Win64="yes". ICE80 would
36 // catch the contradiction, but light runs with -sval.
37 expect(candleArguments('x64', 'out.wixobj', 'in.wxs')).toEqual(['-nologo', '-arch', 'x64', '-out', 'out.wixobj', 'in.wxs'])
38 expect(candleArguments('x86', 'out.wixobj', 'in.wxs')).toContain('x86')
39 expect(candleArguments('arm64', 'out.wixobj', 'in.wxs')).toContain('arm64')
40 })
41
3242 it('renders explicit 32-bit metadata only when requested', () => {
3343 const source = renderWixSource({ name: 'Craft', version: '1.2.3', manufacturer: 'Craft', architecture: 'x86' }, 'Craft.exe')
3444 expect(source).toContain('Platform="x86"')
packages/typescript/src/package.tsmodified+15-1
Changes to packages/typescript/src/package.ts
@@ -964,6 +964,20 @@ export function windowsExecutableName(name: string): string {
964964 return `${name}.exe`
965965}
966966
967/**
968 * `candle.exe` arguments.
969 *
970 * `-arch` is not optional. WiX v3 deprecated `Package/@Platform` in favour of
971 * this switch, and candle defaults to `x86` without it — so an x64 payload was
972 * being compiled into a 32-bit installer whose one component declares
973 * `Win64="yes"` and installs under `ProgramFiles64Folder`. ICE80 exists to
974 * catch exactly that contradiction, and `light -sval` below turns validation
975 * off, so nothing ever reported it.
976 */
977export function candleArguments(architecture: 'x86' | 'x64' | 'arm64', wixobjPath: string, wxsPath: string): string[] {
978 return ['-nologo', '-arch', architecture, '-out', wixobjPath, wxsPath]
979}
980
967981function runCommand(command: string, args: string[], cwd?: string): Promise<void> {
968982 return new Promise((resolve, reject) => {
969983 const child = spawn(command, args, { cwd, stdio: 'inherit', windowsHide: true })
@@ -981,7 +995,7 @@ async function createMSI(opts: MSIOptions): Promise<{ success: boolean; outputPa
981995 const wixobjPath = join(tempDir, 'installer.wixobj')
982996 copyFileSync(opts.binaryPath, sourcePath)
983997 writeFileSync(wxsPath, renderWixSource(opts, binaryName))
984 await runCommand('candle.exe', ['-nologo', '-out', wixobjPath, wxsPath], tempDir)
998 await runCommand('candle.exe', candleArguments(opts.architecture, wixobjPath, wxsPath), tempDir)
985999 await runCommand('light.exe', ['-nologo', '-sval', '-out', opts.outputPath, wixobjPath], tempDir)
9861000 if (opts.certificatePath) {
9871001 const signArgs = ['sign', '/fd', 'sha256', '/tr', 'https://timestamp.digicert.com', '/td', 'sha256', '/f', opts.certificatePath]
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(),