mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-10-14 16:36:16 -08:00
Publish GUI installers and Android APKs under their original names. Use the built commit version in Core, Web and Magisk ZIP names, and match the version-release archive layout while preserving executables. Generate SHA256SUMS for all downloads and validate the packaged asset manifest before publication. Cover native installers, archive layout, checksums and invalid artifacts in the CI script tests. Document versioned download URLs and remove the stable-URL claim.
399 lines
17 KiB
JavaScript
399 lines
17 KiB
JavaScript
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs/promises');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const { execFileSync } = require('node:child_process');
|
|
const { createHash } = require('node:crypto');
|
|
const { test } = require('node:test');
|
|
const { prepare, packageAssets, publish, releaseNotes } = require('./nightly-release.js');
|
|
|
|
const repository = { owner: 'EasyTier', repo: 'EasyTier' };
|
|
const core = { info() {} };
|
|
const notFound = () => Object.assign(new Error('Not found'), { status: 404 });
|
|
|
|
function fixture() {
|
|
const calls = [];
|
|
const state = {
|
|
release: null,
|
|
ancestry: 'ahead',
|
|
publishedComparison: 'ahead',
|
|
tagExists: false,
|
|
version: '2.7.0',
|
|
releases: [
|
|
{ tag_name: 'v2.6.4', published_at: '2026-05-12', html_url: 'stable' },
|
|
{ tag_name: 'v2.7.0', published_at: '2026-10-06', prerelease: true, html_url: 'version' },
|
|
{ tag_name: 'v2.8.0', published_at: '2026-10-12', draft: true },
|
|
],
|
|
runs: Object.fromEntries(['core', 'gui', 'mobile', 'test'].map((name, index) => [name, {
|
|
id: index + 1, name, head_sha: 'built', head_branch: 'main',
|
|
event: 'workflow_dispatch', conclusion: 'success', html_url: `run/${index + 1}`,
|
|
}])),
|
|
artifacts: {
|
|
1: [{ id: 10, name: 'easytier-linux-x86_64' }, { id: 11, name: 'Easytier-Magisk' },
|
|
{ id: 12, name: 'signpath-unsigned-windows-x86_64' }],
|
|
2: [{ id: 20, name: 'easytier-gui-windows-x86_64' }],
|
|
3: [{ id: 30, name: 'easytier-mobile-android-aarch64' }],
|
|
},
|
|
assets: [],
|
|
};
|
|
const repos = {
|
|
async getBranch() { return { data: { commit: { sha: 'main-head' } } }; },
|
|
async getContent(request) {
|
|
calls.push(['version', request.ref]);
|
|
assert.equal(request.path, 'easytier-gui/src-tauri/tauri.conf.json');
|
|
return { data: { content: Buffer.from(JSON.stringify({ version: state.version })).toString('base64') } };
|
|
},
|
|
async compareCommitsWithBasehead(request) {
|
|
calls.push(['compare', request.basehead]);
|
|
return { data: { status: request.basehead.startsWith('nightly...') ? state.publishedComparison : state.ancestry } };
|
|
},
|
|
listReleases() {},
|
|
listReleaseAssets() {},
|
|
async createRelease(request) {
|
|
calls.push(['create', request]);
|
|
state.release = { id: 100, draft: true, html_url: 'nightly' };
|
|
return { data: state.release };
|
|
},
|
|
async deleteReleaseAsset(request) { calls.push(['delete', request.asset_id]); },
|
|
async uploadReleaseAsset(request) {
|
|
calls.push(['upload', request.name]);
|
|
if (state.uploadFails) throw new Error('Upload failed');
|
|
},
|
|
async updateRelease(request) {
|
|
calls.push(['update', request]);
|
|
Object.assign(state.release, request);
|
|
},
|
|
};
|
|
const actions = {
|
|
async listWorkflowRuns(request) {
|
|
assert.equal(request.branch, 'main');
|
|
assert.equal(request.event, 'workflow_dispatch');
|
|
assert.equal(request.status, 'success');
|
|
const run = state.runs[request.workflow_id.replace('.yml', '')];
|
|
return { data: { workflow_runs: run ? [run] : [] } };
|
|
},
|
|
listWorkflowRunArtifacts() {},
|
|
};
|
|
const github = {
|
|
rest: {
|
|
repos, actions,
|
|
git: {
|
|
async getRef() { if (!state.tagExists) throw notFound(); },
|
|
async createRef(request) { calls.push(['createTag', request.sha]); state.tagExists = true; },
|
|
async updateRef(request) { calls.push(['updateTag', request.sha]); },
|
|
},
|
|
},
|
|
async paginate(method, request, map) {
|
|
if (method === repos.listReleases) {
|
|
return state.releases.concat(state.release ? [{ tag_name: 'nightly', ...state.release }] : []);
|
|
}
|
|
if (method === repos.listReleaseAssets) return state.assets;
|
|
if (method === actions.listWorkflowRunArtifacts) return state.artifacts[request.run_id];
|
|
if (method === repos.compareCommitsWithBasehead) {
|
|
calls.push(['changelog', request.basehead]);
|
|
assert.equal(request.per_page, 100);
|
|
// Exercise the response mapper used to collect every comparison page.
|
|
return map({ data: { commits: [commit('old', '2026-10-10T12:00:00Z')] } })
|
|
.concat(map({ data: { commits: [commit('new', '2026-10-12T12:00:00Z')] } }));
|
|
}
|
|
throw new Error('Unexpected API call');
|
|
},
|
|
};
|
|
const context = { repo: repository, payload: { workflow_run: { head_sha: 'built' } } };
|
|
return { state, calls, github, context, core };
|
|
}
|
|
|
|
function commit(sha, date) {
|
|
return { sha, html_url: `commit/${sha}`, commit: { committer: { date }, message: `${sha} change\n\nDetails` } };
|
|
}
|
|
|
|
test('nightly selects one main commit and uses the last published version, including prereleases', async () => {
|
|
const f = fixture();
|
|
f.state.release = { id: 99, published_at: '2026-10-13' };
|
|
const build = await prepare(f);
|
|
assert.equal(build.sha, 'built');
|
|
assert.equal(build.version, 'v2.7.0');
|
|
assert.ok(f.calls.some(call => call[0] === 'version' && call[1] === 'built'));
|
|
assert.deepEqual(build.runs, { core: 1, gui: 2, mobile: 3, test: 4 });
|
|
assert.deepEqual(build.artifacts.core.map(a => a.id), [10, 11]);
|
|
assert.ok(f.calls.some(call => call[0] === 'changelog' && call[1] === 'v2.7.0...built'));
|
|
assert.match(build.body, /Changes since \[v2.7.0\]/);
|
|
assert.ok(build.body.indexOf('2026-10-12') < build.body.indexOf('2026-10-10'));
|
|
});
|
|
|
|
test('asset versions come from the built commit rather than the changelog baseline', async () => {
|
|
const f = fixture();
|
|
f.state.version = '2.8.0-rc.1';
|
|
assert.equal((await prepare(f)).version, 'v2.8.0-rc.1');
|
|
f.state.version = '../invalid';
|
|
await assert.rejects(prepare(f), /valid release version/);
|
|
});
|
|
|
|
test('nightly waits for every component and the full test run', async () => {
|
|
for (const workflow of ['core', 'gui', 'mobile', 'test']) {
|
|
const f = fixture();
|
|
delete f.state.runs[workflow];
|
|
assert.equal(await prepare(f), null);
|
|
}
|
|
});
|
|
|
|
test('nightly rejects a mismatched commit, branch, event or failed result', async () => {
|
|
for (const field of ['head_sha', 'head_branch', 'event', 'conclusion']) {
|
|
const f = fixture();
|
|
f.state.runs.gui[field] = 'different';
|
|
assert.equal(await prepare(f), null);
|
|
}
|
|
});
|
|
|
|
test('nightly accepts successful failed-job reruns without requiring a new run ID', async () => {
|
|
const f = fixture();
|
|
f.state.runs.gui.run_attempt = 2;
|
|
assert.equal((await prepare(f)).runs.gui, 2);
|
|
});
|
|
|
|
test('nightly refuses missing or expired artifacts before changing a release', async () => {
|
|
for (const artifacts of [[], [{ id: 20, name: 'easytier-gui-windows-x86_64', expired: true }]]) {
|
|
const f = fixture();
|
|
f.state.artifacts[2] = artifacts;
|
|
await assert.rejects(prepare(f), /missing or expired/);
|
|
}
|
|
});
|
|
|
|
test('an older completed run cannot replace a newer nightly or a removed main commit', async () => {
|
|
const f = fixture();
|
|
f.state.ancestry = 'diverged';
|
|
assert.equal(await prepare(f), null);
|
|
f.state.ancestry = 'ahead';
|
|
f.state.release = { id: 100 };
|
|
for (const status of ['behind', 'diverged']) {
|
|
f.state.publishedComparison = status;
|
|
assert.equal(await prepare(f), null);
|
|
}
|
|
});
|
|
|
|
test('duplicate completion events skip a fully published commit', async () => {
|
|
const f = fixture();
|
|
f.state.release = { id: 100, body: 'Commit: [built](url)' };
|
|
f.state.publishedComparison = 'identical';
|
|
assert.equal(await prepare(f), null);
|
|
});
|
|
|
|
test('nightly can resume an initial draft or an interrupted release-note update', async () => {
|
|
const f = fixture();
|
|
f.state.release = { id: 100, draft: true };
|
|
assert.ok(await prepare(f));
|
|
assert.ok(!f.calls.some(call => call[1] === 'nightly...built'));
|
|
f.state.release.draft = false;
|
|
f.state.release.body = 'Commit: [old](url)';
|
|
f.state.publishedComparison = 'identical';
|
|
assert.ok(await prepare(f));
|
|
});
|
|
|
|
test('manual publication checks the current main commit', async () => {
|
|
const f = fixture();
|
|
f.context.payload = {};
|
|
for (const run of Object.values(f.state.runs)) run.head_sha = 'main-head';
|
|
assert.equal((await prepare(f)).sha, 'main-head');
|
|
});
|
|
|
|
test('scheduled publication uses the completed commit even after main advances', async () => {
|
|
const f = fixture();
|
|
f.context.payload = {};
|
|
const build = await prepare({ ...f, sha: 'built' });
|
|
assert.equal(build.sha, 'built');
|
|
assert.ok(f.calls.some(call => call[0] === 'compare' && call[1] === 'built...main-head'));
|
|
assert.deepEqual(build.runs, { core: 1, gui: 2, mobile: 3, test: 4 });
|
|
});
|
|
|
|
test('nightly requires a published version baseline and a mutable release', async () => {
|
|
const f = fixture();
|
|
f.state.releases = [];
|
|
await assert.rejects(prepare(f), /No published version/);
|
|
f.state.release = { immutable: true };
|
|
await assert.rejects(prepare(f), /must allow asset and tag updates/);
|
|
});
|
|
|
|
test('notes date every entry in UTC and sort by timestamp, not input order', () => {
|
|
const notes = releaseNotes(repository, { tag_name: 'v2.7.0', html_url: 'version' }, 'built', [
|
|
commit('middle', '2026-10-12T15:00:00Z'),
|
|
commit('oldest', '2026-10-12T01:00:00+08:00'),
|
|
commit('newest', '2026-10-12T19:00:00Z'),
|
|
], []);
|
|
assert.match(notes, /2026-10-11 — oldest change/);
|
|
assert.ok(notes.indexOf('newest change') < notes.indexOf('middle change'));
|
|
assert.ok(notes.indexOf('middle change') < notes.indexOf('oldest change'));
|
|
assert.ok(!notes.includes('Details'));
|
|
});
|
|
|
|
async function packageFiles(t, f) {
|
|
const build = await prepare(f);
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'easytier-nightly-test-'));
|
|
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
|
const source = path.join(root, 'artifacts');
|
|
const directory = path.join(root, 'assets');
|
|
const contents = {
|
|
'easytier-linux-x86_64': ['easytier-core'],
|
|
'Easytier-Magisk': ['module.prop', 'bin/easytier-core'],
|
|
'easytier-gui-windows-x86_64': ['easytier-gui_2.7.0_x64-setup.exe'],
|
|
'easytier-mobile-android-aarch64': ['app-arm64-release.apk'],
|
|
...f.files,
|
|
};
|
|
for (const artifact of Object.values(build.artifacts).flat()) {
|
|
for (const name of contents[artifact.name]) {
|
|
const file = path.join(source, artifact.name, name);
|
|
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
await fs.writeFile(file, `contents of ${name}`, { mode: 0o644 });
|
|
}
|
|
}
|
|
await packageAssets({ build, source, directory });
|
|
return { ...f, build, source, directory };
|
|
}
|
|
|
|
test('nightly exposes native installers and matches version-release ZIP names and layout', async t => {
|
|
const f = fixture();
|
|
f.state.artifacts[1].push({ id: 14, name: 'easytier-web-dashboard' });
|
|
f.state.artifacts[2].push(
|
|
{ id: 21, name: 'easytier-gui-macos-aarch64' },
|
|
{ id: 22, name: 'easytier-gui-linux-x86_64' },
|
|
);
|
|
f.files = {
|
|
'easytier-web-dashboard': ['index.html', 'assets/app.js'],
|
|
'easytier-gui-macos-aarch64': ['easytier-gui_2.7.0_aarch64.dmg'],
|
|
'easytier-gui-linux-x86_64': [
|
|
'easytier-gui_2.7.0_amd64.deb',
|
|
'easytier-gui-2.7.0-1.x86_64.rpm',
|
|
'easytier-gui_2.7.0_amd64.AppImage',
|
|
],
|
|
};
|
|
const { build, directory } = await packageFiles(t, f);
|
|
assert.deepEqual(build.assets, [
|
|
'Easytier-Magisk-v2.7.0.zip',
|
|
'SHA256SUMS',
|
|
'app-arm64-release.apk',
|
|
'easytier-gui-2.7.0-1.x86_64.rpm',
|
|
'easytier-gui_2.7.0_aarch64.dmg',
|
|
'easytier-gui_2.7.0_amd64.AppImage',
|
|
'easytier-gui_2.7.0_amd64.deb',
|
|
'easytier-gui_2.7.0_x64-setup.exe',
|
|
'easytier-linux-x86_64-v2.7.0.zip',
|
|
'easytier-web-dashboard-v2.7.0.zip',
|
|
]);
|
|
for (const [name, entry] of [
|
|
['easytier-linux-x86_64', 'easytier-linux-x86_64/easytier-core'],
|
|
['easytier-web-dashboard', 'easytier-web-dashboard/assets/app.js'],
|
|
['Easytier-Magisk', 'bin/easytier-core'],
|
|
]) {
|
|
const archive = path.join(directory, `${name}-v2.7.0.zip`);
|
|
const files = execFileSync('unzip', ['-Z1', archive], { encoding: 'utf8' }).trim().split('\n');
|
|
assert.ok(files.includes(entry));
|
|
assert.ok(!files.some(file => file.startsWith('Easytier-Magisk/')));
|
|
assert.match(execFileSync('zipinfo', ['-l', archive], { encoding: 'utf8' }), /-rwxr-xr-x/);
|
|
}
|
|
assert.equal(await fs.readFile(path.join(directory, 'app-arm64-release.apk'), 'utf8'),
|
|
'contents of app-arm64-release.apk');
|
|
const sums = await fs.readFile(path.join(directory, 'SHA256SUMS'), 'utf8');
|
|
assert.equal(sums.trim().split('\n').length, build.assets.length - 1);
|
|
for (const line of sums.trim().split('\n')) {
|
|
const [checksum, name] = line.split(' ');
|
|
const data = await fs.readFile(path.join(directory, name));
|
|
assert.equal(checksum, createHash('sha256').update(data).digest('hex'));
|
|
}
|
|
});
|
|
|
|
test('packaging refuses missing, empty or duplicate installer artifacts', async t => {
|
|
const f = fixture();
|
|
const args = await packageFiles(t, f);
|
|
const artifactPath = path.join(args.source, 'easytier-mobile-android-aarch64');
|
|
await fs.rm(args.directory, { recursive: true });
|
|
await fs.rm(artifactPath, { recursive: true });
|
|
await assert.rejects(packageAssets(args), /ENOENT/);
|
|
await fs.rm(args.directory, { recursive: true });
|
|
await fs.mkdir(artifactPath);
|
|
await assert.rejects(packageAssets(args), /Empty artifact/);
|
|
await fs.rm(args.directory, { recursive: true });
|
|
await fs.writeFile(path.join(artifactPath, 'easytier-gui_2.7.0_x64-setup.exe'), 'duplicate');
|
|
await assert.rejects(packageAssets(args), /Duplicate release asset/);
|
|
});
|
|
|
|
test('reruns publish only the newest artifact for each name, ignoring expired older attempts', async t => {
|
|
const f = fixture();
|
|
f.state.artifacts[1].push(
|
|
{ id: 13, name: 'easytier-linux-x86_64' },
|
|
{ id: 9, name: 'easytier-linux-x86_64', expired: true },
|
|
);
|
|
const args = await packageFiles(t, f);
|
|
assert.deepEqual(args.build.artifacts.core.map(a => a.id), [13, 11]);
|
|
await publish(args);
|
|
assert.equal(f.calls.filter(call => call[0] === 'upload' && call[1] === 'easytier-linux-x86_64-v2.7.0.zip').length, 1);
|
|
});
|
|
|
|
test('first publication creates one draft, uploads assets and then publishes a prerelease', async t => {
|
|
const f = fixture();
|
|
await publish(await packageFiles(t, f));
|
|
const creation = f.calls.find(call => call[0] === 'create')[1];
|
|
assert.equal(creation.draft, true);
|
|
assert.equal(creation.prerelease, true);
|
|
assert.equal(creation.make_latest, 'false');
|
|
const update = f.calls.at(-1);
|
|
assert.equal(update[0], 'update');
|
|
assert.equal(update[1].release_id, 100);
|
|
assert.equal(update[1].draft, false);
|
|
assert.equal(update[1].make_latest, 'false');
|
|
assert.ok(f.calls.some(call => call[0] === 'createTag'));
|
|
});
|
|
|
|
test('updates keep the release ID and remove obsolete assets only after uploads finish', async t => {
|
|
const f = fixture();
|
|
f.state.release = { id: 99, draft: false, target_commitish: 'first-build' };
|
|
f.state.tagExists = true;
|
|
f.state.assets = [
|
|
{ id: 50, name: 'easytier-linux-x86_64-v2.7.0.zip' },
|
|
{ id: 51, name: 'retired-platform.zip' },
|
|
];
|
|
await publish(await packageFiles(t, f));
|
|
assert.ok(!f.calls.some(call => call[0] === 'create' || call[0] === 'createTag'));
|
|
assert.equal(f.calls.at(-1)[1].release_id, 99);
|
|
assert.equal(f.state.release.target_commitish, 'built');
|
|
const lastUpload = f.calls.findLastIndex(call => call[0] === 'upload');
|
|
assert.ok(f.calls.findIndex(call => call[0] === 'delete' && call[1] === 51) > lastUpload);
|
|
});
|
|
|
|
test('incomplete packaging cannot mutate the published release', async t => {
|
|
const f = fixture();
|
|
const args = await packageFiles(t, f);
|
|
await fs.unlink(path.join(args.directory, 'Easytier-Magisk-v2.7.0.zip'));
|
|
await assert.rejects(publish(args), /do not match/);
|
|
assert.ok(!f.calls.some(call => ['create', 'delete', 'upload', 'update'].includes(call[0])));
|
|
});
|
|
|
|
test('a failed upload does not advance the tag, notes or draft publication', async t => {
|
|
const f = fixture();
|
|
const args = await packageFiles(t, f);
|
|
f.state.uploadFails = true;
|
|
await assert.rejects(publish(args), /Upload failed/);
|
|
assert.equal(f.state.release.draft, true);
|
|
assert.ok(!f.calls.some(call => ['createTag', 'updateTag', 'update'].includes(call[0])));
|
|
});
|
|
|
|
test('a retry resumes the initial draft after an upload failure without creating another release', async t => {
|
|
const f = fixture();
|
|
const args = await packageFiles(t, f);
|
|
f.state.uploadFails = true;
|
|
await assert.rejects(publish(args), /Upload failed/);
|
|
f.state.uploadFails = false;
|
|
const retry = await prepare(f);
|
|
assert.equal(retry.release_id, 100);
|
|
await fs.rm(args.directory, { recursive: true });
|
|
await packageAssets({ ...args, build: retry });
|
|
await publish({ ...args, build: retry });
|
|
assert.equal(f.calls.filter(call => call[0] === 'create').length, 1);
|
|
assert.equal(f.state.release.draft, false);
|
|
});
|
|
|
|
test('publication stops if the release was replaced during artifact downloads', async t => {
|
|
const f = fixture();
|
|
const args = await packageFiles(t, f);
|
|
f.state.release = { id: 999 };
|
|
await assert.rejects(publish(args), /changed during preparation/);
|
|
});
|