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 @@ running page

-📚 **[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 diff --git a/README_CN.md b/README_CN.md index ebe26335..90cb5a95 100644 --- a/README_CN.md +++ b/README_CN.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)** | ❤️ **[赞助](#赞助)** ## 特性