diff --git a/.github/scripts/builds.test.js b/.github/scripts/builds.test.js
index 57a13182..bcb37cd8 100644
--- a/.github/scripts/builds.test.js
+++ b/.github/scripts/builds.test.js
@@ -54,14 +54,14 @@ test('nightly retries a failed component using its last successful commit', asyn
assert.deepEqual(runs.map(run => run.workflow_id), ['gui.yml']);
});
-test('main and develop are checked independently', async () => {
- const successfulRuns = Object.fromEntries(workflows.map(workflow => [`main/${workflow}`, 'same']));
+test('nightly only builds main even when other branches exist', async () => {
const runs = await nightly([
- { name: 'main', commit: { sha: 'same' } },
+ { name: 'main', commit: { sha: 'new' } },
{ name: 'develop', commit: { sha: 'new' } },
- ], successfulRuns);
+ { name: 'feature', commit: { sha: 'new' } },
+ ]);
assert.deepEqual(runs.map(run => run.workflow_id), workflows);
- assert.ok(runs.every(run => run.ref === 'develop'));
+ assert.ok(runs.every(run => run.ref === 'main'));
});
process.env.CORE_RUN_ID = '1';
diff --git a/.github/scripts/nightly-build.js b/.github/scripts/nightly-build.js
index ad728ba8..f3acaefe 100644
--- a/.github/scripts/nightly-build.js
+++ b/.github/scripts/nightly-build.js
@@ -5,7 +5,7 @@ module.exports = async ({ github, context, core }) => {
per_page: 100,
});
- for (const branch of branches.filter(({ name }) => ['main', 'develop'].includes(name))) {
+ for (const branch of branches.filter(({ name }) => name === 'main')) {
for (const workflow of ['core.yml', 'gui.yml', 'mobile.yml', 'test.yml']) {
const { data } = await github.rest.actions.listWorkflowRuns({
...repository,
diff --git a/.github/scripts/nightly-release.js b/.github/scripts/nightly-release.js
new file mode 100644
index 00000000..59564051
--- /dev/null
+++ b/.github/scripts/nightly-release.js
@@ -0,0 +1,187 @@
+const fs = require('node:fs/promises');
+const path = require('node:path');
+
+const workflows = ['core', 'gui', 'mobile', 'test'];
+
+function releaseNotes(repository, baseline, sha, commits, runs) {
+ const url = `https://github.com/${repository.owner}/${repository.repo}`;
+ const changes = [...commits]
+ .sort((a, b) => Date.parse(b.commit.committer.date) - Date.parse(a.commit.committer.date))
+ .map(commit => {
+ const date = new Date(commit.commit.committer.date).toISOString().slice(0, 10);
+ const subject = commit.commit.message.split('\n')[0];
+ return `- ${date} — ${subject} ([${commit.sha.slice(0, 7)}](${commit.html_url}))`;
+ });
+ return [
+ '# EasyTier Nightly',
+ '',
+ 'Development builds from `main`. This prerelease and its download URLs are updated in place.',
+ '',
+ `Commit: [${sha}](${url}/commit/${sha})`,
+ `Builds: ${runs.map(run => `[${run.name}](${run.html_url})`).join(' · ')}`,
+ '',
+ `## Changes since [${baseline.tag_name}](${baseline.html_url})`,
+ '',
+ 'Dates are commit dates in UTC, newest first.',
+ '',
+ ...changes,
+ ...(changes.length ? [] : ['No changes since this version.']),
+ '',
+ `[Full changelog](${url}/compare/${baseline.tag_name}...${sha})`,
+ '',
+ ].join('\n');
+}
+
+async function prepare({ github, context, core }) {
+ const repository = context.repo;
+ const { data: branch } = await github.rest.repos.getBranch({ ...repository, branch: 'main' });
+ const sha = context.payload.workflow_run?.head_sha || branch.commit.sha;
+ const { data: ancestry } = await github.rest.repos.compareCommitsWithBasehead({
+ ...repository, basehead: `${sha}...${branch.commit.sha}`, per_page: 1,
+ });
+ if (!['ahead', 'identical'].includes(ancestry.status)) {
+ core.info(`${sha} is no longer on main.`);
+ return null;
+ }
+
+ // Listing releases also finds an unfinished draft from the first upload.
+ const releases = await github.paginate(github.rest.repos.listReleases, {
+ ...repository, per_page: 100,
+ });
+ const release = releases.find(item => item.tag_name === 'nightly');
+ if (release?.immutable) throw new Error('The nightly release must allow asset and tag updates.');
+ if (release && !release.draft) {
+ const { data: comparison } = await github.rest.repos.compareCommitsWithBasehead({
+ ...repository, basehead: `nightly...${sha}`, per_page: 1,
+ });
+ if (!['ahead', 'identical'].includes(comparison.status)) {
+ core.info('A newer nightly is already available; skipping this older build.');
+ return null;
+ }
+ if (comparison.status === 'identical' && release.body?.includes(`Commit: [${sha}]`)) {
+ core.info(`${sha} is already published.`);
+ return null;
+ }
+ }
+
+ const runs = [];
+ const artifacts = {};
+ for (const workflow of workflows) {
+ const { data } = await github.rest.actions.listWorkflowRuns({
+ ...repository,
+ workflow_id: `${workflow}.yml`,
+ branch: 'main',
+ head_sha: sha,
+ event: 'workflow_dispatch',
+ status: 'success',
+ per_page: 1,
+ });
+ const run = data.workflow_runs[0];
+ if (!run || run.head_sha !== sha || run.head_branch !== 'main' ||
+ run.event !== 'workflow_dispatch' || run.conclusion !== 'success') {
+ core.info(`Waiting for a successful full ${workflow} build at ${sha}.`);
+ return null;
+ }
+ runs.push(run);
+ if (workflow === 'test') continue;
+ const uploaded = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
+ ...repository, run_id: run.id, per_page: 100,
+ });
+ // Reruns can retain same-name artifacts from earlier attempts. Select the
+ // newest ID, matching download-artifact's latest:true lookup.
+ const byName = new Map();
+ for (const artifact of uploaded) {
+ if (!/^(easytier-[\w.-]+|Easytier-Magisk)$/.test(artifact.name)) continue;
+ if (!byName.has(artifact.name) || artifact.id > byName.get(artifact.name).id) {
+ byName.set(artifact.name, artifact);
+ }
+ }
+ const files = [...byName.values()];
+ if (!files.length || files.some(artifact => artifact.expired)) {
+ throw new Error(`${workflow}: build artifacts are missing or expired.`);
+ }
+ artifacts[workflow] = files.map(artifact => ({ id: artifact.id, name: artifact.name }));
+ }
+
+ // Version releases may be published by a maintainer from a bot-created draft,
+ // and may themselves be prereleases. Neither author nor /releases/latest
+ // identifies the last manually published version reliably.
+ const baseline = releases.filter(item => !item.draft && /^v\d+\.\d+\.\d+(?:[-+].*)?$/.test(item.tag_name))
+ .sort((a, b) => Date.parse(b.published_at) - Date.parse(a.published_at))[0];
+ if (!baseline) throw new Error('No published version release found for the nightly changelog.');
+ const commits = await github.paginate(github.rest.repos.compareCommitsWithBasehead, {
+ ...repository, basehead: `${baseline.tag_name}...${sha}`, per_page: 100,
+ }, response => response.data.commits);
+
+ return {
+ sha,
+ release_id: release?.id,
+ runs: Object.fromEntries(workflows.map((workflow, index) => [workflow, runs[index].id])),
+ artifacts,
+ body: releaseNotes(repository, baseline, sha, commits, runs),
+ };
+}
+
+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 files = (await fs.readdir(directory)).sort();
+ 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) {
+ if (!(await fs.stat(path.join(directory, name))).size) throw new Error(`Empty asset: ${name}`);
+ }
+
+ const releases = await github.paginate(github.rest.repos.listReleases, {
+ ...repository, per_page: 100,
+ });
+ let release = releases.find(item => item.tag_name === 'nightly');
+ if (release?.immutable) throw new Error('The nightly release must allow asset and tag updates.');
+ if ((release?.id || undefined) !== build.release_id) {
+ throw new Error('The nightly release changed during preparation; run the workflow again.');
+ }
+ if (!release) {
+ const { data } = await github.rest.repos.createRelease({
+ ...repository, tag_name: 'nightly', target_commitish: build.sha,
+ name: 'Nightly', draft: true, prerelease: true, make_latest: 'false',
+ });
+ release = data;
+ }
+
+ const previousAssets = await github.paginate(github.rest.repos.listReleaseAssets, {
+ ...repository, release_id: release.id, per_page: 100,
+ });
+ for (const name of files) {
+ const previous = previousAssets.find(asset => asset.name === name);
+ if (previous) {
+ await github.rest.repos.deleteReleaseAsset({ ...repository, asset_id: previous.id });
+ }
+ await github.rest.repos.uploadReleaseAsset({
+ ...repository, release_id: release.id, name,
+ data: await fs.readFile(path.join(directory, name)),
+ headers: { 'content-type': 'application/zip' },
+ });
+ }
+ // Remove retired platforms only after all replacement assets are uploaded.
+ for (const asset of previousAssets.filter(asset => !files.includes(asset.name))) {
+ await github.rest.repos.deleteReleaseAsset({ ...repository, asset_id: asset.id });
+ }
+
+ // A new draft does not necessarily have a tag yet.
+ try {
+ await github.rest.git.getRef({ ...repository, ref: 'tags/nightly' });
+ await github.rest.git.updateRef({ ...repository, ref: 'tags/nightly', sha: build.sha, force: true });
+ } catch (error) {
+ if (error.status !== 404) throw error;
+ await github.rest.git.createRef({ ...repository, ref: 'refs/tags/nightly', sha: build.sha });
+ }
+ await github.rest.repos.updateRelease({
+ ...repository, release_id: release.id, name: 'Nightly', body: build.body,
+ target_commitish: build.sha,
+ draft: false, prerelease: true, make_latest: 'false',
+ });
+ core.info(`Updated nightly at ${build.sha}: ${release.html_url}`);
+}
+
+module.exports = { prepare, publish, releaseNotes };
diff --git a/.github/scripts/nightly-release.test.js b/.github/scripts/nightly-release.test.js
new file mode 100644
index 00000000..80363d95
--- /dev/null
+++ b/.github/scripts/nightly-release.test.js
@@ -0,0 +1,289 @@
+const assert = require('node:assert/strict');
+const fs = require('node:fs/promises');
+const os = require('node:os');
+const path = require('node:path');
+const { test } = require('node:test');
+const { prepare, 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,
+ 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 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.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('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('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 directory = await fs.mkdtemp(path.join(os.tmpdir(), 'easytier-nightly-test-'));
+ t.after(() => fs.rm(directory, { recursive: true, force: true }));
+ for (const artifact of Object.values(build.artifacts).flat()) {
+ await fs.writeFile(path.join(directory, `${artifact.name}.zip`), 'test archive');
+ }
+ return { ...f, build, directory };
+}
+
+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.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.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.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 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/);
+});
diff --git a/.github/workflows/nightly-release.yml b/.github/workflows/nightly-release.yml
new file mode 100644
index 00000000..0ab31470
--- /dev/null
+++ b/.github/workflows/nightly-release.yml
@@ -0,0 +1,97 @@
+name: EasyTier Nightly Release
+
+on:
+ workflow_call:
+ workflow_run:
+ workflows: [EasyTier Core, EasyTier GUI, EasyTier Mobile, EasyTier Test]
+ branches: [main]
+ types: [completed]
+ workflow_dispatch:
+
+concurrency:
+ group: easytier-nightly-release
+ cancel-in-progress: false
+
+permissions:
+ actions: read
+ contents: write
+
+jobs:
+ release:
+ if: >-
+ ((github.event_name == 'workflow_dispatch' || github.event_name == 'schedule') &&
+ github.ref == 'refs/heads/main') ||
+ (github.event.workflow_run.event == 'workflow_dispatch' &&
+ github.event.workflow_run.conclusion == 'success' &&
+ github.event.workflow_run.head_repository.full_name == github.repository)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+ with:
+ ref: main
+
+ - name: Select complete main builds and prepare release notes
+ id: prepare
+ uses: actions/github-script@v8
+ with:
+ script: |
+ const fs = require('node:fs');
+ const { prepare } = require('./.github/scripts/nightly-release.js');
+ const build = await prepare({ github, context, core });
+ if (!build) return;
+ fs.writeFileSync('nightly-release.json', JSON.stringify(build));
+ for (const workflow of ['core', 'gui', 'mobile']) {
+ core.setOutput(`${workflow}_run_id`, build.runs[workflow]);
+ core.setOutput(`${workflow}_artifact_ids`, build.artifacts[workflow].map(a => a.id).join(','));
+ // download-artifact extracts a single artifact directly into path.
+ const suffix = build.artifacts[workflow].length === 1 ? `/${build.artifacts[workflow][0].name}` : '';
+ core.setOutput(`${workflow}_path`, `nightly-artifacts${suffix}`);
+ }
+ core.setOutput('ready', 'true');
+
+ - name: Download Core artifacts
+ if: steps.prepare.outputs.ready == 'true'
+ uses: actions/download-artifact@v5
+ with:
+ github-token: ${{ github.token }}
+ run-id: ${{ steps.prepare.outputs.core_run_id }}
+ artifact-ids: ${{ steps.prepare.outputs.core_artifact_ids }}
+ path: ${{ steps.prepare.outputs.core_path }}
+
+ - name: Download GUI artifacts
+ if: steps.prepare.outputs.ready == 'true'
+ uses: actions/download-artifact@v5
+ with:
+ github-token: ${{ github.token }}
+ run-id: ${{ steps.prepare.outputs.gui_run_id }}
+ artifact-ids: ${{ steps.prepare.outputs.gui_artifact_ids }}
+ path: ${{ steps.prepare.outputs.gui_path }}
+
+ - name: Download Mobile artifacts
+ if: steps.prepare.outputs.ready == 'true'
+ uses: actions/download-artifact@v5
+ with:
+ github-token: ${{ github.token }}
+ run-id: ${{ steps.prepare.outputs.mobile_run_id }}
+ artifact-ids: ${{ steps.prepare.outputs.mobile_artifact_ids }}
+ path: ${{ steps.prepare.outputs.mobile_path }}
+
+ - name: Package assets with stable names
+ 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
+
+ - name: Update the fixed nightly prerelease
+ if: steps.prepare.outputs.ready == 'true'
+ uses: actions/github-script@v8
+ with:
+ script: |
+ const { publish } = require('./.github/scripts/nightly-release.js');
+ const build = require(`${process.env.GITHUB_WORKSPACE}/nightly-release.json`);
+ await publish({ github, context, core, build, directory: 'nightly-assets' });
diff --git a/.github/workflows/nightly-retry.yml b/.github/workflows/nightly-retry.yml
new file mode 100644
index 00000000..cd1a019d
--- /dev/null
+++ b/.github/workflows/nightly-retry.yml
@@ -0,0 +1,28 @@
+name: EasyTier Nightly Retry
+
+on:
+ workflow_run:
+ workflows: [EasyTier Core, EasyTier GUI, EasyTier Mobile, EasyTier Test]
+ branches: [main]
+ types: [completed]
+
+permissions:
+ actions: write
+
+jobs:
+ retry:
+ if: >-
+ github.event.workflow_run.event == 'workflow_dispatch' &&
+ github.event.workflow_run.conclusion == 'failure' &&
+ github.event.workflow_run.run_attempt == 1 &&
+ github.event.workflow_run.head_repository.full_name == github.repository
+ runs-on: ubuntu-latest
+ steps:
+ - name: Retry failed jobs once
+ uses: actions/github-script@v8
+ with:
+ script: |
+ await github.rest.actions.reRunWorkflowFailedJobs({
+ ...context.repo,
+ run_id: context.payload.workflow_run.id,
+ });
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index 884fbf26..6fef62ff 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -16,6 +16,7 @@ permissions:
jobs:
dispatch:
+ if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
@@ -26,3 +27,10 @@ jobs:
script: |
const dispatch = require('./.github/scripts/nightly-build.js');
await dispatch({ github, context, core });
+
+ release:
+ needs: dispatch
+ permissions:
+ actions: read
+ contents: write
+ uses: ./.github/workflows/nightly-release.yml
diff --git a/README.md b/README.md
index 4285804d..55841054 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@
-📚 **[完整文档](https://easytier.cn)** | 🖥️ **[Web 控制台](https://easytier.cn/web)** | 📝 **[下载发布版本](https://github.com/EasyTier/EasyTier/releases)** | 🧩 **[第三方工具](https://easytier.cn/guide/installation_gui.html#%E7%AC%AC%E4%B8%89%E6%96%B9%E5%9B%BE%E5%BD%A2%E7%95%8C%E9%9D%A2)** | ❤️ **[赞助](#赞助)**
+📚 **[完整文档](https://easytier.cn)** | 🖥️ **[Web 控制台](https://easytier.cn/web)** | 📝 **[下载发布版本](https://github.com/EasyTier/EasyTier/releases)** | **[Nightly](https://github.com/EasyTier/EasyTier/releases/tag/nightly)** | 🧩 **[第三方工具](https://easytier.cn/guide/installation_gui.html#%E7%AC%AC%E4%B8%89%E6%96%B9%E5%9B%BE%E5%BD%A2%E7%95%8C%E9%9D%A2)** | ❤️ **[赞助](#赞助)**
## 特性