From 037f59666e63215b032eb4ea2ceed5d5c104e74c Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Thu, 30 Jul 2026 10:21:10 -0300 Subject: [PATCH] fix(ci): five workflow defects, one of them shipping the wrong dmg to Intel Macs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gaps 31, 19, 16, 30 and 12 of the v3.8.49 process dossier. ## 31 — LIVE BUG: an Intel Mac downloads the ARM dmg electron-builder runs once per macOS job and each run emits its own `latest-mac.yml` listing only its own dmg — measured at 338 and 350 bytes, different content, identical filename. `download-artifact` with `merge-multiple: true` resolves that collision by ARRIVAL ORDER, so one silently overwrites the other. arm64 won in the published v3.8.48. Why that breaks Intel, from electron-updater's own selection code (out/providers/Provider.js): files.find(it => [...].some(n => n.includes(process.arch))) ?? files.shift() The Intel dmg is `OmniRoute-X.Y.Z.dmg` — no arch suffix. On Intel `process.arch` is "x64", nothing matches, and the fallback takes the FIRST entry. With an arm64-only manifest that is the ARM build. So ORDER is the fix, not tidiness: the un-suffixed entry must be first, because it is the only one reachable through that fallback. `merge-multiple` is now off (per-artifact subdirectories) and a new `scripts/release/merge-mac-update-manifest.mjs` merges them deliberately. It refuses to write when the inputs disagree on version — a manifest stitched from two builds points at files that were never published together, which is worse than no manifest. Validated against the REAL v3.8.49 manifests, not just fixtures: the script reproduces byte-for-byte the manifest I hand-merged and published, including both sha512 values and the newer releaseDate. ## 19 — one variable, two opposite machines `USE_VPS_RUNNER` governed the build and the test jobs together. The build needs the .113's RAM; the tests need the hosted runner's link. Measured 2026-07-29: `actions/setup-node` took 20m06s on .113 with 4 concurrent runners versus 16s hosted (npm cache restore saturating the link), while the tests themselves tied — 2m54 vs 2m31. Self-hosted is therefore strictly worse for tests, so rather than add a second variable to configure, `test-unit`, `test-vitest`, `fast-unit` and `fast-vitest` are pinned to `ubuntu-latest`. `quality.yml`'s `fast-gates` deliberately keeps the variable — I have no measurement for it, and guessing is what produced this gap. ## 16 — a flaky shard sent the publish into the 40-minute build The artifact reuse filter required `conclusion == "success"` on the whole run, so any unrelated red shard discarded a perfectly good tree. The artifact is only uploaded if the Build job succeeded, so its PRESENCE is the accurate signal. Now it takes the 5 most recent candidate runs and tries each download until one works. `head_repository.full_name == env.REPO` stays — that clause is the artifact-poisoning guard, not a filter refinement. ## 30 — the gate that could be bypassed at merge `check:agent-skills-sync` lived only in quality.yml's PR-only Merge-integrity job, because the CHANGELOG half of that job needs a base to diff against. This half does not. Keeping it PR-only left a real hole: this cycle's merge trains landed with `--admin`, which bypasses required checks, so three SKILL.md files drifted, rode the release squash into `main`, and the sync-back turned them into a base-red blocking EVERY PR into release/v3.8.50 until #8954. It now also runs in ci.yml's lint job, which runs on push to `main`. ## 12 — a cancelled gate reads like a passing one The dashboard already renders `⚫ CANCELLED` per job, so my dossier entry was imprecise: they do not vanish, they sit buried mid-table. A cancelled job reported no verdict at all, and this cycle the Vitest job was cancelled in rounds 1, 2 and 3 — it finished only in round 4, revealing a suite broken the whole cycle plus two production bugs. The summary now opens with a banner naming every cancelled job and saying plainly that nothing was checked. node --import tsx/esm --test tests/unit/mac-update-manifest-merge.test.ts # 11 pass merge against the real v3.8.49 manifests → both dmgs, Intel first all four workflows parse; check:workflows --ratchet → 178, baseline 190 --- .github/workflows/ci.yml | 52 +++++- .github/workflows/electron-release.yml | 26 ++- .github/workflows/npm-publish.yml | 32 +++- .github/workflows/quality.yml | 16 +- scripts/release/merge-mac-update-manifest.mjs | 173 ++++++++++++++++++ tests/unit/mac-update-manifest-merge.test.ts | 142 ++++++++++++++ 6 files changed, 428 insertions(+), 13 deletions(-) create mode 100644 scripts/release/merge-mac-update-manifest.mjs create mode 100644 tests/unit/mac-update-manifest-merge.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36690944f9..db29539f29 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -125,6 +125,17 @@ jobs: - run: npm run check:route-guard-membership - run: npm run check:test-discovery - run: npm run check:tracked-artifacts + # (gap 30) Also lives in quality.yml's PR-only "Merge integrity" job — because the + # CHANGELOG half of that job needs a base to diff against. This half does NOT: the + # generator either reproduces the committed SKILL.md files or it does not. + # + # Keeping it PR-only left a real hole. This cycle's merge trains landed in batches with + # `--admin`, which bypasses required checks, so three SKILL.md files drifted from the route + # catalog, rode the release squash into `main`, and the next cycle's sync-back turned them + # into a base-red that blocked EVERY PR into release/v3.8.50 until #8954. Running it here + # means a push to `main` catches the drift at the source instead of the next cycle + # inheriting it. + - run: npm run check:agent-skills-sync # WS1.7 (v3.8.49 plan): Dockerfile lint (hadolint, pinned by digest). # failure-threshold=error keeps the 5 pre-existing warnings (DL3008/DL3003/ # DL3016 version pinning / WORKDIR) visible without blocking; any ERROR fails. @@ -720,7 +731,13 @@ jobs: test-unit: name: Unit Tests (${{ matrix.shard }}/8) # Same dynamic-runner rule as Build (own-origin only; fallback ubuntu-latest). - runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }} + # PINNED to hosted, deliberately not on the USE_VPS_RUNNER switch (gap 19). One variable + # governed the build and the test jobs, which want OPPOSITE machines: the build needs the + # .113's RAM, the tests need the hosted runner's link. Measured on 2026-07-29 — + # actions/setup-node took 20m06s on .113 with 4 concurrent runners versus 16s hosted (npm + # cache restore saturating the link), while the tests themselves tied, 2m54 vs 2m31. So + # self-hosted is strictly worse here and there is nothing to configure. + runs-on: ubuntu-latest timeout-minutes: 25 # needs: changes (not build) — this job never downloads the next-build artifact; # gating it on Build only serialized ~20min of wall-clock for nothing. Jobs that @@ -792,7 +809,13 @@ jobs: test-vitest: name: Vitest (MCP / autoCombo / UI components) # Same dynamic-runner rule as Build (own-origin only; fallback ubuntu-latest). - runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }} + # PINNED to hosted, deliberately not on the USE_VPS_RUNNER switch (gap 19). One variable + # governed the build and the test jobs, which want OPPOSITE machines: the build needs the + # .113's RAM, the tests need the hosted runner's link. Measured on 2026-07-29 — + # actions/setup-node took 20m06s on .113 with 4 concurrent runners versus 16s hosted (npm + # cache restore saturating the link), while the tests themselves tied, 2m54 vs 2m31. So + # self-hosted is strictly worse here and there is nothing to configure. + runs-on: ubuntu-latest timeout-minutes: 15 # needs: changes (not build) — no artifact consumed; see test-unit note. needs: changes @@ -1229,6 +1252,8 @@ jobs: - name: Generate dashboard env: EVENT_NAME: ${{ github.event_name }} + # Workflow-controlled data (job results), not user input — safe to read here. + NEEDS_JSON: ${{ toJSON(needs) }} run: | status() { case "$1" in @@ -1243,6 +1268,29 @@ jobs: echo "# 🚀 CI Dashboard" >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY" + # (gap 12) A cancelled job never reported a verdict, and in a long table that reads the + # same as a green one. `cancel-in-progress` plus incremental fixing cancels jobs on every + # push, and this cycle the Vitest job was cancelled in rounds 1, 2 and 3 — it only ran to + # completion in round 4, where it revealed a suite that had been broken the whole cycle + # plus two production bugs. A gate that never finishes is indistinguishable from one that + # passes, so name them at the TOP instead of leaving them to be spotted mid-table. + CANCELLED_JOBS=$(printf '%s' "$NEEDS_JSON" \ + | jq -r 'to_entries | map(select(.value.result == "cancelled")) | .[].key' 2>/dev/null \ + | sort | paste -sd", " -) || CANCELLED_JOBS="" + if [ -n "$CANCELLED_JOBS" ]; then + { + echo "> ### ⚫ Cancelled — no verdict was reported" + echo ">" + echo "> \`$CANCELLED_JOBS\`" + echo ">" + echo "> These did not fail; they never finished, so nothing was checked. Treat this" + echo "> run as INCOMPLETE for those gates. If the cancellation came from" + echo "> \`cancel-in-progress\` on a newer push, the newer run covers it — otherwise" + echo "> re-run them before reading this dashboard as green." + echo "" + } >> "$GITHUB_STEP_SUMMARY" + fi + echo "## 🧱 Core Checks" >> "$GITHUB_STEP_SUMMARY" echo "| Job | Status |" >> "$GITHUB_STEP_SUMMARY" echo "|-----|--------|" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/electron-release.yml b/.github/workflows/electron-release.yml index 4fb103549d..f1e3f869e6 100644 --- a/.github/workflows/electron-release.yml +++ b/.github/workflows/electron-release.yml @@ -249,11 +249,33 @@ jobs: persist-credentials: false fetch-depth: 0 + # `merge-multiple` is deliberately OFF. It resolves same-name collisions by ARRIVAL + # ORDER, and the two macOS jobs each emit their own `latest-mac.yml` listing only their + # own dmg (measured: 338 and 350 bytes, different content, identical name). One silently + # overwrote the other — arm64 won in the published v3.8.48, and since the Intel dmg + # carries no arch suffix in its name, electron-updater's + # `files.find(url includes process.arch) ?? files.shift()` sends every Intel Mac to the + # ARM dmg. Downloading into per-artifact subdirectories keeps both, so they can be + # merged on purpose instead of by luck. - name: Download all artifacts uses: actions/download-artifact@v8 with: - path: release-assets - merge-multiple: true + path: artifacts + + # Writes release-assets/latest-mac.yml with BOTH dmgs, un-suffixed entry first (that is + # the one electron-updater can only reach through its fallback). Refuses to write when the + # inputs disagree on version — a manifest stitched from two builds is worse than none. + - name: Merge the per-arch macOS updater manifests + run: node scripts/release/merge-mac-update-manifest.mjs artifacts release-assets + + # Everything else moves across as-is. The partial latest-mac.yml files are excluded so + # they cannot clobber the merged one; -n is a second belt on the same braces. + - name: Collect the remaining artifacts + run: | + mkdir -p release-assets + find artifacts -type f ! -name latest-mac.yml -exec cp -n {} release-assets/ \; + echo "release-assets:" + ls -la release-assets/ - name: Create source archives env: diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index ea9cad488d..16dc215a92 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -179,18 +179,36 @@ jobs: REPO: ${{ github.repository }} run: | set -uo pipefail - RUN=$(gh api "repos/$REPO/actions/runs?head_sha=$HEAD_SHA&per_page=100" \ + # The question is "which run HAS the artifact", not "which run passed" (gap 16). + # Requiring `conclusion == "success"` on the whole run discarded a perfectly good tree + # whenever any unrelated shard went red — one flaky test then pushed the publish into + # the 40-minute build this step exists to avoid. The artifact is only uploaded if the + # Build job itself succeeded, so its PRESENCE is the accurate signal; the run's overall + # conclusion is noise from jobs that have nothing to do with the tree. + # + # `head_repository.full_name == env.REPO` stays, and it is not a filter refinement: + # this tree becomes the published npm tarball, and fork `pull_request` runs execute in + # THIS repository's context uploading their own next-build. That clause is the + # supply-chain guard (CodeQL actions/artifact-poisoning). + CANDIDATES=$(gh api "repos/$REPO/actions/runs?head_sha=$HEAD_SHA&per_page=100" \ --jq '[.workflow_runs[] | select(.name == "CI" - and .conclusion == "success" and .head_repository.full_name == env.REPO)] - | .[0].id // empty') || RUN="" - if [ -z "$RUN" ]; then - echo "::notice::no successful CI run for $HEAD_SHA — falling back to a full build" + | sort_by(.run_started_at) | reverse | .[0:5] | .[].id') || CANDIDATES="" + if [ -z "$CANDIDATES" ]; then + echo "::notice::no CI run from this repository for $HEAD_SHA — falling back to a full build" exit 0 fi - if ! gh run download "$RUN" --repo "$REPO" --name next-build --dir /tmp/next-build; then - echo "::notice::next-build artifact unavailable for run $RUN (expired?) — falling back to a full build" + RUN="" + for candidate in $CANDIDATES; do + if gh run download "$candidate" --repo "$REPO" --name next-build --dir /tmp/next-build 2>/dev/null; then + RUN="$candidate" + break + fi + echo " run $candidate carries no usable next-build — trying the next" + done + if [ -z "$RUN" ]; then + echo "::notice::none of the candidate runs still carries next-build (1-day retention) — falling back to a full build" exit 0 fi tar -xzf /tmp/next-build/e2e-build.tar.gz -C . diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 0b2e854031..bf207a7300 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -260,7 +260,13 @@ jobs: needs: changes if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }} # Dynamic runner — see fast-gates (own-origin + flag; fork/unset → ubuntu-latest). - runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }} + # PINNED to hosted, deliberately not on the USE_VPS_RUNNER switch (gap 19). One variable + # governed the build and the test jobs, which want OPPOSITE machines: the build needs the + # .113's RAM, the tests need the hosted runner's link. Measured on 2026-07-29 — + # actions/setup-node took 20m06s on .113 with 4 concurrent runners versus 16s hosted (npm + # cache restore saturating the link), while the tests themselves tied, 2m54 vs 2m31. So + # self-hosted is strictly worse here and there is nothing to configure. + runs-on: ubuntu-latest env: JWT_SECRET: ci-lint-secret-with-sufficient-length-for-validation API_KEY_SECRET: ci-lint-api-key-secret-long @@ -296,7 +302,13 @@ jobs: # critical path again (~8.5min → ~4.5min on ubuntu-latest; ~2min on the 8-slot # runner box). Node's native --test-shard=N/total takes any denominator — only # this matrix and the TEST_SHARD env below encode the shard count. - runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }} + # PINNED to hosted, deliberately not on the USE_VPS_RUNNER switch (gap 19). One variable + # governed the build and the test jobs, which want OPPOSITE machines: the build needs the + # .113's RAM, the tests need the hosted runner's link. Measured on 2026-07-29 — + # actions/setup-node took 20m06s on .113 with 4 concurrent runners versus 16s hosted (npm + # cache restore saturating the link), while the tests themselves tied, 2m54 vs 2m31. So + # self-hosted is strictly worse here and there is nothing to configure. + runs-on: ubuntu-latest strategy: fail-fast: false matrix: diff --git a/scripts/release/merge-mac-update-manifest.mjs b/scripts/release/merge-mac-update-manifest.mjs new file mode 100644 index 0000000000..ad862763b7 --- /dev/null +++ b/scripts/release/merge-mac-update-manifest.mjs @@ -0,0 +1,173 @@ +#!/usr/bin/env node +// Merge the per-architecture `latest-mac.yml` manifests into one that serves BOTH Macs. +// +// THE BUG THIS FIXES IS LIVE IN v3.8.48: an Intel Mac downloads the ARM dmg. +// +// electron-builder runs once per macOS job (macos-intel, macos-arm64) and each run emits its +// own `latest-mac.yml` listing only its own dmg — 338 and 350 bytes, different content, same +// filename. `actions/download-artifact` with `merge-multiple: true` resolves that name +// collision by ARRIVAL ORDER, so one silently overwrites the other. In the published v3.8.48 +// manifest the arm64 build won. +// +// Why that breaks Intel, from electron-updater's own selection code +// (electron-updater/out/providers/Provider.js): +// +// const result = filteredFiles.find(it => +// [it.url.pathname, it.info.url].some(n => n.includes(process.arch)) +// ) ?? filteredFiles.shift(); +// +// The Intel dmg is named `OmniRoute-X.Y.Z.dmg` — no arch suffix. On an Intel Mac +// `process.arch` is `"x64"`, no file URL contains "x64", the find misses, and the fallback +// takes the FIRST entry. With an arm64-only manifest that is the ARM dmg. +// +// So ORDER IS THE FIX, not merely tidiness: the entry without an arch suffix must come first, +// because it is the one that can only ever be reached by that fallback. arm64 is found by +// substring wherever it sits. +// +// Usage: +// node scripts/release/merge-mac-update-manifest.mjs +// Exit: 0 on success or when there is nothing to merge (a release with no mac build), 1 when +// the inputs are present but unusable — a wrong manifest is worse than none. + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +/** Parse the tiny subset of YAML electron-builder emits. No dependency, no surprises. */ +export function parseManifest(text) { + const src = String(text ?? ""); + const out = { version: "", files: [], path: "", sha512: "", releaseDate: "" }; + const top = /^(version|path|sha512|releaseDate):\s*'?([^'\n]*)'?\s*$/gm; + for (const m of src.matchAll(top)) out[m[1]] = m[2].trim(); + + // files: entries are ` - url: X` followed by indented `sha512:` / `size:` / `blockMapSize:`. + const fileBlocks = src.split(/^\s*-\s+url:\s*/m).slice(1); + for (const block of fileBlocks) { + const url = block.split("\n")[0].trim(); + if (!url) continue; + const grab = (k) => { + const m = new RegExp(`^\\s+${k}:\\s*(.+)$`, "m").exec(block); + return m ? m[1].trim() : ""; + }; + const entry = { url, sha512: grab("sha512"), size: grab("size") }; + const bms = grab("blockMapSize"); + if (bms) entry.blockMapSize = bms; + out.files.push(entry); + } + return out; +} + +/** True when the URL carries an explicit architecture electron-updater can match on. */ +export function hasArchSuffix(url) { + return /-(arm64|x64|universal)\./.test(String(url ?? "")); +} + +/** + * Merge parsed manifests into one, ordered so electron-updater resolves every arch. + * + * Entries WITHOUT an arch suffix come first, because the un-suffixed build is only ever + * reachable through electron-updater's `?? shift()` fallback. Suffixed entries are found by + * substring regardless of position. Duplicate URLs are collapsed, keeping the first. + */ +export function mergeManifests(manifests) { + const usable = (manifests ?? []).filter((m) => m && Array.isArray(m.files) && m.files.length); + if (usable.length === 0) return null; + + const seen = new Set(); + const files = []; + for (const m of usable) { + for (const f of m.files) { + if (!f.url || seen.has(f.url)) continue; + seen.add(f.url); + files.push(f); + } + } + // Stable partition: un-suffixed first, order otherwise preserved. + files.sort((a, b) => Number(hasArchSuffix(a.url)) - Number(hasArchSuffix(b.url))); + + const primary = files[0]; + const versions = [...new Set(usable.map((m) => m.version).filter(Boolean))]; + return { + version: versions[0] ?? "", + versionConflict: versions.length > 1 ? versions : null, + files, + // The legacy top-level fields older clients read must agree with the first entry. + path: primary.url, + sha512: primary.sha512, + releaseDate: usable.map((m) => m.releaseDate).filter(Boolean).sort().pop() ?? "", + }; +} + +export function renderManifest(merged) { + const lines = [`version: ${merged.version}`, "files:"]; + for (const f of merged.files) { + lines.push(` - url: ${f.url}`); + lines.push(` sha512: ${f.sha512}`); + lines.push(` size: ${f.size}`); + if (f.blockMapSize) lines.push(` blockMapSize: ${f.blockMapSize}`); + } + lines.push(`path: ${merged.path}`); + lines.push(`sha512: ${merged.sha512}`); + lines.push(`releaseDate: '${merged.releaseDate}'`); + return lines.join("\n") + "\n"; +} + +function main(argv) { + const [inDir, outDir] = argv; + if (!inDir || !outDir) { + process.stderr.write("usage: merge-mac-update-manifest.mjs \n"); + return 1; + } + if (!fs.existsSync(inDir)) { + process.stdout.write(`[merge-mac-manifest] ${inDir} does not exist — nothing to merge.\n`); + return 0; + } + + // Every latest-mac.yml under the per-artifact subdirectories. + const found = []; + const walk = (dir) => { + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, e.name); + if (e.isDirectory()) walk(p); + else if (e.name === "latest-mac.yml") found.push(p); + } + }; + walk(inDir); + + if (found.length === 0) { + process.stdout.write("[merge-mac-manifest] no latest-mac.yml found — nothing to merge.\n"); + return 0; + } + + const parsed = found.map((p) => parseManifest(fs.readFileSync(p, "utf8"))); + const merged = mergeManifests(parsed); + if (!merged) { + process.stderr.write( + `[merge-mac-manifest] found ${found.length} manifest(s) but none listed any file — ` + + `refusing to write a manifest that would send every Mac to nothing.\n` + ); + return 1; + } + if (merged.versionConflict) { + process.stderr.write( + `[merge-mac-manifest] the manifests disagree on version (${merged.versionConflict.join(", ")}) ` + + `— that means artifacts from two different builds are mixed. Refusing.\n` + ); + return 1; + } + + fs.mkdirSync(outDir, { recursive: true }); + fs.writeFileSync(path.join(outDir, "latest-mac.yml"), renderManifest(merged)); + process.stdout.write( + `[merge-mac-manifest] merged ${found.length} manifest(s) → ${merged.files.length} file(s), ` + + `first entry ${merged.path} (the arch electron-updater reaches by fallback).\n` + ); + return 0; +} + +if ( + process.argv[1] && + fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)) +) { + process.exit(main(process.argv.slice(2))); +} diff --git a/tests/unit/mac-update-manifest-merge.test.ts b/tests/unit/mac-update-manifest-merge.test.ts new file mode 100644 index 0000000000..6b7361981d --- /dev/null +++ b/tests/unit/mac-update-manifest-merge.test.ts @@ -0,0 +1,142 @@ +/** + * Guard for the macOS updater manifest merge (gap 31) — a bug that is LIVE in v3.8.48: + * an Intel Mac downloads the ARM dmg. + * + * electron-builder runs once per macOS job and each run emits its own `latest-mac.yml` listing + * only its own dmg — measured at 338 and 350 bytes, different content, identical filename. + * `actions/download-artifact` with `merge-multiple: true` resolves that collision by ARRIVAL + * ORDER, so one silently overwrites the other; arm64 won in the published v3.8.48. + * + * Why that breaks Intel, from electron-updater's own code + * (electron-updater/out/providers/Provider.js): + * + * files.find(it => [...].some(n => n.includes(process.arch))) ?? files.shift() + * + * The Intel dmg is `OmniRoute-X.Y.Z.dmg` — no arch suffix. On Intel `process.arch` is `"x64"`, + * nothing matches, and the fallback takes the FIRST entry. + * + * So ORDER is the fix, not tidiness, and that is what most of these tests pin: the un-suffixed + * entry must be first, because it is the only one reachable through that fallback. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +// @ts-expect-error — plain .mjs release script, no type declarations by design +import { + hasArchSuffix, + mergeManifests, + parseManifest, + renderManifest, +} from "../../scripts/release/merge-mac-update-manifest.mjs"; + +// Verbatim shape of what the two jobs produced for v3.8.49. +const INTEL = `version: 3.8.49 +files: + - url: OmniRoute-3.8.49.dmg + sha512: FSRnh09fSyOSeB7VXXe== + size: 392717602 +path: OmniRoute-3.8.49.dmg +sha512: FSRnh09fSyOSeB7VXXe== +releaseDate: '2026-07-30T01:07:13.268Z' +`; + +const ARM = `version: 3.8.49 +files: + - url: OmniRoute-3.8.49-arm64.dmg + sha512: 2eVsFVsY0JiCKgNkNYg== + size: 390627465 +path: OmniRoute-3.8.49-arm64.dmg +sha512: 2eVsFVsY0JiCKgNkNYg== +releaseDate: '2026-07-30T01:22:47.282Z' +`; + +test("parses electron-builder's manifest shape", () => { + const m = parseManifest(INTEL); + assert.equal(m.version, "3.8.49"); + assert.equal(m.files.length, 1); + assert.equal(m.files[0].url, "OmniRoute-3.8.49.dmg"); + assert.equal(m.files[0].size, "392717602"); + assert.equal(m.path, "OmniRoute-3.8.49.dmg"); + assert.match(m.releaseDate, /^2026-07-30T01:07/); +}); + +test("the un-suffixed (Intel) entry is FIRST — this is the entire fix", () => { + // Fed in the order that lost in production: arm64 first. + const merged = mergeManifests([parseManifest(ARM), parseManifest(INTEL)]); + assert.equal(merged.files.length, 2); + assert.equal( + merged.files[0].url, + "OmniRoute-3.8.49.dmg", + "electron-updater's `?? shift()` fallback takes files[0]; only the un-suffixed build " + + "depends on it, so it must be there" + ); + assert.equal(merged.files[1].url, "OmniRoute-3.8.49-arm64.dmg"); +}); + +test("order is stable regardless of input order", () => { + const a = mergeManifests([parseManifest(INTEL), parseManifest(ARM)]); + const b = mergeManifests([parseManifest(ARM), parseManifest(INTEL)]); + assert.deepEqual( + a.files.map((f: { url: string }) => f.url), + b.files.map((f: { url: string }) => f.url), + "arrival order is exactly what broke v3.8.48 — the merge must not inherit it" + ); +}); + +test("the legacy top-level fields agree with the first entry", () => { + // Older clients read `path`/`sha512` instead of `files[]`; disagreement sends them to the + // wrong download. + const merged = mergeManifests([parseManifest(ARM), parseManifest(INTEL)]); + assert.equal(merged.path, merged.files[0].url); + assert.equal(merged.sha512, merged.files[0].sha512); +}); + +test("checksums and sizes travel untouched", () => { + const merged = mergeManifests([parseManifest(INTEL), parseManifest(ARM)]); + const byUrl = Object.fromEntries(merged.files.map((f: { url: string }) => [f.url, f])); + assert.equal(byUrl["OmniRoute-3.8.49.dmg"].sha512, "FSRnh09fSyOSeB7VXXe=="); + assert.equal(byUrl["OmniRoute-3.8.49-arm64.dmg"].size, "390627465"); +}); + +test("the newest releaseDate wins", () => { + const merged = mergeManifests([parseManifest(INTEL), parseManifest(ARM)]); + assert.match(merged.releaseDate, /01:22:47/, "arm64 built later"); +}); + +test("duplicate URLs collapse", () => { + const merged = mergeManifests([parseManifest(INTEL), parseManifest(INTEL)]); + assert.equal(merged.files.length, 1); +}); + +test("mixed-version inputs are refused, not merged", () => { + // Artifacts from two different builds in one release-assets dir means something is wrong + // upstream; a manifest stitched from both would point at files that were never published + // together. + const merged = mergeManifests([parseManifest(INTEL), parseManifest(ARM.replace("3.8.49", "3.8.48"))]); + assert.ok(merged.versionConflict, "the caller must be able to refuse"); + assert.deepEqual(merged.versionConflict.sort(), ["3.8.48", "3.8.49"]); +}); + +test("no usable input yields null rather than an empty manifest", () => { + assert.equal(mergeManifests([]), null); + assert.equal(mergeManifests([{ files: [] }]), null); + assert.equal(mergeManifests(undefined), null); +}); + +test("hasArchSuffix recognizes only real arch markers", () => { + assert.equal(hasArchSuffix("OmniRoute-3.8.49-arm64.dmg"), true); + assert.equal(hasArchSuffix("OmniRoute-3.8.49-x64.dmg"), true); + assert.equal(hasArchSuffix("OmniRoute-3.8.49-universal.dmg"), true); + assert.equal(hasArchSuffix("OmniRoute-3.8.49.dmg"), false); + // "arm64" must be a suffix segment, not any substring of the name. + assert.equal(hasArchSuffix("OmniRoute-arm64beta-3.8.49.dmg"), false); +}); + +test("the rendered manifest round-trips", () => { + const merged = mergeManifests([parseManifest(ARM), parseManifest(INTEL)]); + const reparsed = parseManifest(renderManifest(merged)); + assert.equal(reparsed.files.length, 2); + assert.equal(reparsed.files[0].url, "OmniRoute-3.8.49.dmg", "order survives serialization"); + assert.equal(reparsed.version, "3.8.49"); + assert.equal(reparsed.path, "OmniRoute-3.8.49.dmg"); +});