Release: the update feed survived in the built artifact — set publish: null

The artifact from the green Linux run still contains
resources/app-update.yml (provider github, owner 4ian, repo GDevelop).
Deleting the `publish` key is not enough: with no publish config,
electron-builder 26 (PublishManager.getPublishConfigsForUpdateInfo)
derives the feed from package.json `repository` and writes the file
anyway. Only an explicit `publish: null` disables that.

On the Linux zip it happened to be inert (electron-updater needs an
AppImage there). On Windows NSIS and macOS it would be a live feed that
replaces the patched app with stock GDevelop on the next upstream
release — the opposite of the release notes' claim.

The script now replaces the block with `publish: null`, evaluates the
config to prove it, and refuses a config with no publish key at all
(previously accepted as 'already neutered', which is exactly the
auto-detect case).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
BillyOutlastandClaude Opus 5.5 committed 2026-10-11 15:51:06 -04:00
1 parent fcacf7142e
commit 9ceb42e62d
3 files changed
+117 -55

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
# Patches, launcher files and the hash-pinned Tier-2 runtime must stay
# byte-exact on every platform: a CRLF checkout breaks every hunk and
# every sha256 pin. No end-of-line conversion, anywhere.
* -text
+44 -20
View File
@@ -2,21 +2,30 @@
//
// Upstream ships `publish: [{ provider: 'github' }]` in
// newIDE/electron-app/electron-builder-config.js, which points
// electron-updater at 4ian/GDevelop releases. A unlocked build must never
// phone that feed (it would download stock code over the patched app), so
// the release workflow strips the `publish` key before building. With no
// feed, checkForUpdatesAndNotify resolves empty and the explicit
// updates-check IPC becomes a graceful no-op.
// electron-updater at 4ian/GDevelop releases. An unlocked build must never
// follow that feed (it would download stock code over the patched app).
//
// The key is REPLACED with `publish: null`, not deleted. Deleting it is
// not enough: with no publish config, electron-builder (26.x,
// PublishManager.getPublishConfigsForUpdateInfo) falls back to the
// `repository` field of package.json and still writes
// resources/app-update.yml pointing at 4ian/GDevelop — found in the first
// CI-built artifact. Only an explicit `null` disables that fallback.
// Without app-update.yml the app's update check has nothing to read.
//
// Usage: node scripts/strip-updater-feed.mjs <upstream-checkout-root>
// Exits non-zero if no publish block is found (loud is better than
// shipping an updatable build by accident).
// Exits non-zero unless the evaluated config ends with `publish === null`
// (loud is better than shipping an updatable build by accident). A config
// with no `publish` key at all is NOT treated as neutered, for the reason
// above.
import fs from 'node:fs';
import path from 'node:path';
import { createRequire } from 'node:module';
const CONFIG_REL = 'newIDE/electron-app/electron-builder-config.js';
const NEUTERED = /\bpublish:\s*null\b/;
function stripPublishBlock(text) {
function neuterPublishBlock(text) {
const keyIdx = text.indexOf('publish:');
if (keyIdx === -1) throw new Error('no `publish:` key found');
const bracketIdx = text.indexOf('[', keyIdx);
@@ -34,10 +43,14 @@ function stripPublishBlock(text) {
}
}
if (end === -1) throw new Error('unterminated `publish:` array');
// Extend to swallow a trailing comma and the preceding separator comma.
let after = end + 1;
while (after < text.length && /[\s,]/.test(text[after])) after++;
return text.slice(0, keyIdx) + text.slice(after);
return text.slice(0, keyIdx) + 'publish: null' + text.slice(end + 1);
}
/** Evaluate the build config the way electron-builder will. */
function loadedPublishValue(abs) {
const require = createRequire(import.meta.url);
delete require.cache[abs];
return require(abs).publish;
}
const root = path.resolve(process.argv[2] || '.');
@@ -47,12 +60,23 @@ if (!fs.existsSync(abs)) {
process.exit(2);
}
const before = fs.readFileSync(abs, 'utf8');
if (!before.includes('publish:')) {
console.log('[strip-updater-feed] no publish block — already neutered.');
process.exit(0);
if (NEUTERED.test(before)) {
console.log('[strip-updater-feed] publish already null — neutered.');
} else if (!before.includes('publish:')) {
console.error(
'[strip-updater-feed] no `publish:` key in the build config. ' +
'electron-builder would then derive the update feed from ' +
'package.json `repository` — refusing to call that neutered.'
);
process.exit(1);
} else {
fs.writeFileSync(abs, neuterPublishBlock(before));
console.log(`[strip-updater-feed] set publish: null in ${CONFIG_REL}`);
}
const publish = loadedPublishValue(abs);
if (publish !== null) {
console.error(
`[strip-updater-feed] evaluated config still has publish = ${JSON.stringify(publish)}`
);
process.exit(1);
}
const afterText = stripPublishBlock(before);
if (afterText.includes('publish:'))
throw new Error('publish block survived stripping');
fs.writeFileSync(abs, afterText);
console.log(`[strip-updater-feed] removed update feed from ${CONFIG_REL}`);
+69 -35
View File
@@ -1,11 +1,20 @@
// Updater-feed strip tests: the release build must never ship an
// electron-updater feed pointing at upstream releases.
//
// What "neutered" means is load-bearing: the config must evaluate to
// `publish === null`. A config with the key merely deleted still yields
// resources/app-update.yml (electron-builder derives the feed from
// package.json `repository`), which is how the first CI artifact shipped
// one. Limit: electron-builder is not installed here, so these tests pin
// the config value it reads, not the packaged output — release.yml
// asserts the built artifact has no app-update.yml.
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
@@ -13,64 +22,89 @@ const ROOT = path.resolve(here, '..', '..');
const UPSTREAM =
process.env.UPSTREAM_DIR || path.join(ROOT, 'review', 'GDevelop');
const CONFIG = 'newIDE/electron-app/electron-builder-config.js';
const require = createRequire(import.meta.url);
function stripOnCopy() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'updater-strip-'));
const src = path.join(UPSTREAM, CONFIG);
assert.ok(fs.existsSync(src), `missing fixture ${CONFIG}`);
const destDir = path.join(dir, 'newIDE/electron-app');
fs.mkdirSync(destDir, { recursive: true });
fs.copyFileSync(src, path.join(destDir, 'electron-builder-config.js'));
const r = spawnSync('node', ['scripts/strip-updater-feed.mjs', dir], {
function strip(dir) {
return spawnSync('node', ['scripts/strip-updater-feed.mjs', dir], {
cwd: ROOT,
encoding: 'utf8',
});
assert.equal(r.status, 0, `strip failed:\n${r.stdout}\n${r.stderr}`);
return { dir, text: fs.readFileSync(path.join(destDir, 'electron-builder-config.js'), 'utf8') };
}
function makeTree(configText) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'updater-strip-'));
const abs = path.join(dir, CONFIG);
fs.mkdirSync(path.dirname(abs), { recursive: true });
if (configText === undefined) {
const src = path.join(UPSTREAM, CONFIG);
assert.ok(fs.existsSync(src), `missing fixture ${CONFIG}`);
fs.copyFileSync(src, abs);
} else {
fs.writeFileSync(abs, configText);
}
return { dir, abs };
}
describe('release: updater feed is neutered', () => {
it('removes the publish block from the real upstream config', () => {
const { dir, text } = stripOnCopy();
it('real upstream config evaluates to publish === null, rest intact', () => {
const { dir, abs } = makeTree();
try {
assert.ok(!text.includes('publish:'), 'publish block survived');
assert.deepEqual(require(abs).publish, [{ provider: 'github' }]);
delete require.cache[abs];
const r = strip(dir);
assert.equal(r.status, 0, `strip failed:\n${r.stdout}\n${r.stderr}`);
const config = require(abs);
assert.equal(config.publish, null);
assert.ok('publish' in config, 'key must stay: absent means auto-detect');
// Everything else (appId, targets, signing hook) must be intact.
assert.equal(config.appId, 'com.gdevelop-app.ide');
assert.equal(config.win.target.length, 2);
assert.equal(config.afterSign, 'scripts/electron-builder-after-sign.js');
const text = fs.readFileSync(abs, 'utf8');
assert.ok(!text.includes("provider: 'github'"), 'github provider survived');
// Everything else (appId, targets, signing branches) must be intact.
assert.ok(text.includes("appId: 'com.gdevelop-app.ide'"), 'appId lost');
assert.ok(text.includes('GD_PORTABLE_BUILD'), 'portable branch lost');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it('is idempotent (second run is a no-op, exit 0)', () => {
const { dir } = stripOnCopy();
it('is idempotent (second run is a verified no-op, exit 0)', () => {
const { dir, abs } = makeTree();
try {
const r = spawnSync('node', ['scripts/strip-updater-feed.mjs', dir], {
cwd: ROOT,
encoding: 'utf8',
});
assert.equal(strip(dir).status, 0);
const once = fs.readFileSync(abs, 'utf8');
const r = strip(dir);
assert.equal(r.status, 0, `re-run failed:\n${r.stdout}\n${r.stderr}`);
assert.equal(fs.readFileSync(abs, 'utf8'), once);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it('treats a config without publish as already neutered', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'updater-strip-neg-'));
it('refuses a config with no publish key (feed would be auto-detected)', () => {
const { dir } = makeTree(
'const config = { appId: 1 };\nmodule.exports = config;\n'
);
try {
const destDir = path.join(dir, 'newIDE/electron-app');
fs.mkdirSync(destDir, { recursive: true });
fs.writeFileSync(
path.join(destDir, 'electron-builder-config.js'),
'const config = { appId: 1 };\nmodule.exports = config;\n'
);
const r = spawnSync('node', ['scripts/strip-updater-feed.mjs', dir], {
cwd: ROOT,
encoding: 'utf8',
});
// No publish key at all: treated as already-neutered, exit 0.
assert.equal(r.status, 0);
const r = strip(dir);
assert.equal(r.status, 1, 'absent key was accepted as neutered');
assert.ok(r.stderr.includes('no `publish:` key'));
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it('fails when publish is re-assigned after the object literal', () => {
// The text edit alone would pass; evaluating the config catches it.
const { dir } = makeTree(
"const config = { appId: 1, publish: [{ provider: 'github' }] };\n" +
"config.publish = [{ provider: 'generic', url: 'https://example.invalid' }];\n" +
'module.exports = config;\n'
);
try {
const r = strip(dir);
assert.equal(r.status, 1);
assert.ok(r.stderr.includes('evaluated config still has publish'));
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}