mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 13:22:11 +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
* 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>
380 lines
16 KiB
YAML
380 lines
16 KiB
YAML
name: Build Electron Desktop App
|
|
|
|
on:
|
|
push:
|
|
tags:
|
|
- "v*"
|
|
workflow_dispatch:
|
|
inputs:
|
|
version:
|
|
description: "Release version (e.g., v1.6.8)"
|
|
required: true
|
|
type: string
|
|
|
|
# Least-privilege default: read-only at the top level; each job grants the writes it
|
|
# needs (build/release upload assets, publish-npm forwards npm provenance / packages
|
|
# to the reusable workflow) — Scorecard TokenPermissions.
|
|
permissions:
|
|
contents: read
|
|
|
|
jobs:
|
|
validate:
|
|
name: Validate version
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: read
|
|
outputs:
|
|
version: ${{ steps.validate.outputs.version }}
|
|
steps:
|
|
- name: Checkout code
|
|
uses: actions/checkout@v7
|
|
with:
|
|
persist-credentials: false
|
|
fetch-depth: 0
|
|
|
|
- name: Validate version format
|
|
id: validate
|
|
env:
|
|
# Pass workflow context via env (never interpolate ${{ ... }} straight
|
|
# into the run: script body) so the shell receives variables, not
|
|
# inlined text — zizmor template-injection mitigation. INPUT_VERSION is
|
|
# the operator-supplied value and is regex-validated below before use.
|
|
EVENT_NAME: ${{ github.event_name }}
|
|
INPUT_VERSION: ${{ inputs.version }}
|
|
run: |
|
|
if [[ "$EVENT_NAME" == "push" ]]; then
|
|
VERSION="${GITHUB_REF#refs/tags/}"
|
|
else
|
|
VERSION="$INPUT_VERSION"
|
|
fi
|
|
|
|
if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
|
echo "Error: Invalid version format. Expected: v1.6.8"
|
|
exit 1
|
|
fi
|
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
|
echo "✓ Valid version: $VERSION"
|
|
|
|
build:
|
|
name: Build Electron (${{ matrix.platform }})
|
|
needs: validate
|
|
runs-on: ${{ matrix.runner }}
|
|
permissions:
|
|
contents: write # electron-builder may publish artifacts with GH_TOKEN
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
include:
|
|
- platform: windows
|
|
runner: windows-latest
|
|
target: win
|
|
ext: .exe
|
|
- platform: macos-intel
|
|
runner: macos-15-intel
|
|
target: mac-x64
|
|
ext: .dmg
|
|
- platform: macos-arm64
|
|
runner: macos-latest
|
|
target: mac-arm64
|
|
ext: -arm64.dmg
|
|
- platform: linux
|
|
runner: ubuntu-latest
|
|
target: linux
|
|
ext: .AppImage
|
|
deb_ext: .deb
|
|
|
|
steps:
|
|
- uses: actions/checkout@v7
|
|
with:
|
|
persist-credentials: false
|
|
- name: Setup Node
|
|
uses: actions/setup-node@v7
|
|
with:
|
|
node-version: 24
|
|
cache: npm
|
|
|
|
- name: Cache node_modules
|
|
uses: actions/cache@v6.1.0
|
|
with:
|
|
path: node_modules
|
|
key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
|
|
restore-keys: |
|
|
${{ runner.os }}-node-
|
|
|
|
- name: Install dependencies
|
|
run: npm ci
|
|
env:
|
|
NPM_CONFIG_LEGACY_PEER_DEPS: true
|
|
|
|
- name: Sanitize Windows home directory
|
|
if: runner.os == 'Windows'
|
|
shell: bash
|
|
run: |
|
|
# The default USERPROFILE contains junction points (Application Data)
|
|
# that cause EPERM errors during Next.js standalone build glob scans.
|
|
# Create a clean temp profile directory to avoid this.
|
|
mkdir -p "$RUNNER_TEMP/home"
|
|
echo "USERPROFILE=$RUNNER_TEMP/home" >> "$GITHUB_ENV"
|
|
|
|
- name: Build Next.js standalone
|
|
env:
|
|
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
|
NODE_OPTIONS: "--max_old_space_size=6144"
|
|
# Linux builds with webpack, not Turbopack. Turbopack's production build
|
|
# allocates natively (Rust, off the V8 heap), so --max_old_space_size does
|
|
# not bound it, and on this module graph it peaks above what the hosted
|
|
# runner can give — the VM is reclaimed mid-compile with "The runner has
|
|
# received a shutdown signal", no exit code. That is what silently took the
|
|
# whole desktop channel out of v3.8.49: the linux leg died, `release` was
|
|
# skipped, and the release shipped with ZERO assets. Measured on a 32 GB
|
|
# box the same build passes and peaks past 14 GB. The webpack fallback is
|
|
# the project's documented escape hatch for RAM-constrained machines
|
|
# (docs/reference/ENVIRONMENT.md, #6409) and is the same remedy already
|
|
# applied to nightly-compat's Node 26 build (#8090).
|
|
OMNIROUTE_USE_TURBOPACK: ${{ matrix.platform == 'linux' && '0' || '1' }}
|
|
run: npm run build
|
|
|
|
- name: Sync version in electron/package.json
|
|
shell: bash
|
|
env:
|
|
# Pass the validated version via env (never interpolate ${{ ... }}
|
|
# straight into the run: script body) — zizmor template-injection
|
|
# mitigation. Already regex-validated (^v[0-9]+\.[0-9]+\.[0-9]+$) in
|
|
# the `validate` job, so it cannot carry shell metacharacters.
|
|
VERSION: ${{ needs.validate.outputs.version }}
|
|
run: |
|
|
VERSION_NO_V="${VERSION#v}"
|
|
node -e "
|
|
const fs = require('fs');
|
|
const pkg = JSON.parse(fs.readFileSync('electron/package.json'));
|
|
pkg.version = '$VERSION_NO_V';
|
|
fs.writeFileSync('electron/package.json', JSON.stringify(pkg, null, 2) + '\\n');
|
|
"
|
|
echo "✓ electron/package.json version set to $VERSION_NO_V"
|
|
|
|
- name: Install fpm (Linux .deb packaging tool)
|
|
if: matrix.platform == 'linux'
|
|
run: sudo gem install fpm --no-document
|
|
|
|
- name: Install Electron dependencies
|
|
working-directory: electron
|
|
run: npm install --no-audit --no-fund
|
|
|
|
- name: Build Electron for ${{ matrix.platform }}
|
|
working-directory: electron
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
run: npm run build:${{ matrix.target }}
|
|
|
|
- name: Smoke packaged Electron app
|
|
if: matrix.platform != 'linux'
|
|
# Best-effort smoke on Windows + macos-arm64:
|
|
# - Windows: requestSingleInstanceLock() fails due to USERPROFILE
|
|
# sanitization needed for the build step.
|
|
# - macos-arm64: the headless GitHub arm64 runner crashes Electron's GPU
|
|
# process (gpu_process_host exit_code=15 → network service crash →
|
|
# "No rendezvous client, terminating process"), so the app can't bind
|
|
# 127.0.0.1:20128 in time. The identical bundle is smoke-gated on
|
|
# macos-intel + linux, so packaging is still verified per-OS; we don't
|
|
# let the arm64 runner's GPU flakiness block the desktop release.
|
|
continue-on-error: ${{ matrix.platform == 'windows' || matrix.platform == 'macos-arm64' }}
|
|
env:
|
|
ELECTRON_SMOKE_TIMEOUT_MS: 60000
|
|
ELECTRON_SMOKE_STREAM_LOGS: "1"
|
|
run: npm run electron:smoke:packaged
|
|
|
|
- name: Smoke packaged Electron app (Linux)
|
|
if: matrix.platform == 'linux'
|
|
env:
|
|
ELECTRON_SMOKE_TIMEOUT_MS: 60000
|
|
ELECTRON_SMOKE_STREAM_LOGS: "1"
|
|
run: xvfb-run -a npm run electron:smoke:packaged
|
|
|
|
- name: Collect installers
|
|
shell: bash
|
|
run: |
|
|
mkdir -p release-assets
|
|
cd electron/dist-electron
|
|
# Copy only installer files for this platform
|
|
for file in *${{ matrix.ext }}; do
|
|
[ -f "$file" ] && cp "$file" ../../release-assets/
|
|
done
|
|
# Linux: also copy .deb package
|
|
if [ "${{ matrix.platform }}" = "linux" ]; then
|
|
for file in *.deb; do
|
|
[ -f "$file" ] && cp "$file" ../../release-assets/
|
|
done
|
|
fi
|
|
# Windows: also copy portable standalone exe as OmniRoute.exe
|
|
if [ "${{ matrix.platform }}" = "windows" ]; then
|
|
for file in *.exe; do
|
|
# Skip the NSIS installer (contains "Setup")
|
|
case "$file" in *Setup*) continue ;; esac
|
|
[ -f "$file" ] && cp "$file" "../../release-assets/OmniRoute.exe" && break
|
|
done
|
|
fi
|
|
# electron-updater manifests (latest.yml / latest-mac.yml / latest-linux.yml)
|
|
# must be published alongside the installers, or autoUpdater fails with
|
|
# "Cannot find latest.yml in the latest release artifacts" (#6766).
|
|
for file in latest*.yml; do
|
|
[ -f "$file" ] && cp "$file" ../../release-assets/
|
|
done
|
|
|
|
- name: Upload artifacts
|
|
uses: actions/upload-artifact@v7
|
|
with:
|
|
name: electron-${{ matrix.platform }}
|
|
path: release-assets/
|
|
|
|
release:
|
|
name: Create Release
|
|
needs: [validate, build]
|
|
# Fail-partial, not fail-closed. `build` is a 4-leg matrix with `fail-fast: false`,
|
|
# so the legs that succeed still upload their artifacts — but a default `needs:`
|
|
# gate skips this job the moment ANY leg fails, discarding all of them. That is
|
|
# exactly what happened to v3.8.49: the linux leg died and the release shipped with
|
|
# ZERO assets, throwing away 1.7 GB of good Windows/macOS installers **and** the
|
|
# source archives + SBOM, which do not depend on a build at all. The result was
|
|
# indistinguishable from "this version has no desktop channel".
|
|
# Now: attach everything that did build, then fail the job loudly (see the last
|
|
# step) so an incomplete channel is visible instead of silent.
|
|
if: ${{ !cancelled() && needs.validate.result == 'success' }}
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: write # softprops/action-gh-release creates the GitHub Release
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v7
|
|
with:
|
|
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: 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:
|
|
# Pass the validated version via env (never interpolate ${{ ... }}
|
|
# straight into the run: script body) — zizmor template-injection
|
|
# mitigation. Already regex-validated (^v[0-9]+\.[0-9]+\.[0-9]+$) in
|
|
# the `validate` job, so it cannot carry shell metacharacters.
|
|
VERSION: ${{ needs.validate.outputs.version }}
|
|
run: |
|
|
# Create source code archives (excluding dev dependencies and build artifacts)
|
|
export TARBALL="OmniRoute-${VERSION}.source.tar.gz"
|
|
export ZIPBALL="OmniRoute-${VERSION}.source.zip"
|
|
|
|
# Use git archive for clean source export
|
|
git archive --format=tar.gz --prefix="OmniRoute-${VERSION}/" HEAD -o "release-assets/$TARBALL"
|
|
git archive --format=zip --prefix="OmniRoute-${VERSION}/" HEAD -o "release-assets/$ZIPBALL"
|
|
|
|
echo "✓ Created source archives:"
|
|
ls -lh "release-assets/$TARBALL" "release-assets/$ZIPBALL"
|
|
|
|
- name: List release files
|
|
run: ls -la release-assets/
|
|
|
|
- name: Create Release
|
|
uses: softprops/action-gh-release@v3
|
|
with:
|
|
tag_name: ${{ needs.validate.outputs.version }}
|
|
draft: false
|
|
prerelease: false
|
|
generate_release_notes: true
|
|
fail_on_unmatched_files: false
|
|
files: |
|
|
release-assets/*.dmg
|
|
release-assets/*.exe
|
|
release-assets/*.AppImage
|
|
release-assets/*.deb
|
|
release-assets/*.blockmap
|
|
release-assets/*.yml
|
|
release-assets/*.source.tar.gz
|
|
release-assets/*.source.zip
|
|
env:
|
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
|
|
verify-desktop-assets:
|
|
name: Verify desktop assets landed
|
|
needs: [validate, release]
|
|
# Deliberately a SEPARATE job, not a final step of `release`: failing inside
|
|
# `release` would cascade into `publish-npm` (which gates on `needs: release`) and
|
|
# block the npm channel over a desktop-only gap. Here the assets are attached, npm
|
|
# still publishes, and an incomplete desktop channel shows up as a red job instead
|
|
# of passing unnoticed — the v3.8.49 release had ZERO assets and every gate was
|
|
# green, because nothing ever asserted the release HAS binaries.
|
|
if: ${{ !cancelled() && needs.release.result == 'success' }}
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: read
|
|
steps:
|
|
- name: Assert every platform is present on the release
|
|
env:
|
|
# Regex-validated (^v[0-9]+\.[0-9]+\.[0-9]+$) in the `validate` job, and
|
|
# passed via env rather than interpolated into the script body.
|
|
VERSION: ${{ needs.validate.outputs.version }}
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
run: |
|
|
names=$(gh release view "$VERSION" --repo "$GITHUB_REPOSITORY" \
|
|
--json assets --jq '.assets[].name')
|
|
echo "Assets on $VERSION:"
|
|
echo "$names" | sed 's/^/ /'
|
|
|
|
missing=""
|
|
# `[ ... ] && missing=...` as the last command in a branch returns 1 and
|
|
# would abort the whole script under Actions' default `set -e`. Use if/fi.
|
|
for want in '\.exe$' '\.dmg$' '\.AppImage$' '\.deb$' '^latest.*\.yml$' '\.source\.tar\.gz$'; do
|
|
if ! echo "$names" | grep -qE "$want"; then
|
|
missing="$missing $want"
|
|
fi
|
|
done
|
|
|
|
if [ -n "$missing" ]; then
|
|
echo "::error::Desktop channel incomplete on $VERSION — no asset matching:$missing"
|
|
exit 1
|
|
fi
|
|
echo "✓ every platform present on $VERSION"
|
|
|
|
publish-npm:
|
|
name: Publish to npm
|
|
needs: [validate, release]
|
|
permissions:
|
|
# Must be `write`, not `read`: this job calls the reusable npm-publish.yml whose
|
|
# `publish` job needs `contents: write` (gh release upload — attach the SBOM, #3874).
|
|
# A reusable workflow's job cannot request more permission than the caller grants,
|
|
# so a `read` here makes GitHub reject the run at startup (startup_failure).
|
|
contents: write
|
|
id-token: write # npm provenance (forwarded to the reusable workflow)
|
|
packages: write # publish to npm.pkg.github.com
|
|
uses: ./.github/workflows/npm-publish.yml
|
|
with:
|
|
version: ${{ needs.validate.outputs.version }}
|
|
tag: latest
|
|
secrets:
|
|
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|