From ef7caa40e7ffeaf0c62dc99eed8dc53a8dfd95b1 Mon Sep 17 00:00:00 2001 From: elky Date: Tue, 1 Sep 2026 16:43:29 +0800 Subject: [PATCH] ci: publish nightly builds from main --- .github/workflows/nightly.yml | 603 ++++++++++++++++++++++++++++++++++ .github/workflows/rust-ci.yml | 8 +- README.md | 12 + install.sh | 25 +- 4 files changed, 640 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/nightly.yml diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 000000000..9cedd955e --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,603 @@ +name: Nightly Release + +on: + # 02:17 Asia/Shanghai (18:17 UTC) every day. + schedule: + - cron: '17 18 * * *' + workflow_dispatch: + +# Checks and builds only need read access. Publishing jobs opt into write access +# below so a failed build cannot modify the existing nightly release. +permissions: + actions: read + contents: read + +# A rolling tag and image are shared by scheduled and manually retried runs. +# Keep GitHub Release immutability disabled for this repository: the tag and +# assets intentionally move after each successful daily build. +concurrency: + group: nightly-main + cancel-in-progress: false + +env: + CARGO_INCREMENTAL: '0' + CARGO_PROFILE_DEV_DEBUG: '0' + CARGO_PROFILE_TEST_DEBUG: '0' + CARGO_TERM_COLOR: always + RUST_BACKTRACE: '1' + GHCR_IMAGE: ghcr.io/fawney19/aether + +jobs: + source: + name: Resolve main snapshot + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + sha: ${{ steps.snapshot.outputs.sha }} + short_sha: ${{ steps.snapshot.outputs.short_sha }} + date: ${{ steps.snapshot.outputs.date }} + steps: + - name: Require main branch + id: snapshot + shell: bash + run: | + set -euo pipefail + + if [[ "${GITHUB_REF}" != "refs/heads/main" ]]; then + echo "Nightly releases must run from refs/heads/main (got ${GITHUB_REF})." >&2 + exit 1 + fi + + sha="${GITHUB_SHA}" + echo "sha=${sha}" >> "${GITHUB_OUTPUT}" + echo "short_sha=${sha:0:7}" >> "${GITHUB_OUTPUT}" + echo "date=$(date -u +'%Y-%m-%d')" >> "${GITHUB_OUTPUT}" + echo "Building main at ${sha}." + + # Keep the scheduled backend coverage in one place so it cannot drift from PR CI. + rust_ci: + name: Rust CI + needs: source + uses: ./.github/workflows/rust-ci.yml + + rust_extended: + name: Rust extended checks + needs: source + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ needs.source.outputs.sha }} + + - name: Install pinned Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: 1.95.0 + + - name: Show Rust toolchain + run: rustc -Vv + + - name: Rust cache + uses: Swatinem/rust-cache@v2 + with: + shared-key: nightly-rust-1.95-${{ runner.os }} + workspaces: . -> target + + - name: Setup sccache + uses: mozilla-actions/sccache-action@v0.0.9 + + - name: Check all workspace targets + env: + RUSTC_WRAPPER: sccache + SCCACHE_GHA_ENABLED: 'true' + run: cargo check --workspace --all-targets --all-features --locked + + - name: Run workspace doctests + env: + RUSTC_WRAPPER: sccache + SCCACHE_GHA_ENABLED: 'true' + run: cargo test --workspace --all-features --doc --locked + + - name: Show sccache stats + if: always() + env: + RUSTC_WRAPPER: sccache + SCCACHE_GHA_ENABLED: 'true' + run: sccache --show-stats + + frontend: + name: Frontend checks and build + needs: source + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ needs.source.outputs.sha }} + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: '22' + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + working-directory: frontend + run: npm ci + + - name: Lint + working-directory: frontend + run: npx --no-install eslint . + + - name: Type-check + working-directory: frontend + run: npm run type-check + + - name: Run unit tests + working-directory: frontend + run: npm run test:run + + - name: Build nightly frontend + working-directory: frontend + env: + AETHER_BUILD_VERSION: nightly-${{ needs.source.outputs.short_sha }} + AETHER_VERSION: nightly + run: npm run build + + - name: Upload frontend artifact + uses: actions/upload-artifact@v5 + with: + name: nightly-frontend-dist + path: frontend/dist/ + if-no-files-found: error + overwrite: true + retention-days: 7 + + repository_health: + name: Repository health checks + needs: source + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ needs.source.outputs.sha }} + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: '22' + + - name: Check generated format coverage matrix + run: python3 docs/api/generate_format_field_coverage.py --check + + - name: Test pressure report checker + run: node --test tools/pressure/check_gateway_stage_report.test.js + + checks: + name: Nightly check gate + runs-on: ubuntu-latest + if: ${{ always() }} + needs: + - source + - rust_ci + - rust_extended + - frontend + - repository_health + steps: + - name: Verify check jobs + shell: bash + run: | + set -euo pipefail + + failed=0 + echo "source=${{ needs.source.result }}" + echo "rust_ci=${{ needs.rust_ci.result }}" + echo "rust_extended=${{ needs.rust_extended.result }}" + echo "frontend=${{ needs.frontend.result }}" + echo "repository_health=${{ needs.repository_health.result }}" + + for result in \ + "${{ needs.source.result }}" \ + "${{ needs.rust_ci.result }}" \ + "${{ needs.rust_extended.result }}" \ + "${{ needs.frontend.result }}" \ + "${{ needs.repository_health.result }}"; do + if [[ "${result}" != "success" ]]; then + failed=1 + fi + done + + if [[ "${failed}" -ne 0 ]]; then + echo 'One or more nightly checks failed or were cancelled.' >&2 + exit 1 + fi + + build: + name: Build ${{ matrix.name }} + needs: [source, checks] + if: ${{ needs.checks.result == 'success' }} + runs-on: ${{ matrix.os }} + timeout-minutes: 120 + strategy: + fail-fast: false + matrix: + include: + - name: linux-amd64 + target: x86_64-unknown-linux-musl + platform: linux + arch: amd64 + os: ubuntu-latest + use_cross: true + - name: linux-arm64 + target: aarch64-unknown-linux-musl + platform: linux + arch: arm64 + os: ubuntu-latest + use_cross: true + - name: macos-amd64 + target: x86_64-apple-darwin + platform: macos + arch: amd64 + os: macos-15-intel + use_cross: false + - name: macos-arm64 + target: aarch64-apple-darwin + platform: macos + arch: arm64 + os: macos-15 + use_cross: false + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ needs.source.outputs.sha }} + + - name: Install pinned Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: 1.95.0 + targets: ${{ matrix.target }} + + - name: Rust cache + uses: Swatinem/rust-cache@v2 + with: + shared-key: nightly-release-${{ matrix.target }} + workspaces: . -> target + + - name: Install cross + if: matrix.use_cross + uses: taiki-e/install-action@cross + + - name: Build release binary + env: + AETHER_BUILD_VERSION: nightly-${{ needs.source.outputs.short_sha }} + AETHER_VERSION: nightly + AETHER_BUILD_TYPE: release + CARGO_TERM_COLOR: always + shell: bash + run: | + if [[ "${{ matrix.use_cross }}" == "true" ]]; then + cross build --release --locked -p aether-gateway --target "${{ matrix.target }}" + else + cargo build --release --locked -p aether-gateway --target "${{ matrix.target }}" + fi + + - name: Upload binary artifact + uses: actions/upload-artifact@v5 + with: + name: nightly-gateway-${{ matrix.platform }}-${{ matrix.arch }} + path: target/${{ matrix.target }}/release/aether-gateway + if-no-files-found: error + overwrite: true + retention-days: 7 + + docker: + name: Publish nightly GHCR image + needs: [source, checks, build] + if: ${{ needs.checks.result == 'success' && needs.build.result == 'success' }} + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + packages: write + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ needs.source.outputs.sha }} + + - name: Download Linux binaries and frontend + uses: actions/download-artifact@v5 + with: + pattern: nightly-* + path: artifacts + merge-multiple: false + + - name: Prepare Docker build context + shell: bash + run: | + set -euo pipefail + mkdir -p dist/frontend + cp artifacts/nightly-gateway-linux-amd64/aether-gateway dist/aether-gateway-amd64 + cp artifacts/nightly-gateway-linux-arm64/aether-gateway dist/aether-gateway-arm64 + chmod 0755 dist/aether-gateway-amd64 dist/aether-gateway-arm64 + cp -R artifacts/nightly-frontend-dist/. dist/frontend/ + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push nightly image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile.app + push: true + platforms: linux/amd64,linux/arm64 + tags: | + ${{ env.GHCR_IMAGE }}:nightly + ${{ env.GHCR_IMAGE }}:nightly-${{ needs.source.outputs.sha }} + labels: | + org.opencontainers.image.title=Aether + org.opencontainers.image.version=nightly + org.opencontainers.image.revision=${{ needs.source.outputs.sha }} + org.opencontainers.image.source=https://github.com/${{ github.repository }} + + package: + name: Package nightly archives + needs: [source, checks, build] + if: ${{ needs.checks.result == 'success' && needs.build.result == 'success' }} + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ needs.source.outputs.sha }} + + - name: Download nightly artifacts + uses: actions/download-artifact@v5 + with: + pattern: nightly-* + path: artifacts + merge-multiple: false + + - name: Build nightly release packages + shell: bash + env: + SOURCE_REF: ${{ needs.source.outputs.sha }} + run: | + set -euo pipefail + VERSION="nightly" + + mkdir -p package release-assets + for platform in linux macos; do + for arch in amd64 arm64; do + bundle="aether-${VERSION}-${platform}-${arch}" + root="package/${bundle}" + mkdir -p "${root}/bin" "${root}/frontend" + + install -m 0755 \ + "artifacts/nightly-gateway-${platform}-${arch}/aether-gateway" \ + "${root}/bin/aether-gateway" + cp -R artifacts/nightly-frontend-dist/. "${root}/frontend/" + sed \ + -e "s/^SOURCE_REF=\"\${AETHER_SOURCE_REF:-main}\"/SOURCE_REF=\"\${AETHER_SOURCE_REF:-${SOURCE_REF}}\"/" \ + -e "s/^VERSION=\"\${AETHER_VERSION:-}\"/VERSION=\"\${AETHER_VERSION:-${VERSION}}\"/" \ + install.sh > "${root}/install.sh" + chmod 0755 "${root}/install.sh" + install -m 0755 update.sh "${root}/update.sh" + install -m 0644 docker-compose.yml "${root}/docker-compose.yml" + install -m 0644 docker-compose.single-node.yml "${root}/docker-compose.single-node.yml" + install -m 0644 .env.example "${root}/.env.example" + install -m 0755 generate_keys.sh "${root}/generate_keys.sh" + install -m 0644 README.md "${root}/README.md" + install -m 0644 LICENSE "${root}/LICENSE" + + tar -C package -czf "release-assets/${bundle}.tar.gz" "${bundle}" + done + done + + sed \ + -e "s/^SOURCE_REF=\"\${AETHER_SOURCE_REF:-main}\"/SOURCE_REF=\"\${AETHER_SOURCE_REF:-${SOURCE_REF}}\"/" \ + -e "s/^VERSION=\"\${AETHER_VERSION:-}\"/VERSION=\"\${AETHER_VERSION:-${VERSION}}\"/" \ + install.sh > release-assets/install.sh + chmod 0755 release-assets/install.sh + (cd release-assets && sha256sum *.tar.gz > SHA256SUMS) + + test "$(find release-assets -maxdepth 1 -name '*.tar.gz' | wc -l)" -eq 4 + test "$(wc -l < release-assets/SHA256SUMS)" -eq 4 + (cd release-assets && sha256sum -c SHA256SUMS) + for archive in release-assets/*.tar.gz; do + tar -tzf "${archive}" >/dev/null + done + + - name: Upload nightly package artifact + uses: actions/upload-artifact@v5 + with: + name: nightly-release-assets + path: release-assets/* + if-no-files-found: error + overwrite: true + retention-days: 7 + + github_release: + name: Publish nightly GitHub Release + needs: [source, checks, docker, package] + if: ${{ needs.checks.result == 'success' && needs.docker.result == 'success' && needs.package.result == 'success' }} + runs-on: ubuntu-latest + permissions: + actions: read + contents: write + steps: + - name: Download nightly package artifact + uses: actions/download-artifact@v5 + with: + name: nightly-release-assets + path: release-assets + + - name: Update rolling nightly release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + RELEASE_TAG: nightly + SOURCE_SHA: ${{ needs.source.outputs.sha }} + SOURCE_SHORT_SHA: ${{ needs.source.outputs.short_sha }} + RELEASE_DATE: ${{ needs.source.outputs.date }} + run: | + set -euo pipefail + + release_title="Aether Nightly ${RELEASE_DATE} (${SOURCE_SHORT_SHA})" + notes_file="${RUNNER_TEMP}/nightly-release-notes.md" + cat > "${notes_file}" </dev/null 2>&1; then + gh release create "${RELEASE_TAG}" \ + --repo "${REPOSITORY}" \ + --draft \ + --prerelease \ + --latest=false \ + --target "${SOURCE_SHA}" \ + --title "${release_title}" \ + --notes-file "${notes_file}" + fi + + # Upload archives first, then the checksum/installer metadata. This + # keeps a failed upload from leaving a checksum that describes files + # which have not reached the Release yet. + gh release upload "${RELEASE_TAG}" release-assets/*.tar.gz \ + --repo "${REPOSITORY}" \ + --clobber + gh release upload "${RELEASE_TAG}" \ + release-assets/SHA256SUMS \ + release-assets/install.sh \ + --repo "${REPOSITORY}" \ + --clobber + + # target_commitish does not move an existing git tag. Move the ref + # only after the complete asset set is available. + if gh api "repos/${REPOSITORY}/git/ref/tags/${RELEASE_TAG}" >/dev/null 2>&1; then + gh api -X PATCH "repos/${REPOSITORY}/git/refs/tags/${RELEASE_TAG}" \ + -f "sha=${SOURCE_SHA}" \ + -F 'force=true' >/dev/null + else + gh api -X POST "repos/${REPOSITORY}/git/refs" \ + -f "ref=refs/tags/${RELEASE_TAG}" \ + -f "sha=${SOURCE_SHA}" >/dev/null + fi + + gh release edit "${RELEASE_TAG}" \ + --repo "${REPOSITORY}" \ + --draft=false \ + --prerelease \ + --latest=false \ + --target "${SOURCE_SHA}" \ + --title "${release_title}" \ + --notes-file "${notes_file}" + + expected_assets=( + aether-nightly-linux-amd64.tar.gz + aether-nightly-linux-arm64.tar.gz + aether-nightly-macos-amd64.tar.gz + aether-nightly-macos-arm64.tar.gz + SHA256SUMS + install.sh + ) + asset_names="$(gh release view "${RELEASE_TAG}" --repo "${REPOSITORY}" --json assets --jq '.assets[].name')" + for expected_asset in "${expected_assets[@]}"; do + if ! grep -Fxq "${expected_asset}" <<<"${asset_names}"; then + echo "Published release is missing asset ${expected_asset}." >&2 + exit 1 + fi + done + + resolved_sha="" + for attempt in {1..10}; do + resolved_sha="$(gh api "repos/${REPOSITORY}/commits/${RELEASE_TAG}" --jq '.sha' 2>/dev/null || true)" + if [[ "${resolved_sha}" == "${SOURCE_SHA}" ]]; then + break + fi + sleep 2 + done + if [[ "${resolved_sha}" != "${SOURCE_SHA}" ]]; then + echo "nightly tag resolved to ${resolved_sha}, expected ${SOURCE_SHA}." >&2 + exit 1 + fi + + release_state="$(gh release view "${RELEASE_TAG}" --repo "${REPOSITORY}" --json isDraft,isPrerelease --jq '[.isDraft, .isPrerelease] | @tsv')" + if [[ "${release_state}" != $'false\ttrue' ]]; then + echo "nightly release has unexpected state: ${release_state}" >&2 + exit 1 + fi + + echo "Published ${RELEASE_TAG} for ${SOURCE_SHA}." + + summary: + name: Nightly summary + runs-on: ubuntu-latest + if: ${{ always() }} + needs: + - source + - rust_ci + - rust_extended + - frontend + - repository_health + - checks + - build + - docker + - package + - github_release + steps: + - name: Verify nightly pipeline + shell: bash + run: | + set -euo pipefail + + failed=0 + for entry in \ + "source=${{ needs.source.result }}" \ + "rust_ci=${{ needs.rust_ci.result }}" \ + "rust_extended=${{ needs.rust_extended.result }}" \ + "frontend=${{ needs.frontend.result }}" \ + "repository_health=${{ needs.repository_health.result }}" \ + "checks=${{ needs.checks.result }}" \ + "build=${{ needs.build.result }}" \ + "docker=${{ needs.docker.result }}" \ + "package=${{ needs.package.result }}" \ + "github_release=${{ needs.github_release.result }}"; do + echo "${entry}" + if [[ "${entry#*=}" != "success" ]]; then + failed=1 + fi + done + + if [[ "${failed}" -ne 0 ]]; then + echo 'Nightly pipeline did not publish a new release.' >&2 + exit 1 + fi diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index c8943c245..6f115c467 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -1,6 +1,7 @@ name: Rust CI on: + workflow_call: push: branches: - master @@ -11,6 +12,7 @@ on: - "crates/**" - "apps/**" - ".github/workflows/rust-ci.yml" + - ".github/workflows/nightly.yml" pull_request: paths: - "Cargo.toml" @@ -18,11 +20,15 @@ on: - "crates/**" - "apps/**" - ".github/workflows/rust-ci.yml" + - ".github/workflows/nightly.yml" concurrency: - group: rust-ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + group: rust-ci-${{ github.event_name }}-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true +permissions: + contents: read + env: CARGO_INCREMENTAL: 0 CARGO_PROFILE_DEV_DEBUG: 0 diff --git a/README.md b/README.md index 207819a70..73e9100ab 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,18 @@ cd Aether curl -fsSL https://raw.githubusercontent.com/fawney19/Aether/main/install.sh | sudo bash ``` +### Nightly(每日 main 构建) + +Nightly workflow 每天从 `main` 的固定 commit 构建并发布滚动的 GitHub Release `nightly`,同时推送多架构 GHCR 镜像 `ghcr.io/fawney19/aether:nightly`。Nightly 是预发布版本,适合验证最新代码,不保证与正式版相同的稳定性。滚动 Release 需要仓库保持关闭 GitHub Release immutability。 + +安装最新 nightly(Linux systemd / macOS launchd + SQLite): + +```bash +curl -fsSL https://raw.githubusercontent.com/fawney19/Aether/main/install.sh | sudo bash -s -- --channel nightly +``` + +Docker Compose 用户可在部署目录的 `.env` 中设置 `APP_IMAGE=ghcr.io/fawney19/aether:nightly`,然后运行 `./update.sh` 获取下一次 nightly。二进制方式可重新执行上述安装命令升级;当前管理后台的在线更新列表只跟踪正式版/RC/Beta,不会自动提示下一次 nightly。 + ## 本地开发 依赖 Docker、Rust toolchain、Node.js 和 make。 diff --git a/install.sh b/install.sh index 070aa343a..a735cde5d 100755 --- a/install.sh +++ b/install.sh @@ -74,11 +74,12 @@ Options: compose-single-node: Docker Compose single-node app single-node: single-node system service Linux services use systemd; macOS services use launchd - --channel CHANNEL Release channel to resolve when --version is omitted: stable, latest, rc, or beta + --channel CHANNEL Release channel to resolve when --version is omitted: stable, latest, rc, beta, or nightly stable/latest resolves the latest stable tag (default) rc resolves the latest tag like v0.7.0-rc.1 beta resolves the latest tag like v0.7.0-beta.1 - --version VERSION Exact release tag to install, for example v0.7.0-rc.1 + nightly resolves the rolling nightly build from main + --version VERSION Exact release tag to install, for example v0.7.0-rc.1 or nightly --repo OWNER/REPO GitHub repository to download from (default: fawney19/Aether) --source-ref REF Source branch/tag used for compose templates (default: main) --archive PATH Install from a local release tarball instead of downloading @@ -389,7 +390,8 @@ select_version() { 1) 最新正式版 2) 最新 RC 预发布版 3) 最新 Beta 预发布版 - 4) 指定 tag,例如 v0.7.0-rc.1 + 4) 最新 nightly 构建版 + 5) 指定 tag,例如 v0.7.0-rc.1 请输入选项 [1]: EOF @@ -400,7 +402,8 @@ Choose Aether version: 1) Latest stable release 2) Latest RC prerelease 3) Latest beta prerelease - 4) Exact tag, for example v0.7.0-rc.1 + 4) Latest nightly build + 5) Exact tag, for example v0.7.0-rc.1 Enter choice [1]: EOF @@ -418,6 +421,9 @@ EOF CHANNEL="beta" ;; 4) + CHANNEL="nightly" + ;; + 5) if ui_is_zh; then cat >/dev/tty <<'EOF' 请输入准确 tag: @@ -905,8 +911,13 @@ resolve_version() { grep -E '^v[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+$' | head -n1 || true)" ;; + nightly) + # The nightly release is a single rolling tag, so no API listing is + # needed (and unauthenticated release-list calls are rate-limited). + tag="nightly" + ;; *) - die "unsupported release channel: ${CHANNEL}; expected stable, latest, rc, or beta" + die "unsupported release channel: ${CHANNEL}; expected stable, latest, rc, beta, or nightly" ;; esac echo "${tag}" @@ -1215,11 +1226,11 @@ compose_image() { stable|latest) tag="latest" ;; - rc|beta) + rc|beta|nightly) tag="${CHANNEL}" ;; *) - die "unsupported release channel: ${CHANNEL}; expected stable, latest, rc, or beta" + die "unsupported release channel: ${CHANNEL}; expected stable, latest, rc, beta, or nightly" ;; esac fi