mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 21:32:10 +03:00
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:
52
.github/workflows/ci.yml
vendored
52
.github/workflows/ci.yml
vendored
@@ -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"
|
||||
|
||||
26
.github/workflows/electron-release.yml
vendored
26
.github/workflows/electron-release.yml
vendored
@@ -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:
|
||||
|
||||
32
.github/workflows/npm-publish.yml
vendored
32
.github/workflows/npm-publish.yml
vendored
@@ -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 .
|
||||
|
||||
16
.github/workflows/quality.yml
vendored
16
.github/workflows/quality.yml
vendored
@@ -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:
|
||||
|
||||
173
scripts/release/merge-mac-update-manifest.mjs
Normal file
173
scripts/release/merge-mac-update-manifest.mjs
Normal 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)));
|
||||
}
|
||||
142
tests/unit/mac-update-manifest-merge.test.ts
Normal file
142
tests/unit/mac-update-manifest-merge.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user