build: unify Node 24 and upgrade release flow

This commit is contained in:
whyour
2026-10-26 23:26:40 +08:00
parent b92621bdb8
commit 2efc5e4503
20 changed files with 430 additions and 206 deletions
+22 -4
View File
@@ -19,7 +19,7 @@ const {
sourceCommit,
} = require('../helpers/upgrade-fixture.cjs');
async function fixture(t) {
async function fixture(t, targetVersion = '2.24.0') {
const base = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-panel-job-'));
const root = path.join(base, 'panel'),
tmp = path.join(root, '.tmp');
@@ -30,7 +30,10 @@ async function fixture(t) {
await fs.writeFile(name, contents);
};
for (const dir of [root, source]) {
await write(path.join(dir, 'package.json'), '{"version":"2.24.0"}');
await write(
path.join(dir, 'package.json'),
JSON.stringify({ version: targetVersion }),
);
await write(path.join(dir, 'pnpm-lock.yaml'), 'lock');
await write(path.join(dir, 'sample/config.sample.sh'), '# config');
}
@@ -39,7 +42,7 @@ async function fixture(t) {
'installed dependency',
);
await write(path.join(root, 'version.yaml'), 'version: 2.23.0');
await write(path.join(source, 'version.yaml'), 'version: 2.24.0');
await write(path.join(source, 'version.yaml'), `version: ${targetVersion}`);
await write(path.join(staticRoot, 'build/app.js'), 'new backend');
writeManifest(source, staticRoot);
for (const repo of ['qinglong', 'qinglong-static'])
@@ -81,7 +84,7 @@ fs.writeFileSync(args[args.indexOf('--output') + 1], buffer);
);
await fs.chmod(path.join(base, 'bin/curl'), 0o700);
const runtime = {
version: '2.24.0',
version: targetVersion,
sourceCommit,
instanceId: 'after-restart',
parentPid: 100002,
@@ -186,6 +189,21 @@ test('detached download survives its initiating request and is idempotent; apply
);
});
test('prerelease version is retained through download and restart verification', async (t) => {
const f = await fixture(t, '2.24.0-dev.0');
await startJob(f, 'download');
const ready = await finished(f.tmp);
assert.equal(ready.status, 'ready', ready.log);
assert.equal(ready.version, '2.24.0-dev.0');
await startJob(f, 'apply');
const complete = await finished(f.tmp);
assert.equal(complete.status, 'completed', complete.log);
assert.equal(
await fs.readFile(path.join(f.root, 'version.yaml'), 'utf8'),
'version: 2.24.0-dev.0',
);
});
test('invalid staged payload fails asynchronously before stopping the panel', async (t) => {
const f = await fixture(t);
await startJob(f, 'download');
+3 -1
View File
@@ -591,7 +591,9 @@ test('publishing requires every platform and validates all immutable archive has
);
}
const index = releaseIndex(f.directory, sourceCommit, repository);
assert.equal(Object.keys(index.packages).length, 12);
assert.equal(Object.keys(index.packages).length, 11);
assert.ok(index.packages['linux-armv7-musl']);
assert.equal(index.packages['linux-armv7-glibc'], undefined);
const armv6 = path.join(
f.directory,
`qinglong-${sourceCommit}-linux-armv6-musl.tar.gz`,
+130 -32
View File
@@ -66,6 +66,17 @@ function fixture(t) {
const gh = (...args) => {
state.calls.push(args);
if (args[0] === 'api') {
if (args[1] === '-X' && args[2] === 'DELETE') {
if (state.failDelete) throw new Error('Delete unavailable');
const id = Number(args[3].split('/').at(-1));
for (const release of state.releases.values())
for (const asset of release.values())
if (asset.id === id) {
release.delete(asset.name);
return '';
}
return notFound();
}
const route = args[1];
if (route.endsWith('/git/ref/heads/master')) return state.head;
if (route.endsWith('/git/ref/heads/develop')) return state.developHead;
@@ -127,7 +138,11 @@ function fixture(t) {
digest: `sha256:${sha256(bytes)}`,
});
}
if (tag === 'upgrade-develop' && state.corruptChannel)
if (
files.some((file) => path.basename(file) === 'latest.json') &&
((tag === 'upgrade-develop' && state.corruptChannel) ||
tag === state.corruptIndexTag)
)
release.get('latest.json').digest = 'sha256:wrong';
if (files.length > 1) {
if (state.corruptUploadedBatch)
@@ -162,14 +177,19 @@ test('publishes every platform and installer before advancing version and legacy
assert.equal(index.version, '2.24.0');
assert.equal(index.buildId, '100-1');
assert.equal(index.sourceCommit, f.options.commit);
assert.equal(Object.keys(index.packages).length, 12);
assert.equal(Object.keys(index.packages).length, 11);
assert.ok(index.packages['linux-armv7-musl']);
assert.equal(index.packages['linux-armv7-glibc'], undefined);
assert.deepEqual(index, f.index('upgrade-v2.24.0'));
const tag = 'upgrade-build-v2.24.0-100-1';
assert.equal(f.state.releases.get(tag).size, 26);
const tag = 'upgrade-v2.24.0';
assert.equal(f.state.releases.get(tag).size, 24);
assert.equal(f.state.releases.get('upgrade-master').size, 1);
assert.ok(!f.state.releases.has('upgrade-build-v2.24.0-100-1'));
for (const entry of [...Object.values(index.packages), index.installer]) {
const url = new URL(entry.url),
filename = path.basename(url.pathname);
assert.ok(url.pathname.includes(`/releases/download/${tag}/`));
assert.ok(filename.includes('-100-1.'));
const bytes = f.state.releases.get(tag).get(filename).bytes;
assert.equal(bytes.length, entry.size);
assert.equal(sha256(bytes), entry.sha256);
@@ -179,7 +199,7 @@ test('publishes every platform and installer before advancing version and legacy
);
assert.deepEqual(
writes.map((a) => a[2]),
[tag, 'upgrade-v2.24.0', 'upgrade-master'],
[tag, tag, 'upgrade-master'],
);
assert.ok(writes.slice(1).every((a) => a.includes('--clobber')));
});
@@ -192,10 +212,10 @@ for (const sameCommit of [false, true])
f.publish();
const before = f.index();
const oldAssets = new Map(
Array.from(
f.state.releases.get('upgrade-build-v2.24.0-100-1'),
([name, a]) => [name, a.bytes.toString('base64')],
),
Array.from(f.state.releases.get('upgrade-v2.24.0'), ([name, a]) => [
name,
a.bytes.toString('base64'),
]).filter(([name]) => name !== 'latest.json'),
);
const commit = sameCommit ? f.options.commit : 'b'.repeat(40);
f.state.head = commit;
@@ -217,13 +237,14 @@ for (const sameCommit of [false, true])
assert.notEqual(after.installer.sha256, before.installer.sha256);
assert.deepEqual(
new Map(
Array.from(
f.state.releases.get('upgrade-build-v2.24.0-100-1'),
([name, a]) => [name, a.bytes.toString('base64')],
),
Array.from(f.state.releases.get('upgrade-v2.24.0'), ([name, a]) => [
name,
a.bytes.toString('base64'),
]).filter(([name]) => oldAssets.has(name)),
),
oldAssets,
);
assert.equal(f.state.releases.get('upgrade-v2.24.0').size, 47);
});
test('retrying a publication is idempotent, but changed bytes require a new build ID', (t) => {
@@ -234,7 +255,8 @@ test('retrying a publication is idempotent, but changed bytes require a new buil
(a) =>
a[0] === 'release' &&
a[1] === 'upload' &&
a[2] === 'upgrade-build-v2.24.0-100-1',
a[2] === 'upgrade-v2.24.0' &&
!a.includes('--clobber'),
).length;
assert.equal(count(), 1);
f.publish();
@@ -244,6 +266,41 @@ test('retrying a publication is idempotent, but changed bytes require a new buil
assert.equal(f.index().buildId, '100-1');
});
test('a new version keeps the previous version release while master advances', (t) => {
const f = fixture(t);
f.publish();
const previous = f.index('upgrade-v2.24.0');
const commit = 'b'.repeat(40);
f.state.head = commit;
f.write(commit, 'next version', '2.25.0');
assert.equal(
f.publish({ commit, version: '2.25.0', buildId: '101-1' }),
true,
);
assert.deepEqual(f.index('upgrade-v2.24.0'), previous);
assert.equal(f.state.releases.get('upgrade-v2.24.0').size, 24);
assert.equal(f.state.releases.get('upgrade-v2.25.0').size, 24);
assert.equal(f.state.releases.get('upgrade-master').size, 1);
assert.equal(f.index().version, '2.25.0');
assert.ok(!f.state.releases.has('upgrade-build-v2.25.0-101-1'));
});
test('a failed version index verification leaves master on the previous version', (t) => {
const f = fixture(t);
f.publish();
const previous = f.index();
const commit = 'b'.repeat(40);
f.state.head = commit;
f.write(commit, 'next version', '2.25.0');
f.state.corruptIndexTag = 'upgrade-v2.25.0';
assert.throws(
() => f.publish({ commit, version: '2.25.0', buildId: '101-1' }),
/Published channel index verification failed/,
);
assert.deepEqual(f.index(), previous);
assert.deepEqual(f.index('upgrade-v2.24.0'), previous);
});
for (const fault of ['missing', 'corrupt', 'wrong-version'])
test(`incomplete, corrupt or wrong-version packages never publish (${fault})`, (t) => {
const f = fixture(t);
@@ -283,7 +340,7 @@ test('stale commits do not publish; a source change during upload cannot advance
const before = f.index();
f.state.head = 'b'.repeat(40);
assert.equal(f.publish({ buildId: '101-1' }), false);
assert.ok(!f.state.releases.has('upgrade-build-v2.24.0-101-1'));
assert.equal(f.state.releases.get('upgrade-v2.24.0').size, 24);
f.state.head = f.options.commit;
f.state.changeHeadAfterUpload = true;
assert.equal(f.publish({ buildId: '102-1' }), false);
@@ -335,38 +392,58 @@ test('existing master clients can migrate from indexes without version/build met
assert.equal(f.index().buildId, '100-2');
});
test('automatic development publication shares musl/glibc packages without changing version or latest production assets', (t) => {
test('automatic development publication keeps its prerelease version separate from production assets', (t) => {
const f = fixture(t);
f.publish();
const production = f.index();
f.write(f.state.developHead, 'development');
const developmentVersion = '2.24.0-dev.0';
f.write(f.state.developHead, 'development', developmentVersion);
assert.equal(
f.publish({
branch: 'develop',
commit: f.state.developHead,
version: developmentVersion,
}),
true,
);
const development = f.index('upgrade-develop');
assert.equal(development.channel, 'develop');
assert.equal(development.version, developmentVersion);
assert.equal(development.sourceCommit, f.state.developHead);
assert.equal(Object.keys(development.packages).length, 12);
assert.equal(Object.keys(development.packages).length, 11);
assert.ok(development.packages['linux-x64-musl']);
assert.ok(development.packages['linux-x64-glibc']);
assert.ok(development.packages['linux-armv6-musl']);
assert.equal(development.packages['linux-armv6-glibc'], undefined);
assert.ok(development.packages['linux-armv7-musl']);
assert.equal(development.packages['linux-armv7-glibc'], undefined);
for (const entry of [
...Object.values(development.packages),
development.installer,
])
assert.ok(entry.url.includes('/upgrade-build-develop-v2.24.0-100-1/'));
assert.ok(entry.url.includes('/upgrade-develop/'));
for (const entry of Object.values(development.packages))
assert.ok(entry.url.endsWith('-100-1.tar.gz'));
assert.ok(development.installer.url.endsWith('/install-bundle-100-1.cjs'));
assert.ok(
f.state.calls.some(
(args) =>
args[0] === 'release' &&
args[1] === 'create' &&
args[2] === 'upgrade-develop' &&
args[args.indexOf('--title') + 1] === 'Upgrade channel develop',
),
);
assert.deepEqual(f.index(), production);
assert.deepEqual(f.index('upgrade-v2.24.0'), production);
assert.equal(f.state.releases.get('upgrade-build-v2.24.0-100-1').size, 26);
assert.equal(
f.state.releases.get('upgrade-build-develop-v2.24.0-100-1').size,
26,
assert.equal(f.state.releases.get('upgrade-v2.24.0').size, 24);
assert.equal(f.state.releases.get('upgrade-develop').size, 24);
assert.ok(!f.state.releases.has('upgrade-build-develop-100-1'));
const archiveName = path.basename(development.packages['linux-x64-musl'].url);
const sidecar = JSON.parse(
f.state.releases.get('upgrade-develop').get(`${archiveName}.json`).bytes,
);
assert.equal(sidecar.filename, archiveName);
});
test('development builds cannot publish obsolete development source', (t) => {
@@ -416,14 +493,21 @@ for (const fault of [
const options = { branch: 'develop', commit: f.state.developHead };
f.write(options.commit, 'development');
f.publish(options);
const oldTag = 'upgrade-build-develop-v2.24.0-100-1';
const oldTag = 'upgrade-build-develop-100-1';
const legacyTag = 'upgrade-build-develop-v2.24.0-99-1';
f.state.releases.set(oldTag, new Map());
f.state.releases.set(legacyTag, new Map());
const oldAsset = Array.from(
f.state.releases.get('upgrade-develop').keys(),
).find((name) => name.endsWith('-100-1.tar.gz'));
assert.ok(oldAsset);
// Never prune production, channel aliases, legacy/unclassified releases,
// drafts or a non-prerelease even when its name resembles a dev build.
const protectedTags = [
'upgrade-86c37f426a5ba9945d6af1910a7885a26f77e4bc',
'v2.24.0',
'upgrade-build-develop-v2.24.0-10-1',
'upgrade-build-develop-v2.24.0-11-1',
'upgrade-build-develop-10-1',
'upgrade-build-develop-11-1',
];
for (const tag of protectedTags) f.state.releases.set(tag, new Map());
f.state.releaseMetadata = {
@@ -441,7 +525,7 @@ for (const fault of [
assert.equal(f.publish({ ...options, buildId: '101-1' }), true);
assert.equal(f.index('upgrade-develop').buildId, '101-1');
const deletion = f.state.calls.findIndex(
(a) => a[0] === 'release' && a[1] === 'delete',
(a) => a[0] === 'api' && a[1] === '-X' && a[2] === 'DELETE',
);
const aliasEdit = f.state.calls.findLastIndex(
(a) => a[0] === 'release' && a[1] === 'edit',
@@ -455,9 +539,15 @@ for (const fault of [
a[0] === 'api' && a[1].includes('/releases/tags/upgrade-develop'),
),
);
assert.ok(f.state.releases.has('upgrade-build-develop-v2.24.0-101-1'));
assert.ok(f.state.releases.has('upgrade-develop'));
assert.ok(!f.state.releases.has('upgrade-build-develop-101-1'));
}
assert.equal(f.state.releases.has(oldTag), Boolean(fault));
assert.equal(f.state.releases.has(legacyTag), Boolean(fault));
assert.equal(
f.state.releases.get('upgrade-develop').has(oldAsset),
Boolean(fault),
);
for (const tag of protectedTags) assert.ok(f.state.releases.has(tag));
assert.deepEqual(f.index(), production);
assert.deepEqual(f.index('upgrade-v2.24.0'), production);
@@ -468,17 +558,21 @@ test('failed development cleanup is retryable without overwriting immutable asse
const options = { branch: 'develop', commit: f.state.developHead };
f.write(options.commit, 'development');
f.publish(options);
const oldAsset = Array.from(
f.state.releases.get('upgrade-develop').keys(),
).find((name) => name.endsWith('-100-1.tar.gz'));
assert.ok(oldAsset);
f.state.failDelete = true;
assert.throws(
() => f.publish({ ...options, buildId: '101-1' }),
/Delete unavailable/,
);
assert.equal(f.index('upgrade-develop').buildId, '101-1');
assert.ok(f.state.releases.has('upgrade-build-develop-v2.24.0-100-1'));
assert.ok(f.state.releases.get('upgrade-develop').has(oldAsset));
f.state.failDelete = false;
f.publish({ ...options, buildId: '101-1' });
assert.ok(!f.state.releases.has('upgrade-build-develop-v2.24.0-100-1'));
assert.ok(f.state.releases.has('upgrade-build-develop-v2.24.0-101-1'));
assert.ok(!f.state.releases.get('upgrade-develop').has(oldAsset));
assert.equal(f.state.releases.get('upgrade-develop').size, 24);
});
test('a newer development commit during upload preserves the published channel and its old package', (t) => {
@@ -487,10 +581,14 @@ test('a newer development commit during upload preserves the published channel a
f.write(options.commit, 'development');
f.publish(options);
const before = f.index('upgrade-develop');
const oldAsset = Array.from(
f.state.releases.get('upgrade-develop').keys(),
).find((name) => name.endsWith('-100-1.tar.gz'));
assert.ok(oldAsset);
f.state.changeDevelopHeadAfterUpload = true;
assert.equal(f.publish({ ...options, buildId: '101-1' }), false);
assert.deepEqual(f.index('upgrade-develop'), before);
assert.ok(f.state.releases.has('upgrade-build-develop-v2.24.0-100-1'));
assert.ok(f.state.releases.get('upgrade-develop').has(oldAsset));
assert.ok(
!f.state.calls.some((a) => a[0] === 'release' && a[1] === 'delete'),
);
+2 -2
View File
@@ -32,7 +32,7 @@ test('a running process retains its own build identity when installed files chan
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-runtime-build-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
fs.mkdirSync(path.join(root, 'static/build'), { recursive: true });
fs.writeFileSync(path.join(root, 'version.yaml'), 'version: 2.24.0');
fs.writeFileSync(path.join(root, 'version.yaml'), 'version: 2.24.0-dev.0');
fs.writeFileSync(path.join(root, 'static/build/app.js'), 'old backend');
fs.writeFileSync(
path.join(root, 'static/build-info.json'),
@@ -45,7 +45,7 @@ test('a running process retains its own build identity when installed files chan
JSON.stringify({ sourceCommit: 'b'.repeat(40) }),
);
fs.writeFileSync(path.join(root, 'version.yaml'), 'version: 2.25.0');
assert.equal(running.version, '2.24.0');
assert.equal(running.version, '2.24.0-dev.0');
assert.equal(running.sourceCommit, 'a'.repeat(40));
assert.equal(
running.backendSha256,
+8 -4
View File
@@ -10,8 +10,12 @@ const {
repairDependencies,
} = require('../../cli/src/internal/maintenance/upgradePrestart.cjs');
for (const exitCode of [0, 1])
test(`prestart exit ${exitCode} reaps children retaining output pipes before removing its workspace`, async (t) => {
for (const [version, exitCode] of [
['2.23.0', 0],
['2.23.0', 1],
['2.24.0-dev.0', 0],
])
test(`prestart ${version} exit ${exitCode} reaps children retaining output pipes before removing its workspace`, async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-probe-exit-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const source = path.join(root, 'source'),
@@ -19,7 +23,7 @@ for (const exitCode of [0, 1])
survivor = path.join(root, 'survivor');
await fs.mkdir(path.join(source, 'node_modules'), { recursive: true });
await fs.mkdir(path.join(statics, 'build/bootstrap'), { recursive: true });
await fs.writeFile(path.join(source, 'version.yaml'), 'version: 2.23.0');
await fs.writeFile(path.join(source, 'version.yaml'), `version: ${version}`);
await fs.writeFile(path.join(statics, 'build/app.js'), 'backend');
await fs.writeFile(
path.join(statics, 'build-info.json'),
@@ -36,7 +40,7 @@ for (const exitCode of [0, 1])
)}],{stdio:'inherit'});
process.send({type:'upgrade-probe',ok:${
exitCode === 0
},message:'simulated prestart failure',build:{sourceCommit:'a'.repeat(40),version:'2.23.0',backendSha256:crypto.createHash('sha256').update('backend').digest('hex')}},()=>process.exit(${exitCode}));
},message:'simulated prestart failure',build:{sourceCommit:'a'.repeat(40),version:${JSON.stringify(version)},backendSha256:crypto.createHash('sha256').update('backend').digest('hex')}},()=>process.exit(${exitCode}));
`,
);
const operation = prestartBackend(