ci: align nightly assets with version releases (#2699)

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.
This commit is contained in:
KKRainbow
2026-10-14 14:11:53 +08:00
committed by GitHub
parent d25ccd4c17
commit 0ca2fde0fb
5 changed files with 189 additions and 22 deletions
+7
View File
@@ -53,6 +53,13 @@ events still handle full main builds dispatched manually by a maintainer.
Nightly Release can also be started manually to publish existing main HEAD
builds without rebuilding them.
Nightly uses the same asset names and archive layout as version releases.
Core, Web, and Magisk ZIP names include the built commit's version (for example,
`easytier-linux-x86_64-v2.7.0.zip`). GUI installers and Android APKs retain their
original filenames without an extra ZIP wrapper. `SHA256SUMS` covers every
download. Asset URLs can change when the project version changes; use the
fixed `nightly` release page to find the current files.
## Manual builds and releases
Repository Write access is required. In Actions, run Core, GUI, and Mobile
+6
View File
@@ -46,6 +46,12 @@ Nightly 失败并阻止发布,构建和等待任务的超时为三小时。四
发布和重试直接由 Nightly 执行。维护者手动触发的 main 全量构建仍由完成事件
处理。也可手动运行 Nightly Release,使用 main HEAD 的现有成功构建直接发布。
Nightly 的产物名称和压缩包目录结构与版本发布保持一致。Core、Web 和 Magisk
ZIP 文件名包含构建提交中的版本号,例如 `easytier-linux-x86_64-v2.7.0.zip`。
GUI 安装包和 Android APK 保留原始文件名,不再套一层 ZIP。`SHA256SUMS`
包含所有下载文件的校验值。项目版本变化时产物下载地址也会变化,可通过固定的
`nightly` 发布页面查找当前文件。
## 手动构建与发布
需要仓库 Write 权限。在 Actions 分别运行 Core、GUI、Mobile,选择 PR 分支
+59 -5
View File
@@ -1,5 +1,10 @@
const fs = require('node:fs/promises');
const path = require('node:path');
const { execFile } = require('node:child_process');
const { createHash } = require('node:crypto');
const { promisify } = require('node:util');
const exec = promisify(execFile);
const workflows = ['core', 'gui', 'mobile', 'test'];
@@ -15,7 +20,7 @@ function releaseNotes(repository, baseline, sha, commits, runs) {
return [
'# EasyTier Nightly',
'',
'Development builds from `main`. This prerelease and its download URLs are updated in place.',
'Development builds from `main`. This prerelease is updated in place. Asset names follow version releases.',
'',
`Commit: [${sha}](${url}/commit/${sha})`,
`Builds: ${runs.map(run => `[${run.name}](${run.html_url})`).join(' · ')}`,
@@ -112,9 +117,17 @@ async function prepare({ github, context, core, sha }) {
const commits = await github.paginate(github.rest.repos.compareCommitsWithBasehead, {
...repository, basehead: `${baseline.tag_name}...${sha}`, per_page: 100,
}, response => response.data.commits);
const { data: config } = await github.rest.repos.getContent({
...repository, path: 'easytier-gui/src-tauri/tauri.conf.json', ref: sha,
});
const { version } = JSON.parse(Buffer.from(config.content, 'base64').toString('utf8'));
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)) {
throw new Error('The build must declare a valid release version.');
}
return {
sha,
version: `v${version}`,
release_id: release?.id,
runs: Object.fromEntries(workflows.map((workflow, index) => [workflow, runs[index].id])),
artifacts,
@@ -122,11 +135,52 @@ async function prepare({ github, context, core, sha }) {
};
}
async function packageAssets({ build, source, directory }) {
directory = path.resolve(directory);
await fs.mkdir(directory);
const assets = [];
for (const [workflow, artifacts] of Object.entries(build.artifacts)) {
for (const artifact of artifacts) {
const artifactPath = path.resolve(source, artifact.name);
const files = [];
for (const file of await fs.readdir(artifactPath, { recursive: true })) {
if ((await fs.stat(path.join(artifactPath, file))).isFile()) files.push(file);
}
if (!files.length) throw new Error(`Empty artifact: ${artifact.name}`);
if (workflow === 'core') {
const name = `${artifact.name}-${build.version}.zip`;
await exec('chmod', ['-R', '755', artifactPath]);
// Match release.yml: only Magisk has its files at the ZIP root.
const magisk = artifact.name === 'Easytier-Magisk';
await exec('zip', ['-qr', path.join(directory, name), magisk ? '.' : artifact.name], {
cwd: magisk ? artifactPath : path.resolve(source),
});
assets.push(name);
} else {
for (const file of files) {
const name = path.basename(file);
if (assets.includes(name)) throw new Error(`Duplicate release asset: ${name}`);
await fs.copyFile(path.join(artifactPath, file), path.join(directory, name));
assets.push(name);
}
}
}
}
const checksums = [];
for (const name of assets.sort()) {
const data = await fs.readFile(path.join(directory, name));
if (!data.length) throw new Error(`Empty asset: ${name}`);
checksums.push(`${createHash('sha256').update(data).digest('hex')} ${name}\n`);
}
await fs.writeFile(path.join(directory, 'SHA256SUMS'), checksums.join(''));
build.assets = [...assets, 'SHA256SUMS'].sort();
}
async function publish({ github, context, core, build, directory }) {
const repository = context.repo;
const names = Object.values(build.artifacts).flat().map(artifact => `${artifact.name}.zip`).sort();
const names = build.assets;
const files = (await fs.readdir(directory)).sort();
if (!names.length || JSON.stringify(files) !== JSON.stringify(names)) {
if (!names?.length || JSON.stringify(files) !== JSON.stringify(names)) {
throw new Error('Packaged files do not match the selected nightly artifacts.');
}
for (const name of files) {
@@ -160,7 +214,7 @@ async function publish({ github, context, core, build, directory }) {
await github.rest.repos.uploadReleaseAsset({
...repository, release_id: release.id, name,
data: await fs.readFile(path.join(directory, name)),
headers: { 'content-type': 'application/zip' },
headers: { 'content-type': name.endsWith('.zip') ? 'application/zip' : 'application/octet-stream' },
});
}
// Remove retired platforms only after all replacement assets are uploaded.
@@ -184,4 +238,4 @@ async function publish({ github, context, core, build, directory }) {
core.info(`Updated nightly at ${build.sha}: ${release.html_url}`);
}
module.exports = { prepare, publish, releaseNotes };
module.exports = { prepare, packageAssets, publish, releaseNotes };
+108 -8
View File
@@ -2,8 +2,10 @@ 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, publish, releaseNotes } = require('./nightly-release.js');
const { prepare, packageAssets, publish, releaseNotes } = require('./nightly-release.js');
const repository = { owner: 'EasyTier', repo: 'EasyTier' };
const core = { info() {} };
@@ -16,6 +18,7 @@ function fixture() {
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' },
@@ -35,6 +38,11 @@ function fixture() {
};
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 } };
@@ -104,6 +112,8 @@ test('nightly selects one main commit and uses the last published version, inclu
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'));
@@ -111,6 +121,14 @@ test('nightly selects one main commit and uses the last published version, inclu
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();
@@ -209,14 +227,94 @@ test('notes date every entry in UTC and sort by timestamp, not input order', ()
async function packageFiles(t, f) {
const build = await prepare(f);
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'easytier-nightly-test-'));
t.after(() => fs.rm(directory, { recursive: true, force: true }));
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()) {
await fs.writeFile(path.join(directory, `${artifact.name}.zip`), 'test archive');
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 });
}
}
return { ...f, build, directory };
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(
@@ -226,7 +324,7 @@ test('reruns publish only the newest artifact for each name, ignoring expired ol
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.zip').length, 1);
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 => {
@@ -249,7 +347,7 @@ test('updates keep the release ID and remove obsolete assets only after uploads
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.zip' },
{ id: 50, name: 'easytier-linux-x86_64-v2.7.0.zip' },
{ id: 51, name: 'retired-platform.zip' },
];
await publish(await packageFiles(t, f));
@@ -263,7 +361,7 @@ test('updates keep the release ID and remove obsolete assets only after uploads
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.zip'));
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])));
});
@@ -285,6 +383,8 @@ test('a retry resumes the initial draft after an upload failure without creating
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);
+9 -9
View File
@@ -83,16 +83,16 @@ jobs:
artifact-ids: ${{ steps.prepare.outputs.mobile_artifact_ids }}
path: ${{ steps.prepare.outputs.mobile_path }}
- name: Package assets with stable names
- name: Package assets like version releases
if: steps.prepare.outputs.ready == 'true'
shell: bash
run: |
mkdir nightly-assets
chmod -R 755 nightly-artifacts
for artifact in nightly-artifacts/*; do
name=$(basename "$artifact")
(cd "$artifact" && zip -qr "$GITHUB_WORKSPACE/nightly-assets/$name.zip" .)
done
uses: actions/github-script@v8
with:
script: |
const fs = require('node:fs');
const { packageAssets } = require('./.github/scripts/nightly-release.js');
const build = require(`${process.env.GITHUB_WORKSPACE}/nightly-release.json`);
await packageAssets({ build, source: 'nightly-artifacts', directory: 'nightly-assets' });
fs.writeFileSync('nightly-release.json', JSON.stringify(build));
- name: Update the fixed nightly prerelease
if: steps.prepare.outputs.ready == 'true'