From 1df0bcaa359b3afbff38553aad07991bafcdc1dc Mon Sep 17 00:00:00 2001 From: Threepwood-7 Date: Mon, 9 Nov 2026 21:50:43 +0100 Subject: [PATCH 1/8] ci(platforms): isolate optional outcomes and bind candidate profiles (RUST-CI-035) --- .github/workflows/ci.yml | 94 +----- .github/workflows/optional-platforms.yml | 330 +++++++++++++++++++ .github/workflows/release.yml | 245 +++++++++++++- tools/check_rust_client_policy.py | 46 ++- tools/nightly_release.py | 49 ++- tools/release_publish.py | 45 +++ tools/tests/test_check_rust_client_policy.py | 17 +- tools/tests/test_ci_environment.py | 4 +- tools/tests/test_ci_parity.py | 9 +- tools/tests/test_nightly_release.py | 78 ++++- tools/tests/test_platform_gate_graph.py | 99 ++++++ 11 files changed, 876 insertions(+), 140 deletions(-) create mode 100644 .github/workflows/optional-platforms.yml create mode 100644 tools/tests/test_platform_gate_graph.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e168e336..93267d4c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -222,15 +222,13 @@ jobs: name: build+test (${{ matrix.runner }}) needs: [single-active-pr, workflow-lint] runs-on: ${{ matrix.runner }} - # WHY: the cold Intel macOS build reached the old deadline while tests - # were still passing; retain the full suite with a bounded larger budget. - timeout-minutes: ${{ matrix.runner == 'macos-15-intel' && 120 || 45 }} + timeout-minutes: 45 env: EMULEBB_WORKSPACE_ROOT: ${{ github.workspace }} strategy: fail-fast: false matrix: - runner: [ubuntu-24.04-arm, windows-latest, windows-11-arm, macos-15-intel, macos-15] + runner: [ubuntu-24.04-arm, windows-latest, windows-11-arm] steps: - name: Checkout emulebb-rust uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -425,90 +423,6 @@ jobs: if-no-files-found: warn retention-days: 14 - build-android: - name: Android compile+ELF (${{ matrix.abi }}) - needs: [single-active-pr, workflow-lint] - runs-on: ubuntu-24.04 - timeout-minutes: 60 - strategy: - fail-fast: false - matrix: - abi: [arm64-v8a, x86_64] - env: - EMULEBB_WORKSPACE_ROOT: ${{ github.workspace }} - EMULEBB_RUST_REPO: ${{ github.workspace }} - EMULEBB_TOOLING_REPO: ${{ github.workspace }}/.ci/emulebb-tooling - MINIUPNP_ROOT: ${{ github.workspace }}/.ci/emulebb-miniupnp - PCPNATPMP_ROOT: ${{ github.workspace }}/.ci/emulebb-libpcpnatpmp - steps: - - name: Checkout emulebb-rust - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - name: Checkout pinned Android orchestration - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - repository: emulebb/emulebb-build - ref: ${{ env.EMULEBB_ANDROID_BUILD_REF }} - path: .ci/emulebb-build - - name: Checkout pinned product documentation - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - repository: emulebb/emulebb-tooling - ref: ${{ env.EMULEBB_TOOLING_REF }} - path: .ci/emulebb-tooling - - name: Checkout pinned MiniUPnP source - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - repository: emulebb/emulebb-miniupnp - ref: ${{ env.MINIUPNP_REF }} - path: .ci/emulebb-miniupnp - - name: Checkout pinned PCP/NAT-PMP source - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - repository: emulebb/emulebb-libpcpnatpmp - ref: ${{ env.PCPNATPMP_REF }} - path: .ci/emulebb-libpcpnatpmp - - name: Set up Python - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 - with: - python-version: "3.15.2" - - name: Install pinned workspace CLI dependencies - run: python -m pip install click==8.4.1 pydantic==2.14.3 - - name: Set up pinned Docker - uses: docker/setup-docker-action@7ec28a010822b4626af4874f9448a4b05ac6f3e5 # v5.6.0 - with: - version: v29.6.1 - daemon-config: '{"features":{"containerd-snapshotter":true}}' - - name: Set up pinned Buildx - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 - with: - version: v0.35.0 - driver: docker - use: false - - name: Run the local Android compile and ELF gate - working-directory: .ci/emulebb-build - env: - EMULEBB_WORKSPACE_OUTPUT_ROOT: ${{ runner.temp }}/emulebb-rust-out - CARGO_TARGET_DIR: ${{ runner.temp }}/emulebb-rust-out/builds/rust/target - run: | - mkdir -p "$CARGO_TARGET_DIR" - python -B -m emule_workspace validate-rust-android-ci --abi ${{ matrix.abi }} - - name: Retain Android compilation evidence - if: always() - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: ci-evidence-android-${{ matrix.abi }}-${{ github.sha }}-${{ github.run_attempt }} - path: | - ${{ runner.temp }}/emulebb-rust-out/reports/android-toolchain/ - ${{ runner.temp }}/emulebb-rust-out/reports/rust-android/ - if-no-files-found: warn - retention-days: 14 - rest-openapi: name: live REST/OpenAPI conformance needs: build-linux @@ -708,7 +622,7 @@ jobs: required-ci: name: Required CI if: ${{ always() }} - needs: [single-active-pr, workflow-lint, build-linux, build-test, build-musl, build-android, rest-openapi, quality, supply-chain] + needs: [single-active-pr, workflow-lint, build-linux, build-test, build-musl, rest-openapi, quality, supply-chain] runs-on: ubuntu-24.04 timeout-minutes: 5 steps: @@ -720,7 +634,6 @@ jobs: BUILD_LINUX_RESULT: ${{ needs.build-linux.result }} BUILD_TEST_RESULT: ${{ needs.build-test.result }} BUILD_MUSL_RESULT: ${{ needs.build-musl.result }} - BUILD_ANDROID_RESULT: ${{ needs.build-android.result }} REST_OPENAPI_RESULT: ${{ needs.rest-openapi.result }} QUALITY_RESULT: ${{ needs.quality.result }} SUPPLY_CHAIN_RESULT: ${{ needs.supply-chain.result }} @@ -732,7 +645,6 @@ jobs: "$BUILD_LINUX_RESULT" \ "$BUILD_TEST_RESULT" \ "$BUILD_MUSL_RESULT" \ - "$BUILD_ANDROID_RESULT" \ "$REST_OPENAPI_RESULT" \ "$QUALITY_RESULT" \ "$SUPPLY_CHAIN_RESULT"; do diff --git a/.github/workflows/optional-platforms.yml b/.github/workflows/optional-platforms.yml new file mode 100644 index 00000000..02dfe28d --- /dev/null +++ b/.github/workflows/optional-platforms.yml @@ -0,0 +1,330 @@ +name: Optional platform CI + +on: + push: + branches: [main] + pull_request: + types: [opened, reopened, synchronize, ready_for_review, labeled, unlabeled] + workflow_dispatch: + +concurrency: + group: optional-platform-ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + EMULEBB_BUILD_REF: 3135ee1d106637871d884e5f628bcd4ca596863c + EMULEBB_ANDROID_BUILD_REF: 3135ee1d106637871d884e5f628bcd4ca596863c + EMULEBB_BUILD_TESTS_REF: 20a007f34d88b99c40d456cfe1dce3d0537ba00d + EMULEBB_TOOLING_REF: 856e0499d12e281697618cc81a0b09805e96f59e + # Pin the native NAT sources linked by the sys crates. + MINIUPNP_REF: 4e7a10948004ca2085ac8103e14fc77edb898b4c + PCPNATPMP_REF: a6e787f837e507336a8774522c33b54264ef4a4b + +jobs: + single-active-pr: + name: Optional platform PR coordination + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + pull-requests: read + steps: + - name: Enforce one human pull request per author + if: ${{ github.event_name == 'pull_request' }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const pullRequest = context.payload.pull_request; + if (pullRequest.user.type === "Bot") { + core.info(`Bot-authored pull request #${pullRequest.number} is exempt.`); + return; + } + + const exceptionLabel = "parallel-approved"; + const hasException = (candidate) => + candidate.labels.some((label) => label.name === exceptionLabel); + const openPullRequests = await github.paginate(github.rest.pulls.list, { + owner: context.repo.owner, + repo: context.repo.repo, + state: "open", + base: pullRequest.base.ref, + per_page: 100, + }); + const parallelPullRequests = openPullRequests.filter( + (candidate) => + candidate.number !== pullRequest.number && + candidate.user.login === pullRequest.user.login && + candidate.user.type !== "Bot", + ); + + if (parallelPullRequests.length === 0) { + core.info(`Pull request #${pullRequest.number} is the author's only open pull request.`); + return; + } + + const unapprovedPullRequests = [pullRequest, ...parallelPullRequests].filter( + (candidate) => !hasException(candidate), + ); + if (unapprovedPullRequests.length === 0) { + core.notice( + `Parallel work is approved on every open pull request by ${pullRequest.user.login}.`, + ); + return; + } + + const parallelSummary = parallelPullRequests + .map((candidate) => `#${candidate.number} (${candidate.html_url})`) + .join(", "); + core.setFailed( + `@${pullRequest.user.login} already has open pull request(s): ${parallelSummary}. ` + + `Close or merge them before continuing, or apply '${exceptionLabel}' to every ` + + "affected pull request after recording the maintainer-approved reason on each one.", + ); + + - name: Skip pull request policy outside pull requests + if: ${{ github.event_name != 'pull_request' }} + run: echo "The single-active-PR policy applies only to pull requests." + + workflow-lint: + name: GitHub Actions workflow lint + needs: single-active-pr + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout emulebb-rust + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install pinned Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: "1.27.3" + cache: false + + - name: Set up Python + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: "3.15.2" + + - name: Validate GitHub Actions workflows + env: + EMULEBB_WORKSPACE_ROOT: ${{ github.workspace }} + EMULEBB_WORKSPACE_OUTPUT_ROOT: ${{ runner.temp }}/emulebb-rust-out + CARGO_TARGET_DIR: ${{ runner.temp }}/emulebb-rust-out/builds/rust/target + run: python tools/rust_quality_gate.py workflow-lint + + - name: Retain CI diagnostics and build inputs + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: ci-evidence-${{ runner.os }}-${{ runner.arch }}-${{ github.job }}-${{ github.sha }}-${{ github.run_attempt }} + path: | + ${{ runner.temp }}/emulebb-rust-out/reports/rust-quality-gate/ + ${{ runner.temp }}/emulebb-rust-out/reports/ci-environment/ + ${{ runner.temp }}/emulebb-rust-out/reports/webui-stage/ + if-no-files-found: warn + retention-days: 14 + + build-macos: + name: build+test (${{ matrix.runner }}) + needs: [single-active-pr, workflow-lint] + runs-on: ${{ matrix.runner }} + # WHY: the cold Intel macOS build reached the old deadline while tests + # were still passing; retain the full suite with a bounded larger budget. + timeout-minutes: ${{ matrix.runner == 'macos-15-intel' && 120 || 45 }} + env: + EMULEBB_WORKSPACE_ROOT: ${{ github.workspace }} + strategy: + fail-fast: false + matrix: + runner: [macos-15-intel, macos-15] + steps: + - name: Checkout emulebb-rust + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Checkout emulebb-miniupnp (native C source) + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + repository: emulebb/emulebb-miniupnp + ref: ${{ env.MINIUPNP_REF }} + path: .ci/emulebb-miniupnp + + - name: Checkout emulebb-libpcpnatpmp (native C source) + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + repository: emulebb/emulebb-libpcpnatpmp + ref: ${{ env.PCPNATPMP_REF }} + path: .ci/emulebb-libpcpnatpmp + + - name: Install pinned Rust + uses: dtolnay/rust-toolchain@ce678459e9fc7500d337468f904b95f1b5c10b5e # 1.98.1 + with: + components: rustfmt, clippy + + - name: Set up Python + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: "3.15.2" + + - name: Set up Node.js + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: "26.13.0" + package-manager-cache: false + + - name: Install pinned npm + run: npm install --global npm@11.20.0 + + - name: Install Linux UI build dependencies + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libfontconfig1-dev + + - name: Cache cargo build + uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + env: + CARGO_TARGET_DIR: ${{ runner.temp }}/emulebb-rust-out/builds/rust/target + + - name: Rust build/test gate + env: + EMULEBB_WORKSPACE_OUTPUT_ROOT: ${{ runner.temp }}/emulebb-rust-out + CARGO_TARGET_DIR: ${{ runner.temp }}/emulebb-rust-out/builds/rust/target + MINIUPNP_ROOT: ${{ github.workspace }}/.ci/emulebb-miniupnp + PCPNATPMP_ROOT: ${{ github.workspace }}/.ci/emulebb-libpcpnatpmp + run: python tools/rust_quality_gate.py ci-test + + - name: Retain tested beta build + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: emulebb-rust-${{ runner.os }}-${{ runner.arch }}-${{ github.sha }} + overwrite: true + path: | + ${{ runner.temp }}/emulebb-rust-out/tools/emulebb-rust/bin/ + !${{ runner.temp }}/emulebb-rust-out/tools/emulebb-rust/bin/*.pdb + if-no-files-found: error + retention-days: 14 + + - name: Retain CI diagnostics and build inputs + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: ci-evidence-${{ runner.os }}-${{ runner.arch }}-${{ github.job }}-${{ github.sha }}-${{ github.run_attempt }} + path: | + ${{ runner.temp }}/emulebb-rust-out/reports/rust-quality-gate/ + ${{ runner.temp }}/emulebb-rust-out/reports/ci-environment/ + ${{ runner.temp }}/emulebb-rust-out/reports/webui-stage/ + if-no-files-found: warn + retention-days: 14 + + build-android: + name: Android compile+ELF (${{ matrix.abi }}) + needs: [single-active-pr, workflow-lint] + runs-on: ubuntu-24.04 + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + abi: [arm64-v8a, x86_64] + env: + EMULEBB_WORKSPACE_ROOT: ${{ github.workspace }} + EMULEBB_RUST_REPO: ${{ github.workspace }} + EMULEBB_TOOLING_REPO: ${{ github.workspace }}/.ci/emulebb-tooling + MINIUPNP_ROOT: ${{ github.workspace }}/.ci/emulebb-miniupnp + PCPNATPMP_ROOT: ${{ github.workspace }}/.ci/emulebb-libpcpnatpmp + steps: + - name: Checkout emulebb-rust + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Checkout pinned Android orchestration + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + repository: emulebb/emulebb-build + ref: ${{ env.EMULEBB_ANDROID_BUILD_REF }} + path: .ci/emulebb-build + - name: Checkout pinned product documentation + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + repository: emulebb/emulebb-tooling + ref: ${{ env.EMULEBB_TOOLING_REF }} + path: .ci/emulebb-tooling + - name: Checkout pinned MiniUPnP source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + repository: emulebb/emulebb-miniupnp + ref: ${{ env.MINIUPNP_REF }} + path: .ci/emulebb-miniupnp + - name: Checkout pinned PCP/NAT-PMP source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + repository: emulebb/emulebb-libpcpnatpmp + ref: ${{ env.PCPNATPMP_REF }} + path: .ci/emulebb-libpcpnatpmp + - name: Set up Python + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: "3.15.2" + - name: Install pinned workspace CLI dependencies + run: python -m pip install click==8.4.1 pydantic==2.14.3 + - name: Set up pinned Docker + uses: docker/setup-docker-action@7ec28a010822b4626af4874f9448a4b05ac6f3e5 # v5.6.0 + with: + version: v29.6.1 + daemon-config: '{"features":{"containerd-snapshotter":true}}' + - name: Set up pinned Buildx + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 + with: + version: v0.35.0 + driver: docker + use: false + - name: Run the local Android compile and ELF gate + working-directory: .ci/emulebb-build + env: + EMULEBB_WORKSPACE_OUTPUT_ROOT: ${{ runner.temp }}/emulebb-rust-out + CARGO_TARGET_DIR: ${{ runner.temp }}/emulebb-rust-out/builds/rust/target + run: | + mkdir -p "$CARGO_TARGET_DIR" + python -B -m emule_workspace validate-rust-android-ci --abi ${{ matrix.abi }} + - name: Retain Android compilation evidence + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: ci-evidence-android-${{ matrix.abi }}-${{ github.sha }}-${{ github.run_attempt }} + path: | + ${{ runner.temp }}/emulebb-rust-out/reports/android-toolchain/ + ${{ runner.temp }}/emulebb-rust-out/reports/rust-android/ + if-no-files-found: warn + retention-days: 14 + + optional-platform-ci: + name: Optional Platform CI + if: ${{ always() }} + needs: [single-active-pr, workflow-lint, build-macos, build-android] + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Report independent platform outcomes + shell: bash + env: + COORDINATION_RESULT: ${{ needs.single-active-pr.result }} + WORKFLOW_LINT_RESULT: ${{ needs.workflow-lint.result }} + MACOS_RESULT: ${{ needs.build-macos.result }} + ANDROID_RESULT: ${{ needs.build-android.result }} + run: | + set -euo pipefail + printf '### Optional platform CI\n\nmacOS: %s\n\nAndroid: %s\n' "$MACOS_RESULT" "$ANDROID_RESULT" >> "$GITHUB_STEP_SUMMARY" + for result in "$COORDINATION_RESULT" "$WORKFLOW_LINT_RESULT" "$MACOS_RESULT" "$ANDROID_RESULT"; do + test "$result" = success + done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 54c3190d..ce2cf994 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,11 +7,20 @@ on: tags: ["rust-v*"] workflow_dispatch: inputs: + platform_profile: + description: "Required target inventory; reduced profile is unpublished candidate-only" + type: choice + options: [full, windows-linux] + default: full ref: description: "Optional exact source SHA assertion; must match the selected workflow revision" required: false workflow_call: inputs: + platform_profile: + description: "Full publication remains the default platform contract" + type: string + default: full release_version: description: "Distribution version assigned to the packages and release" required: true @@ -40,6 +49,7 @@ permissions: contents: read env: + RELEASE_PLATFORM_PROFILE: ${{ inputs.platform_profile || 'full' }} RELEASE_CHANNEL: ${{ inputs.channel || (startsWith(github.ref, 'refs/tags/rust-v') && 'beta') || 'candidate' }} EMULEBB_BUILD_REF: b13ddb7cc4341d0d063c687ed8786e0311c45eba EMULEBB_BUILD_TESTS_REF: 20a007f34d88b99c40d456cfe1dce3d0537ba00d @@ -53,6 +63,8 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 10 outputs: + platform_profile: ${{ steps.profile.outputs.platform_profile }} + native_matrix: ${{ steps.profile.outputs.native_matrix }} release_version: ${{ steps.identity.outputs.version }} source_sha: ${{ steps.source.outputs.sha }} steps: @@ -65,6 +77,15 @@ jobs: uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: python-version: "3.15.2" + - name: Bind the platform inventory before any producer runs + id: profile + env: + REQUESTED_PUBLISH: ${{ inputs.publish || false }} + run: >- + python tools/release_publish.py platform-profile + --profile "$RELEASE_PLATFORM_PROFILE" --event-name "$GITHUB_EVENT_NAME" + --channel "$RELEASE_CHANNEL" --publish "$REQUESTED_PUBLISH" + --github-output "$GITHUB_OUTPUT" # WHY: default-branch jobs must not execute an independently selected # checkout with access to the default branch's cache scope. - name: Bind source and workflow revision @@ -127,7 +148,7 @@ jobs: - name: Verify required checks on the exact source commit env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: python tools/nightly_release.py verify-source --sha "${{ needs.prepare.outputs.source_sha }}" + run: python tools/nightly_release.py verify-source --sha "${{ needs.prepare.outputs.source_sha }}" --platform-profile "${{ needs.prepare.outputs.platform_profile }}" native-package: name: ${{ matrix.os }}-${{ matrix.arch }} package @@ -137,13 +158,7 @@ jobs: strategy: fail-fast: false matrix: - include: - - {os: windows, arch: x64, slug: windows-x64, runner: windows-latest} - - {os: windows, arch: ARM64, slug: windows-arm64, runner: windows-11-arm} - - {os: linux, arch: x64, slug: linux-x64, runner: ubuntu-24.04} - - {os: linux, arch: ARM64, slug: linux-arm64, runner: ubuntu-24.04-arm} - - {os: macos, arch: x64, slug: macos-x64, runner: macos-15-intel} - - {os: macos, arch: ARM64, slug: macos-arm64, runner: macos-15} + include: ${{ fromJSON(needs.prepare.outputs.native_matrix) }} env: RELEASE_VERSION: ${{ needs.prepare.outputs.release_version }} EMULEBB_WORKSPACE_ROOT: ${{ github.workspace }} @@ -268,14 +283,194 @@ jobs: needs: [prepare, native-package] runs-on: ${{ matrix.runner }} timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.prepare.outputs.native_matrix) }} + env: + RELEASE_VERSION: ${{ needs.prepare.outputs.release_version }} + steps: + - name: Checkout trusted release-control source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ github.workflow_sha }} + - name: Checkout candidate schema as data + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.workflow_sha }} + persist-credentials: false + path: .ci/candidate + - name: Checkout pinned package smoke harness + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + repository: emulebb/emulebb-build-tests + ref: ${{ env.EMULEBB_BUILD_TESTS_REF }} + path: .ci/emulebb-build-tests + - name: Set up Python + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: "3.15.2" + - name: Install pinned rendered WebUI smoke dependency + if: matrix.os == 'windows' && matrix.arch == 'x64' + run: python -m pip install playwright==1.59.0 && python -m playwright install chromium + - name: Download native package + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: emulebb-rust-native-payload-${{ matrix.slug }} + path: ${{ runner.temp }}/emulebb-rust-out/release/rust-v${{ env.RELEASE_VERSION }} + - name: Prove packaged lifecycle, local transfer, and embedded WebUI + run: python .ci/emulebb-build-tests/scripts/smoke-rust-release-package.py --release-dir "${{ runner.temp }}/emulebb-rust-out/release/rust-v${{ env.RELEASE_VERSION }}" --release-version ${{ env.RELEASE_VERSION }} --rust-repo .ci/candidate --expected-source-sha "${{ needs.prepare.outputs.source_sha }}" --report-path "${{ runner.temp }}/emulebb-rust-out/evidence/emulebb-rust-v${{ env.RELEASE_VERSION }}-package-smoke-${{ matrix.slug }}.json" --target-os ${{ matrix.os }} --platform ${{ matrix.arch }} ${{ matrix.os == 'windows' && matrix.arch == 'x64' && '--render-webui' || '' }} + - name: Retain package lifecycle evidence + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: emulebb-rust-package-smoke-${{ matrix.slug }} + path: ${{ runner.temp }}/emulebb-rust-out/evidence/emulebb-rust-v${{ env.RELEASE_VERSION }}-package-smoke-${{ matrix.slug }}.json + if-no-files-found: error + retention-days: 30 + + optional-macos-package: + if: ${{ needs.prepare.outputs.platform_profile == 'windows-linux' }} + name: ${{ matrix.os }}-${{ matrix.arch }} package + needs: [prepare, verify-source] + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - {os: macos, arch: x64, slug: macos-x64, runner: macos-15-intel} + - {os: macos, arch: ARM64, slug: macos-arm64, runner: macos-15} + env: + RELEASE_VERSION: ${{ needs.prepare.outputs.release_version }} + EMULEBB_WORKSPACE_ROOT: ${{ github.workspace }} + EMULEBB_RUST_REPO: ${{ github.workspace }} + EMULEBB_TOOLING_REPO: ${{ github.workspace }}/.ci/emulebb-tooling + MINIUPNP_ROOT: ${{ github.workspace }}/.ci/emulebb-miniupnp + PCPNATPMP_ROOT: ${{ github.workspace }}/.ci/emulebb-libpcpnatpmp + steps: + - name: Checkout candidate + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ github.workflow_sha }} + - name: Checkout native MiniUPnP source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + repository: emulebb/emulebb-miniupnp + ref: ${{ env.MINIUPNP_REF }} + path: .ci/emulebb-miniupnp + - name: Checkout native libpcpnatpmp source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + repository: emulebb/emulebb-libpcpnatpmp + ref: ${{ env.PCPNATPMP_REF }} + path: .ci/emulebb-libpcpnatpmp + - name: Checkout pinned workspace packaging + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + repository: emulebb/emulebb-build + ref: ${{ env.EMULEBB_BUILD_REF }} + path: .ci/emulebb-build + - name: Checkout pinned release documentation + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + repository: emulebb/emulebb-tooling + ref: ${{ env.EMULEBB_TOOLING_REF }} + path: .ci/emulebb-tooling + - name: Install pinned Rust + uses: dtolnay/rust-toolchain@ce678459e9fc7500d337468f904b95f1b5c10b5e # 1.98.1 + - name: Set up Python + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: "3.15.2" + - name: Install pinned workspace CLI dependencies + run: python -m pip install -r tools/requirements-ci.txt click==8.4.1 pydantic==2.14.3 + - name: Set up Node.js + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: "26.13.0" + package-manager-cache: false + - name: Install pinned npm + run: npm install --global npm@11.20.0 + + - name: Install Linux build dependencies + if: matrix.os == 'linux' + run: sudo apt-get update && sudo apt-get install --no-install-recommends -y dpkg-dev libfontconfig1-dev + - name: Install verified AppImage builder + if: matrix.os == 'linux' + shell: bash + run: | + if [[ '${{ matrix.arch }}' == 'ARM64' ]]; then + tool_arch=aarch64 + expected=f0837e7448a0c1e4e650a93bb3e85802546e60654ef287576f46c71c126a9158 + else + tool_arch=x86_64 + expected=ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0 + fi + tool="$RUNNER_TEMP/appimagetool-${tool_arch}.AppImage" + curl --fail --location --silent --show-error \ + "https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-${tool_arch}.AppImage" \ + --output "$tool" + echo "$expected $tool" | sha256sum --check + chmod 755 "$tool" + { + echo "APPIMAGETOOL=$tool" + echo "APPIMAGE_EXTRACT_AND_RUN=1" + echo "ARCH=$tool_arch" + } >> "$GITHUB_ENV" + - name: Assert release build excludes test-only features + run: python tools/check_rust_client_policy.py + - name: Prepare inherited build output directory + env: + CARGO_TARGET_DIR: ${{ runner.temp }}/emulebb-rust-out/builds/rust/target + run: python -c "from pathlib import Path; import os; Path(os.environ['CARGO_TARGET_DIR']).mkdir(parents=True, exist_ok=True)" + - name: Build and package native release asset + working-directory: .ci/emulebb-build + env: + EMULEBB_WORKSPACE_OUTPUT_ROOT: ${{ runner.temp }}/emulebb-rust-out + CARGO_TARGET_DIR: ${{ runner.temp }}/emulebb-rust-out/builds/rust/target + EMULEBB_RELEASE_VERSION: ${{ env.RELEASE_VERSION }} + EMULEBB_RELEASE_CHANNEL: ${{ env.RELEASE_CHANNEL }} + EMULEBB_SOURCE_REVISION: ${{ needs.prepare.outputs.source_sha }} + RUSTUP_TOOLCHAIN: "1.98.1" + run: python -m emule_workspace package-emulebb-rust-ci --release-version ${{ env.RELEASE_VERSION }} --target-os ${{ matrix.os }} --platform ${{ matrix.arch }} + - name: Retain build diagnostics + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: emulebb-rust-build-logs-${{ matrix.os }}-${{ matrix.arch }} + path: ${{ runner.temp }}/emulebb-rust-out/logs/builds/**/*.log + if-no-files-found: warn + retention-days: 30 + - name: Retain package and provenance + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: emulebb-rust-optional-native-payload-${{ matrix.slug }} + path: | + ${{ runner.temp }}/emulebb-rust-out/release/rust-v${{ env.RELEASE_VERSION }}/*.zip + ${{ runner.temp }}/emulebb-rust-out/release/rust-v${{ env.RELEASE_VERSION }}/*.deb + ${{ runner.temp }}/emulebb-rust-out/release/rust-v${{ env.RELEASE_VERSION }}/*.AppImage + ${{ runner.temp }}/emulebb-rust-out/release/rust-v${{ env.RELEASE_VERSION }}/*.dmg + ${{ runner.temp }}/emulebb-rust-out/release/rust-v${{ env.RELEASE_VERSION }}/*.json + if-no-files-found: error + retention-days: 30 + + optional-macos-smoke: + if: ${{ needs.prepare.outputs.platform_profile == 'windows-linux' }} + name: ${{ matrix.slug }} packaged lifecycle + needs: [prepare, optional-macos-package] + runs-on: ${{ matrix.runner }} + timeout-minutes: 15 strategy: fail-fast: false matrix: include: - - {os: windows, arch: x64, slug: windows-x64, runner: windows-latest} - - {os: windows, arch: ARM64, slug: windows-arm64, runner: windows-11-arm} - - {os: linux, arch: x64, slug: linux-x64, runner: ubuntu-24.04} - - {os: linux, arch: ARM64, slug: linux-arm64, runner: ubuntu-24.04-arm} - {os: macos, arch: x64, slug: macos-x64, runner: macos-15-intel} - {os: macos, arch: ARM64, slug: macos-arm64, runner: macos-15} env: @@ -309,18 +504,36 @@ jobs: - name: Download native package uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: - name: emulebb-rust-native-payload-${{ matrix.slug }} + name: emulebb-rust-optional-native-payload-${{ matrix.slug }} path: ${{ runner.temp }}/emulebb-rust-out/release/rust-v${{ env.RELEASE_VERSION }} - name: Prove packaged lifecycle, local transfer, and embedded WebUI run: python .ci/emulebb-build-tests/scripts/smoke-rust-release-package.py --release-dir "${{ runner.temp }}/emulebb-rust-out/release/rust-v${{ env.RELEASE_VERSION }}" --release-version ${{ env.RELEASE_VERSION }} --rust-repo .ci/candidate --expected-source-sha "${{ needs.prepare.outputs.source_sha }}" --report-path "${{ runner.temp }}/emulebb-rust-out/evidence/emulebb-rust-v${{ env.RELEASE_VERSION }}-package-smoke-${{ matrix.slug }}.json" --target-os ${{ matrix.os }} --platform ${{ matrix.arch }} ${{ matrix.os == 'windows' && matrix.arch == 'x64' && '--render-webui' || '' }} - name: Retain package lifecycle evidence uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: - name: emulebb-rust-package-smoke-${{ matrix.slug }} + name: emulebb-rust-optional-package-smoke-${{ matrix.slug }} path: ${{ runner.temp }}/emulebb-rust-out/evidence/emulebb-rust-v${{ env.RELEASE_VERSION }}-package-smoke-${{ matrix.slug }}.json if-no-files-found: error retention-days: 30 + optional-release-platforms: + name: Optional release platform outcomes + if: ${{ always() && needs.prepare.outputs.platform_profile == 'windows-linux' }} + needs: [prepare, optional-macos-package, optional-macos-smoke] + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Report optional macOS package and lifecycle outcomes + shell: bash + env: + PACKAGE_RESULT: ${{ needs.optional-macos-package.result }} + SMOKE_RESULT: ${{ needs.optional-macos-smoke.result }} + run: | + set -euo pipefail + printf '### Optional macOS release outcomes\n\nPackage: %s\n\nLifecycle: %s\n' "$PACKAGE_RESULT" "$SMOKE_RESULT" >> "$GITHUB_STEP_SUMMARY" + test "$PACKAGE_RESULT" = success + test "$SMOKE_RESULT" = success + musl-package: name: Static musl ${{ matrix.architecture }} needs: [prepare, verify-source] @@ -805,7 +1018,7 @@ jobs: pattern: emulebb-rust-android-developer-payload-* merge-multiple: true path: ${{ runner.temp }}/emulebb-rust-out/release/rust-v${{ env.RELEASE_VERSION }} - - name: Download all eight native package lifecycle proofs + - name: Download every selected native package lifecycle proof uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: pattern: emulebb-rust-package-smoke-* @@ -858,7 +1071,7 @@ jobs: RELEASE_DIR: ${{ runner.temp }}/emulebb-rust-out/release/rust-v${{ env.RELEASE_VERSION }} run: >- python -B -m emule_workspace assemble-emulebb-rust-release-ci - --release-version "$RELEASE_VERSION" --assets-dir "$RELEASE_DIR" + --release-version "$RELEASE_VERSION" --assets-dir "$RELEASE_DIR" --platform-profile "$RELEASE_PLATFORM_PROFILE" - name: Verify exact source pins, native proofs and candidate bytes id: candidate env: diff --git a/tools/check_rust_client_policy.py b/tools/check_rust_client_policy.py index e345c81c..086da62f 100644 --- a/tools/check_rust_client_policy.py +++ b/tools/check_rust_client_policy.py @@ -671,11 +671,14 @@ def check_workflow_lint_ci(workflow_text: str | None = None) -> list[str]: def check_required_ci_gates( ci_text: str | None = None, codeql_text: str | None = None, + optional_text: str | None = None, ) -> list[str]: """Require stable aggregate checks for default-branch and release gates.""" ci_path = ROOT / ".github" / "workflows" / "ci.yml" ci = ci_path.read_text(encoding="utf-8") if ci_text is None else ci_text + optional_path = ROOT / ".github/workflows/optional-platforms.yml" + optional = optional_path.read_text(encoding="utf-8") if optional_text is None else optional_text codeql_path = ROOT / ".github" / "workflows" / "codeql.yml" codeql = ( codeql_path.read_text(encoding="utf-8") @@ -692,7 +695,7 @@ def check_required_ci_gates( ), "required-ci:": (ci, "CI aggregate job"), "name: Required CI": (ci, "stable CI check name"), - "needs: [single-active-pr, workflow-lint, build-linux, build-test, build-musl, build-android, rest-openapi, quality, supply-chain]": ( + "needs: [single-active-pr, workflow-lint, build-linux, build-test, build-musl, rest-openapi, quality, supply-chain]": ( ci, "complete CI aggregate dependency set", ), @@ -700,7 +703,10 @@ def check_required_ci_gates( 'test "$result" = success': (ci, "strict CI result verification"), '"$BUILD_LINUX_RESULT"': (ci, "Linux build result verification"), '"$BUILD_MUSL_RESULT"': (ci, "musl build result verification"), - '"$BUILD_ANDROID_RESULT"': (ci, "Android build result verification"), + "name: Optional Platform CI": (optional, "independent optional platform aggregate"), + "needs: [single-active-pr, workflow-lint, build-macos, build-android]": (optional, "complete optional platform dependency set"), + '"$ANDROID_RESULT"': (optional, "Android build result verification"), + '"$MACOS_RESULT"': (optional, "macOS build result verification"), "required-codeql:": (codeql, "CodeQL aggregate job"), "name: Required CodeQL": (codeql, "stable CodeQL check name"), "needs: analyze": (codeql, "complete CodeQL matrix dependency"), @@ -715,15 +721,29 @@ def check_required_ci_gates( if fragment not in source ] jobs = (yaml.safe_load(ci) or {}).get("jobs", {}) - for job in ("workflow-lint", "build-linux", "build-test", "build-musl", "build-android", "quality", "supply-chain"): + for job in ("workflow-lint", "build-linux", "build-test", "build-musl", "quality", "supply-chain"): needs = jobs.get(job, {}).get("needs", []) needs = [needs] if isinstance(needs, str) else needs if "single-active-pr" not in needs: errors.append( f"required branch gate is missing {job} fail-fast PR policy dependency" ) - elif job in {"build-linux", "build-test", "build-musl", "build-android", "quality"} and "workflow-lint" not in needs: + elif job in {"build-linux", "build-test", "build-musl", "quality"} and "workflow-lint" not in needs: errors.append(f"{job} must wait for workflow-lint before expensive validation") + optional_jobs = (yaml.safe_load(optional) or {}).get("jobs", {}) + if 'test "$result" = success' not in optional: + errors.append("optional platform aggregate must fail on unsuccessful outcomes") + for name in ("build-macos", "build-android"): + job = optional_jobs.get(name, {}) + if not {"single-active-pr", "workflow-lint"} <= set(job.get("needs", [])): + errors.append(f"optional {name} must retain coordination and workflow-lint dependencies") + if job.get("continue-on-error") or any(step.get("continue-on-error") for step in job.get("steps", [])): + errors.append(f"optional {name} must report failures without continue-on-error") + native = {jobs.get("build-linux", {}).get("runs-on"), + *jobs.get("build-test", {}).get("strategy", {}).get("matrix", {}).get("runner", []), + *optional_jobs.get("build-macos", {}).get("strategy", {}).get("matrix", {}).get("runner", [])} + if native != {"ubuntu-24.04", "ubuntu-24.04-arm", "windows-latest", "windows-11-arm", "macos-15-intel", "macos-15"}: + errors.append("required and optional CI must preserve all six supported native targets") return errors @@ -816,14 +836,18 @@ def check_workflow_job_timeouts( "ci.yml": { "single-active-pr": 5, "workflow-lint": 10, - "build-test": "${{ matrix.runner == 'macos-15-intel' && 120 || 45 }}", + "build-test": 45, "build-musl": 45, - "build-android": 60, "rest-openapi": 15, "quality": 30, "supply-chain": 10, "required-ci": 5, }, + "optional-platforms.yml": { + "single-active-pr": 5, "workflow-lint": 10, + "build-macos": "${{ matrix.runner == 'macos-15-intel' && 120 || 45 }}", + "build-android": 60, "optional-platform-ci": 5, + }, "codeql.yml": {"analyze": 30, "required-codeql": 5}, "fuzz.yml": {"parser-fuzz": 30}, "nightly.yml": {"prepare": 10, "verify-source": 10}, @@ -832,6 +856,9 @@ def check_workflow_job_timeouts( "verify-source": 10, "native-package": 45, "native-package-smoke": 15, + "optional-macos-package": 45, + "optional-macos-smoke": 15, + "optional-release-platforms": 5, "musl-package": 60, "android-package": 60, "image-candidate": 45, @@ -1104,7 +1131,7 @@ def check_nightly_coordination( 'source_sha = git_output("rev-parse", "HEAD").lower()': ( helper, "checked-out workflow revision selection", ), - "verify_source_checks(repository, source_sha)": (helper, "exact-source qualification"), + "wait_for_source_checks(repository, source_sha)": (helper, "exact-source qualification"), "except SourceChecksNotReady as error": (helper, "explicit source-not-ready handling"), '"skip_reason": skip_reason': (helper, "visible scheduled skip reason"), "def published_nightly_for_date(": (helper, "daily publication guard"), @@ -1473,7 +1500,10 @@ def check_release_output_paths(workflow_text: str | None = None) -> list[str]: for fragment, description in required.items() if fragment not in text ] - if text.count("smoke-rust-release-package.py") != 1: + # Each disjoint matrix has one smoke entrypoint; optional results stay out + # of the selected candidate's dependency graph and artifact namespace. + smoke_count = 2 if re.search(r"(?m)^ optional-macos-smoke:", text) else 1 + if text.count("smoke-rust-release-package.py") != smoke_count: errors.append( ".github/workflows/release.yml must run smoke-rust-release-package.py " "exactly once per native matrix target" diff --git a/tools/nightly_release.py b/tools/nightly_release.py index 1d6f3922..3d4759e1 100644 --- a/tools/nightly_release.py +++ b/tools/nightly_release.py @@ -8,6 +8,7 @@ import re import subprocess import sys +import time import tomllib from dataclasses import dataclass from datetime import datetime, timezone @@ -31,12 +32,17 @@ METADATA_SCHEMA_RE = re.compile(r"^pub const SCHEMA_VERSION: i64 = (?P\d+);$", re.MULTILINE) REQUIRED_SOURCE_WORKFLOWS = {"Required CI": "ci.yml", "Required CodeQL": "codeql.yml"} REQUIRED_SOURCE_CHECKS = tuple(REQUIRED_SOURCE_WORKFLOWS) +OPTIONAL_PLATFORM_WORKFLOW = {"Optional Platform CI": "optional-platforms.yml"} class SourceChecksNotReady(RuntimeError): """Required source workflows are missing or their latest invocation did not pass.""" +class SourceChecksPending(SourceChecksNotReady): + """An exact source workflow has not finished; terminal failures are different.""" + + @dataclass(frozen=True) class Change: """One commit-derived nightly changelog entry.""" @@ -282,7 +288,7 @@ def metadata( skip_reason = "" if scheduled and should_build: try: - verify_source_checks(repository, source_sha) + wait_for_source_checks(repository, source_sha) except SourceChecksNotReady as error: should_build = False skip_reason = str(error) @@ -542,17 +548,23 @@ def _action_rows(endpoint: str, field: str) -> list[dict[str, object]]: return rows -def verify_source_checks(repository: str, sha: str) -> dict[str, dict[str, int]]: - """Require the newest CI and CodeQL invocation and its exact aggregate job.""" +def verify_source_checks(repository: str, sha: str, *, platform_profile: str = "full") -> dict[str, dict[str, int]]: + """Require exact latest workflow attempts for the explicit caller target profile.""" if (re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository) is None or re.fullmatch(r"[0-9a-f]{40}", sha) is None): raise RuntimeError("Source eligibility requires a repository and full lowercase source SHA") + if platform_profile not in {"full", "windows-linux"}: + raise RuntimeError("Source eligibility requires an explicit supported platform profile") + workflows = dict(REQUIRED_SOURCE_WORKFLOWS) + # WHY: optional failures cannot gate scoped candidates, while default full + # nightlies/tags retain all existing native and Android qualification. + if platform_profile == "full": + workflows.update(OPTIONAL_PLATFORM_WORKFLOW) evidence = {} - for check in REQUIRED_SOURCE_CHECKS: - workflow = REQUIRED_SOURCE_WORKFLOWS[check] + for check, workflow in workflows.items(): rows = _action_rows(f"repos/{repository}/actions/workflows/{workflow}/runs?head_sha={sha}&per_page=100", "workflow_runs") if not rows: - raise SourceChecksNotReady(f"No {check} workflow invocation for source {sha}") + raise SourceChecksPending(f"No {check} workflow invocation for source {sha}") # WHY: this endpoint already filters by source. Dropping malformed or # foreign rows would disguise a broken inventory as an ordinary skip. for row in rows: @@ -577,7 +589,9 @@ def verify_source_checks(repository: str, sha: str) -> dict[str, dict[str, int]] "action_required", "stale", "startup_failure", })): raise RuntimeError(f"Invalid {check} latest workflow state") - if (status, conclusion) != ("completed", "success"): + if status != "completed": + raise SourceChecksPending(f"{check} latest workflow attempt must complete successfully") + if conclusion != "success": raise SourceChecksNotReady(f"{check} latest workflow attempt must complete successfully") jobs = _action_rows(f"repos/{repository}/actions/runs/{latest['id']}/attempts/{latest['run_attempt']}/jobs?per_page=100", "jobs") aggregate = [job for job in jobs if job.get("name") == check] @@ -594,6 +608,24 @@ def verify_source_checks(repository: str, sha: str) -> dict[str, dict[str, int]] return evidence +def wait_for_source_checks(repository: str, sha: str, *, platform_profile: str = "full", + timeout_seconds: float = 480, interval_seconds: float = 30) -> dict[str, dict[str, int]]: + """Wait within the existing job budget, re-reading exact latest attempts each time.""" + if not 0 <= timeout_seconds <= 480 or not 0 < interval_seconds <= 60: + raise RuntimeError("Source qualification wait must be bounded within the workflow job budget") + deadline = time.monotonic() + timeout_seconds + while True: + try: + if platform_profile == "full": + return verify_source_checks(repository, sha) + return verify_source_checks(repository, sha, platform_profile=platform_profile) + except SourceChecksPending as error: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise SourceChecksNotReady(f"Exact-source qualification timed out: {error}") from error + time.sleep(min(interval_seconds, remaining)) + + def nightly_tags_to_prune(releases: Sequence[dict[str, object]], keep: int) -> list[str]: """Selects only old immutable nightly prereleases for deletion.""" @@ -672,6 +704,7 @@ def build_parser() -> argparse.ArgumentParser: ) verify_parser.add_argument("--repository", default=DEFAULT_REPOSITORY) verify_parser.add_argument("--sha", required=True) + verify_parser.add_argument("--platform-profile", choices=("full", "windows-linux"), default="full") prune_parser = subparsers.add_parser("prune", help="Keep only the newest immutable nightly releases.") prune_parser.add_argument("--repository", default=DEFAULT_REPOSITORY) @@ -728,7 +761,7 @@ def main(argv: Sequence[str] | None = None) -> int: print(args.output) return 0 if args.command == "verify-source": - verify_source_checks(args.repository, args.sha) + wait_for_source_checks(args.repository, args.sha, platform_profile=args.platform_profile) print(f"required source checks passed for {args.sha}") return 0 if args.command == "prune": diff --git a/tools/release_publish.py b/tools/release_publish.py index 6031d438..1c01c7ad 100644 --- a/tools/release_publish.py +++ b/tools/release_publish.py @@ -76,6 +76,33 @@ def resolve_workflow_source( return workflow_sha +def resolve_platform_profile(profile: str, *, event_name: str, channel: str, publish: str) -> str: + """Allow a reduced inventory only for explicitly unpublished manual candidates.""" + if profile not in {"full", "windows-linux"} or publish not in {"true", "false"}: + raise RuntimeError("Invalid release platform profile or publish assertion") + if profile != "full" and (event_name != "workflow_dispatch" or channel != "candidate" or publish != "false"): + raise RuntimeError("Windows/Linux profile requires an unpublished manual candidate") + return profile + + +def native_matrix(platform_profile: str) -> list[dict[str, str]]: + """Bind native package and smoke matrices to the validated caller profile.""" + if platform_profile not in {"full", "windows-linux"}: + raise RuntimeError("Invalid release platform profile") + targets = [ + {"os": "windows", "arch": "x64", "slug": "windows-x64", "runner": "windows-latest"}, + {"os": "windows", "arch": "ARM64", "slug": "windows-arm64", "runner": "windows-11-arm"}, + {"os": "linux", "arch": "x64", "slug": "linux-x64", "runner": "ubuntu-24.04"}, + {"os": "linux", "arch": "ARM64", "slug": "linux-arm64", "runner": "ubuntu-24.04-arm"}, + ] + if platform_profile == "full": + targets.extend([ + {"os": "macos", "arch": "x64", "slug": "macos-x64", "runner": "macos-15-intel"}, + {"os": "macos", "arch": "ARM64", "slug": "macos-arm64", "runner": "macos-15"}, + ]) + return targets + + def workspace_version(root: Path = ROOT) -> str: """Return the validated Cargo workspace package version.""" @@ -267,6 +294,7 @@ def candidate_context(args: argparse.Namespace, owner): "emulebbMiniupnp": args.emulebb_miniupnp_ref, "emulebbLibpcpnatpmp": args.emulebb_libpcpnatpmp_ref}, workflow={"repository": args.repository, "runId": args.run_id, "runAttempt": args.run_attempt, "workflowSha": args.workflow_sha}, + platform_profile=args.platform_profile, ).checked() @@ -437,6 +465,12 @@ def build_parser() -> argparse.ArgumentParser: source.add_argument("--source-ref", default="") source.add_argument("--candidate-ref", default="") source.add_argument("--github-output", required=True, type=Path) + profile = subparsers.add_parser("platform-profile", help="bind the target inventory before any producer runs") + profile.add_argument("--profile", default="full") + profile.add_argument("--event-name", required=True) + profile.add_argument("--channel", required=True) + profile.add_argument("--publish", required=True) + profile.add_argument("--github-output", type=Path, required=True) identity = subparsers.add_parser( "release-identity", help="derive and validate the release version against Cargo metadata", @@ -451,6 +485,8 @@ def build_parser() -> argparse.ArgumentParser: for command in ("candidate-manifest", "verify-candidate", "verify-public-candidate", "release-assets", "publish-images"): candidate = subparsers.add_parser(command, help="strict source-bound release evidence control") candidate.add_argument("--release-dir", required=True, type=Path) + candidate.add_argument("--platform-profile", choices=("full", "windows-linux"), + default=os.environ.get("RELEASE_PLATFORM_PROFILE", "full")) expected_arguments = { "release-version": "RELEASE_VERSION", "channel": "RELEASE_CHANNEL", "source-sha": "EMULEBB_SOURCE_REVISION", "emulebb-build-ref": "EMULEBB_BUILD_REF", @@ -490,6 +526,13 @@ def build_parser() -> argparse.ArgumentParser: def main(argv: Sequence[str] | None = None) -> int: args = build_parser().parse_args(argv) + if args.command == "platform-profile": + profile = resolve_platform_profile(args.profile, event_name=args.event_name, + channel=args.channel, publish=args.publish) + with args.github_output.open("a", encoding="utf-8") as stream: + stream.write(f"platform_profile={profile}\n") + stream.write("native_matrix=" + json.dumps(native_matrix(profile), separators=(",", ":")) + "\n") + return 0 if args.command == "workflow-source": sha = resolve_workflow_source( args.workflow_sha, source_ref=args.source_ref, candidate_ref=args.candidate_ref, @@ -517,6 +560,8 @@ def main(argv: Sequence[str] | None = None) -> int: print(version) return 0 if args.command in {"candidate-manifest", "verify-candidate", "verify-public-candidate", "release-assets", "publish-images"}: + if args.command in {"release-assets", "publish-images"} and args.platform_profile != "full": + raise RuntimeError("Publication requires the full platform profile") owner = candidate_owner(args) context = candidate_context(args, owner) release_dir = args.release_dir.resolve() diff --git a/tools/tests/test_check_rust_client_policy.py b/tools/tests/test_check_rust_client_policy.py index 9545f676..94d4d7c1 100644 --- a/tools/tests/test_check_rust_client_policy.py +++ b/tools/tests/test_check_rust_client_policy.py @@ -309,7 +309,7 @@ def test_current_workflows_expose_stable_aggregate_checks(self) -> None: def test_rejects_incomplete_aggregate_checks(self) -> None: errors = CHECKER.check_required_ci_gates("jobs: {}\n", "jobs: {}\n") - self.assertEqual(len(errors), 23) + self.assertEqual(len(errors), 22) self.assertTrue(any("single-active-PR job" in error for error in errors)) self.assertTrue(any("workflow-lint fail-fast" in error for error in errors)) self.assertTrue(any("complete CI aggregate dependency" in error for error in errors)) @@ -320,9 +320,9 @@ def test_linux_build_cannot_be_omitted_from_aggregate(self) -> None: errors = CHECKER.check_required_ci_gates(ci.replace('"$BUILD_LINUX_RESULT"', '"success"')) self.assertTrue(any("Linux build result verification" in error for error in errors)) - def test_android_failure_cannot_be_omitted_from_required_ci(self) -> None: - ci = (CHECKER.ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8") - errors = CHECKER.check_required_ci_gates(ci.replace('"$BUILD_ANDROID_RESULT"', '"success"')) + def test_android_failure_cannot_be_omitted_from_optional_outcomes(self) -> None: + optional = (CHECKER.ROOT / ".github/workflows/optional-platforms.yml").read_text(encoding="utf-8") + errors = CHECKER.check_required_ci_gates(optional_text=optional.replace('"$ANDROID_RESULT"', '"success"')) self.assertTrue(any("Android build result verification" in error for error in errors)) @@ -343,20 +343,21 @@ def test_current_workflows_bound_every_runnable_job(self) -> None: self.assertEqual(CHECKER.check_workflow_job_timeouts(), []) def test_matrix_budget_cannot_be_replaced_with_an_unbounded_expression(self) -> None: - text = (CHECKER.ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8") + text = (CHECKER.ROOT / ".github/workflows/optional-platforms.yml").read_text(encoding="utf-8") changed = text.replace( "${{ matrix.runner == 'macos-15-intel' && 120 || 45 }}", "${{ matrix.unreviewed_budget }}", ) - errors = CHECKER.check_workflow_job_timeouts({"ci.yml": changed}) + errors = CHECKER.check_workflow_job_timeouts({"optional-platforms.yml": changed}) self.assertEqual(len(errors), 1) - self.assertIn("ci.yml job build-test", errors[0]) + self.assertIn("optional-platforms.yml job build-macos", errors[0]) def test_rejects_missing_timeout_budgets(self) -> None: incomplete = { filename: "jobs: {}\n" for filename in ( "ci.yml", + "optional-platforms.yml", "codeql.yml", "fuzz.yml", "nightly.yml", @@ -366,7 +367,7 @@ def test_rejects_missing_timeout_budgets(self) -> None: errors = CHECKER.check_workflow_job_timeouts(incomplete) - self.assertEqual(len(errors), 27) + self.assertEqual(len(errors), 34) self.assertTrue(any("ci.yml job build-test" in error for error in errors)) self.assertTrue(any("release.yml job publish-native" in error for error in errors)) diff --git a/tools/tests/test_ci_environment.py b/tools/tests/test_ci_environment.py index a703d8e4..0e5327b5 100644 --- a/tools/tests/test_ci_environment.py +++ b/tools/tests/test_ci_environment.py @@ -141,7 +141,7 @@ def test_release_documents_are_preflighted_before_packaging_and_downloaded_befor self.assertFalse(any('cp "emulebb-tooling/docs/' in step.get("run", "") for step in assembly)) def test_android_ci_uses_both_local_abis_and_immutable_orchestration(self): - workflow = yaml.safe_load((ROOT / ".github/workflows/ci.yml").read_text()) + workflow = yaml.safe_load((ROOT / ".github/workflows/optional-platforms.yml").read_text()) android = workflow["jobs"]["build-android"] self.assertEqual(set(android["strategy"]["matrix"]["abi"]), {"arm64-v8a", "x86_64"}) self.assertFalse(android["strategy"]["fail-fast"]) @@ -156,7 +156,7 @@ def test_android_ci_uses_both_local_abis_and_immutable_orchestration(self): self.assertIn('mkdir -p "$CARGO_TARGET_DIR"', compile_step["run"]) self.assertEqual(compile_step["env"]["EMULEBB_WORKSPACE_OUTPUT_ROOT"], "${{ runner.temp }}/emulebb-rust-out") - self.assertIn("build-android", workflow["jobs"]["required-ci"]["needs"]) + self.assertIn("build-android", workflow["jobs"]["optional-platform-ci"]["needs"]) def test_release_packaging_installs_policy_dependencies_before_policy_check(self): workflow = yaml.safe_load((ROOT / ".github/workflows/release.yml").read_text()) diff --git a/tools/tests/test_ci_parity.py b/tools/tests/test_ci_parity.py index 87d06a7d..0ad1600b 100644 --- a/tools/tests/test_ci_parity.py +++ b/tools/tests/test_ci_parity.py @@ -32,11 +32,14 @@ def test_omitted_dispatch_is_rejected(self): with self.assertRaises(AssertionError): self.assert_workflow_parity(jobs) - def test_required_matrix_covers_six_native_targets_and_both_musl_cpus(self): + def test_required_and_optional_matrices_cover_six_native_targets_and_both_musl_cpus(self): jobs = yaml.safe_load((ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8"))["jobs"] + optional = yaml.safe_load((ROOT / ".github/workflows/optional-platforms.yml").read_text(encoding="utf-8"))["jobs"] native = {jobs["build-linux"]["runs-on"], *jobs["build-test"]["strategy"]["matrix"]["runner"]} - self.assertEqual(native, {"ubuntu-24.04", "ubuntu-24.04-arm", "windows-latest", "windows-11-arm", - "macos-15-intel", "macos-15"}) + self.assertEqual(native, {"ubuntu-24.04", "ubuntu-24.04-arm", "windows-latest", "windows-11-arm"}) + self.assertEqual(set(optional["build-macos"]["strategy"]["matrix"]["runner"]), {"macos-15-intel", "macos-15"}) + self.assertEqual(set(optional["build-android"]["strategy"]["matrix"]["abi"]), {"arm64-v8a", "x86_64"}) + self.assertNotIn("build-android", jobs["required-ci"]["needs"]) musl = jobs["build-musl"] self.assertEqual({(row["architecture"], row["runner"]) for row in musl["strategy"]["matrix"]["include"]}, {("amd64", "ubuntu-24.04"), ("arm64", "ubuntu-24.04-arm")}) diff --git a/tools/tests/test_nightly_release.py b/tools/tests/test_nightly_release.py index 06490de9..dd7fd471 100644 --- a/tools/tests/test_nightly_release.py +++ b/tools/tests/test_nightly_release.py @@ -387,12 +387,15 @@ def setUp(self) -> None: self.ci = {"id": 20, "run_number": 8, "run_attempt": 2, "head_sha": self.sha, "path": ".github/workflows/ci.yml", "status": "completed", "conclusion": "success"} self.codeql = {**self.ci, "id": 30, "run_number": 15, "run_attempt": 1, "path": ".github/workflows/codeql.yml"} + self.optional = {**self.ci, "id": 40, "run_number": 4, "run_attempt": 1, "path": ".github/workflows/optional-platforms.yml"} self.runs = {"ci.yml": [{**self.ci, "id": 10, "run_number": 7, "conclusion": "failure"}, self.ci], - "codeql.yml": [self.codeql]} + "codeql.yml": [self.codeql], "optional-platforms.yml": [self.optional]} self.jobs = {"ci.yml": [{"id": 200, "run_id": 20, "run_attempt": 2, "head_sha": self.sha, "name": "Required CI", "status": "completed", "conclusion": "success"}], "codeql.yml": [{"id": 300, "run_id": 30, "run_attempt": 1, "head_sha": self.sha, - "name": "Required CodeQL", "status": "completed", "conclusion": "success"}]} + "name": "Required CodeQL", "status": "completed", "conclusion": "success"}], + "optional-platforms.yml": [{"id": 400, "run_id": 40, "run_attempt": 1, "head_sha": self.sha, + "name": "Optional Platform CI", "status": "completed", "conclusion": "success"}]} self.calls = [] patcher = mock.patch.object(NIGHTLY, "run", side_effect=self.respond) patcher.start() @@ -402,7 +405,7 @@ def respond(self, command, **_kwargs): self.calls.append(command) self.assertEqual(command[:4], ("gh", "api", "--paginate", "--slurp")) endpoint = command[-1] - for workflow, run_id, attempt in (("ci.yml", 20, 2), ("codeql.yml", 30, 1)): + for workflow, run_id, attempt in (("ci.yml", 20, 2), ("codeql.yml", 30, 1), ("optional-platforms.yml", 40, 1)): if f"/workflows/{workflow}/runs?" in endpoint: self.assertIn(f"head_sha={self.sha}", endpoint) pages = [{"workflow_runs": self.runs[workflow][:1]}, {"workflow_runs": self.runs[workflow][1:]}] @@ -422,8 +425,39 @@ def test_latest_success_and_exact_attempt_aggregate_are_required_across_all_page self.assertEqual(self.verify(), { "Required CI": {"runId": 20, "runNumber": 8, "runAttempt": 2, "jobId": 200}, "Required CodeQL": {"runId": 30, "runNumber": 15, "runAttempt": 1, "jobId": 300}, + "Optional Platform CI": {"runId": 40, "runNumber": 4, "runAttempt": 1, "jobId": 400}, }) - self.assertEqual(len(self.calls), 4) + self.assertEqual(len(self.calls), 6) + + def test_optional_platform_failure_blocks_full_but_not_explicit_scoped_qualification(self): + self.runs["optional-platforms.yml"] = [{**self.optional, "conclusion": "failure"}] + with self.assertRaisesRegex(NIGHTLY.SourceChecksNotReady, "Optional Platform CI latest"): + self.verify() + self.calls.clear() + evidence = NIGHTLY.verify_source_checks("example/project", self.sha, platform_profile="windows-linux") + self.assertEqual(set(evidence), {"Required CI", "Required CodeQL"}) + self.assertFalse(any("optional-platforms" in call[-1] for call in self.calls)) + + def test_scoped_qualification_cannot_ignore_shared_ci_or_codeql_failure(self): + for workflow in ("ci.yml", "codeql.yml"): + original = self.runs[workflow] + self.runs[workflow] = [{**original[-1], "conclusion": "failure"}] + with self.assertRaises(NIGHTLY.SourceChecksNotReady): + NIGHTLY.verify_source_checks("example/project", self.sha, platform_profile="windows-linux") + self.runs[workflow] = original + + def test_full_requires_optional_aggregate_in_exact_latest_attempt(self): + self.jobs["optional-platforms.yml"][0]["run_attempt"] = 2 + with self.assertRaisesRegex(RuntimeError, "exact latest"): + self.verify() + + def test_optional_older_success_cannot_mask_new_pending_or_failed_invocation(self): + for status, conclusion in (("in_progress", None), ("completed", "failure")): + with self.subTest(status=status): + self.runs["optional-platforms.yml"] = [self.optional, + {**self.optional, "id": 41, "run_number": 5, "status": status, "conclusion": conclusion}] + with self.assertRaises(NIGHTLY.SourceChecksNotReady): + self.verify() def test_old_success_never_masks_new_pending_failed_cancelled_or_skipped_workflow(self) -> None: for status, conclusion in (("queued", None), ("in_progress", None), ("completed", "failure"), @@ -543,5 +577,41 @@ def test_mutable_source_ref_is_rejected_before_api_calls(self) -> None: self.assertFalse(self.calls) +class TestSourceQualificationWait(unittest.TestCase): + def test_pending_then_success_rechecks_the_same_source_without_real_sleep(self): + expected = {"Required CI": {"runId": 20}} + with mock.patch.object(NIGHTLY, "verify_source_checks", side_effect=[ + NIGHTLY.SourceChecksPending("Optional Platform CI pending"), expected, + ]) as verify, mock.patch.object(NIGHTLY.time, "monotonic", side_effect=[0, 1]), \ + mock.patch.object(NIGHTLY.time, "sleep") as sleep: + self.assertEqual(NIGHTLY.wait_for_source_checks("example/project", "a" * 40), expected) + self.assertEqual(verify.call_args_list, [mock.call("example/project", "a" * 40)] * 2) + sleep.assert_called_once_with(30) + + def test_pending_then_terminal_failure_does_not_wait_or_choose_an_older_success(self): + with mock.patch.object(NIGHTLY, "verify_source_checks", side_effect=[ + NIGHTLY.SourceChecksPending("pending"), NIGHTLY.SourceChecksNotReady("new attempt failed"), + ]), mock.patch.object(NIGHTLY.time, "monotonic", side_effect=[0, 1]), \ + mock.patch.object(NIGHTLY.time, "sleep") as sleep: + with self.assertRaisesRegex(NIGHTLY.SourceChecksNotReady, "new attempt failed"): + NIGHTLY.wait_for_source_checks("example/project", "a" * 40) + sleep.assert_called_once_with(30) + + def test_pending_timeout_is_bounded_and_explicit(self): + with mock.patch.object(NIGHTLY, "verify_source_checks", side_effect=NIGHTLY.SourceChecksPending("pending")), \ + mock.patch.object(NIGHTLY.time, "monotonic", side_effect=[0, 481]), \ + mock.patch.object(NIGHTLY.time, "sleep") as sleep: + with self.assertRaisesRegex(NIGHTLY.SourceChecksNotReady, "timed out"): + NIGHTLY.wait_for_source_checks("example/project", "a" * 40) + sleep.assert_not_called() + + def test_inventory_errors_do_not_become_pending_retries(self): + with mock.patch.object(NIGHTLY, "verify_source_checks", side_effect=RuntimeError("invalid inventory")), \ + mock.patch.object(NIGHTLY.time, "sleep") as sleep: + with self.assertRaisesRegex(RuntimeError, "invalid inventory"): + NIGHTLY.wait_for_source_checks("example/project", "a" * 40) + sleep.assert_not_called() + + if __name__ == "__main__": unittest.main() diff --git a/tools/tests/test_platform_gate_graph.py b/tools/tests/test_platform_gate_graph.py new file mode 100644 index 00000000..7fcbead3 --- /dev/null +++ b/tools/tests/test_platform_gate_graph.py @@ -0,0 +1,99 @@ +"""Exercise real profile selection and aggregate scripts with failed producers.""" +import importlib.util +import os +from pathlib import Path +import shutil +import subprocess +import unittest +from types import SimpleNamespace +from unittest import mock + +import yaml + +ROOT = Path(__file__).resolve().parents[2] +SPEC = importlib.util.spec_from_file_location("platform_release_control", ROOT / "tools/release_publish.py") +PUBLISH = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(PUBLISH) + + +def jobs(name): + return yaml.safe_load((ROOT / ".github/workflows" / name).read_text(encoding="utf-8"))["jobs"] + + +class TestPlatformGraph(unittest.TestCase): + def test_selected_package_and_smoke_graphs_share_the_actual_profile_matrix(self): + release = jobs("release.yml") + selected_matrix = "${{ fromJSON(needs.prepare.outputs.native_matrix) }}" + for name in ("native-package", "native-package-smoke"): + self.assertEqual(release[name]["strategy"]["matrix"]["include"], selected_matrix) + self.assertEqual({row["slug"] for row in PUBLISH.native_matrix("full")}, { + "windows-x64", "windows-arm64", "linux-x64", "linux-arm64", "macos-x64", "macos-arm64", + }) + self.assertEqual({row["slug"] for row in PUBLISH.native_matrix("windows-linux")}, { + "windows-x64", "windows-arm64", "linux-x64", "linux-arm64", + }) + self.assertEqual(set(release["assemble-candidate"]["needs"]), { + "prepare", "native-package-smoke", "musl-package", "image-native-validation", + }) + self.assertEqual(set(release["image-candidate"]["needs"]), {"prepare", "native-package", "musl-package"}) + self.assertEqual(release["native-package-smoke"]["needs"], ["prepare", "native-package"]) + for name in ("optional-macos-package", "optional-macos-smoke"): + self.assertIn("platform_profile == 'windows-linux'", release[name]["if"]) + self.assertEqual({row["os"] for row in release[name]["strategy"]["matrix"]["include"]}, {"macos"}) + optional_artifacts = [step["with"]["name"] for name in ("optional-macos-package", "optional-macos-smoke") + for step in release[name]["steps"] + if step.get("uses", "").startswith("actions/upload-artifact@")] + self.assertTrue(any("optional-native-payload" in name for name in optional_artifacts)) + self.assertTrue(any("optional-package-smoke" in name for name in optional_artifacts)) + + def test_reduced_profile_is_only_an_explicit_unpublished_manual_candidate(self): + self.assertEqual(PUBLISH.resolve_platform_profile( + "windows-linux", event_name="workflow_dispatch", channel="candidate", publish="false"), "windows-linux") + for event, channel, publish in (("push", "beta", "false"), ("schedule", "nightly", "true"), + ("workflow_dispatch", "nightly", "false"), + ("workflow_dispatch", "candidate", "true")): + with self.subTest(event=event, channel=channel, publish=publish), self.assertRaises(RuntimeError): + PUBLISH.resolve_platform_profile("windows-linux", event_name=event, channel=channel, publish=publish) + for profile in ("unknown", "", "FULL"): + with self.assertRaises(RuntimeError): + PUBLISH.resolve_platform_profile(profile, event_name="workflow_dispatch", channel="candidate", publish="false") + + def test_optional_workflow_has_independent_concurrency_and_retains_pins(self): + main = yaml.safe_load((ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8")) + optional = yaml.safe_load((ROOT / ".github/workflows/optional-platforms.yml").read_text(encoding="utf-8")) + self.assertNotEqual(main["concurrency"]["group"], optional["concurrency"]["group"]) + self.assertEqual(main["permissions"], optional["permissions"]) + self.assertEqual(main["env"], optional["env"]) + self.assertEqual(optional["jobs"]["optional-platform-ci"]["needs"], + ["single-active-pr", "workflow-lint", "build-macos", "build-android"]) + self.assertFalse(any("optional" in name or "android" in name or "macos" in name + for name in main["jobs"]["required-ci"]["needs"])) + + def test_reduced_profile_publication_is_rejected_before_loading_any_packaging_owner(self): + for command in ("publish-images", "release-assets"): + with self.subTest(command=command), mock.patch.object(PUBLISH, "build_parser") as parser, \ + mock.patch.object(PUBLISH, "candidate_owner") as owner: + parser.return_value.parse_args.return_value = SimpleNamespace(command=command, platform_profile="windows-linux") + with self.assertRaisesRegex(RuntimeError, "Publication requires the full"): + PUBLISH.main([]) + owner.assert_not_called() + + @unittest.skipIf(os.name == "nt" or not shutil.which("bash"), "Aggregate scripts execute on Ubuntu; source/profile tests are portable") + def test_actual_aggregate_shell_fails_for_each_failed_cancelled_or_skipped_dependency(self): + for workflow, name in (("ci.yml", "required-ci"), ("optional-platforms.yml", "optional-platform-ci")): + step = jobs(workflow)[name]["steps"][0] + environment = {**os.environ, **dict.fromkeys(step["env"], "success"), "GITHUB_STEP_SUMMARY": "/dev/null"} + def execute(): + return subprocess.run(["bash", "-c", step["run"]], env=environment, + capture_output=True, text=True, timeout=5).returncode + self.assertEqual(execute(), 0) + for key in step["env"]: + for failed in ("failure", "cancelled", "skipped"): + with self.subTest(workflow=workflow, dependency=key, state=failed): + environment[key] = failed + self.assertNotEqual(execute(), 0) + environment[key] = "success" + + +if __name__ == "__main__": + unittest.main() From dee716a25bfc64c64677dc1e3abf97d703fceef8 Mon Sep 17 00:00:00 2001 From: Threepwood-7 Date: Wed, 11 Nov 2026 08:05:48 +0100 Subject: [PATCH 2/8] fix(ci): preserve platform proof and qualification deadlines (RUST-CI-035) --- tools/check_rust_client_policy.py | 12 +++++--- tools/nightly_release.py | 31 +++++++++++++------ tools/tests/test_android_release_workflow.py | 9 ++++-- tools/tests/test_check_rust_client_policy.py | 11 +++++++ tools/tests/test_nightly_release.py | 32 +++++++++++++++++--- tools/tests/test_platform_gate_graph.py | 2 +- 6 files changed, 75 insertions(+), 22 deletions(-) diff --git a/tools/check_rust_client_policy.py b/tools/check_rust_client_policy.py index 086da62f..acdfeff6 100644 --- a/tools/check_rust_client_policy.py +++ b/tools/check_rust_client_policy.py @@ -739,11 +739,13 @@ def check_required_ci_gates( errors.append(f"optional {name} must retain coordination and workflow-lint dependencies") if job.get("continue-on-error") or any(step.get("continue-on-error") for step in job.get("steps", [])): errors.append(f"optional {name} must report failures without continue-on-error") - native = {jobs.get("build-linux", {}).get("runs-on"), - *jobs.get("build-test", {}).get("strategy", {}).get("matrix", {}).get("runner", []), - *optional_jobs.get("build-macos", {}).get("strategy", {}).get("matrix", {}).get("runner", [])} - if native != {"ubuntu-24.04", "ubuntu-24.04-arm", "windows-latest", "windows-11-arm", "macos-15-intel", "macos-15"}: - errors.append("required and optional CI must preserve all six supported native targets") + primary_native = {jobs.get("build-linux", {}).get("runs-on"), + *jobs.get("build-test", {}).get("strategy", {}).get("matrix", {}).get("runner", [])} + if primary_native != {"ubuntu-24.04", "ubuntu-24.04-arm", "windows-latest", "windows-11-arm"}: + errors.append("mandatory CI must preserve native Windows/Linux x64 and ARM64 targets") + optional_native = set(optional_jobs.get("build-macos", {}).get("strategy", {}).get("matrix", {}).get("runner", [])) + if optional_native != {"macos-15-intel", "macos-15"}: + errors.append("optional CI must preserve both native macOS targets") return errors diff --git a/tools/nightly_release.py b/tools/nightly_release.py index 3d4759e1..630e6b53 100644 --- a/tools/nightly_release.py +++ b/tools/nightly_release.py @@ -52,7 +52,8 @@ class Change: category: str -def run(command: Sequence[str], *, cwd: Path = ROOT) -> subprocess.CompletedProcess[str]: +def run(command: Sequence[str], *, cwd: Path = ROOT, + timeout: float | None = None) -> subprocess.CompletedProcess[str]: """Runs one checked command and captures its text output.""" return subprocess.run( @@ -62,6 +63,7 @@ def run(command: Sequence[str], *, cwd: Path = ROOT) -> subprocess.CompletedProc text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + timeout=timeout, ) @@ -531,9 +533,16 @@ def write_release_documents(*, version: str, channel: str, source_sha: str, sour stream.write(content) -def _action_rows(endpoint: str, field: str) -> list[dict[str, object]]: +def _action_rows(endpoint: str, field: str, *, deadline: float | None = None) -> list[dict[str, object]]: """Read every Actions page, rejecting incomplete or malformed inventories.""" - response = run(("gh", "api", "--paginate", "--slurp", endpoint)) + # WHY: the qualification deadline must also bound a stalled API request. + timeout = 120 if deadline is None else min(120, deadline - time.monotonic()) + if timeout <= 0: + raise SourceChecksNotReady("Exact-source qualification timed out before an Actions request") + try: + response = run(("gh", "api", "--paginate", "--slurp", endpoint), timeout=timeout) + except subprocess.TimeoutExpired as error: + raise SourceChecksNotReady("Exact-source qualification Actions request timed out") from error pages = json.loads(response.stdout) if not isinstance(pages, list) or not pages: raise RuntimeError("Actions eligibility requires a paginated response") @@ -548,7 +557,8 @@ def _action_rows(endpoint: str, field: str) -> list[dict[str, object]]: return rows -def verify_source_checks(repository: str, sha: str, *, platform_profile: str = "full") -> dict[str, dict[str, int]]: +def verify_source_checks(repository: str, sha: str, *, platform_profile: str = "full", + deadline: float | None = None) -> dict[str, dict[str, int]]: """Require exact latest workflow attempts for the explicit caller target profile.""" if (re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository) is None or re.fullmatch(r"[0-9a-f]{40}", sha) is None): @@ -562,7 +572,8 @@ def verify_source_checks(repository: str, sha: str, *, platform_profile: str = " workflows.update(OPTIONAL_PLATFORM_WORKFLOW) evidence = {} for check, workflow in workflows.items(): - rows = _action_rows(f"repos/{repository}/actions/workflows/{workflow}/runs?head_sha={sha}&per_page=100", "workflow_runs") + rows = _action_rows(f"repos/{repository}/actions/workflows/{workflow}/runs?head_sha={sha}&per_page=100", + "workflow_runs", deadline=deadline) if not rows: raise SourceChecksPending(f"No {check} workflow invocation for source {sha}") # WHY: this endpoint already filters by source. Dropping malformed or @@ -593,7 +604,8 @@ def verify_source_checks(repository: str, sha: str, *, platform_profile: str = " raise SourceChecksPending(f"{check} latest workflow attempt must complete successfully") if conclusion != "success": raise SourceChecksNotReady(f"{check} latest workflow attempt must complete successfully") - jobs = _action_rows(f"repos/{repository}/actions/runs/{latest['id']}/attempts/{latest['run_attempt']}/jobs?per_page=100", "jobs") + jobs = _action_rows(f"repos/{repository}/actions/runs/{latest['id']}/attempts/{latest['run_attempt']}/jobs?per_page=100", + "jobs", deadline=deadline) aggregate = [job for job in jobs if job.get("name") == check] if len(aggregate) != 1: raise RuntimeError(f"{check} latest attempt must contain exactly one required aggregate job") @@ -616,9 +628,10 @@ def wait_for_source_checks(repository: str, sha: str, *, platform_profile: str = deadline = time.monotonic() + timeout_seconds while True: try: - if platform_profile == "full": - return verify_source_checks(repository, sha) - return verify_source_checks(repository, sha, platform_profile=platform_profile) + evidence = verify_source_checks(repository, sha, platform_profile=platform_profile, deadline=deadline) + if time.monotonic() > deadline: + raise SourceChecksNotReady("Exact-source qualification timed out after checking Actions") + return evidence except SourceChecksPending as error: remaining = deadline - time.monotonic() if remaining <= 0: diff --git a/tools/tests/test_android_release_workflow.py b/tools/tests/test_android_release_workflow.py index 6323c8f1..0a0abaf4 100644 --- a/tools/tests/test_android_release_workflow.py +++ b/tools/tests/test_android_release_workflow.py @@ -9,6 +9,7 @@ ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT / "tools")) import check_rust_client_policy as POLICY +import release_publish as PUBLISH class AndroidReleaseWorkflowTests(unittest.TestCase): @@ -76,15 +77,17 @@ def test_only_four_public_files_per_abi_flow_into_unified_candidate(self): "${{ runner.temp }}/emulebb-rust-out/release/rust-v${{ env.RELEASE_VERSION }}") def test_existing_native_musl_and_oci_proofs_keep_full_inventory(self): - self.assertEqual(len(self.jobs["native-package"]["strategy"]["matrix"]["include"]), 6) - self.assertEqual(len(self.jobs["native-package-smoke"]["strategy"]["matrix"]["include"]), 6) + self.assertEqual(len(PUBLISH.native_matrix("full")), 6) + for job in ("native-package", "native-package-smoke"): + self.assertEqual(self.jobs[job]["strategy"]["matrix"]["include"], + "${{ fromJSON(needs.prepare.outputs.native_matrix) }}") self.assertEqual({row["architecture"] for row in self.jobs["musl-package"]["strategy"]["matrix"]["include"]}, {"amd64", "arm64"}) self.assertEqual(self.jobs["image-candidate"]["strategy"]["matrix"]["variant"], ["ubuntu", "alpine"]) images = self.jobs["image-native-validation"]["strategy"]["matrix"] self.assertEqual(images["variant"], ["ubuntu", "alpine"]) self.assertEqual({row["architecture"] for row in images["include"]}, {"amd64", "arm64"}) - self.assertEqual(self.text.count("smoke-rust-release-package.py"), 1) + self.assertEqual(self.text.count("smoke-rust-release-package.py"), 2) self.assertNotIn("package-smoke", str(self.jobs["android-package"])) def test_android_uses_pinned_frontend_and_compiler_recipe_inputs(self): diff --git a/tools/tests/test_check_rust_client_policy.py b/tools/tests/test_check_rust_client_policy.py index 94d4d7c1..3f41e9b9 100644 --- a/tools/tests/test_check_rust_client_policy.py +++ b/tools/tests/test_check_rust_client_policy.py @@ -325,6 +325,17 @@ def test_android_failure_cannot_be_omitted_from_optional_outcomes(self) -> None: errors = CHECKER.check_required_ci_gates(optional_text=optional.replace('"$ANDROID_RESULT"', '"success"')) self.assertTrue(any("Android build result verification" in error for error in errors)) + def test_native_arm64_cannot_move_from_mandatory_to_optional_ci(self) -> None: + import yaml + ci = yaml.safe_load((CHECKER.ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8")) + optional = yaml.safe_load( + (CHECKER.ROOT / ".github/workflows/optional-platforms.yml").read_text(encoding="utf-8") + ) + ci["jobs"]["build-test"]["strategy"]["matrix"]["runner"].remove("windows-11-arm") + optional["jobs"]["build-macos"]["strategy"]["matrix"]["runner"].append("windows-11-arm") + errors = CHECKER.check_required_ci_gates(yaml.safe_dump(ci), optional_text=yaml.safe_dump(optional)) + self.assertTrue(any("mandatory CI must preserve native Windows/Linux" in error for error in errors)) + class TestDefaultBranchPolicy(unittest.TestCase): def test_current_policy_is_single_maintainer_safe_and_fail_closed(self) -> None: diff --git a/tools/tests/test_nightly_release.py b/tools/tests/test_nightly_release.py index dd7fd471..026ac95c 100644 --- a/tools/tests/test_nightly_release.py +++ b/tools/tests/test_nightly_release.py @@ -141,7 +141,9 @@ def test_scheduled_nightly_qualifies_its_own_checkout(self): self.assertEqual(values["sha"], "a" * 40) self.assertEqual(values["should_build"], "true") self.assertEqual(values["skip_reason"], "") - verify.assert_called_once_with("emulebb/emulebb-rust", "a" * 40) + verify.assert_called_once_with( + "emulebb/emulebb-rust", "a" * 40, platform_profile="full", deadline=mock.ANY, + ) version.assert_called_once_with("a" * 40) def test_unqualified_checkout_skips_without_selecting_an_older_source(self): @@ -150,7 +152,9 @@ def test_unqualified_checkout_skips_without_selecting_an_older_source(self): self.assertEqual(values["sha"], "a" * 40) self.assertEqual(values["should_build"], "false") self.assertEqual(values["skip_reason"], reason) - verify.assert_called_once_with("emulebb/emulebb-rust", "a" * 40) + verify.assert_called_once_with( + "emulebb/emulebb-rust", "a" * 40, platform_profile="full", deadline=mock.ANY, + ) version.assert_called_once_with("a" * 40) def test_source_inventory_and_transport_errors_are_not_normal_skips(self): @@ -582,10 +586,12 @@ def test_pending_then_success_rechecks_the_same_source_without_real_sleep(self): expected = {"Required CI": {"runId": 20}} with mock.patch.object(NIGHTLY, "verify_source_checks", side_effect=[ NIGHTLY.SourceChecksPending("Optional Platform CI pending"), expected, - ]) as verify, mock.patch.object(NIGHTLY.time, "monotonic", side_effect=[0, 1]), \ + ]) as verify, mock.patch.object(NIGHTLY.time, "monotonic", side_effect=[0, 1, 2]), \ mock.patch.object(NIGHTLY.time, "sleep") as sleep: self.assertEqual(NIGHTLY.wait_for_source_checks("example/project", "a" * 40), expected) - self.assertEqual(verify.call_args_list, [mock.call("example/project", "a" * 40)] * 2) + self.assertEqual(verify.call_args_list, [mock.call( + "example/project", "a" * 40, platform_profile="full", deadline=480, + )] * 2) sleep.assert_called_once_with(30) def test_pending_then_terminal_failure_does_not_wait_or_choose_an_older_success(self): @@ -612,6 +618,24 @@ def test_inventory_errors_do_not_become_pending_retries(self): NIGHTLY.wait_for_source_checks("example/project", "a" * 40) sleep.assert_not_called() + def test_actions_request_uses_only_the_remaining_budget(self): + response = subprocess.CompletedProcess([], 0, stdout='[{"workflow_runs": []}]') + with mock.patch.object(NIGHTLY.time, "monotonic", return_value=8), \ + mock.patch.object(NIGHTLY, "run", return_value=response) as run: + self.assertEqual(NIGHTLY._action_rows("synthetic", "workflow_runs", deadline=10), []) + self.assertEqual(run.call_args.kwargs["timeout"], 2) + + def test_actions_timeout_is_terminal_for_this_qualification(self): + with mock.patch.object(NIGHTLY, "run", side_effect=subprocess.TimeoutExpired("gh", 120)): + with self.assertRaisesRegex(NIGHTLY.SourceChecksNotReady, "Actions request timed out"): + NIGHTLY._action_rows("synthetic", "workflow_runs") + + def test_success_after_the_deadline_cannot_qualify_source(self): + with mock.patch.object(NIGHTLY, "verify_source_checks", return_value={}), \ + mock.patch.object(NIGHTLY.time, "monotonic", side_effect=[0, 481]): + with self.assertRaisesRegex(NIGHTLY.SourceChecksNotReady, "timed out after"): + NIGHTLY.wait_for_source_checks("example/project", "a" * 40) + if __name__ == "__main__": unittest.main() diff --git a/tools/tests/test_platform_gate_graph.py b/tools/tests/test_platform_gate_graph.py index 7fcbead3..f14b0244 100644 --- a/tools/tests/test_platform_gate_graph.py +++ b/tools/tests/test_platform_gate_graph.py @@ -33,7 +33,7 @@ def test_selected_package_and_smoke_graphs_share_the_actual_profile_matrix(self) "windows-x64", "windows-arm64", "linux-x64", "linux-arm64", }) self.assertEqual(set(release["assemble-candidate"]["needs"]), { - "prepare", "native-package-smoke", "musl-package", "image-native-validation", + "prepare", "native-package-smoke", "musl-package", "image-native-validation", "android-package", }) self.assertEqual(set(release["image-candidate"]["needs"]), {"prepare", "native-package", "musl-package"}) self.assertEqual(release["native-package-smoke"]["needs"], ["prepare", "native-package"]) From 72b31c42e980ba0880b09190fa1bd8cfd83f77cc Mon Sep 17 00:00:00 2001 From: Threepwood-7 Date: Wed, 11 Nov 2026 08:08:08 +0100 Subject: [PATCH 3/8] ci(platforms): explain required aggregate boundary (RUST-CI-035) --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93267d4c..f8f13c2b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -619,6 +619,8 @@ jobs: if-no-files-found: warn retention-days: 14 + # WHY: separate optional workflows let this merge aggregate finish while + # macOS or Android validation remains pending or reports a platform failure. required-ci: name: Required CI if: ${{ always() }} From 0c1098951b851653d00e9607807dfa7782b2598a Mon Sep 17 00:00:00 2001 From: Threepwood-7 Date: Wed, 11 Nov 2026 08:28:40 +0100 Subject: [PATCH 4/8] refactor(ci): share native package and smoke recipes (RUST-CI-035) --- .github/workflows/release.yml | 169 ++----------------- tools/check_rust_client_policy.py | 9 +- tools/tests/test_android_release_workflow.py | 2 +- tools/tests/test_check_rust_client_policy.py | 4 +- tools/tests/test_platform_gate_graph.py | 22 ++- 5 files changed, 38 insertions(+), 168 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ce2cf994..d1d2597f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -160,13 +160,14 @@ jobs: matrix: include: ${{ fromJSON(needs.prepare.outputs.native_matrix) }} env: + NATIVE_PAYLOAD_PREFIX: emulebb-rust-native-payload RELEASE_VERSION: ${{ needs.prepare.outputs.release_version }} EMULEBB_WORKSPACE_ROOT: ${{ github.workspace }} EMULEBB_RUST_REPO: ${{ github.workspace }} EMULEBB_TOOLING_REPO: ${{ github.workspace }}/.ci/emulebb-tooling MINIUPNP_ROOT: ${{ github.workspace }}/.ci/emulebb-miniupnp PCPNATPMP_ROOT: ${{ github.workspace }}/.ci/emulebb-libpcpnatpmp - steps: + steps: &native-package-steps - name: Checkout candidate uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -268,7 +269,7 @@ jobs: - name: Retain package and provenance uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: - name: emulebb-rust-native-payload-${{ matrix.slug }} + name: ${{ env.NATIVE_PAYLOAD_PREFIX }}-${{ matrix.slug }} path: | ${{ runner.temp }}/emulebb-rust-out/release/rust-v${{ env.RELEASE_VERSION }}/*.zip ${{ runner.temp }}/emulebb-rust-out/release/rust-v${{ env.RELEASE_VERSION }}/*.deb @@ -288,8 +289,10 @@ jobs: matrix: include: ${{ fromJSON(needs.prepare.outputs.native_matrix) }} env: + NATIVE_PAYLOAD_PREFIX: emulebb-rust-native-payload + PACKAGE_SMOKE_PREFIX: emulebb-rust-package-smoke RELEASE_VERSION: ${{ needs.prepare.outputs.release_version }} - steps: + steps: &native-package-smoke-steps - name: Checkout trusted release-control source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -318,14 +321,14 @@ jobs: - name: Download native package uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: - name: emulebb-rust-native-payload-${{ matrix.slug }} + name: ${{ env.NATIVE_PAYLOAD_PREFIX }}-${{ matrix.slug }} path: ${{ runner.temp }}/emulebb-rust-out/release/rust-v${{ env.RELEASE_VERSION }} - name: Prove packaged lifecycle, local transfer, and embedded WebUI run: python .ci/emulebb-build-tests/scripts/smoke-rust-release-package.py --release-dir "${{ runner.temp }}/emulebb-rust-out/release/rust-v${{ env.RELEASE_VERSION }}" --release-version ${{ env.RELEASE_VERSION }} --rust-repo .ci/candidate --expected-source-sha "${{ needs.prepare.outputs.source_sha }}" --report-path "${{ runner.temp }}/emulebb-rust-out/evidence/emulebb-rust-v${{ env.RELEASE_VERSION }}-package-smoke-${{ matrix.slug }}.json" --target-os ${{ matrix.os }} --platform ${{ matrix.arch }} ${{ matrix.os == 'windows' && matrix.arch == 'x64' && '--render-webui' || '' }} - name: Retain package lifecycle evidence uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: - name: emulebb-rust-package-smoke-${{ matrix.slug }} + name: ${{ env.PACKAGE_SMOKE_PREFIX }}-${{ matrix.slug }} path: ${{ runner.temp }}/emulebb-rust-out/evidence/emulebb-rust-v${{ env.RELEASE_VERSION }}-package-smoke-${{ matrix.slug }}.json if-no-files-found: error retention-days: 30 @@ -343,123 +346,14 @@ jobs: - {os: macos, arch: x64, slug: macos-x64, runner: macos-15-intel} - {os: macos, arch: ARM64, slug: macos-arm64, runner: macos-15} env: + NATIVE_PAYLOAD_PREFIX: emulebb-rust-optional-native-payload RELEASE_VERSION: ${{ needs.prepare.outputs.release_version }} EMULEBB_WORKSPACE_ROOT: ${{ github.workspace }} EMULEBB_RUST_REPO: ${{ github.workspace }} EMULEBB_TOOLING_REPO: ${{ github.workspace }}/.ci/emulebb-tooling MINIUPNP_ROOT: ${{ github.workspace }}/.ci/emulebb-miniupnp PCPNATPMP_ROOT: ${{ github.workspace }}/.ci/emulebb-libpcpnatpmp - steps: - - name: Checkout candidate - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - ref: ${{ github.workflow_sha }} - - name: Checkout native MiniUPnP source - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - repository: emulebb/emulebb-miniupnp - ref: ${{ env.MINIUPNP_REF }} - path: .ci/emulebb-miniupnp - - name: Checkout native libpcpnatpmp source - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - repository: emulebb/emulebb-libpcpnatpmp - ref: ${{ env.PCPNATPMP_REF }} - path: .ci/emulebb-libpcpnatpmp - - name: Checkout pinned workspace packaging - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - repository: emulebb/emulebb-build - ref: ${{ env.EMULEBB_BUILD_REF }} - path: .ci/emulebb-build - - name: Checkout pinned release documentation - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - repository: emulebb/emulebb-tooling - ref: ${{ env.EMULEBB_TOOLING_REF }} - path: .ci/emulebb-tooling - - name: Install pinned Rust - uses: dtolnay/rust-toolchain@ce678459e9fc7500d337468f904b95f1b5c10b5e # 1.98.1 - - name: Set up Python - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 - with: - python-version: "3.15.2" - - name: Install pinned workspace CLI dependencies - run: python -m pip install -r tools/requirements-ci.txt click==8.4.1 pydantic==2.14.3 - - name: Set up Node.js - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version: "26.13.0" - package-manager-cache: false - - name: Install pinned npm - run: npm install --global npm@11.20.0 - - - name: Install Linux build dependencies - if: matrix.os == 'linux' - run: sudo apt-get update && sudo apt-get install --no-install-recommends -y dpkg-dev libfontconfig1-dev - - name: Install verified AppImage builder - if: matrix.os == 'linux' - shell: bash - run: | - if [[ '${{ matrix.arch }}' == 'ARM64' ]]; then - tool_arch=aarch64 - expected=f0837e7448a0c1e4e650a93bb3e85802546e60654ef287576f46c71c126a9158 - else - tool_arch=x86_64 - expected=ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0 - fi - tool="$RUNNER_TEMP/appimagetool-${tool_arch}.AppImage" - curl --fail --location --silent --show-error \ - "https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-${tool_arch}.AppImage" \ - --output "$tool" - echo "$expected $tool" | sha256sum --check - chmod 755 "$tool" - { - echo "APPIMAGETOOL=$tool" - echo "APPIMAGE_EXTRACT_AND_RUN=1" - echo "ARCH=$tool_arch" - } >> "$GITHUB_ENV" - - name: Assert release build excludes test-only features - run: python tools/check_rust_client_policy.py - - name: Prepare inherited build output directory - env: - CARGO_TARGET_DIR: ${{ runner.temp }}/emulebb-rust-out/builds/rust/target - run: python -c "from pathlib import Path; import os; Path(os.environ['CARGO_TARGET_DIR']).mkdir(parents=True, exist_ok=True)" - - name: Build and package native release asset - working-directory: .ci/emulebb-build - env: - EMULEBB_WORKSPACE_OUTPUT_ROOT: ${{ runner.temp }}/emulebb-rust-out - CARGO_TARGET_DIR: ${{ runner.temp }}/emulebb-rust-out/builds/rust/target - EMULEBB_RELEASE_VERSION: ${{ env.RELEASE_VERSION }} - EMULEBB_RELEASE_CHANNEL: ${{ env.RELEASE_CHANNEL }} - EMULEBB_SOURCE_REVISION: ${{ needs.prepare.outputs.source_sha }} - RUSTUP_TOOLCHAIN: "1.98.1" - run: python -m emule_workspace package-emulebb-rust-ci --release-version ${{ env.RELEASE_VERSION }} --target-os ${{ matrix.os }} --platform ${{ matrix.arch }} - - name: Retain build diagnostics - if: always() - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: emulebb-rust-build-logs-${{ matrix.os }}-${{ matrix.arch }} - path: ${{ runner.temp }}/emulebb-rust-out/logs/builds/**/*.log - if-no-files-found: warn - retention-days: 30 - - name: Retain package and provenance - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: emulebb-rust-optional-native-payload-${{ matrix.slug }} - path: | - ${{ runner.temp }}/emulebb-rust-out/release/rust-v${{ env.RELEASE_VERSION }}/*.zip - ${{ runner.temp }}/emulebb-rust-out/release/rust-v${{ env.RELEASE_VERSION }}/*.deb - ${{ runner.temp }}/emulebb-rust-out/release/rust-v${{ env.RELEASE_VERSION }}/*.AppImage - ${{ runner.temp }}/emulebb-rust-out/release/rust-v${{ env.RELEASE_VERSION }}/*.dmg - ${{ runner.temp }}/emulebb-rust-out/release/rust-v${{ env.RELEASE_VERSION }}/*.json - if-no-files-found: error - retention-days: 30 + steps: *native-package-steps optional-macos-smoke: if: ${{ needs.prepare.outputs.platform_profile == 'windows-linux' }} @@ -474,47 +368,10 @@ jobs: - {os: macos, arch: x64, slug: macos-x64, runner: macos-15-intel} - {os: macos, arch: ARM64, slug: macos-arm64, runner: macos-15} env: + NATIVE_PAYLOAD_PREFIX: emulebb-rust-optional-native-payload + PACKAGE_SMOKE_PREFIX: emulebb-rust-optional-package-smoke RELEASE_VERSION: ${{ needs.prepare.outputs.release_version }} - steps: - - name: Checkout trusted release-control source - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - ref: ${{ github.workflow_sha }} - - name: Checkout candidate schema as data - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.workflow_sha }} - persist-credentials: false - path: .ci/candidate - - name: Checkout pinned package smoke harness - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - repository: emulebb/emulebb-build-tests - ref: ${{ env.EMULEBB_BUILD_TESTS_REF }} - path: .ci/emulebb-build-tests - - name: Set up Python - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 - with: - python-version: "3.15.2" - - name: Install pinned rendered WebUI smoke dependency - if: matrix.os == 'windows' && matrix.arch == 'x64' - run: python -m pip install playwright==1.59.0 && python -m playwright install chromium - - name: Download native package - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - name: emulebb-rust-optional-native-payload-${{ matrix.slug }} - path: ${{ runner.temp }}/emulebb-rust-out/release/rust-v${{ env.RELEASE_VERSION }} - - name: Prove packaged lifecycle, local transfer, and embedded WebUI - run: python .ci/emulebb-build-tests/scripts/smoke-rust-release-package.py --release-dir "${{ runner.temp }}/emulebb-rust-out/release/rust-v${{ env.RELEASE_VERSION }}" --release-version ${{ env.RELEASE_VERSION }} --rust-repo .ci/candidate --expected-source-sha "${{ needs.prepare.outputs.source_sha }}" --report-path "${{ runner.temp }}/emulebb-rust-out/evidence/emulebb-rust-v${{ env.RELEASE_VERSION }}-package-smoke-${{ matrix.slug }}.json" --target-os ${{ matrix.os }} --platform ${{ matrix.arch }} ${{ matrix.os == 'windows' && matrix.arch == 'x64' && '--render-webui' || '' }} - - name: Retain package lifecycle evidence - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: emulebb-rust-optional-package-smoke-${{ matrix.slug }} - path: ${{ runner.temp }}/emulebb-rust-out/evidence/emulebb-rust-v${{ env.RELEASE_VERSION }}-package-smoke-${{ matrix.slug }}.json - if-no-files-found: error - retention-days: 30 + steps: *native-package-smoke-steps optional-release-platforms: name: Optional release platform outcomes diff --git a/tools/check_rust_client_policy.py b/tools/check_rust_client_policy.py index acdfeff6..9a404026 100644 --- a/tools/check_rust_client_policy.py +++ b/tools/check_rust_client_policy.py @@ -1481,7 +1481,8 @@ def check_release_output_paths(workflow_text: str | None = None) -> list[str]: "smoke-rust-release-package.py": "native package and WebUI smoke", "--rust-repo .": "package smoke source schema", "--report-path \"${{ runner.temp }}/emulebb-rust-out/evidence/emulebb-rust-v${{ env.RELEASE_VERSION }}-package-smoke-${{ matrix.slug }}.json\"": "package lifecycle report output", - "name: emulebb-rust-package-smoke-${{ matrix.slug }}": "package lifecycle report retention", + "PACKAGE_SMOKE_PREFIX: emulebb-rust-package-smoke": "primary package lifecycle report namespace", + "name: ${{ env.PACKAGE_SMOKE_PREFIX }}-${{ matrix.slug }}": "package lifecycle report retention", "pattern: emulebb-rust-package-smoke-*": "package lifecycle report assembly", "validate-emulebb-rust-image-ci": "native first-run LinuxServer image smoke", "${{ runner.temp }}/emulebb-rust-out/release/": "external release assets", @@ -1502,10 +1503,8 @@ def check_release_output_paths(workflow_text: str | None = None) -> list[str]: for fragment, description in required.items() if fragment not in text ] - # Each disjoint matrix has one smoke entrypoint; optional results stay out - # of the selected candidate's dependency graph and artifact namespace. - smoke_count = 2 if re.search(r"(?m)^ optional-macos-smoke:", text) else 1 - if text.count("smoke-rust-release-package.py") != smoke_count: + # Each job expands the same anchored recipe once for every selected CPU. + if text.count("smoke-rust-release-package.py") != 1: errors.append( ".github/workflows/release.yml must run smoke-rust-release-package.py " "exactly once per native matrix target" diff --git a/tools/tests/test_android_release_workflow.py b/tools/tests/test_android_release_workflow.py index 0a0abaf4..9aff9691 100644 --- a/tools/tests/test_android_release_workflow.py +++ b/tools/tests/test_android_release_workflow.py @@ -87,7 +87,7 @@ def test_existing_native_musl_and_oci_proofs_keep_full_inventory(self): images = self.jobs["image-native-validation"]["strategy"]["matrix"] self.assertEqual(images["variant"], ["ubuntu", "alpine"]) self.assertEqual({row["architecture"] for row in images["include"]}, {"amd64", "arm64"}) - self.assertEqual(self.text.count("smoke-rust-release-package.py"), 2) + self.assertEqual(self.text.count("smoke-rust-release-package.py"), 1) self.assertNotIn("package-smoke", str(self.jobs["android-package"])) def test_android_uses_pinned_frontend_and_compiler_recipe_inputs(self): diff --git a/tools/tests/test_check_rust_client_policy.py b/tools/tests/test_check_rust_client_policy.py index 3f41e9b9..5dd9688c 100644 --- a/tools/tests/test_check_rust_client_policy.py +++ b/tools/tests/test_check_rust_client_policy.py @@ -833,10 +833,12 @@ def test_accepts_external_release_paths(self) -> None: steps: [] native-package-smoke: needs: [prepare, native-package] + env: + PACKAGE_SMOKE_PREFIX: emulebb-rust-package-smoke steps: - ref: ${{ github.workflow_sha }} - run: smoke-rust-release-package.py --rust-repo . --report-path "${{ runner.temp }}/emulebb-rust-out/evidence/emulebb-rust-v${{ env.RELEASE_VERSION }}-package-smoke-${{ matrix.slug }}.json" --render-webui - - name: emulebb-rust-package-smoke-${{ matrix.slug }} + - name: ${{ env.PACKAGE_SMOKE_PREFIX }}-${{ matrix.slug }} - pattern: emulebb-rust-package-smoke-* package-emulebb-rust-ci --release-version 0.1.0-beta.2 --target-os ${{ matrix.os }} --platform ${{ matrix.arch }} validate-emulebb-rust-image-ci diff --git a/tools/tests/test_platform_gate_graph.py b/tools/tests/test_platform_gate_graph.py index f14b0244..2e843067 100644 --- a/tools/tests/test_platform_gate_graph.py +++ b/tools/tests/test_platform_gate_graph.py @@ -40,11 +40,23 @@ def test_selected_package_and_smoke_graphs_share_the_actual_profile_matrix(self) for name in ("optional-macos-package", "optional-macos-smoke"): self.assertIn("platform_profile == 'windows-linux'", release[name]["if"]) self.assertEqual({row["os"] for row in release[name]["strategy"]["matrix"]["include"]}, {"macos"}) - optional_artifacts = [step["with"]["name"] for name in ("optional-macos-package", "optional-macos-smoke") - for step in release[name]["steps"] - if step.get("uses", "").startswith("actions/upload-artifact@")] - self.assertTrue(any("optional-native-payload" in name for name in optional_artifacts)) - self.assertTrue(any("optional-package-smoke" in name for name in optional_artifacts)) + self.assertIs(release["native-package"]["steps"], release["optional-macos-package"]["steps"]) + self.assertIs(release["native-package-smoke"]["steps"], release["optional-macos-smoke"]["steps"]) + for name, payload, smoke in ( + ("native-package", "emulebb-rust-native-payload", None), + ("optional-macos-package", "emulebb-rust-optional-native-payload", None), + ("native-package-smoke", "emulebb-rust-native-payload", "emulebb-rust-package-smoke"), + ("optional-macos-smoke", "emulebb-rust-optional-native-payload", "emulebb-rust-optional-package-smoke"), + ): + job = release[name] + self.assertEqual(job["env"]["NATIVE_PAYLOAD_PREFIX"], payload) + if smoke: + self.assertEqual(job["env"]["PACKAGE_SMOKE_PREFIX"], smoke) + artifacts = [step["with"]["name"] for step in job["steps"] + if step.get("uses", "").startswith(("actions/upload-artifact@", "actions/download-artifact@"))] + self.assertIn("${{ env.NATIVE_PAYLOAD_PREFIX }}-${{ matrix.slug }}", artifacts) + if smoke: + self.assertIn("${{ env.PACKAGE_SMOKE_PREFIX }}-${{ matrix.slug }}", artifacts) def test_reduced_profile_is_only_an_explicit_unpublished_manual_candidate(self): self.assertEqual(PUBLISH.resolve_platform_profile( From 470ce155222732be4df80220b54d5b9ac1fe1940 Mon Sep 17 00:00:00 2001 From: Threepwood-7 Date: Tue, 10 Nov 2026 13:30:56 +0100 Subject: [PATCH 5/8] ci: explicitly cache dependency crates only (Build #37) --- .github/workflows/ci.yml | 4 ++++ tools/tests/test_ci_environment.py | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8f13c2b..99cd3c89 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -184,6 +184,8 @@ jobs: - name: Cache cargo build uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + with: + cache-workspace-crates: false env: CARGO_TARGET_DIR: ${{ runner.temp }}/emulebb-rust-out/builds/rust/target @@ -276,6 +278,8 @@ jobs: - name: Cache cargo build uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + with: + cache-workspace-crates: false env: CARGO_TARGET_DIR: ${{ runner.temp }}/emulebb-rust-out/builds/rust/target diff --git a/tools/tests/test_ci_environment.py b/tools/tests/test_ci_environment.py index 0e5327b5..8fd1864f 100644 --- a/tools/tests/test_ci_environment.py +++ b/tools/tests/test_ci_environment.py @@ -73,6 +73,14 @@ def test_successful_probe_keeps_version_parsing_and_bounds_unrecognized_output(s ENV.command_version(["cargo", "deny", "--version"], r"cargo-deny (\S+)") self.assertLess(len(str(failure.exception).encode("utf-8")), ENV.VERSION_PROBE_DIAGNOSTIC_BYTES + 100) + def test_hosted_cargo_cache_explicitly_excludes_workspace_crates(self): + workflow = yaml.safe_load((ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8")) + caches = [step for job in workflow["jobs"].values() for step in job.get("steps", []) + if step.get("uses", "").startswith("Swatinem/rust-cache@")] + self.assertEqual(len(caches), 2) + for step in caches: + self.assertIs(step["with"]["cache-workspace-crates"], False) + def test_native_sources_are_stable_across_canonical_and_worktree_checkouts(self): with tempfile.TemporaryDirectory() as directory: workspace = Path(directory) From f9b5e9a08c7da5deee807f39dac86e1dea3d75a2 Mon Sep 17 00:00:00 2001 From: Threepwood-7 Date: Wed, 11 Nov 2026 12:35:14 +0100 Subject: [PATCH 6/8] ci(platforms): bind merged profile provider and optional cache (RUST-CI-035) --- .github/workflows/ci.yml | 4 ++-- .github/workflows/optional-platforms.yml | 6 ++++-- .github/workflows/release.yml | 2 +- tools/tests/test_ci_environment.py | 14 ++++++++------ 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99cd3c89..9826d3d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,8 +17,8 @@ permissions: env: CARGO_TERM_COLOR: always RUST_BACKTRACE: 1 - EMULEBB_BUILD_REF: 3135ee1d106637871d884e5f628bcd4ca596863c - EMULEBB_ANDROID_BUILD_REF: 3135ee1d106637871d884e5f628bcd4ca596863c + EMULEBB_BUILD_REF: c85fa0aee9fe503d5b939ea990941452b2dbc9a7 + EMULEBB_ANDROID_BUILD_REF: c85fa0aee9fe503d5b939ea990941452b2dbc9a7 EMULEBB_BUILD_TESTS_REF: 20a007f34d88b99c40d456cfe1dce3d0537ba00d EMULEBB_TOOLING_REF: 856e0499d12e281697618cc81a0b09805e96f59e # Pin the native NAT sources linked by the sys crates. diff --git a/.github/workflows/optional-platforms.yml b/.github/workflows/optional-platforms.yml index 02dfe28d..df692c8a 100644 --- a/.github/workflows/optional-platforms.yml +++ b/.github/workflows/optional-platforms.yml @@ -17,8 +17,8 @@ permissions: env: CARGO_TERM_COLOR: always RUST_BACKTRACE: 1 - EMULEBB_BUILD_REF: 3135ee1d106637871d884e5f628bcd4ca596863c - EMULEBB_ANDROID_BUILD_REF: 3135ee1d106637871d884e5f628bcd4ca596863c + EMULEBB_BUILD_REF: c85fa0aee9fe503d5b939ea990941452b2dbc9a7 + EMULEBB_ANDROID_BUILD_REF: c85fa0aee9fe503d5b939ea990941452b2dbc9a7 EMULEBB_BUILD_TESTS_REF: 20a007f34d88b99c40d456cfe1dce3d0537ba00d EMULEBB_TOOLING_REF: 856e0499d12e281697618cc81a0b09805e96f59e # Pin the native NAT sources linked by the sys crates. @@ -190,6 +190,8 @@ jobs: - name: Cache cargo build uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + with: + cache-workspace-crates: false env: CARGO_TARGET_DIR: ${{ runner.temp }}/emulebb-rust-out/builds/rust/target diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d1d2597f..a8a1bd09 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,7 +51,7 @@ permissions: env: RELEASE_PLATFORM_PROFILE: ${{ inputs.platform_profile || 'full' }} RELEASE_CHANNEL: ${{ inputs.channel || (startsWith(github.ref, 'refs/tags/rust-v') && 'beta') || 'candidate' }} - EMULEBB_BUILD_REF: b13ddb7cc4341d0d063c687ed8786e0311c45eba + EMULEBB_BUILD_REF: c85fa0aee9fe503d5b939ea990941452b2dbc9a7 EMULEBB_BUILD_TESTS_REF: 20a007f34d88b99c40d456cfe1dce3d0537ba00d EMULEBB_TOOLING_REF: c9aa7c7b5c0a298a6ba3b0485f29d9f4c86b5d05 MINIUPNP_REF: 4e7a10948004ca2085ac8103e14fc77edb898b4c diff --git a/tools/tests/test_ci_environment.py b/tools/tests/test_ci_environment.py index 8fd1864f..a44a0c51 100644 --- a/tools/tests/test_ci_environment.py +++ b/tools/tests/test_ci_environment.py @@ -74,12 +74,14 @@ def test_successful_probe_keeps_version_parsing_and_bounds_unrecognized_output(s self.assertLess(len(str(failure.exception).encode("utf-8")), ENV.VERSION_PROBE_DIAGNOSTIC_BYTES + 100) def test_hosted_cargo_cache_explicitly_excludes_workspace_crates(self): - workflow = yaml.safe_load((ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8")) - caches = [step for job in workflow["jobs"].values() for step in job.get("steps", []) - if step.get("uses", "").startswith("Swatinem/rust-cache@")] - self.assertEqual(len(caches), 2) - for step in caches: - self.assertIs(step["with"]["cache-workspace-crates"], False) + for filename, expected in (("ci.yml", 2), ("optional-platforms.yml", 1)): + with self.subTest(workflow=filename): + workflow = yaml.safe_load((ROOT / ".github/workflows" / filename).read_text(encoding="utf-8")) + caches = [step for job in workflow["jobs"].values() for step in job.get("steps", []) + if step.get("uses", "").startswith("Swatinem/rust-cache@")] + self.assertEqual(len(caches), expected) + for step in caches: + self.assertIs(step["with"]["cache-workspace-crates"], False) def test_native_sources_are_stable_across_canonical_and_worktree_checkouts(self): with tempfile.TemporaryDirectory() as directory: From b98249d3c0853c2636a06be7c164e5872c2342ec Mon Sep 17 00:00:00 2001 From: Threepwood-7 Date: Wed, 11 Nov 2026 12:39:30 +0100 Subject: [PATCH 7/8] fix(ci): initialize hosted musl target before packaging (RUST-CI-035) --- .github/workflows/ci.yml | 4 +++- tools/tests/test_ci_parity.py | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9826d3d9..05af98d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -407,7 +407,9 @@ jobs: env: EMULEBB_WORKSPACE_OUTPUT_ROOT: ${{ runner.temp }}/emulebb-rust-out CARGO_TARGET_DIR: ${{ runner.temp }}/emulebb-rust-out/builds/rust/target - run: python -B -m emule_workspace package-emulebb-rust-linux-ci --libc musl --architecture ${{ matrix.architecture }} --release-version "$RELEASE_VERSION" --test-scope full --smoke + run: | + mkdir -p "$CARGO_TARGET_DIR" + python -B -m emule_workspace package-emulebb-rust-linux-ci --libc musl --architecture ${{ matrix.architecture }} --release-version "$RELEASE_VERSION" --test-scope full --smoke - name: Retain tested musl package uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: diff --git a/tools/tests/test_ci_parity.py b/tools/tests/test_ci_parity.py index 0ad1600b..07107e24 100644 --- a/tools/tests/test_ci_parity.py +++ b/tools/tests/test_ci_parity.py @@ -43,8 +43,12 @@ def test_required_and_optional_matrices_cover_six_native_targets_and_both_musl_c musl = jobs["build-musl"] self.assertEqual({(row["architecture"], row["runner"]) for row in musl["strategy"]["matrix"]["include"]}, {("amd64", "ubuntu-24.04"), ("arm64", "ubuntu-24.04-arm")}) - self.assertTrue(any("package-emulebb-rust-linux-ci" in step.get("run", "") - and "--test-scope full --smoke" in step["run"] for step in musl["steps"])) + package = next(step for step in musl["steps"] + if "package-emulebb-rust-linux-ci" in step.get("run", "")) + self.assertIn("--test-scope full --smoke", package["run"]) + self.assertIn('mkdir -p "$CARGO_TARGET_DIR"', package["run"]) + self.assertLess(package["run"].index('mkdir -p "$CARGO_TARGET_DIR"'), + package["run"].index("python -B -m emule_workspace")) self.assertIn("build-musl", jobs["required-ci"]["needs"]) aggregate = jobs["required-ci"]["steps"][0] self.assertEqual(aggregate["env"]["BUILD_MUSL_RESULT"], "${{ needs.build-musl.result }}") From 8867e28c183036207db7aa0be8b896fd14a03ca1 Mon Sep 17 00:00:00 2001 From: Threepwood-7 Date: Wed, 11 Nov 2026 13:04:18 +0100 Subject: [PATCH 8/8] ci(platforms): pin verified native-source link fix (RUST-CI-035) --- .github/workflows/ci.yml | 4 ++-- .github/workflows/optional-platforms.yml | 4 ++-- .github/workflows/release.yml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05af98d3..91088a5a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,8 +17,8 @@ permissions: env: CARGO_TERM_COLOR: always RUST_BACKTRACE: 1 - EMULEBB_BUILD_REF: c85fa0aee9fe503d5b939ea990941452b2dbc9a7 - EMULEBB_ANDROID_BUILD_REF: c85fa0aee9fe503d5b939ea990941452b2dbc9a7 + EMULEBB_BUILD_REF: 995d13f350c8342437fdb3122d2a271696ac6e29 + EMULEBB_ANDROID_BUILD_REF: 995d13f350c8342437fdb3122d2a271696ac6e29 EMULEBB_BUILD_TESTS_REF: 20a007f34d88b99c40d456cfe1dce3d0537ba00d EMULEBB_TOOLING_REF: 856e0499d12e281697618cc81a0b09805e96f59e # Pin the native NAT sources linked by the sys crates. diff --git a/.github/workflows/optional-platforms.yml b/.github/workflows/optional-platforms.yml index df692c8a..5d9105ad 100644 --- a/.github/workflows/optional-platforms.yml +++ b/.github/workflows/optional-platforms.yml @@ -17,8 +17,8 @@ permissions: env: CARGO_TERM_COLOR: always RUST_BACKTRACE: 1 - EMULEBB_BUILD_REF: c85fa0aee9fe503d5b939ea990941452b2dbc9a7 - EMULEBB_ANDROID_BUILD_REF: c85fa0aee9fe503d5b939ea990941452b2dbc9a7 + EMULEBB_BUILD_REF: 995d13f350c8342437fdb3122d2a271696ac6e29 + EMULEBB_ANDROID_BUILD_REF: 995d13f350c8342437fdb3122d2a271696ac6e29 EMULEBB_BUILD_TESTS_REF: 20a007f34d88b99c40d456cfe1dce3d0537ba00d EMULEBB_TOOLING_REF: 856e0499d12e281697618cc81a0b09805e96f59e # Pin the native NAT sources linked by the sys crates. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a8a1bd09..19d37662 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,7 +51,7 @@ permissions: env: RELEASE_PLATFORM_PROFILE: ${{ inputs.platform_profile || 'full' }} RELEASE_CHANNEL: ${{ inputs.channel || (startsWith(github.ref, 'refs/tags/rust-v') && 'beta') || 'candidate' }} - EMULEBB_BUILD_REF: c85fa0aee9fe503d5b939ea990941452b2dbc9a7 + EMULEBB_BUILD_REF: 995d13f350c8342437fdb3122d2a271696ac6e29 EMULEBB_BUILD_TESTS_REF: 20a007f34d88b99c40d456cfe1dce3d0537ba00d EMULEBB_TOOLING_REF: c9aa7c7b5c0a298a6ba3b0485f29d9f4c86b5d05 MINIUPNP_REF: 4e7a10948004ca2085ac8103e14fc77edb898b4c