From 9ceb42e62d245faf6ddbaee956d95570781165c1 Mon Sep 17 00:00:00 2001 From: BillyOutlast <172061051+BillyOutlast@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:51:06 -0400 Subject: [PATCH] =?UTF-8?q?Release:=20the=20update=20feed=20survived=20in?= =?UTF-8?q?=20the=20built=20artifact=20=E2=80=94=20set=20publish:=20null?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitattributes | 4 ++ scripts/strip-updater-feed.mjs | 64 +++++++++++------ tests/patches/updater-strip.test.mjs | 104 ++++++++++++++++++--------- 3 files changed, 117 insertions(+), 55 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d9c478f --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/scripts/strip-updater-feed.mjs b/scripts/strip-updater-feed.mjs index 6db9a17..eca7edd 100644 --- a/scripts/strip-updater-feed.mjs +++ b/scripts/strip-updater-feed.mjs @@ -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 -// 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}`); diff --git a/tests/patches/updater-strip.test.mjs b/tests/patches/updater-strip.test.mjs index 95719ca..d32890f 100644 --- a/tests/patches/updater-strip.test.mjs +++ b/tests/patches/updater-strip.test.mjs @@ -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 }); }