ci: use lightweight PR checks and conditional nightly builds (#2677)

* ci: use lightweight PR checks and conditional nightly builds

Check three Core platforms, Linux GUI, and Android ARM64 on PRs and
ordinary branch pushes. Keep formatting, clippy, WASI, and all Linux
tests; reserve the per-feature sweep for full builds.

Run complete platform matrices on releases/** pushes and manual
dispatch. At 03:17 Asia/Shanghai, check existing main and develop
branches and dispatch only workflows whose last successful full build
used a different commit, retrying failed components on the next night.

Cache compilation outputs by target and build mode. Build only the GUI
frontend dependencies and reuse the dashboard artifact for Core builds.
Separate full-build concurrency so ordinary pushes cannot cancel them.

Keep the required result checks and propagate failures and
cancellations. Validate successful full-build origins and matching Core,
GUI, and Mobile commits before preparing release assets.

Validation: actionlint 1.7.12, 10 Node tests, read-only GitHub nightly
dry run, GUI frontend build, Linux GUI check, Android ARM64 check, and
Linux musl Core/Web checks including embedded dashboard assets.

* docs: describe CI behavior in English and Chinese

Document PR checks, nightly dispatch, manual builds, release validation,
artifacts, and independent workflows. Keep both versions aligned with
current workflow definitions.

* ci: decouple PR checks from Web dashboard builds

Run PR dashboard builds only when frontend sources, Core configuration
sources, protobuf schemas, or build configuration change. Keep embedded
Web builds in full mode and let ordinary Rust checks start without
frontend artifacts.

Upload dashboard artifacts only for full builds. Skip frontend setup for
ordinary lint runs and remove GUI/frontend setup from test archives.
Update both CI documents to match the new behavior.

Validation: actionlint; frontend dependency and workflow routing cases;
Linux Core cargo check and workspace clippy with frontend outputs
absent.
This commit is contained in:
KKRainbow authored and GitHub committed 2026-10-12 00:13:12 +08:00
1 parent 7592951b80
commit f8a45ccc20
11 files changed
+519 -56

No files matched your search

+133
View File
@@ -0,0 +1,133 @@
const assert = require('node:assert/strict');
const { test } = require('node:test');
const dispatch = require('./nightly-build.js');
const validate = require('./validate-release-builds.js');
const context = { repo: { owner: 'EasyTier', repo: 'EasyTier' } };
const core = { info() {} };
const workflows = ['core.yml', 'gui.yml', 'mobile.yml', 'test.yml'];
async function nightly(branches, successfulRuns = {}) {
const dispatched = [];
const github = {
paginate: async () => branches,
rest: {
repos: { listBranches() {} },
actions: {
async listWorkflowRuns(request) {
assert.equal(request.event, 'workflow_dispatch');
assert.equal(request.status, 'success');
const sha = successfulRuns[`${request.branch}/${request.workflow_id}`];
return { data: { workflow_runs: sha ? [{ head_sha: sha }] : [] } };
},
async createWorkflowDispatch(request) {
dispatched.push(request);
},
},
},
};
await dispatch({ github, context, core });
return dispatched;
}
test('first nightly builds every workflow on an existing main branch', async () => {
const runs = await nightly([{ name: 'main', commit: { sha: 'new' } }]);
assert.deepEqual(runs.map(run => run.workflow_id), workflows);
assert.ok(runs.every(run => run.ref === 'main'));
assert.deepEqual(runs[0].inputs, { sign_windows: false });
assert.ok(runs.slice(1).every(run => !run.inputs));
});
test('unchanged branches skip builds and unmonitored branches are ignored', async () => {
const successfulRuns = Object.fromEntries(workflows.map(workflow => [`main/${workflow}`, 'same']));
const runs = await nightly([
{ name: 'main', commit: { sha: 'same' } },
{ name: 'feature', commit: { sha: 'new' } },
], successfulRuns);
assert.deepEqual(runs, []);
});
test('nightly retries a failed component using its last successful commit', async () => {
const successfulRuns = Object.fromEntries(workflows.map(workflow => [`main/${workflow}`, 'new']));
successfulRuns['main/gui.yml'] = 'old';
const runs = await nightly([{ name: 'main', commit: { sha: 'new' } }], successfulRuns);
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']));
const runs = await nightly([
{ name: 'main', commit: { sha: 'same' } },
{ name: 'develop', commit: { sha: 'new' } },
], successfulRuns);
assert.deepEqual(runs.map(run => run.workflow_id), workflows);
assert.ok(runs.every(run => run.ref === 'develop'));
});
process.env.CORE_RUN_ID = '1';
process.env.GUI_RUN_ID = '2';
process.env.MOBILE_RUN_ID = '3';
function releaseRuns() {
return ['core.yml', 'gui.yml', 'mobile.yml'].map(workflow => ({
path: `.github/workflows/${workflow}`,
conclusion: 'success',
event: 'workflow_dispatch',
head_branch: 'main',
head_sha: 'same',
}));
}
async function release(runs) {
const github = {
rest: {
actions: {
async getWorkflowRun({ run_id }) {
return { data: runs[run_id - 1] };
},
},
},
};
return validate({ github, context, core });
}
test('release accepts successful full builds from one commit', async () => {
await release(releaseRuns());
});
test('release accepts full builds triggered by a release branch push', async () => {
const runs = releaseRuns();
runs[0].event = 'push';
runs[0].head_branch = 'releases/2.7';
runs[1].event = 'push';
runs[1].head_branch = 'releases/2.7';
await release(runs);
});
test('release rejects PR builds and regular branch pushes', async () => {
for (const event of ['pull_request', 'push']) {
const runs = releaseRuns();
runs[0].event = event;
await assert.rejects(release(runs), /successful full build/);
}
});
test('release rejects failed and cancelled builds', async () => {
for (const conclusion of ['failure', 'cancelled']) {
const runs = releaseRuns();
runs[1].conclusion = conclusion;
await assert.rejects(release(runs), /successful full build/);
}
});
test('release rejects run IDs from the wrong workflow', async () => {
const runs = releaseRuns();
runs[1].path = runs[0].path;
await assert.rejects(release(runs), /successful full build/);
});
test('release rejects builds from different commits', async () => {
const runs = releaseRuns();
runs[2].head_sha = 'different';
await assert.rejects(release(runs), /same commit/);
});
+32
View File
@@ -0,0 +1,32 @@
module.exports = async ({ github, context, core }) => {
const repository = context.repo;
const branches = await github.paginate(github.rest.repos.listBranches, {
...repository,
per_page: 100,
});
for (const branch of branches.filter(({ name }) => ['main', 'develop'].includes(name))) {
for (const workflow of ['core.yml', 'gui.yml', 'mobile.yml', 'test.yml']) {
const { data } = await github.rest.actions.listWorkflowRuns({
...repository,
workflow_id: workflow,
branch: branch.name,
event: 'workflow_dispatch',
status: 'success',
per_page: 1,
});
if (data.workflow_runs[0]?.head_sha === branch.commit.sha) {
core.info(`${workflow}: ${branch.name} is already built successfully.`);
continue;
}
await github.rest.actions.createWorkflowDispatch({
...repository,
workflow_id: workflow,
ref: branch.name,
...(workflow === 'core.yml' ? { inputs: { sign_windows: false } } : {}),
});
core.info(`${workflow}: dispatched a full build for ${branch.name}.`);
}
}
};
@@ -0,0 +1,25 @@
module.exports = async ({ github, context, core }) => {
const builds = [
['core.yml', process.env.CORE_RUN_ID],
['gui.yml', process.env.GUI_RUN_ID],
['mobile.yml', process.env.MOBILE_RUN_ID],
];
let commit;
for (const [workflow, runId] of builds) {
const { data: run } = await github.rest.actions.getWorkflowRun({
...context.repo,
run_id: Number(runId),
});
const fullBuild = run.event === 'workflow_dispatch' ||
(run.event === 'push' && run.head_branch.startsWith('releases/'));
if (run.path !== `.github/workflows/${workflow}` || run.conclusion !== 'success' || !fullBuild) {
throw new Error(`${workflow}: run ${runId} must be a successful full build.`);
}
if (commit && run.head_sha !== commit) {
throw new Error('Core, GUI and Mobile must be built from the same commit.');
}
commit = run.head_sha;
core.info(`${workflow}: validated full build ${runId} at ${commit}.`);
}
};