diff --git a/.github/CI.md b/.github/CI.md index 1f9448a4..3c76c44b 100644 --- a/.github/CI.md +++ b/.github/CI.md @@ -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 diff --git a/.github/CI.zh-CN.md b/.github/CI.zh-CN.md index f44fdf2f..dcf65df8 100644 --- a/.github/CI.zh-CN.md +++ b/.github/CI.zh-CN.md @@ -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 分支 diff --git a/.github/scripts/nightly-release.js b/.github/scripts/nightly-release.js index a88cdb93..52c71eb2 100644 --- a/.github/scripts/nightly-release.js +++ b/.github/scripts/nightly-release.js @@ -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 }; diff --git a/.github/scripts/nightly-release.test.js b/.github/scripts/nightly-release.test.js index 7f4e4f1d..5fba5a31 100644 --- a/.github/scripts/nightly-release.test.js +++ b/.github/scripts/nightly-release.test.js @@ -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); diff --git a/.github/workflows/nightly-release.yml b/.github/workflows/nightly-release.yml index 468f44ac..2d4994eb 100644 --- a/.github/workflows/nightly-release.yml +++ b/.github/workflows/nightly-release.yml @@ -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'