mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 13:52:09 +03:00
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>
This commit is contained in:
committed by
GitHub
parent
60cea3cab0
commit
9b3efef806
52
.github/workflows/ci.yml
vendored
52
.github/workflows/ci.yml
vendored
@@ -137,6 +137,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.
|
||||
@@ -740,7 +751,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
|
||||
@@ -812,7 +829,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
|
||||
@@ -1249,6 +1272,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
|
||||
@@ -1263,6 +1288,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 .
|
||||
|
||||
31
.github/workflows/quality.yml
vendored
31
.github/workflows/quality.yml
vendored
@@ -112,7 +112,20 @@ jobs:
|
||||
# release captain has USE_VPS_RUNNER=true AND this is not a fork PR (own-origin
|
||||
# branches only — a fork PR must never execute on the LAN runner). Var unset/false
|
||||
# or a fork PR falls back to ubuntu-latest, so this is inert until the flag flips.
|
||||
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 (gap 19). This job carried the USE_VPS_RUNNER expression, and that
|
||||
# expression was DEAD CONFIGURATION: across 160 quality.yml runs the job never once landed on
|
||||
# a self-hosted runner — every non-skipped sample is `GitHub Actions NNNN`. 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.
|
||||
#
|
||||
# And if it ever HAD fired it would have inherited the measured penalty, because this job's
|
||||
# first two steps are exactly the bottleneck: actions/setup-node + npm ci took 20m06s on .113
|
||||
# with 4 concurrent runners versus 16s hosted (npm cache restore saturating the link). Median
|
||||
# here is 5.6 min hosted across 72 successful runs.
|
||||
#
|
||||
# With this pinned, USE_VPS_RUNNER governs ONLY build-like jobs — one variable, one coherent
|
||||
# purpose. That is what gap 19 asked for; a second variable turned out to be unnecessary.
|
||||
runs-on: ubuntu-latest
|
||||
# tsx gates (known-symbols, route-guard-membership) import modules that open
|
||||
# SQLite on load; provide DB env so a fresh CI DB initializes cleanly.
|
||||
env:
|
||||
@@ -260,7 +273,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 +315,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:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(ci):** fixed a live auto-update defect where **Intel Macs downloaded the ARM dmg** — the two macOS jobs each emitted their own `latest-mac.yml` and `merge-multiple` let one silently overwrite the other by arrival order, leaving `electron-updater`'s arch fallback pointing at the wrong build. The manifests are now merged deliberately, un-suffixed entry first. Also: test jobs pinned to hosted runners (`setup-node` measured 20m06s self-hosted vs 16s hosted), the npm publish no longer discards a valid build artifact because an unrelated shard was flaky, the agent-skills gate now runs on pushes to `main` instead of PRs only, and the CI summary names every job that ended cancelled ([#8988](https://github.com/diegosouzapw/OmniRoute/pull/8988))
|
||||
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");
|
||||
});
|
||||
@@ -58,7 +58,29 @@ test("the artifact fast path only trusts runs from THIS repository", () => {
|
||||
/head_sha=\$HEAD_SHA/,
|
||||
"the run must still be matched on head_sha — that is the tree-equality guarantee"
|
||||
);
|
||||
assert.match(step, /\.conclusion == "success"/, "only a successful run may be restored");
|
||||
// Was: `assert.match(step, /\.conclusion == "success"/)`. That assertion encoded the wrong
|
||||
// criterion (gap 16) and is deliberately INVERTED here, not deleted. Requiring the whole run to
|
||||
// have concluded successfully discarded a perfectly good tree whenever any unrelated shard went
|
||||
// red, pushing the publish into the 40-minute build this fast path exists to avoid. The artifact
|
||||
// is only uploaded when the Build job itself succeeded, so its PRESENCE is the accurate signal.
|
||||
//
|
||||
// The replacement is strictly stronger: it pins BOTH that the loose criterion is gone AND that
|
||||
// the step actually probes candidates for the artifact, which the old assertion never checked.
|
||||
assert.doesNotMatch(
|
||||
step,
|
||||
/\.conclusion == "success"/,
|
||||
"an unrelated red shard must not discard a valid build artifact"
|
||||
);
|
||||
assert.match(
|
||||
step,
|
||||
/gh run download .*--name next-build/,
|
||||
"the criterion is now whether the run HAS the artifact — so it must try to download it"
|
||||
);
|
||||
assert.match(
|
||||
step,
|
||||
/for candidate in \$CANDIDATES/,
|
||||
"…across more than one candidate run, or a single miss still falls back to a full build"
|
||||
);
|
||||
});
|
||||
|
||||
test("REPO is passed through env, never interpolated into the script body", () => {
|
||||
|
||||
82
tests/unit/vps-runner-variable-scope.test.ts
Normal file
82
tests/unit/vps-runner-variable-scope.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Guard for what `USE_VPS_RUNNER` is allowed to govern (gap 19).
|
||||
*
|
||||
* One variable used to switch the build AND the test jobs, which want OPPOSITE machines. The
|
||||
* build needs the .113's RAM — the production `next build` wants 18–20 GB and a hosted runner
|
||||
* has 16, which killed the same step nine times in the v3.8.49 cycle. The test jobs need the
|
||||
* hosted runner's link:
|
||||
*
|
||||
* actions/setup-node on .113, 4 concurrent runners ..... 20m06s
|
||||
* actions/setup-node hosted ............................ 16s
|
||||
* the tests themselves .................. 2m54 vs 2m31 — a tie
|
||||
*
|
||||
* So there was no setting that served both, and flipping the variable for a release traded a
|
||||
* working build for twenty-minute test jobs.
|
||||
*
|
||||
* The fix is not a second variable. Self-hosted is strictly worse for anything whose cost is
|
||||
* dominated by `setup-node` + `npm ci`, so those jobs are pinned to hosted and the variable now
|
||||
* means exactly one thing: "this job needs the .113's memory".
|
||||
*
|
||||
* `fast-gates` was the last holdout and the evidence for pinning it is unusually clean: across
|
||||
* 160 quality.yml runs it NEVER landed on a self-hosted runner — every non-skipped sample is
|
||||
* `GitHub Actions NNNN`. That was dead configuration, not a working escape hatch, and the
|
||||
* classifier is not at fault: ci.yml's Build ran on omniroute-113-7 and -6 in the same window.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const workflowDir = path.join(repoRoot, ".github/workflows");
|
||||
|
||||
/** Map every `runs-on:` line mentioning USE_VPS_RUNNER back to the job that owns it. */
|
||||
function jobsSwitchedByVpsVar(): Array<{ file: string; job: string }> {
|
||||
const out: Array<{ file: string; job: string }> = [];
|
||||
for (const name of fs.readdirSync(workflowDir)) {
|
||||
if (!name.endsWith(".yml") && !name.endsWith(".yaml")) continue;
|
||||
const lines = fs.readFileSync(path.join(workflowDir, name), "utf-8").split("\n");
|
||||
let job = "";
|
||||
for (const line of lines) {
|
||||
const m = /^ {2}([A-Za-z0-9_-]+):\s*$/.exec(line);
|
||||
if (m) job = m[1];
|
||||
if (line.includes("USE_VPS_RUNNER") && line.includes("runs-on")) {
|
||||
out.push({ file: name, job });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Jobs whose cost is dominated by setup-node + npm ci, never by memory. */
|
||||
const TEST_LIKE = /(^|-)(test|tests|vitest|unit|gates)($|-)/i;
|
||||
|
||||
test("USE_VPS_RUNNER never governs a test-like job", () => {
|
||||
const offenders = jobsSwitchedByVpsVar().filter((j) => TEST_LIKE.test(j.job));
|
||||
assert.deepEqual(
|
||||
offenders.map((j) => `${j.file}:${j.job}`),
|
||||
[],
|
||||
"self-hosted is strictly worse for these — setup-node measured 20m06s there vs 16s hosted, " +
|
||||
"while the tests themselves tie. Pin the job to ubuntu-latest instead of switching it."
|
||||
);
|
||||
});
|
||||
|
||||
test("it still governs the build-like jobs — the ones that actually need the RAM", () => {
|
||||
const jobs = jobsSwitchedByVpsVar().map((j) => j.job);
|
||||
assert.ok(
|
||||
jobs.includes("build"),
|
||||
"the build is the whole reason the variable exists: 18-20 GB working set vs a 16 GB hosted runner"
|
||||
);
|
||||
assert.ok(jobs.includes("publish"), "npm-publish falls back to a full build when the artifact is missing");
|
||||
});
|
||||
|
||||
test("fast-gates specifically stays hosted", () => {
|
||||
// The holdout. 160 runs, zero self-hosted samples — dead configuration, and it would have
|
||||
// inherited the setup-node penalty the day it fired.
|
||||
const wf = fs.readFileSync(path.join(workflowDir, "quality.yml"), "utf-8");
|
||||
const block = wf.split(/^ {2}fast-gates:\s*$/m)[1] ?? "";
|
||||
const runsOn = /^ {4}runs-on:\s*(.+)$/m.exec(block);
|
||||
assert.ok(runsOn, "fast-gates must declare runs-on");
|
||||
assert.match(runsOn[1].trim(), /^ubuntu-latest$/, "fast-gates must be pinned, not switched");
|
||||
});
|
||||
Reference in New Issue
Block a user