diff --git a/.github/CI.md b/.github/CI.md new file mode 100644 index 00000000..123b1aaa --- /dev/null +++ b/.github/CI.md @@ -0,0 +1,56 @@ +# CI + +[中文](CI.zh-CN.md) + +| Trigger | Core | GUI | Mobile | +| --- | --- | --- | --- | +| PR targeting `main`/`develop`, or push to either branch | Core/Web `cargo check`: Linux x64, Windows x64, macOS ARM64 | Linux frontend build and Rust check | Android ARM64 frontend build and Rust check | +| Push to `releases/**`, or manual dispatch | 16 targets and Magisk artifacts | Installers for 7 targets | APKs for 4 architectures | + +Test runs CI script tests, fmt, clippy, WASI, Cargo.lock validation, and +Linux tests for `easytier`/`easytier-core`, including `three_node`. Full mode +also checks each `easytier` feature except `macos-ne`. + +These four workflows skip build/test jobs for draft PRs. Path and duplicate +content checks can skip ordinary builds and Rust jobs. The result jobs are +`core-result`, `gui-result`, `mobile-result`, and `test`. + +PR dashboard builds run only for frontend, Core configuration, schema, or +build configuration changes. Ordinary branch pushes build the dashboard. +Core checks run independently; Web embedding is compiled only in full mode. +Test skips the frontend build in ordinary mode; its test archive builds +only Rust packages. + +PR runs can upload the test archive for reuse within CI. The dashboard is +uploaded only in full mode. +Core binaries, GUI installers, APKs, and Magisk artifacts require full mode. +Compilation outputs are cached. Ordinary pushes do not cancel full builds. + +## Nightly + +Scheduled at 03:17 Asia/Shanghai. Check Core, GUI, Mobile, and Test separately +on each existing `main`/`develop` branch. Compare HEAD with that workflow's +latest successful `workflow_dispatch` run: skip matching commits; dispatch +otherwise. Commits without a successful run are retried on the next check. + +Nightly only dispatches jobs; results appear in the corresponding workflows. +Core's `sign_windows` is set to `false` for nightly builds. + +## Manual builds and releases + +Repository Write access is required. In Actions, run Core, GUI, and Mobile +separately on the PR branch to obtain full artifacts. The branch must contain +these workflow definitions. Fork PRs need a temporary branch in this repository. + +Manual Core builds default to SignPath test signing (`sign_windows: true`). +Full macOS GUI builds are signed and notarized. + +Release is a separate manual workflow. Its Core/GUI/Mobile run IDs must refer +to successful full builds from their respective workflows at the same commit. +It downloads their artifacts and creates a draft release. + +## Other workflows + +- OHOS runs independently; relevant PRs can still upload HAR artifacts. +- Go, JavaScript Hosts, and Nix use their own path filters. +- Docker is manual; it downloads a specified Core run's artifacts and pushes images. diff --git a/.github/CI.zh-CN.md b/.github/CI.zh-CN.md new file mode 100644 index 00000000..263560f6 --- /dev/null +++ b/.github/CI.zh-CN.md @@ -0,0 +1,54 @@ +# CI + +[English](CI.md) + +| 触发方式 | Core | GUI | Mobile | +| --- | --- | --- | --- | +| 目标为 `main`/`develop` 的 PR,或 push 到这两个分支 | Linux x64、Windows x64、macOS ARM64 的 Core/Web `cargo check` | Linux 前端构建和 Rust 检查 | Android ARM64 前端构建和 Rust 检查 | +| Push 到 `releases/**`,或手动触发 | 16 个目标和 Magisk 产物 | 7 个目标的安装包 | 4 个架构的 APK | + +Test 运行 CI 脚本测试、fmt、clippy、WASI、Cargo.lock 检查,以及 +`easytier`/`easytier-core` 的 Linux 测试(含 `three_node`)。全量模式还逐个 +检查 `easytier` 的 feature,排除 `macos-ne`。 + +这四个 workflow 的构建和测试任务跳过 draft PR。普通构建和 Rust 任务可因 +无关路径或重复内容而跳过。汇总检查为 `core-result`、`gui-result`、 +`mobile-result` 和 `test`。 + +PR 仅在前端、Core 配置源码、schema 或构建配置变化时构建 Web 前端。 +普通分支 push 仍构建 Web 前端。Core 检查独立运行;Web 的嵌入模式仅在 +全量构建中编译。 +Test 在普通模式下跳过前端构建,测试归档仅构建 Rust 包。 + +PR 可上传供 CI 内部复用的测试归档。Web 前端仅在全量模式上传。 +Core 二进制、GUI 安装包、APK 和 Magisk 产物仅在全量模式上传。编译产物 +启用缓存。普通 push 不会取消全量构建。 + +## 每日构建 + +定时为北京时间 03:17。对实际存在的 `main`/`develop` 分支,分别检查 Core、 +GUI、Mobile、Test:比较分支 HEAD 与该 workflow 最近一次成功的 +`workflow_dispatch` 提交,相同则跳过,不同或无成功记录则触发该 workflow。 +尚未成功构建的提交在下一轮继续触发。 + +Nightly 只负责触发,结果见对应 workflow。每日 Core 构建设置 +`sign_windows: false`。 + +## 手动构建与发布 + +需要仓库 Write 权限。在 Actions 分别运行 Core、GUI、Mobile,选择 PR 分支 +即可生成全量产物。该分支必须包含这些 workflow 定义。Fork PR 需要先在本 +仓库建立临时分支。 + +手动 Core 构建默认开启 SignPath 测试签名(`sign_windows: true`)。 +全量 macOS GUI 构建会签名和公证。 + +Release 是独立的手动 workflow。输入的 Core/GUI/Mobile run ID 必须分别来自 +正确的 workflow、全量构建成功,且提交相同。它下载这些产物并创建 draft +release。 + +## 其他 workflow + +- OHOS 独立运行,相关 PR 仍可上传 HAR。 +- Go、JavaScript Hosts、Nix 使用各自的路径过滤。 +- Docker 手动触发,下载指定 Core 构建的产物并推送镜像。 diff --git a/.github/scripts/builds.test.js b/.github/scripts/builds.test.js new file mode 100644 index 00000000..57a13182 --- /dev/null +++ b/.github/scripts/builds.test.js @@ -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/); +}); diff --git a/.github/scripts/nightly-build.js b/.github/scripts/nightly-build.js new file mode 100644 index 00000000..ad728ba8 --- /dev/null +++ b/.github/scripts/nightly-build.js @@ -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}.`); + } + } +}; diff --git a/.github/scripts/validate-release-builds.js b/.github/scripts/validate-release-builds.js new file mode 100644 index 00000000..81d02b76 --- /dev/null +++ b/.github/scripts/validate-release-builds.js @@ -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}.`); + } +}; diff --git a/.github/workflows/core.yml b/.github/workflows/core.yml index 93634d72..3f9585da 100644 --- a/.github/workflows/core.yml +++ b/.github/workflows/core.yml @@ -2,6 +2,11 @@ name: EasyTier Core on: workflow_dispatch: + inputs: + sign_windows: + description: Submit Windows artifacts for SignPath test signing + type: boolean + default: true push: branches: [ "develop", "main", "releases/**" ] pull_request: @@ -9,7 +14,8 @@ on: types: [ opened, synchronize, reopened, ready_for_review ] concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + # A new PR or branch push must not cancel a full build. + group: ${{ github.workflow }}-${{ (github.event_name == 'workflow_dispatch' || startsWith(github.ref_name, 'releases/')) && 'full' || 'check' }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true env: @@ -31,7 +37,17 @@ jobs: "easytier-core/**", "easytier-proto/**", "easytier-rpc-build/**", "easytier-web/**", "easytier-contrib/easytier-magisk/**", ".github/workflows/core.yml", ".github/actions/**"] + FRONTEND_PATHS: >- + ["easytier-web/frontend/**", "easytier-web/frontend-lib/**", + "easytier-web/config-generator/**", "easytier-proto/proto/**", + "easytier-core/src/config/**", "easytier-core/src/lib.rs", + "pnpm-lock.yaml", "pnpm-workspace.yaml", "package.json", + "Cargo.toml", "Cargo.lock", "rust-toolchain.toml", ".cargo/**", + "easytier-core/Cargo.toml", "easytier-proto/Cargo.toml", + ".github/workflows/core.yml", ".github/actions/prepare-pnpm/**"] outputs: + full_build: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref_name, 'releases/') }} + frontend_changed: ${{ steps.path_filter.outputs.frontend }} # Release builds must run even if they only change release metadata. should_skip: >- ${{ @@ -42,15 +58,16 @@ jobs: steps.path_filter.outputs.relevant != 'true' ) ) && + github.event_name != 'workflow_dispatch' && !startsWith(github.ref_name, 'releases/') }} steps: - id: skip_check + if: github.event_name != 'workflow_dispatch' && !startsWith(github.ref_name, 'releases/') uses: fkirc/skip-duplicate-actions@v5 with: concurrent_skipping: 'same_content_newer' skip_after_successful_duplicate: 'true' - cancel_others: 'true' paths: ${{ env.RELEVANT_PATHS }} - id: path_filter @@ -61,10 +78,54 @@ jobs: with: filters: | relevant: ${{ env.RELEVANT_PATHS }} + frontend: ${{ env.FRONTEND_PATHS }} + + check: + name: Check (${{ matrix.TARGET }}) + needs: pre_job + if: needs.pre_job.outputs.should_skip != 'true' && needs.pre_job.outputs.full_build != 'true' + strategy: + fail-fast: true + matrix: + include: + - TARGET: x86_64-unknown-linux-musl + OS: ubuntu-24.04 + FEATURES: jemalloc + - TARGET: aarch64-apple-darwin + OS: macos-latest + FEATURES: jemalloc + - TARGET: x86_64-pc-windows-msvc + OS: windows-latest + FEATURES: mimalloc + runs-on: ${{ matrix.OS }} + steps: + - uses: actions/checkout@v5 + + - uses: ./.github/actions/prepare-build + with: + target: ${{ matrix.TARGET }} + gui: false + pnpm: false + token: ${{ github.token }} + + - uses: Swatinem/rust-cache@v2 + with: + shared-key: core-check-${{ matrix.TARGET }} + save-if: ${{ github.event_name != 'pull_request' }} + + - name: Check Core and Web + run: cargo check --locked --all-targets --target ${{ matrix.TARGET }} --features ${{ matrix.FEATURES }} + build_web: runs-on: ubuntu-latest needs: pre_job - if: needs.pre_job.outputs.should_skip != 'true' + if: >- + needs.pre_job.outputs.should_skip != 'true' && + ( + needs.pre_job.outputs.full_build == 'true' || + github.event_name != 'pull_request' || + needs.pre_job.outputs.frontend_changed == 'true' + ) steps: - uses: actions/checkout@v5 @@ -75,6 +136,7 @@ jobs: build-filter: './easytier-web/*' - name: Archive artifact + if: needs.pre_job.outputs.full_build == 'true' uses: actions/upload-artifact@v5 with: name: easytier-web-dashboard @@ -150,7 +212,7 @@ jobs: needs: - pre_job - build_web - if: needs.pre_job.outputs.should_skip != 'true' + if: needs.pre_job.outputs.should_skip != 'true' && needs.pre_job.outputs.full_build == 'true' steps: - uses: actions/checkout@v5 @@ -168,8 +230,8 @@ jobs: uses: ./.github/actions/prepare-build with: target: ${{ matrix.TARGET }} - gui: true - pnpm: true + gui: false + pnpm: false token: ${{ secrets.GITHUB_TOKEN }} - uses: Swatinem/rust-cache@v2 @@ -177,8 +239,8 @@ jobs: # The prefix cache key, this can be changed to start a new cache manually. # default: "v0-rust" prefix-key: "" - shared-key: "core-registry" - cache-targets: "false" + shared-key: core-release-${{ matrix.TARGET }} + cache-targets: "true" - uses: mlugg/setup-zig@v2 if: ${{ contains(matrix.OS, 'ubuntu') }} @@ -286,7 +348,7 @@ jobs: ./artifacts/* - name: Prepare SignPath test artifact - if: ${{ github.event_name == 'workflow_dispatch' && contains(matrix.TARGET, 'windows') }} + if: ${{ github.event_name == 'workflow_dispatch' && inputs.sign_windows && contains(matrix.TARGET, 'windows') }} run: | mkdir -p ./signpath-artifacts cp \ @@ -297,7 +359,7 @@ jobs: ./signpath-artifacts/ - name: Upload unsigned SignPath test artifact - if: ${{ github.event_name == 'workflow_dispatch' && contains(matrix.TARGET, 'windows') }} + if: ${{ github.event_name == 'workflow_dispatch' && inputs.sign_windows && contains(matrix.TARGET, 'windows') }} id: signpath-upload uses: actions/upload-artifact@v5 with: @@ -306,7 +368,7 @@ jobs: if-no-files-found: error - name: Submit SignPath test-signing request - if: ${{ github.event_name == 'workflow_dispatch' && contains(matrix.TARGET, 'windows') }} + if: ${{ github.event_name == 'workflow_dispatch' && inputs.sign_windows && contains(matrix.TARGET, 'windows') }} uses: signpath/github-action-submit-signing-request@v2 with: api-token: ${{ secrets.SIGNPATH_API_TOKEN }} @@ -319,7 +381,7 @@ jobs: output-artifact-directory: ./signpath-signed-artifacts - name: Upload signed SignPath test artifact - if: ${{ github.event_name == 'workflow_dispatch' && contains(matrix.TARGET, 'windows') }} + if: ${{ github.event_name == 'workflow_dispatch' && inputs.sign_windows && contains(matrix.TARGET, 'windows') }} uses: actions/upload-artifact@v5 with: name: signpath-signed-${{ matrix.ARTIFACT_NAME }} @@ -329,7 +391,7 @@ jobs: build_magisk: runs-on: ubuntu-latest needs: [ pre_job, build_web, build ] - if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled() + if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && needs.pre_job.outputs.full_build == 'true' && !cancelled() steps: - name: Checkout Code uses: actions/checkout@v5 # 必须先检出代码才能获取模块配置 @@ -371,9 +433,9 @@ jobs: core-result: runs-on: ubuntu-latest - needs: [ pre_job, build_web, build, build_magisk ] - if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled() + needs: [ pre_job, check, build_web, build, build_magisk ] + if: ${{ always() }} steps: - name: Mark result as failed - if: contains(needs.*.result, 'failure') + if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') run: exit 1 diff --git a/.github/workflows/gui.yml b/.github/workflows/gui.yml index e07ccc0a..6a4f7dd3 100644 --- a/.github/workflows/gui.yml +++ b/.github/workflows/gui.yml @@ -1,6 +1,7 @@ name: EasyTier GUI on: + workflow_dispatch: push: branches: ["develop", "main", "releases/**"] pull_request: @@ -8,7 +9,8 @@ on: types: [opened, synchronize, reopened, ready_for_review] concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + # A new PR or branch push must not cancel a full build. + group: ${{ github.workflow }}-${{ (github.event_name == 'workflow_dispatch' || startsWith(github.ref_name, 'releases/')) && 'full' || 'check' }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true env: @@ -32,6 +34,7 @@ jobs: "easytier-web/frontend-lib/**", ".github/workflows/gui.yml", ".github/actions/**"] outputs: + full_build: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref_name, 'releases/') }} # Release builds must run even if they only change release metadata. should_skip: >- ${{ @@ -42,15 +45,16 @@ jobs: steps.path_filter.outputs.relevant != 'true' ) ) && + github.event_name != 'workflow_dispatch' && !startsWith(github.ref_name, 'releases/') }} steps: - id: skip_check + if: github.event_name != 'workflow_dispatch' && !startsWith(github.ref_name, 'releases/') uses: fkirc/skip-duplicate-actions@v5 with: concurrent_skipping: 'same_content_newer' skip_after_successful_duplicate: 'true' - cancel_others: 'true' paths: ${{ env.RELEVANT_PATHS }} - id: path_filter @@ -62,6 +66,29 @@ jobs: filters: | relevant: ${{ env.RELEVANT_PATHS }} + check: + name: Check GUI (Linux) + needs: pre_job + if: needs.pre_job.outputs.should_skip != 'true' && needs.pre_job.outputs.full_build != 'true' + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v5 + + - uses: ./.github/actions/prepare-build + with: + gui: true + pnpm: true + pnpm-build-filter: easytier-gui... + token: ${{ github.token }} + + - uses: Swatinem/rust-cache@v2 + with: + shared-key: gui-check-x86_64-unknown-linux-gnu + save-if: ${{ github.event_name != 'pull_request' }} + + - name: Check GUI + run: cargo check --locked --all-targets --package easytier-gui --features custom-protocol + build-gui: strategy: fail-fast: true @@ -106,7 +133,7 @@ jobs: GUI_TARGET: ${{ matrix.GUI_TARGET }} OSS_BUCKET: ${{ secrets.ALIYUN_OSS_BUCKET }} needs: pre_job - if: needs.pre_job.outputs.should_skip != 'true' + if: needs.pre_job.outputs.should_skip != 'true' && needs.pre_job.outputs.full_build == 'true' steps: - uses: actions/checkout@v5 @@ -120,7 +147,7 @@ jobs: target: ${{ matrix.TARGET }} gui: true pnpm: true - pnpm-build-filter: '' + pnpm-build-filter: easytier-gui... token: ${{ secrets.GITHUB_TOKEN }} - uses: Swatinem/rust-cache@v2 @@ -128,8 +155,8 @@ jobs: # The prefix cache key, this can be changed to start a new cache manually. # default: "v0-rust" prefix-key: "" - shared-key: "gui-registry" - cache-targets: "false" + shared-key: gui-release-${{ matrix.GUI_TARGET }} + cache-targets: "true" - name: copy correct DLLs if: ${{ contains(matrix.GUI_TARGET, 'windows') }} @@ -262,9 +289,9 @@ jobs: gui-result: runs-on: ubuntu-latest - needs: [ pre_job, build-gui ] - if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled() + needs: [ pre_job, check, build-gui ] + if: ${{ always() }} steps: - name: Mark result as failed - if: contains(needs.*.result, 'failure') + if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') run: exit 1 diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml index aa785b6f..062eeb92 100644 --- a/.github/workflows/mobile.yml +++ b/.github/workflows/mobile.yml @@ -1,6 +1,7 @@ name: EasyTier Mobile on: + workflow_dispatch: push: branches: ["develop", "main", "releases/**"] pull_request: @@ -8,7 +9,8 @@ on: types: [opened, synchronize, reopened, ready_for_review] concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + # A new PR or branch push must not cancel a full build. + group: ${{ github.workflow }}-${{ (github.event_name == 'workflow_dispatch' || startsWith(github.ref_name, 'releases/')) && 'full' || 'check' }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true env: @@ -32,6 +34,7 @@ jobs: "easytier-web/frontend-lib/**", ".github/workflows/mobile.yml", ".github/actions/**"] outputs: + full_build: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref_name, 'releases/') }} # Release builds must run even if they only change release metadata. should_skip: >- ${{ @@ -42,15 +45,16 @@ jobs: steps.path_filter.outputs.relevant != 'true' ) ) && + github.event_name != 'workflow_dispatch' && !startsWith(github.ref_name, 'releases/') }} steps: - id: skip_check + if: github.event_name != 'workflow_dispatch' && !startsWith(github.ref_name, 'releases/') uses: fkirc/skip-duplicate-actions@v5 with: concurrent_skipping: 'same_content_newer' skip_after_successful_duplicate: 'true' - cancel_others: 'true' paths: ${{ env.RELEVANT_PATHS }} - id: path_filter @@ -66,19 +70,11 @@ jobs: strategy: fail-fast: true matrix: - include: - - TARGET: aarch64-linux-android - ARCH: aarch64 - - TARGET: armv7-linux-androideabi - ARCH: armv7 - - TARGET: i686-linux-android - ARCH: i686 - - TARGET: x86_64-linux-android - ARCH: x86_64 + ARCH: ${{ fromJSON(needs.pre_job.outputs.full_build == 'true' && '["aarch64", "armv7", "i686", "x86_64"]' || '["aarch64"]') }} runs-on: ubuntu-latest env: NAME: easytier - TARGET: ${{ matrix.TARGET }} + TARGET: ${{ matrix.ARCH == 'armv7' && 'armv7-linux-androideabi' || format('{0}-linux-android', matrix.ARCH) }} ARCH: ${{ matrix.ARCH }} OSS_BUCKET: ${{ secrets.ALIYUN_OSS_BUCKET }} needs: pre_job @@ -110,10 +106,10 @@ jobs: - name: Prepare build environment uses: ./.github/actions/prepare-build with: - target: ${{ matrix.TARGET }} + target: ${{ env.TARGET }} gui: false pnpm: true - pnpm-build-filter: '' + pnpm-build-filter: easytier-gui... token: ${{ secrets.GITHUB_TOKEN }} - uses: Swatinem/rust-cache@v2 @@ -121,15 +117,22 @@ jobs: # The prefix cache key, this can be changed to start a new cache manually. # default: "v0-rust" prefix-key: "" - shared-key: "gui-registry" - cache-targets: "false" + shared-key: mobile-${{ env.TARGET }}-${{ needs.pre_job.outputs.full_build }} + cache-targets: "true" + save-if: ${{ github.event_name != 'pull_request' }} + + - name: Check Android ARM64 + if: needs.pre_job.outputs.full_build != 'true' + run: cargo check --locked --lib --target "$TARGET" --package easytier-gui --features custom-protocol - name: Build + if: needs.pre_job.outputs.full_build == 'true' run: | cd easytier-gui pnpm tauri android build --apk --target "$ARCH" --split-per-abi - name: Collect artifact + if: needs.pre_job.outputs.full_build == 'true' run: | mkdir -p ./artifacts/objects/ mv easytier-gui/src-tauri/gen/android/app/build/outputs/apk/*/release/*.apk ./artifacts/objects/ @@ -144,6 +147,7 @@ jobs: rm -rf ./artifacts/objects/ - name: Archive artifact + if: needs.pre_job.outputs.full_build == 'true' uses: actions/upload-artifact@v5 with: name: easytier-mobile-android-${{ matrix.ARCH }} @@ -153,8 +157,8 @@ jobs: mobile-result: runs-on: ubuntu-latest needs: [ pre_job, build-mobile ] - if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled() + if: ${{ always() }} steps: - name: Mark result as failed - if: contains(needs.*.result, 'failure') + if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') run: exit 1 diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 00000000..884fbf26 --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,28 @@ +name: EasyTier Nightly + +on: + schedule: + # 03:17 Asia/Shanghai; avoid the busy start of the hour. + - cron: '17 19 * * *' + workflow_dispatch: + +concurrency: + group: easytier-nightly + cancel-in-progress: false + +permissions: + actions: write + contents: read + +jobs: + dispatch: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Build branches with new commits + uses: actions/github-script@v8 + with: + script: | + const dispatch = require('./.github/scripts/nightly-build.js'); + await dispatch({ github, context, core }); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index da53d1e3..28d4ce02 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,6 +27,7 @@ on: required: true permissions: + actions: read contents: write jobs: @@ -37,6 +38,17 @@ jobs: name: Checkout uses: actions/checkout@v5 + - name: Validate complete builds + uses: actions/github-script@v8 + env: + CORE_RUN_ID: ${{ inputs.core_run_id }} + GUI_RUN_ID: ${{ inputs.gui_run_id }} + MOBILE_RUN_ID: ${{ inputs.mobile_run_id }} + with: + script: | + const validate = require('./.github/scripts/validate-release-builds.js'); + await validate({ github, context, core }); + - name: Download Core Artifact uses: dawidd6/action-download-artifact@v11 with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 19388d28..ecee55ae 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,13 +1,16 @@ name: EasyTier Test on: + workflow_dispatch: push: - branches: [ "develop", "main" ] + branches: [ "develop", "main", "releases/**" ] pull_request: branches: [ "develop", "main" ] + types: [ opened, synchronize, reopened, ready_for_review ] concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + # A new PR or branch push must not cancel a full build. + group: ${{ github.workflow }}-${{ (github.event_name == 'workflow_dispatch' || startsWith(github.ref_name, 'releases/')) && 'full' || 'check' }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true env: @@ -21,8 +24,22 @@ defaults: shell: bash jobs: + ci-scripts: + name: Test CI scripts + runs-on: ubuntu-latest + if: github.event_name != 'pull_request' || !github.event.pull_request.draft + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-node@v5 + with: + node-version: 22 + + - run: node --test .github/scripts/*.test.js + pre_job: runs-on: ubuntu-latest + if: github.event_name != 'pull_request' || !github.event.pull_request.draft env: RELEVANT_PATHS: >- ["Cargo.toml", "Cargo.lock", "rust-toolchain.toml", ".cargo/**", @@ -32,16 +49,22 @@ jobs: "tauri-plugin-vpnservice/**", "easytier-contrib/**", ".github/workflows/test.yml", ".github/actions/**"] outputs: + full_build: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref_name, 'releases/') }} should_skip: >- ${{ - steps.skip_check.outputs.should_skip == 'true' || ( - github.event_name == 'pull_request' && - steps.path_filter.outputs.relevant != 'true' - ) + steps.skip_check.outputs.should_skip == 'true' || + ( + github.event_name == 'pull_request' && + steps.path_filter.outputs.relevant != 'true' + ) + ) && + github.event_name != 'workflow_dispatch' && + !startsWith(github.ref_name, 'releases/') }} steps: - id: skip_check + if: github.event_name != 'workflow_dispatch' && !startsWith(github.ref_name, 'releases/') uses: fkirc/skip-duplicate-actions@v5 with: concurrent_skipping: 'never' @@ -69,16 +92,23 @@ jobs: uses: ./.github/actions/prepare-build with: gui: true - pnpm: true + pnpm: ${{ needs.pre_job.outputs.full_build == 'true' }} token: ${{ secrets.GITHUB_TOKEN }} - uses: actions-rust-lang/setup-rust-toolchain@v1 with: components: rustfmt,clippy target: wasm32-wasip1 + cache: false rustflags: '' + - uses: Swatinem/rust-cache@v2 + with: + shared-key: test-check + save-if: ${{ github.event_name != 'pull_request' }} + - uses: taiki-e/install-action@cargo-hack + if: needs.pre_job.outputs.full_build == 'true' - name: Check formatting if: ${{ !cancelled() }} @@ -89,7 +119,7 @@ jobs: run: cargo clippy --all-targets --features full --all -- -D warnings - name: Check features - if: ${{ !cancelled() }} + if: ${{ !cancelled() && needs.pre_job.outputs.full_build == 'true' }} run: cargo hack check --package easytier --each-feature --exclude-features macos-ne --verbose - name: Check WASI @@ -117,8 +147,8 @@ jobs: - name: Prepare build environment uses: ./.github/actions/prepare-build with: - gui: true - pnpm: true + gui: false + pnpm: false token: ${{ secrets.GITHUB_TOKEN }} - uses: Swatinem/rust-cache@v2 @@ -191,9 +221,9 @@ jobs: test: runs-on: ubuntu-latest - needs: [ pre_job, check, test_matrix ] - if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && !cancelled() + needs: [ pre_job, ci-scripts, check, pre-test, test_matrix ] + if: ${{ always() }} steps: - name: Mark result as failed - if: contains(needs.*.result, 'failure') + if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') run: exit 1