Files
OmniRoute/scripts/release/merge-mac-update-manifest.mjs
Diego Rodrigues de Sa e Souza 9b3efef806 fix(ci): five workflow defects, one of them shipping the wrong dmg to Intel Macs (#8988)
* 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

* docs(changelog): fragment for #8988

* test(ci): align the artifact-provenance guard with the gap-16 criterion

My own assertion from #8953 encoded the criterion this PR deliberately removes:
it required `.conclusion == "success"` on the whole CI run, which discarded a
perfectly good build tree whenever any unrelated shard went red — pushing the
publish into the 40-minute build the fast path exists to avoid.

Inverted rather than deleted, and the replacement is strictly stronger. It now
pins three things where the old one pinned one: that the loose criterion is gone,
that the step actually probes for the artifact (the accurate signal, since it is
only uploaded when the Build job succeeded), and that it probes MORE THAN ONE
candidate run — without which a single miss still falls back to a full build.

The provenance clause it was originally written to protect
(head_repository.full_name == env.REPO) is untouched and still asserted above.

* fix(ci): finish gap 19 — pin fast-gates and give USE_VPS_RUNNER one meaning

This was left deliberately partial because `fast-gates` had never been measured,
and guessing is what produced gap 19 in the first place. Measured now, and the
evidence is cleaner than expected:

    fast-gates, 160 quality.yml runs .... ZERO self-hosted samples
                                          every non-skipped one is "GitHub Actions NNNN"
    median duration, 72 successful runs .. 5.6 min hosted

The classifier is not at fault — in the same window ci.yml's Build demonstrably
ran on omniroute-113-7 and omniroute-113-6, so self-hosted runs are visible when
they happen. The USE_VPS_RUNNER expression on this job was dead configuration.

And had it ever fired it would have inherited the measured penalty, because this
job's first two steps are exactly the bottleneck:

    actions/setup-node on .113 with 4 concurrent runners .... 20m06s
    actions/setup-node hosted ..............................     16s

So it is pinned rather than switched, and the second variable the gap proposed
(USE_VPS_RUNNER_BUILD / _TESTS) turns out to be unnecessary. After this the
variable governs exactly five jobs, all of them build-like:

    ci.yml:build · quality.yml:build · npm-publish:publish
    nightly-release-green: release-green, main-green

One variable, one meaning: "this job needs the .113's memory". A guard test pins
that — it fails if the variable is ever attached to a test-like job again, and it
also asserts the build KEEPS it, so nobody closes this gap by removing the
variable outright.

    node --import tsx/esm --test tests/unit/vps-runner-variable-scope.test.ts   # 3 pass
    check:workflows --ratchet → 178, baseline 190

---------

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-07-30 12:49:52 -03:00

174 lines
6.6 KiB
JavaScript

#!/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)));
}