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:
Diego Rodrigues de Sa e Souza
2026-07-30 09:07:29 -03:00
committed by GitHub
parent 321488abee
commit a36dbcfd88
2 changed files with 201 additions and 0 deletions

View File

@@ -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]

View 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"
);
});