mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 12:52:11 +03:00
fix(ci): stop one failing platform from taking the whole desktop channel down (#8957)
v3.8.49 shipped with ZERO release assets. v3.8.48 had 16. Every gate was green.
Measured from run 30503231362, not inferred:
Build Electron (windows) ....... success
Build Electron (macos-intel) ... success
Build Electron (macos-arm64) ... success
Build Electron (linux) ......... failure
Create Release ................. skipped
Publish to npm ................. skipped
The linux leg died 8 min into "Creating an optimized production build" with
"The runner has received a shutdown signal" and no exit code, on runner
`GitHub Actions 1000378558` — github-hosted, so this was the VM being
reclaimed, not a Node heap error. Reproduced on a 32 GB machine from the exact
tag commit: the same build succeeds and peaks past 18 GB.
Three independent defects compounded, one fix each:
1. Turbopack allocates natively (Rust, off the V8 heap), so the existing
--max_old_space_size=6144 does not bound it. The project already documents
the webpack fallback as the escape hatch for RAM-constrained machines
(docs/reference/ENVIRONMENT.md, #6409), and nightly-compat already applies
it for the same reason on Node 26 (#8090). The linux leg now selects it;
Windows/macOS keep Turbopack since they build fine and it is faster.
2. `release` gated on `needs: [validate, build]` with no `if:`, so ONE failing
leg skipped it and discarded the three artifacts that DID build — 1.7 GB,
still retained — plus the source archives, which depend on no build at all.
Fail-closed made a partial failure look total. It is now fail-partial:
attach what exists, still requiring `validate` to have passed.
3. Nothing asserted the release HAS assets, so 16 binaries and 0 binaries were
indistinguishable to CI. New `verify-desktop-assets` job asserts one asset
per platform and fails loudly.
The check is a separate job on purpose: failing inside `release` would cascade
into `publish-npm` (`needs: [validate, release]`) and block the npm channel
over a desktop-only gap. Now the assets attach, npm still publishes, and an
incomplete desktop channel is a red job instead of silence.
TDD: 3 of the 4 new assertions fail on the tip of release/v3.8.50 and pass
with this change. The 4th asserts a negative (publish-npm must not gain a
dependency on the asset check) and guards against future regression rather
than reproducing the bug.
node --import tsx/esm --test tests/unit/electron-release-desktop-channel-8949.test.ts
# 4 pass, 0 fail (base: 1 pass, 3 fail)
check:workflows --ratchet → 189 findings, baseline 190, no regression
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
321488abee
commit
a36dbcfd88
63
.github/workflows/electron-release.yml
vendored
63
.github/workflows/electron-release.yml
vendored
@@ -120,6 +120,18 @@ jobs:
|
||||
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
|
||||
@@ -217,6 +229,16 @@ jobs:
|
||||
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
|
||||
@@ -275,6 +297,47 @@ jobs:
|
||||
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]
|
||||
|
||||
138
tests/unit/electron-release-desktop-channel-8949.test.ts
Normal file
138
tests/unit/electron-release-desktop-channel-8949.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Regression guard for the v3.8.49 desktop channel, which shipped with ZERO assets
|
||||
* while every gate stayed green.
|
||||
*
|
||||
* What actually happened, measured from the run (30503231362) and not inferred:
|
||||
*
|
||||
* Build Electron (windows) ....... success
|
||||
* Build Electron (macos-intel) ... success
|
||||
* Build Electron (macos-arm64) ... success
|
||||
* Build Electron (linux) ......... failure — "The runner has received a shutdown
|
||||
* signal", 8 min into "Creating an optimized
|
||||
* production build", on runner `GitHub Actions
|
||||
* 1000378558` (github-hosted)
|
||||
* Create Release ................. skipped
|
||||
* Publish to npm ................. skipped
|
||||
*
|
||||
* Three independent defects compounded:
|
||||
*
|
||||
* 1. Turbopack's production build allocates natively (Rust, off the V8 heap), so
|
||||
* `--max_old_space_size` does not bound it. On this module graph it outgrows what
|
||||
* the hosted runner can give and the VM is reclaimed mid-compile — the same wall
|
||||
* nightly-compat hit on Node 26 (#8090) and the same documented remedy applies:
|
||||
* the webpack fallback (OMNIROUTE_USE_TURBOPACK=0, docs/reference/ENVIRONMENT.md,
|
||||
* #6409). The identical build passes on a 32 GB machine, peaking past 14 GB.
|
||||
*
|
||||
* 2. `release` gated on `needs: [validate, build]` with no `if:`, so ONE failing
|
||||
* matrix leg skipped it and discarded the three artifacts that DID build (1.7 GB,
|
||||
* retained for 90 days and recoverable by hand) plus the source archives, which
|
||||
* depend on no build at all. Fail-closed made a partial failure look total.
|
||||
*
|
||||
* 3. Nothing anywhere asserted the release HAS assets. A release with 16 binaries and
|
||||
* a release with 0 were indistinguishable to CI.
|
||||
*/
|
||||
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 workflowPath = path.join(repoRoot, ".github/workflows/electron-release.yml");
|
||||
|
||||
function readWorkflow(): string {
|
||||
return fs.readFileSync(workflowPath, "utf-8");
|
||||
}
|
||||
|
||||
/** Job keys live at 2-space indentation under `jobs:`; the next such key ends the block. */
|
||||
function extractJobBlock(yaml: string, jobName: string): string {
|
||||
const lines = yaml.split("\n");
|
||||
const startIdx = lines.findIndex((l) => new RegExp(`^ ${jobName}:\\s*$`).test(l));
|
||||
assert.ok(startIdx !== -1, `electron-release.yml must define the ${jobName} job`);
|
||||
const block: string[] = [];
|
||||
for (let i = startIdx + 1; i < lines.length; i++) {
|
||||
if (/^ \S/.test(lines[i])) break;
|
||||
block.push(lines[i]);
|
||||
}
|
||||
return block.join("\n");
|
||||
}
|
||||
|
||||
test("the linux leg builds with the webpack fallback, not Turbopack", () => {
|
||||
const buildBlock = extractJobBlock(readWorkflow(), "build");
|
||||
|
||||
assert.match(buildBlock, /npm run build/, "the build job must still run the production build");
|
||||
assert.match(
|
||||
buildBlock,
|
||||
/OMNIROUTE_USE_TURBOPACK:/,
|
||||
"the build job must pin the bundler explicitly — leaving it to the default is what " +
|
||||
"let the linux leg die on the hosted runner and take the whole desktop channel down"
|
||||
);
|
||||
// The value is per-platform: linux gets webpack ('0'), the platforms that already
|
||||
// build fine keep Turbopack. A blanket '0' would slow Windows/macOS for no reason.
|
||||
assert.match(
|
||||
buildBlock,
|
||||
/matrix\.platform == 'linux'[^\n]*'0'/,
|
||||
"linux specifically must select the webpack fallback ('0')"
|
||||
);
|
||||
});
|
||||
|
||||
test("one failing platform does not discard the platforms that built", () => {
|
||||
const releaseBlock = extractJobBlock(readWorkflow(), "release");
|
||||
|
||||
assert.match(
|
||||
releaseBlock,
|
||||
/if:\s*\$\{\{\s*!cancelled\(\)/,
|
||||
"the release job must run even when a build leg failed — a bare `needs: [.., build]` " +
|
||||
"skips it on the first failure and throws away every artifact that succeeded"
|
||||
);
|
||||
assert.match(
|
||||
releaseBlock,
|
||||
/needs\.validate\.result == 'success'/,
|
||||
"…but it must still require `validate` to have passed, so a bad version never releases"
|
||||
);
|
||||
assert.match(
|
||||
releaseBlock,
|
||||
/fail_on_unmatched_files:\s*false/,
|
||||
"attaching a partial set requires tolerating globs that match nothing"
|
||||
);
|
||||
});
|
||||
|
||||
test("a release with no binaries fails a job instead of passing unnoticed", () => {
|
||||
const wf = readWorkflow();
|
||||
const verifyBlock = extractJobBlock(wf, "verify-desktop-assets");
|
||||
|
||||
for (const pattern of ["exe", "dmg", "AppImage", "deb"]) {
|
||||
assert.ok(
|
||||
verifyBlock.includes(pattern),
|
||||
`the asset check must assert a ${pattern} installer is present`
|
||||
);
|
||||
}
|
||||
assert.match(verifyBlock, /exit 1/, "a missing platform must fail the job, not just warn");
|
||||
});
|
||||
|
||||
test("the asset check is a separate job, so it cannot block the npm channel", () => {
|
||||
const wf = readWorkflow();
|
||||
|
||||
// publish-npm gates on `release`. If the assert lived inside `release`, a desktop-only
|
||||
// gap would also stop the npm publish — the primary distribution channel.
|
||||
const publishBlock = extractJobBlock(wf, "publish-npm");
|
||||
assert.match(
|
||||
publishBlock,
|
||||
/needs:\s*\[validate, release\]/,
|
||||
"publish-npm is expected to gate on release; if that changes, revisit this reasoning"
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
publishBlock,
|
||||
/verify-desktop-assets/,
|
||||
"publish-npm must NOT depend on the desktop asset check — npm must publish even when " +
|
||||
"a desktop platform is missing"
|
||||
);
|
||||
|
||||
const releaseBlock = extractJobBlock(wf, "release");
|
||||
assert.doesNotMatch(
|
||||
releaseBlock,
|
||||
/exit 1/,
|
||||
"the release job must not fail on missing assets — that would cascade into publish-npm; " +
|
||||
"the dedicated verify-desktop-assets job carries that failure instead"
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user