ci: publish rolling nightly releases with build deduplication (#2692)

* ci: publish rolling main nightly builds and retry failed jobs

Keep one nightly prerelease with stable artifact filenames. Publish
only successful Core, GUI, Mobile and Test runs from the same main
commit, and prevent older runs from replacing a newer nightly.

Generate dated changes against the latest published version release,
including manual prereleases, in newest-first order. Link the fixed
nightly download page from both READMEs.

Retry failed full-build jobs once on main and develop, excluding
cancelled runs and attempts after the first.

Validated with 28 Node tests, actionlint, archive packaging checks,
and read-only selection of real GitHub builds and release notes.
This commit is contained in:
KKRainbow authored and GitHub committed 2026-10-14 01:24:00 +08:00
1 parent 03078b4791
commit 36b4ffcc74
9 files changed
+617 -8

No files matched your search

+5 -5
View File
@@ -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';
+1 -1
View File
@@ -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,
+187
View File
@@ -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 };
+289
View File
@@ -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/);
});
+97
View File
@@ -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' });
+28
View File
@@ -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,
});
+8
View File
@@ -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
+1 -1
View File
@@ -18,7 +18,7 @@
<img src="assets/running-page.png" width="300" alt="running page">
</p>
📚 **[Full Documentation](https://easytier.cn/en/)** | 🖥️ **[Web Console](https://easytier.cn/web)** | 📝 **[Download Releases](https://github.com/EasyTier/EasyTier/releases)** | 🧩 **[Third Party Tools](https://easytier.cn/en/guide/installation_gui.html#third-party-graphical-interfaces)** | ❤️ **[Sponsor](#sponsor)**
📚 **[Full Documentation](https://easytier.cn/en/)** | 🖥️ **[Web Console](https://easytier.cn/web)** | 📝 **[Download Releases](https://github.com/EasyTier/EasyTier/releases)** | **[Nightly](https://github.com/EasyTier/EasyTier/releases/tag/nightly)** | 🧩 **[Third Party Tools](https://easytier.cn/en/guide/installation_gui.html#third-party-graphical-interfaces)** | ❤️ **[Sponsor](#sponsor)**
## Features
+1 -1
View File
@@ -18,7 +18,7 @@
<img src="assets/running-page.png" width="300" alt="运行页面">
</p>
📚 **[完整文档](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)** | ❤️ **[赞助](#赞助)**
## 特性