also looking at this
fix(ci): unblock the three failing native lifecycle legs
#37
4 files
+130
-20
| @@ -30,18 +30,19 @@ jobs: | ||
| 30 | 30 | fail-fast: false |
| 31 | 31 | matrix: |
| 32 | 32 | 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". | |
| 33 | 38 | - os: macos-15 |
| 34 | 39 | name: darwin-arm64 |
| 35 | report: darwin-arm64 | |
| 36 | 40 | - os: macos-15-intel |
| 37 | 41 | name: darwin-x64 |
| 38 | report: darwin-x64 | |
| 39 | 42 | - os: ubuntu-24.04 |
| 40 | 43 | name: linux-x64 |
| 41 | report: linux-x64 | |
| 42 | 44 | - os: windows-2025 |
| 43 | 45 | name: windows-x64 |
| 44 | report: win32-x64 | |
| 45 | 46 | runs-on: ${{ matrix.os }} |
| 46 | 47 | timeout-minutes: 30 |
| 47 | 48 | env: |
| @@ -60,9 +61,28 @@ jobs: | ||
| 60 | 61 | if: runner.os == 'Windows' |
| 61 | 62 | shell: pwsh |
| 62 | 63 | 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)" | |
| 66 | 86 | $bin.FullName | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append |
| 67 | 87 | |
| 68 | 88 | - name: Test packaging API |
renamed throughout · 9 hidden lines
| @@ -77,7 +97,7 @@ jobs: renamed throughout | ||
| 77 | 97 | with: |
| 78 | 98 | name: native-lifecycle-${{ matrix.name }} |
| 79 | 99 | 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-*/** | |
| 82 | 102 | if-no-files-found: error |
| 83 | 103 | retention-days: 90 |
| @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from 'bun:test' | ||
| 2 | 2 | import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' |
| 3 | 3 | import { tmpdir } from 'node:os' |
| 4 | 4 | import { join } from 'node:path' |
| 5 | import { codesignArguments, dmgCapacityMegabytes, dmgCreateArguments, formatPackagingCommandError, macOSInfoPlist, notarytoolArguments, packageApp, productbuildArguments, renderWixSource, shouldRetryHdiutil, windowsArchitecture, windowsExecutableName } from './package' | |
| 5 | import { candleArguments, codesignArguments, dmgCapacityMegabytes, dmgCreateArguments, formatPackagingCommandError, macOSInfoPlist, notarytoolArguments, packageApp, productbuildArguments, renderWixSource, shouldRetryHdiutil, windowsArchitecture, windowsExecutableName } from './package' | |
| 6 | 6 | |
| 7 | 7 | describe('Windows MSI packaging', () => { |
| 8 | 8 | it('renders a deterministic major-upgrade installer without shell interpolation', () => { |
| @@ -29,6 +29,16 @@ describe('Windows MSI packaging', () => { | ||
| 29 | 29 | expect(() => windowsExecutableName('CON')).toThrow('Reserved Windows application name') |
| 30 | 30 | }) |
| 31 | 31 | |
| 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 | ||
| 32 | 42 | it('renders explicit 32-bit metadata only when requested', () => { |
| 33 | 43 | const source = renderWixSource({ name: 'Craft', version: '1.2.3', manufacturer: 'Craft', architecture: 'x86' }, 'Craft.exe') |
| 34 | 44 | expect(source).toContain('Platform="x86"') |
| @@ -964,6 +964,20 @@ export function windowsExecutableName(name: string): string { | ||
| 964 | 964 | return `${name}.exe` |
| 965 | 965 | } |
| 966 | 966 | |
| 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 | */ | |
| 977 | export function candleArguments(architecture: 'x86' | 'x64' | 'arm64', wixobjPath: string, wxsPath: string): string[] { | |
| 978 | return ['-nologo', '-arch', architecture, '-out', wixobjPath, wxsPath] | |
| 979 | } | |
| 980 | ||
| 967 | 981 | function runCommand(command: string, args: string[], cwd?: string): Promise<void> { |
| 968 | 982 | return new Promise((resolve, reject) => { |
| 969 | 983 | const child = spawn(command, args, { cwd, stdio: 'inherit', windowsHide: true }) |
| @@ -981,7 +995,7 @@ async function createMSI(opts: MSIOptions): Promise<{ success: boolean; outputPa | ||
| 981 | 995 | const wixobjPath = join(tempDir, 'installer.wixobj') |
| 982 | 996 | copyFileSync(opts.binaryPath, sourcePath) |
| 983 | 997 | 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) | |
| 985 | 999 | await runCommand('light.exe', ['-nologo', '-sval', '-out', opts.outputPath, wixobjPath], tempDir) |
| 986 | 1000 | if (opts.certificatePath) { |
| 987 | 1001 | const signArgs = ['sign', '/fd', 'sha256', '/tr', 'https://timestamp.digicert.com', '/td', 'sha256', '/f', opts.certificatePath] |
| @@ -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(), |