fix(ci): five workflow defects, one of them shipping the wrong dmg to Intel Macs

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
This commit is contained in:
diegosouzapw
2026-07-30 10:21:10 -03:00
parent 2c243cf1fc
commit 037f59666e
6 changed files with 428 additions and 13 deletions

View File

@@ -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 <dir-with-per-artifact-subdirs> <out-dir>
// 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 <in-dir> <out-dir>\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)));
}