From c2a8f5d5a44740ec3a71c8b0254fc2839b243f80 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Wed, 23 Sep 2026 04:44:55 -0700 Subject: [PATCH 1/5] ci: add fractional platform tiers and exact-SHA release preflight --- .github/workflows/_build.yml | 25 +- .github/workflows/_integration-test.yml | 15 + .github/workflows/_lint.yml | 15 + .github/workflows/_terminal-test.yml | 15 + .github/workflows/_unit-test.yml | 15 + .github/workflows/auto-release.yml | 326 ++++++++---------- .github/workflows/full-coverage.yml | 52 +++ .github/workflows/linux-arm-build.yml | 9 +- .../workflows/linux-arm-integration-test.yml | 9 +- .github/workflows/linux-arm-lint.yml | 9 +- .github/workflows/linux-arm-unit-test.yml | 9 +- .github/workflows/linux-x86-build.yml | 9 +- .github/workflows/linux-x86-dwarf-smoke.yml | 19 +- .../workflows/linux-x86-integration-test.yml | 9 +- .github/workflows/linux-x86-lint.yml | 8 +- .github/workflows/linux-x86-render-smoke.yml | 19 +- .github/workflows/linux-x86-terminal-test.yml | 9 +- .github/workflows/linux-x86-unit-test.yml | 9 +- .github/workflows/macos-arm-build.yml | 12 +- .../workflows/macos-arm-integration-test.yml | 12 +- .github/workflows/macos-arm-lint.yml | 12 +- .github/workflows/macos-arm-live-test.yml | 30 +- .github/workflows/macos-arm-unit-test.yml | 12 +- .../macos-x64-guest-webkit-probe.yml | 24 +- .github/workflows/macos-x86-build.yml | 12 +- .../workflows/macos-x86-integration-test.yml | 12 +- .github/workflows/macos-x86-lint.yml | 12 +- .github/workflows/macos-x86-unit-test.yml | 12 +- .../workflows/vscode-extension-release.yml | 20 +- .github/workflows/vscode-extension.yml | 11 +- .github/workflows/windows-arm-build.yml | 9 +- .../windows-arm-integration-test.yml | 27 ++ .github/workflows/windows-arm-unit-test.yml | 27 ++ .github/workflows/windows-x86-build.yml | 9 +- .../windows-x86-integration-test.yml | 9 +- .github/workflows/windows-x86-lint.yml | 9 +- .../workflows/windows-x86-terminal-test.yml | 9 +- .github/workflows/windows-x86-unit-test.yml | 9 +- CLAUDE.md | 5 + ci/check_full_coverage.py | 192 +++++++++++ ci/full_coverage.json | 61 ++++ ci/release_artifact_lint.py | 101 ++++++ ci/verify_full_coverage.py | 98 ++++++ docs/FRACTIONAL_CI.md | 52 +++ setup.py | 4 +- tests/unit/test_ci_modes.py | 311 +++++++++++++++++ tests/unit/test_setup_packaging.py | 15 + 47 files changed, 1466 insertions(+), 243 deletions(-) create mode 100644 .github/workflows/full-coverage.yml create mode 100644 .github/workflows/windows-arm-integration-test.yml create mode 100644 .github/workflows/windows-arm-unit-test.yml create mode 100644 ci/check_full_coverage.py create mode 100644 ci/full_coverage.json create mode 100644 ci/release_artifact_lint.py create mode 100644 ci/verify_full_coverage.py create mode 100644 docs/FRACTIONAL_CI.md create mode 100644 tests/unit/test_ci_modes.py diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index 25185971..b7c8f8b8 100644 --- a/.github/workflows/_build.yml +++ b/.github/workflows/_build.yml @@ -3,6 +3,10 @@ name: Build on: workflow_call: inputs: + source-sha: + required: false + type: string + default: "" runs-on: required: true type: string @@ -24,6 +28,17 @@ jobs: shell: bash steps: - uses: actions/checkout@v4 + with: + ref: ${{ inputs.source-sha || github.sha }} + - name: Verify checkout SHA + env: + EXPECTED_SHA: ${{ inputs.source-sha || github.sha }} + run: | + [[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]] + if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch ]]; then + test "$EXPECTED_SHA" = "$GITHUB_SHA" + fi + test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" - uses: astral-sh/setup-uv@v5 with: @@ -102,6 +117,10 @@ jobs: ls -lh src/fastled/bin/ - name: Build wheel + env: + # setup.py rebuilds the bundled binary; keep that rebuild on the + # requested target, especially Windows ARM on an x64 runner. + FASTLED_RUST_TARGET: ${{ inputs.rust-target }} run: | PLAT_NAME="" case "${{ inputs.rust-target }}" in @@ -129,7 +148,7 @@ jobs: if: always() && inputs.wheel-smoke uses: actions/upload-artifact@v4 with: - name: wheel-smoke-screenshot-${{ inputs.runs-on }}-${{ inputs.rust-target || 'native' }} + name: smoke-wheel-screenshot-${{ inputs.runs-on }}-${{ inputs.rust-target || 'native' }} path: ${{ runner.temp }}/fastled-wheel-smoke/artifacts/*.png if-no-files-found: error @@ -137,7 +156,7 @@ jobs: if: always() && inputs.wheel-smoke uses: actions/upload-artifact@v4 with: - name: wheel-smoke-log-${{ inputs.runs-on }}-${{ inputs.rust-target || 'native' }} + name: smoke-wheel-log-${{ inputs.runs-on }}-${{ inputs.rust-target || 'native' }} path: ${{ runner.temp }}/fastled-wheel-smoke/artifacts/*.log if-no-files-found: warn @@ -146,12 +165,14 @@ jobs: with: name: wheel-${{ inputs.runs-on }}-${{ inputs.rust-target || 'native' }} path: dist/*.whl + if-no-files-found: error - name: Upload binary uses: actions/upload-artifact@v4 with: name: binary-${{ inputs.runs-on }}-${{ inputs.rust-target || 'native' }} path: src/fastled/bin/fastled* + if-no-files-found: error - name: Upload failure logs if: failure() diff --git a/.github/workflows/_integration-test.yml b/.github/workflows/_integration-test.yml index ee484a8f..d94aa349 100644 --- a/.github/workflows/_integration-test.yml +++ b/.github/workflows/_integration-test.yml @@ -3,6 +3,10 @@ name: Integration Test on: workflow_call: inputs: + source-sha: + required: false + type: string + default: "" runs-on: required: true type: string @@ -18,6 +22,17 @@ jobs: PYTHONIOENCODING: utf-8 steps: - uses: actions/checkout@v4 + with: + ref: ${{ inputs.source-sha || github.sha }} + - name: Verify checkout SHA + env: + EXPECTED_SHA: ${{ inputs.source-sha || github.sha }} + run: | + [[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]] + if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch ]]; then + test "$EXPECTED_SHA" = "$GITHUB_SHA" + fi + test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" - uses: astral-sh/setup-uv@v5 with: diff --git a/.github/workflows/_lint.yml b/.github/workflows/_lint.yml index 5be260a6..2e2ad035 100644 --- a/.github/workflows/_lint.yml +++ b/.github/workflows/_lint.yml @@ -3,6 +3,10 @@ name: Lint on: workflow_call: inputs: + source-sha: + required: false + type: string + default: "" runs-on: required: true type: string @@ -16,6 +20,17 @@ jobs: shell: bash steps: - uses: actions/checkout@v4 + with: + ref: ${{ inputs.source-sha || github.sha }} + - name: Verify checkout SHA + env: + EXPECTED_SHA: ${{ inputs.source-sha || github.sha }} + run: | + [[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]] + if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch ]]; then + test "$EXPECTED_SHA" = "$GITHUB_SHA" + fi + test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" - uses: astral-sh/setup-uv@v5 with: diff --git a/.github/workflows/_terminal-test.yml b/.github/workflows/_terminal-test.yml index 60a0f3e4..d4ec872c 100644 --- a/.github/workflows/_terminal-test.yml +++ b/.github/workflows/_terminal-test.yml @@ -14,6 +14,10 @@ name: Terminal Test on: workflow_call: inputs: + source-sha: + required: false + type: string + default: "" runs-on: required: true type: string @@ -39,6 +43,17 @@ jobs: PYTHONIOENCODING: utf-8 steps: - uses: actions/checkout@v4 + with: + ref: ${{ inputs.source-sha || github.sha }} + - name: Verify checkout SHA + env: + EXPECTED_SHA: ${{ inputs.source-sha || github.sha }} + run: | + [[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]] + if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch ]]; then + test "$EXPECTED_SHA" = "$GITHUB_SHA" + fi + test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" - uses: astral-sh/setup-uv@v5 with: diff --git a/.github/workflows/_unit-test.yml b/.github/workflows/_unit-test.yml index d89cb0b2..fd40c693 100644 --- a/.github/workflows/_unit-test.yml +++ b/.github/workflows/_unit-test.yml @@ -3,6 +3,10 @@ name: Unit Test on: workflow_call: inputs: + source-sha: + required: false + type: string + default: "" runs-on: required: true type: string @@ -18,6 +22,17 @@ jobs: PYTHONIOENCODING: utf-8 steps: - uses: actions/checkout@v4 + with: + ref: ${{ inputs.source-sha || github.sha }} + - name: Verify checkout SHA + env: + EXPECTED_SHA: ${{ inputs.source-sha || github.sha }} + run: | + [[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]] + if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch ]]; then + test "$EXPECTED_SHA" = "$GITHUB_SHA" + fi + test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" - uses: astral-sh/setup-uv@v5 with: diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index c76698ce..be177372 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -1,10 +1,17 @@ name: Auto Release on: - push: - branches: - - main workflow_dispatch: + inputs: + candidate_sha: + description: Exact 40-character branch-head commit SHA to release + required: true + type: string + dry_run: + description: Validate full CI and build artifacts without tagging or publishing + required: false + default: false + type: boolean # Never cancelled and never coalesced: every publish runs to completion. concurrency: @@ -17,256 +24,196 @@ permissions: actions: read jobs: + full-coverage: + runs-on: ubuntu-24.04 + permissions: + actions: read + contents: read + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ inputs.candidate_sha }} + - name: Verify candidate and full release CI + env: + EXPECTED_SHA: ${{ inputs.candidate_sha }} + GH_TOKEN: ${{ github.token }} + run: | + [[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]] + test "$EXPECTED_SHA" = "$GITHUB_SHA" + test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" + python ci/verify_full_coverage.py --sha "$EXPECTED_SHA" + detect-version: + needs: [full-coverage] + if: needs.full-coverage.result == 'success' runs-on: ubuntu-24.04 outputs: version: ${{ steps.version_check.outputs.current }} should_release: ${{ steps.version_check.outputs.should_release }} steps: - uses: actions/checkout@v5 + with: + ref: ${{ inputs.candidate_sha }} - name: Check if version needs release id: version_check + env: + DRY_RUN: ${{ inputs.dry_run }} run: | VERSION=$(python -c 'import tomllib; from pathlib import Path; manifest = tomllib.loads(Path("Cargo.toml").read_text(encoding="utf-8")); print(manifest["workspace"]["package"]["version"])') echo "current=$VERSION" >> $GITHUB_OUTPUT - STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://pypi.org/pypi/fastled/$VERSION/json") + TAG="v${VERSION}" + if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then + git fetch --no-tags origin "refs/tags/${TAG}:refs/tags/${TAG}" + test "$(git rev-parse "${TAG}^{commit}")" = "$GITHUB_SHA" || { + echo "Existing tag ${TAG} points to another commit" >&2 + exit 1 + } + fi + STATUS=$(curl --silent --show-error -o /dev/null -w "%{http_code}" "https://pypi.org/pypi/fastled/$VERSION/json") if [ "$STATUS" = "200" ]; then + if [ "$DRY_RUN" = "true" ]; then + echo "Cannot prove publish readiness: version $VERSION already exists on PyPI" >&2 + exit 1 + fi echo "should_release=false" >> $GITHUB_OUTPUT echo "Version $VERSION already on PyPI, skipping release" - else + elif [ "$STATUS" = "404" ]; then echo "should_release=true" >> $GITHUB_OUTPUT echo "Version $VERSION not on PyPI, will release" + else + echo "PyPI version lookup failed with HTTP $STATUS" >&2 + exit 1 fi - # The per-platform CI build workflows already produce release-identical - # wheel/binary artifacts for this same SHA (same rust targets, plat-name - # tags, and soldr cache keys — see the build wrappers). Wait for those runs - # and reuse their artifacts instead of rebuilding all 6 platforms (#170). - # Any platform whose CI run is missing, failed, or timed out falls back to - # a build-* job below. Runs only on push: a workflow_dispatch SHA may have - # no CI runs, so dispatch always builds. - collect-artifacts: - needs: [detect-version] - if: needs.detect-version.outputs.should_release == 'true' && github.event_name == 'push' - runs-on: ubuntu-24.04 - timeout-minutes: 45 - outputs: - reused_linux_x86: ${{ steps.collect.outputs.reused_linux_x86 }} - reused_linux_arm: ${{ steps.collect.outputs.reused_linux_arm }} - reused_windows_x86: ${{ steps.collect.outputs.reused_windows_x86 }} - reused_windows_arm: ${{ steps.collect.outputs.reused_windows_arm }} - reused_macos_x86: ${{ steps.collect.outputs.reused_macos_x86 }} - reused_macos_arm: ${{ steps.collect.outputs.reused_macos_arm }} - steps: - - name: Wait for CI build runs and download artifacts - id: collect - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - SHA: ${{ github.sha }} - run: | - set -uo pipefail - mkdir -p artifacts - declare -A WF_SUFFIX=( - ["linux-x86-build.yml"]="ubuntu-24.04-x86_64-unknown-linux-gnu" - ["linux-arm-build.yml"]="ubuntu-24.04-arm-aarch64-unknown-linux-gnu" - ["windows-x86-build.yml"]="windows-2025-x86_64-pc-windows-msvc" - ["windows-arm-build.yml"]="windows-2025-aarch64-pc-windows-msvc" - ["macos-x86-build.yml"]="macos-14-x86_64-apple-darwin" - ["macos-arm-build.yml"]="macos-15-aarch64-apple-darwin" - ) - deadline=$(( $(date +%s) + 2100 )) - for wf in linux-x86-build.yml linux-arm-build.yml windows-x86-build.yml windows-arm-build.yml macos-x86-build.yml macos-arm-build.yml; do - suffix="${WF_SUFFIX[$wf]}" - key="reused_$(echo "$wf" | sed 's/-build\.yml$//; s/-/_/g')" - reused=false - while :; do - run_json=$(gh api "repos/$REPO/actions/workflows/$wf/runs?head_sha=$SHA&per_page=1" --jq '.workflow_runs[0] // empty' 2>/dev/null || true) - if [ -n "$run_json" ]; then - status=$(jq -r .status <<<"$run_json") - if [ "$status" = "completed" ]; then - conclusion=$(jq -r .conclusion <<<"$run_json") - run_id=$(jq -r .id <<<"$run_json") - if [ "$conclusion" = "success" ] && \ - gh run download "$run_id" -R "$REPO" --dir artifacts -n "wheel-$suffix" -n "binary-$suffix"; then - reused=true - else - echo "$wf run $run_id concluded '$conclusion' or artifact download failed; falling back to build." - fi - break - fi - fi - if [ "$(date +%s)" -ge "$deadline" ]; then - echo "Timed out waiting for $wf; falling back to build." - break - fi - sleep 30 - done - echo "$wf -> $key=$reused" - echo "$key=$reused" >> "$GITHUB_OUTPUT" - done - - - uses: actions/upload-artifact@v4 - if: steps.collect.outputs.reused_linux_x86 == 'true' - with: - name: wheel-ubuntu-24.04-x86_64-unknown-linux-gnu - path: artifacts/wheel-ubuntu-24.04-x86_64-unknown-linux-gnu/* - - uses: actions/upload-artifact@v4 - if: steps.collect.outputs.reused_linux_x86 == 'true' - with: - name: binary-ubuntu-24.04-x86_64-unknown-linux-gnu - path: artifacts/binary-ubuntu-24.04-x86_64-unknown-linux-gnu/* - - - uses: actions/upload-artifact@v4 - if: steps.collect.outputs.reused_linux_arm == 'true' - with: - name: wheel-ubuntu-24.04-arm-aarch64-unknown-linux-gnu - path: artifacts/wheel-ubuntu-24.04-arm-aarch64-unknown-linux-gnu/* - - uses: actions/upload-artifact@v4 - if: steps.collect.outputs.reused_linux_arm == 'true' - with: - name: binary-ubuntu-24.04-arm-aarch64-unknown-linux-gnu - path: artifacts/binary-ubuntu-24.04-arm-aarch64-unknown-linux-gnu/* - - - uses: actions/upload-artifact@v4 - if: steps.collect.outputs.reused_windows_x86 == 'true' - with: - name: wheel-windows-2025-x86_64-pc-windows-msvc - path: artifacts/wheel-windows-2025-x86_64-pc-windows-msvc/* - - uses: actions/upload-artifact@v4 - if: steps.collect.outputs.reused_windows_x86 == 'true' - with: - name: binary-windows-2025-x86_64-pc-windows-msvc - path: artifacts/binary-windows-2025-x86_64-pc-windows-msvc/* - - - uses: actions/upload-artifact@v4 - if: steps.collect.outputs.reused_windows_arm == 'true' - with: - name: wheel-windows-2025-aarch64-pc-windows-msvc - path: artifacts/wheel-windows-2025-aarch64-pc-windows-msvc/* - - uses: actions/upload-artifact@v4 - if: steps.collect.outputs.reused_windows_arm == 'true' - with: - name: binary-windows-2025-aarch64-pc-windows-msvc - path: artifacts/binary-windows-2025-aarch64-pc-windows-msvc/* - - - uses: actions/upload-artifact@v4 - if: steps.collect.outputs.reused_macos_x86 == 'true' - with: - name: wheel-macos-14-x86_64-apple-darwin - path: artifacts/wheel-macos-14-x86_64-apple-darwin/* - - uses: actions/upload-artifact@v4 - if: steps.collect.outputs.reused_macos_x86 == 'true' - with: - name: binary-macos-14-x86_64-apple-darwin - path: artifacts/binary-macos-14-x86_64-apple-darwin/* - - - uses: actions/upload-artifact@v4 - if: steps.collect.outputs.reused_macos_arm == 'true' - with: - name: wheel-macos-15-aarch64-apple-darwin - path: artifacts/wheel-macos-15-aarch64-apple-darwin/* - - uses: actions/upload-artifact@v4 - if: steps.collect.outputs.reused_macos_arm == 'true' - with: - name: binary-macos-15-aarch64-apple-darwin - path: artifacts/binary-macos-15-aarch64-apple-darwin/* - - # Fallback builds: each runs only when the matching CI artifact could not - # be reused (collect skipped on workflow_dispatch, CI run failed/missing, - # or download failed). + # Release builds produce all six artifact pairs at this workflow's SHA. + # Ordinary main CI no longer provides per-platform release artifacts. build-linux-x86: - needs: [detect-version, collect-artifacts] - if: >- - !cancelled() && - needs.detect-version.outputs.should_release == 'true' && - !(needs.collect-artifacts.result == 'success' && needs.collect-artifacts.outputs.reused_linux_x86 == 'true') + needs: [detect-version] + if: needs.detect-version.outputs.should_release == 'true' uses: ./.github/workflows/_build.yml with: runs-on: ubuntu-24.04 rust-target: x86_64-unknown-linux-gnu build-linux-arm: - needs: [detect-version, collect-artifacts] - if: >- - !cancelled() && - needs.detect-version.outputs.should_release == 'true' && - !(needs.collect-artifacts.result == 'success' && needs.collect-artifacts.outputs.reused_linux_arm == 'true') + needs: [detect-version] + if: needs.detect-version.outputs.should_release == 'true' uses: ./.github/workflows/_build.yml with: runs-on: ubuntu-24.04-arm rust-target: aarch64-unknown-linux-gnu build-windows-x86: - needs: [detect-version, collect-artifacts] - if: >- - !cancelled() && - needs.detect-version.outputs.should_release == 'true' && - !(needs.collect-artifacts.result == 'success' && needs.collect-artifacts.outputs.reused_windows_x86 == 'true') + needs: [detect-version] + if: needs.detect-version.outputs.should_release == 'true' uses: ./.github/workflows/_build.yml with: runs-on: windows-2025 rust-target: x86_64-pc-windows-msvc build-windows-arm: - needs: [detect-version, collect-artifacts] - if: >- - !cancelled() && - needs.detect-version.outputs.should_release == 'true' && - !(needs.collect-artifacts.result == 'success' && needs.collect-artifacts.outputs.reused_windows_arm == 'true') + needs: [detect-version] + if: needs.detect-version.outputs.should_release == 'true' uses: ./.github/workflows/_build.yml with: runs-on: windows-2025 rust-target: aarch64-pc-windows-msvc build-macos-x86: - needs: [detect-version, collect-artifacts] - if: >- - !cancelled() && - needs.detect-version.outputs.should_release == 'true' && - !(needs.collect-artifacts.result == 'success' && needs.collect-artifacts.outputs.reused_macos_x86 == 'true') + needs: [detect-version] + if: needs.detect-version.outputs.should_release == 'true' uses: ./.github/workflows/_build.yml with: runs-on: macos-14 rust-target: x86_64-apple-darwin build-macos-arm: - needs: [detect-version, collect-artifacts] - if: >- - !cancelled() && - needs.detect-version.outputs.should_release == 'true' && - !(needs.collect-artifacts.result == 'success' && needs.collect-artifacts.outputs.reused_macos_arm == 'true') + needs: [detect-version] + if: needs.detect-version.outputs.should_release == 'true' uses: ./.github/workflows/_build.yml with: runs-on: macos-15 rust-target: aarch64-apple-darwin + artifact-preflight: + needs: + - detect-version + - build-linux-x86 + - build-linux-arm + - build-windows-x86 + - build-windows-arm + - build-macos-x86 + - build-macos-arm + if: >- + !cancelled() && + needs.detect-version.outputs.should_release == 'true' && + needs.build-linux-x86.result == 'success' && + needs.build-linux-arm.result == 'success' && + needs.build-windows-x86.result == 'success' && + needs.build-windows-arm.result == 'success' && + needs.build-macos-x86.result == 'success' && + needs.build-macos-arm.result == 'success' + runs-on: ubuntu-24.04 + permissions: + actions: read + contents: read + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ inputs.candidate_sha }} + - uses: actions/download-artifact@v4 + with: + pattern: wheel-* + path: dist + merge-multiple: true + - uses: actions/download-artifact@v4 + with: + pattern: binary-* + path: binaries + - name: Check wheel and binary artifacts before tagging + env: + VERSION: ${{ needs.detect-version.outputs.version }} + run: | + python ci/release_artifact_lint.py --wheels dist --binaries binaries --version "$VERSION" + echo "Artifact preflight passed for ${{ inputs.candidate_sha }}. PyPI project quota and registry availability remain unverified." >> "$GITHUB_STEP_SUMMARY" + create-tag: needs: + - full-coverage - detect-version + - artifact-preflight - build-linux-x86 - build-linux-arm - build-windows-x86 - build-windows-arm - build-macos-x86 - build-macos-arm - # A skipped build means its artifact was reused from CI by - # collect-artifacts; any failed or cancelled build blocks the release. + # Every build must succeed before tagging. No ordinary CI artifact is reused. if: >- !cancelled() && + needs.full-coverage.result == 'success' && needs.detect-version.outputs.should_release == 'true' && - contains(fromJSON('["success", "skipped"]'), needs.build-linux-x86.result) && - contains(fromJSON('["success", "skipped"]'), needs.build-linux-arm.result) && - contains(fromJSON('["success", "skipped"]'), needs.build-windows-x86.result) && - contains(fromJSON('["success", "skipped"]'), needs.build-windows-arm.result) && - contains(fromJSON('["success", "skipped"]'), needs.build-macos-x86.result) && - contains(fromJSON('["success", "skipped"]'), needs.build-macos-arm.result) + needs.artifact-preflight.result == 'success' && + inputs.dry_run != true && + needs.build-linux-x86.result == 'success' && + needs.build-linux-arm.result == 'success' && + needs.build-windows-x86.result == 'success' && + needs.build-windows-arm.result == 'success' && + needs.build-macos-x86.result == 'success' && + needs.build-macos-arm.result == 'success' runs-on: ubuntu-24.04 permissions: contents: write steps: - uses: actions/checkout@v5 + with: + ref: ${{ inputs.candidate_sha }} + + - name: Verify tag source SHA + env: + EXPECTED_SHA: ${{ inputs.candidate_sha }} + run: test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" - name: Create tag if not present env: @@ -276,7 +223,12 @@ jobs: git config --local user.name "GitHub Action" TAG="v${{ needs.detect-version.outputs.version }}" if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then - echo "Tag ${TAG} already exists; continuing release workflow." + git fetch --no-tags origin "refs/tags/${TAG}:refs/tags/${TAG}" + test "$(git rev-parse "${TAG}^{commit}")" = "$GITHUB_SHA" || { + echo "Existing tag ${TAG} points to another commit" >&2 + exit 1 + } + echo "Tag ${TAG} already points to the verified SHA." else git tag -a "${TAG}" -m "Release ${{ needs.detect-version.outputs.version }}" git push origin "${TAG}" @@ -284,12 +236,10 @@ jobs: publish-pypi: needs: [detect-version, create-tag] - # Job-level success() (implied by a plain `if`) evaluates false when any - # transitive ancestor was skipped — and the build-* jobs are skipped - # whenever collect-artifacts reused CI artifacts, which silently skipped - # publishing on the v2.0.12 release. Gate explicitly on create-tag instead. + # Gate explicitly on the tag job result. if: >- !cancelled() && + inputs.dry_run != true && needs.detect-version.outputs.should_release == 'true' && needs.create-tag.result == 'success' runs-on: ubuntu-24.04 @@ -314,9 +264,10 @@ jobs: create-release: needs: [detect-version, create-tag] - # Same skipped-ancestor gotcha as publish-pypi above. + # Gate explicitly on the tag job result. if: >- !cancelled() && + inputs.dry_run != true && needs.detect-version.outputs.should_release == 'true' && needs.create-tag.result == 'success' runs-on: ubuntu-24.04 @@ -326,8 +277,7 @@ jobs: steps: # With merge-multiple unset, each artifact lands in its own # binaries// directory, so the case match below on the - # target-suffixed artifact name works for both reused and rebuilt - # artifacts. + # target-suffixed artifact name identifies each release build. - name: Download all binaries uses: actions/download-artifact@v4 with: diff --git a/.github/workflows/full-coverage.yml b/.github/workflows/full-coverage.yml new file mode 100644 index 00000000..ecee3e49 --- /dev/null +++ b/.github/workflows/full-coverage.yml @@ -0,0 +1,52 @@ +name: Full CI Coverage + +on: + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] + workflow_dispatch: + inputs: + candidate_sha: + description: Exact 40-character branch-head commit SHA to validate + required: true + type: string + +permissions: + actions: read + contents: read + +jobs: + full-coverage: + if: github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'ci-full') + runs-on: ubuntu-24.04 + timeout-minutes: 95 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event.pull_request.head.sha }} + - name: Verify requested SHA + env: + EXPECTED_SHA: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event.pull_request.head.sha }} + run: | + [[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]] + if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch ]]; then + test "$EXPECTED_SHA" = "$GITHUB_SHA" + fi + test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" + - name: Require every full platform job on this SHA + env: + GH_TOKEN: ${{ github.token }} + COVERAGE_SHA: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event.pull_request.head.sha }} + COVERAGE_EVENT: ${{ github.event_name }} + run: | + python ci/check_full_coverage.py \ + --sha "$COVERAGE_SHA" \ + --event "$COVERAGE_EVENT" \ + --coverage-run-id "$GITHUB_RUN_ID" \ + --report full-coverage.json + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: full-coverage-${{ github.run_id }} + path: full-coverage.json + if-no-files-found: error diff --git a/.github/workflows/linux-arm-build.yml b/.github/workflows/linux-arm-build.yml index 06e1e9bc..3ea9b59b 100644 --- a/.github/workflows/linux-arm-build.yml +++ b/.github/workflows/linux-arm-build.yml @@ -4,8 +4,13 @@ on: push: branches: [main] pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] workflow_dispatch: - + inputs: + candidate_sha: + description: Exact 40-character commit SHA to validate + required: true + type: string # One live run per workflow per ref: a new push to a branch or PR cancels the # superseded run instead of queuing behind it in the shared account-wide runner # pool. Runs on main get a unique group, so they are never cancelled or coalesced. @@ -15,8 +20,10 @@ concurrency: jobs: call: + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ci-full')) uses: ./.github/workflows/_build.yml with: + source-sha: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} runs-on: ubuntu-24.04-arm # Explicit target so CI wheels/binaries are identical to the release # builds in auto-release.yml (same artifact names, plat-name tags, and diff --git a/.github/workflows/linux-arm-integration-test.yml b/.github/workflows/linux-arm-integration-test.yml index eaaff8ce..ff58f161 100644 --- a/.github/workflows/linux-arm-integration-test.yml +++ b/.github/workflows/linux-arm-integration-test.yml @@ -4,8 +4,13 @@ on: push: branches: [main] pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] workflow_dispatch: - + inputs: + candidate_sha: + description: Exact 40-character commit SHA to validate + required: true + type: string # One live run per workflow per ref: a new push to a branch or PR cancels the # superseded run instead of queuing behind it in the shared account-wide runner # pool. Runs on main get a unique group, so they are never cancelled or coalesced. @@ -15,6 +20,8 @@ concurrency: jobs: call: + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ci-full')) uses: ./.github/workflows/_integration-test.yml with: + source-sha: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} runs-on: ubuntu-24.04-arm diff --git a/.github/workflows/linux-arm-lint.yml b/.github/workflows/linux-arm-lint.yml index 8c348771..fbe29118 100644 --- a/.github/workflows/linux-arm-lint.yml +++ b/.github/workflows/linux-arm-lint.yml @@ -4,8 +4,13 @@ on: push: branches: [main] pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] workflow_dispatch: - + inputs: + candidate_sha: + description: Exact 40-character commit SHA to validate + required: true + type: string # One live run per workflow per ref: a new push to a branch or PR cancels the # superseded run instead of queuing behind it in the shared account-wide runner # pool. Runs on main get a unique group, so they are never cancelled or coalesced. @@ -15,6 +20,8 @@ concurrency: jobs: call: + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ci-full')) uses: ./.github/workflows/_lint.yml with: + source-sha: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} runs-on: ubuntu-24.04-arm diff --git a/.github/workflows/linux-arm-unit-test.yml b/.github/workflows/linux-arm-unit-test.yml index 1bb26317..e739dbee 100644 --- a/.github/workflows/linux-arm-unit-test.yml +++ b/.github/workflows/linux-arm-unit-test.yml @@ -4,8 +4,13 @@ on: push: branches: [main] pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] workflow_dispatch: - + inputs: + candidate_sha: + description: Exact 40-character commit SHA to validate + required: true + type: string # One live run per workflow per ref: a new push to a branch or PR cancels the # superseded run instead of queuing behind it in the shared account-wide runner # pool. Runs on main get a unique group, so they are never cancelled or coalesced. @@ -15,6 +20,8 @@ concurrency: jobs: call: + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ci-full')) uses: ./.github/workflows/_unit-test.yml with: + source-sha: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} runs-on: ubuntu-24.04-arm diff --git a/.github/workflows/linux-x86-build.yml b/.github/workflows/linux-x86-build.yml index 6bc74dc4..bb10b0f1 100644 --- a/.github/workflows/linux-x86-build.yml +++ b/.github/workflows/linux-x86-build.yml @@ -4,8 +4,13 @@ on: push: branches: [main] pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] workflow_dispatch: - + inputs: + candidate_sha: + description: Exact 40-character commit SHA to validate + required: true + type: string # One live run per workflow per ref: a new push to a branch or PR cancels the # superseded run instead of queuing behind it in the shared account-wide runner # pool. Runs on main get a unique group, so they are never cancelled or coalesced. @@ -15,8 +20,10 @@ concurrency: jobs: call: + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ci-full')) uses: ./.github/workflows/_build.yml with: + source-sha: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} runs-on: ubuntu-24.04 # Explicit target so CI wheels/binaries are identical to the release # builds in auto-release.yml (same artifact names, plat-name tags, and diff --git a/.github/workflows/linux-x86-dwarf-smoke.yml b/.github/workflows/linux-x86-dwarf-smoke.yml index 0ba4c23e..a4d52dff 100644 --- a/.github/workflows/linux-x86-dwarf-smoke.yml +++ b/.github/workflows/linux-x86-dwarf-smoke.yml @@ -4,8 +4,13 @@ on: push: branches: [main] pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] workflow_dispatch: - + inputs: + candidate_sha: + description: Exact 40-character commit SHA to validate + required: true + type: string # One live run per workflow per ref: a new push to a branch or PR cancels the # superseded run instead of queuing behind it in the shared account-wide runner # pool. Runs on main get a unique group, so they are never cancelled or coalesced. @@ -15,6 +20,7 @@ concurrency: jobs: dwarf-smoke: + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ci-full')) runs-on: ubuntu-24.04 timeout-minutes: 60 defaults: @@ -24,6 +30,17 @@ jobs: PYTHONIOENCODING: utf-8 steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + - name: Verify checkout SHA + env: + EXPECTED_SHA: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + run: | + [[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]] + if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch ]]; then + test "$EXPECTED_SHA" = "$GITHUB_SHA" + fi + test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" - uses: actions/setup-node@v4 with: diff --git a/.github/workflows/linux-x86-integration-test.yml b/.github/workflows/linux-x86-integration-test.yml index 1bfc9e02..4ad13462 100644 --- a/.github/workflows/linux-x86-integration-test.yml +++ b/.github/workflows/linux-x86-integration-test.yml @@ -4,8 +4,13 @@ on: push: branches: [main] pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] workflow_dispatch: - + inputs: + candidate_sha: + description: Exact 40-character commit SHA to validate + required: true + type: string # One live run per workflow per ref: a new push to a branch or PR cancels the # superseded run instead of queuing behind it in the shared account-wide runner # pool. Runs on main get a unique group, so they are never cancelled or coalesced. @@ -15,6 +20,8 @@ concurrency: jobs: call: + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && (contains(github.event.pull_request.labels.*.name, 'ci-test') || contains(github.event.pull_request.labels.*.name, 'ci-full'))) uses: ./.github/workflows/_integration-test.yml with: + source-sha: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} runs-on: ubuntu-24.04 diff --git a/.github/workflows/linux-x86-lint.yml b/.github/workflows/linux-x86-lint.yml index 58bc853e..7d205473 100644 --- a/.github/workflows/linux-x86-lint.yml +++ b/.github/workflows/linux-x86-lint.yml @@ -4,8 +4,13 @@ on: push: branches: [main] pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] workflow_dispatch: - + inputs: + candidate_sha: + description: Exact 40-character commit SHA to validate + required: true + type: string # One live run per workflow per ref: a new push to a branch or PR cancels the # superseded run instead of queuing behind it in the shared account-wide runner # pool. Runs on main get a unique group, so they are never cancelled or coalesced. @@ -17,4 +22,5 @@ jobs: call: uses: ./.github/workflows/_lint.yml with: + source-sha: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} runs-on: ubuntu-24.04 diff --git a/.github/workflows/linux-x86-render-smoke.yml b/.github/workflows/linux-x86-render-smoke.yml index b641f673..e19023ce 100644 --- a/.github/workflows/linux-x86-render-smoke.yml +++ b/.github/workflows/linux-x86-render-smoke.yml @@ -22,8 +22,13 @@ on: push: branches: [main] pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] workflow_dispatch: - + inputs: + candidate_sha: + description: Exact 40-character commit SHA to validate + required: true + type: string # One live run per workflow per ref: a new push to a branch or PR cancels the # superseded run instead of queuing behind it in the shared account-wide runner # pool. Runs on main get a unique group, so they are never cancelled or coalesced. @@ -33,6 +38,7 @@ concurrency: jobs: render-smoke: + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ci-full')) runs-on: ubuntu-24.04 timeout-minutes: 45 defaults: @@ -42,6 +48,17 @@ jobs: PYTHONIOENCODING: utf-8 steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + - name: Verify checkout SHA + env: + EXPECTED_SHA: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + run: | + [[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]] + if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch ]]; then + test "$EXPECTED_SHA" = "$GITHUB_SHA" + fi + test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" - uses: astral-sh/setup-uv@v5 with: diff --git a/.github/workflows/linux-x86-terminal-test.yml b/.github/workflows/linux-x86-terminal-test.yml index ce59948e..a31fb202 100644 --- a/.github/workflows/linux-x86-terminal-test.yml +++ b/.github/workflows/linux-x86-terminal-test.yml @@ -4,8 +4,13 @@ on: push: branches: [main] pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] workflow_dispatch: - + inputs: + candidate_sha: + description: Exact 40-character commit SHA to validate + required: true + type: string # One live run per workflow per ref: a new push to a branch or PR cancels the # superseded run instead of queuing behind it in the shared account-wide runner # pool. Runs on main get a unique group, so they are never cancelled or coalesced. @@ -15,6 +20,8 @@ concurrency: jobs: call: + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ci-full')) uses: ./.github/workflows/_terminal-test.yml with: + source-sha: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} runs-on: ubuntu-24.04 diff --git a/.github/workflows/linux-x86-unit-test.yml b/.github/workflows/linux-x86-unit-test.yml index f1473d11..1af9a1ef 100644 --- a/.github/workflows/linux-x86-unit-test.yml +++ b/.github/workflows/linux-x86-unit-test.yml @@ -4,8 +4,13 @@ on: push: branches: [main] pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] workflow_dispatch: - + inputs: + candidate_sha: + description: Exact 40-character commit SHA to validate + required: true + type: string # One live run per workflow per ref: a new push to a branch or PR cancels the # superseded run instead of queuing behind it in the shared account-wide runner # pool. Runs on main get a unique group, so they are never cancelled or coalesced. @@ -15,6 +20,8 @@ concurrency: jobs: call: + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && (contains(github.event.pull_request.labels.*.name, 'ci-test') || contains(github.event.pull_request.labels.*.name, 'ci-full'))) uses: ./.github/workflows/_unit-test.yml with: + source-sha: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} runs-on: ubuntu-24.04 diff --git a/.github/workflows/macos-arm-build.yml b/.github/workflows/macos-arm-build.yml index a14df090..00eb9075 100644 --- a/.github/workflows/macos-arm-build.yml +++ b/.github/workflows/macos-arm-build.yml @@ -3,10 +3,14 @@ name: macOS ARM Build on: push: branches: [main] - # Pull requests run macos-arm-live-test.yml instead: it cross-compiles on - # Linux and holds a macOS runner only to execute the result. + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] workflow_dispatch: - + inputs: + candidate_sha: + description: Exact 40-character commit SHA to validate + required: true + type: string # One live run per workflow per ref: a new push to a branch or PR cancels the # superseded run instead of queuing behind it in the shared account-wide runner # pool. Runs on main get a unique group, so they are never cancelled or coalesced. @@ -16,8 +20,10 @@ concurrency: jobs: call: + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ci-full')) uses: ./.github/workflows/_build.yml with: + source-sha: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} runs-on: macos-15 # Explicit target so CI wheels/binaries are identical to the release # builds in auto-release.yml (same artifact names, plat-name tags, and diff --git a/.github/workflows/macos-arm-integration-test.yml b/.github/workflows/macos-arm-integration-test.yml index 7d818969..007cd11c 100644 --- a/.github/workflows/macos-arm-integration-test.yml +++ b/.github/workflows/macos-arm-integration-test.yml @@ -3,10 +3,14 @@ name: macOS ARM Integration Test on: push: branches: [main] - # Pull requests run macos-arm-live-test.yml instead: it cross-compiles on - # Linux and holds a macOS runner only to execute the result. + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] workflow_dispatch: - + inputs: + candidate_sha: + description: Exact 40-character commit SHA to validate + required: true + type: string # One live run per workflow per ref: a new push to a branch or PR cancels the # superseded run instead of queuing behind it in the shared account-wide runner # pool. Runs on main get a unique group, so they are never cancelled or coalesced. @@ -16,6 +20,8 @@ concurrency: jobs: call: + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ci-full')) uses: ./.github/workflows/_integration-test.yml with: + source-sha: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} runs-on: macos-15 diff --git a/.github/workflows/macos-arm-lint.yml b/.github/workflows/macos-arm-lint.yml index aa3bf385..25277a79 100644 --- a/.github/workflows/macos-arm-lint.yml +++ b/.github/workflows/macos-arm-lint.yml @@ -3,10 +3,14 @@ name: macOS ARM Lint on: push: branches: [main] - # Pull requests run macos-arm-live-test.yml instead: it cross-compiles on - # Linux and holds a macOS runner only to execute the result. + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] workflow_dispatch: - + inputs: + candidate_sha: + description: Exact 40-character commit SHA to validate + required: true + type: string # One live run per workflow per ref: a new push to a branch or PR cancels the # superseded run instead of queuing behind it in the shared account-wide runner # pool. Runs on main get a unique group, so they are never cancelled or coalesced. @@ -16,6 +20,8 @@ concurrency: jobs: call: + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ci-full')) uses: ./.github/workflows/_lint.yml with: + source-sha: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} runs-on: macos-15 diff --git a/.github/workflows/macos-arm-live-test.yml b/.github/workflows/macos-arm-live-test.yml index 9a7222ad..179f0f94 100644 --- a/.github/workflows/macos-arm-live-test.yml +++ b/.github/workflows/macos-arm-live-test.yml @@ -23,8 +23,13 @@ on: push: branches: [main] pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] workflow_dispatch: - + inputs: + candidate_sha: + description: Exact 40-character commit SHA to validate + required: true + type: string # One live run per workflow per ref: a new push to a branch or PR cancels the # superseded run instead of queuing behind it in the shared account-wide runner # pool. Runs on main get a unique group, so they are never cancelled or coalesced. @@ -38,6 +43,7 @@ env: jobs: cross-build: + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ci-full')) runs-on: ubuntu-latest timeout-minutes: 45 defaults: @@ -45,6 +51,17 @@ jobs: shell: bash steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + - name: Verify checkout SHA + env: + EXPECTED_SHA: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + run: | + [[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]] + if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch ]]; then + test "$EXPECTED_SHA" = "$GITHUB_SHA" + fi + test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" - uses: astral-sh/setup-uv@v5 with: @@ -108,6 +125,17 @@ jobs: # The checkout provides the test fixtures and smoke scripts, and the # workspace the test archive is remapped onto. Nothing is compiled. - uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + - name: Verify checkout SHA + env: + EXPECTED_SHA: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + run: | + [[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]] + if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch ]]; then + test "$EXPECTED_SHA" = "$GITHUB_SHA" + fi + test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" - uses: astral-sh/setup-uv@v5 with: diff --git a/.github/workflows/macos-arm-unit-test.yml b/.github/workflows/macos-arm-unit-test.yml index ccbad0c6..33f14cf9 100644 --- a/.github/workflows/macos-arm-unit-test.yml +++ b/.github/workflows/macos-arm-unit-test.yml @@ -3,10 +3,14 @@ name: macOS ARM Unit Test on: push: branches: [main] - # Pull requests run macos-arm-live-test.yml instead: it cross-compiles on - # Linux and holds a macOS runner only to execute the result. + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] workflow_dispatch: - + inputs: + candidate_sha: + description: Exact 40-character commit SHA to validate + required: true + type: string # One live run per workflow per ref: a new push to a branch or PR cancels the # superseded run instead of queuing behind it in the shared account-wide runner # pool. Runs on main get a unique group, so they are never cancelled or coalesced. @@ -16,6 +20,8 @@ concurrency: jobs: call: + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ci-full')) uses: ./.github/workflows/_unit-test.yml with: + source-sha: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} runs-on: macos-15 diff --git a/.github/workflows/macos-x64-guest-webkit-probe.yml b/.github/workflows/macos-x64-guest-webkit-probe.yml index 7e6fa17c..09a20831 100644 --- a/.github/workflows/macos-x64-guest-webkit-probe.yml +++ b/.github/workflows/macos-x64-guest-webkit-probe.yml @@ -23,12 +23,14 @@ name: macOS x64 Guest WebKit Probe # the probe itself. The job summary reports what each WebKit client saw. on: - workflow_dispatch: pull_request: - paths: - - .github/workflows/macos-x64-guest-webkit-probe.yml - - ci/macos-x64/** - + types: [opened, synchronize, reopened, labeled, unlabeled] + workflow_dispatch: + inputs: + candidate_sha: + description: Exact 40-character commit SHA to validate + required: true + type: string concurrency: group: ${{ github.workflow }}-${{ github.ref == 'refs/heads/main' && github.run_id || github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} @@ -38,10 +40,22 @@ permissions: jobs: probe: + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ci-full')) runs-on: ubuntu-latest timeout-minutes: 90 steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + - name: Verify checkout SHA + env: + EXPECTED_SHA: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + run: | + [[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]] + if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch ]]; then + test "$EXPECTED_SHA" = "$GITHUB_SHA" + fi + test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" # Same pin as macos-arm-live-test.yml: a Soldr with cross targets and the # managed macOS SDK. diff --git a/.github/workflows/macos-x86-build.yml b/.github/workflows/macos-x86-build.yml index 76c84b2d..5c233596 100644 --- a/.github/workflows/macos-x86-build.yml +++ b/.github/workflows/macos-x86-build.yml @@ -3,10 +3,14 @@ name: macOS x86 Build on: push: branches: [main] - # Pull requests run macos-arm-live-test.yml instead: it cross-compiles on - # Linux and holds a macOS runner only to execute the result. + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] workflow_dispatch: - + inputs: + candidate_sha: + description: Exact 40-character commit SHA to validate + required: true + type: string # One live run per workflow per ref: a new push to a branch or PR cancels the # superseded run instead of queuing behind it in the shared account-wide runner # pool. Runs on main get a unique group, so they are never cancelled or coalesced. @@ -16,8 +20,10 @@ concurrency: jobs: call: + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ci-full')) uses: ./.github/workflows/_build.yml with: + source-sha: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} runs-on: macos-14 # Explicit target so CI wheels/binaries are identical to the release # builds in auto-release.yml (same artifact names, plat-name tags, and diff --git a/.github/workflows/macos-x86-integration-test.yml b/.github/workflows/macos-x86-integration-test.yml index a41fc143..07a33e5d 100644 --- a/.github/workflows/macos-x86-integration-test.yml +++ b/.github/workflows/macos-x86-integration-test.yml @@ -3,10 +3,14 @@ name: macOS x86 Integration Test on: push: branches: [main] - # Pull requests run macos-arm-live-test.yml instead: it cross-compiles on - # Linux and holds a macOS runner only to execute the result. + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] workflow_dispatch: - + inputs: + candidate_sha: + description: Exact 40-character commit SHA to validate + required: true + type: string # One live run per workflow per ref: a new push to a branch or PR cancels the # superseded run instead of queuing behind it in the shared account-wide runner # pool. Runs on main get a unique group, so they are never cancelled or coalesced. @@ -16,6 +20,8 @@ concurrency: jobs: call: + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ci-full')) uses: ./.github/workflows/_integration-test.yml with: + source-sha: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} runs-on: macos-14 diff --git a/.github/workflows/macos-x86-lint.yml b/.github/workflows/macos-x86-lint.yml index e6334e16..b63d273e 100644 --- a/.github/workflows/macos-x86-lint.yml +++ b/.github/workflows/macos-x86-lint.yml @@ -3,10 +3,14 @@ name: macOS x86 Lint on: push: branches: [main] - # Pull requests run macos-arm-live-test.yml instead: it cross-compiles on - # Linux and holds a macOS runner only to execute the result. + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] workflow_dispatch: - + inputs: + candidate_sha: + description: Exact 40-character commit SHA to validate + required: true + type: string # One live run per workflow per ref: a new push to a branch or PR cancels the # superseded run instead of queuing behind it in the shared account-wide runner # pool. Runs on main get a unique group, so they are never cancelled or coalesced. @@ -16,6 +20,8 @@ concurrency: jobs: call: + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ci-full')) uses: ./.github/workflows/_lint.yml with: + source-sha: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} runs-on: macos-14 diff --git a/.github/workflows/macos-x86-unit-test.yml b/.github/workflows/macos-x86-unit-test.yml index 90d5f851..c0b7ceb9 100644 --- a/.github/workflows/macos-x86-unit-test.yml +++ b/.github/workflows/macos-x86-unit-test.yml @@ -3,10 +3,14 @@ name: macOS x86 Unit Test on: push: branches: [main] - # Pull requests run macos-arm-live-test.yml instead: it cross-compiles on - # Linux and holds a macOS runner only to execute the result. + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] workflow_dispatch: - + inputs: + candidate_sha: + description: Exact 40-character commit SHA to validate + required: true + type: string # One live run per workflow per ref: a new push to a branch or PR cancels the # superseded run instead of queuing behind it in the shared account-wide runner # pool. Runs on main get a unique group, so they are never cancelled or coalesced. @@ -16,6 +20,8 @@ concurrency: jobs: call: + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ci-full')) uses: ./.github/workflows/_unit-test.yml with: + source-sha: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} runs-on: macos-14 diff --git a/.github/workflows/vscode-extension-release.yml b/.github/workflows/vscode-extension-release.yml index 85edfdcb..eb5b2f1e 100644 --- a/.github/workflows/vscode-extension-release.yml +++ b/.github/workflows/vscode-extension-release.yml @@ -10,6 +10,18 @@ concurrency: cancel-in-progress: false jobs: + full-coverage: + runs-on: ubuntu-24.04 + permissions: + actions: read + contents: read + steps: + - uses: actions/checkout@v4 + - name: Require exact-SHA full release CI before publication + env: + GH_TOKEN: ${{ github.token }} + run: python ci/verify_full_coverage.py --sha "$GITHUB_SHA" + validate-tag: runs-on: ubuntu-24.04 outputs: @@ -22,10 +34,14 @@ jobs: test "${GITHUB_REF_NAME}" = "vscode-v${VERSION}" echo "version=${VERSION}" >> "$GITHUB_OUTPUT" build: - needs: validate-tag + needs: [validate-tag, full-coverage] + if: needs.full-coverage.result == 'success' uses: ./.github/workflows/vscode-extension.yml + with: + release_build: true publish: - needs: [validate-tag, build] + needs: [validate-tag, full-coverage, build] + if: needs.full-coverage.result == 'success' && needs.build.result == 'success' runs-on: ubuntu-24.04 permissions: contents: write diff --git a/.github/workflows/vscode-extension.yml b/.github/workflows/vscode-extension.yml index a13fb831..0ca40260 100644 --- a/.github/workflows/vscode-extension.yml +++ b/.github/workflows/vscode-extension.yml @@ -2,14 +2,20 @@ name: VS Code extension on: workflow_call: + inputs: + release_build: + description: Build VSIX artifacts for the tag-triggered release caller + required: false + type: boolean + default: false pull_request: - paths: ["vscode-plugin/**", ".github/workflows/vscode-extension.yml"] + types: [opened, synchronize, reopened, labeled, unlabeled] push: branches: [main] - paths: ["vscode-plugin/**", ".github/workflows/vscode-extension.yml"] jobs: native: + if: inputs.release_build || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ci-full')) name: ${{ matrix.target }} strategy: fail-fast: false @@ -54,6 +60,7 @@ jobs: if-no-files-found: error universal: + if: inputs.release_build || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ci-full')) runs-on: ubuntu-24.04 defaults: run: diff --git a/.github/workflows/windows-arm-build.yml b/.github/workflows/windows-arm-build.yml index 074ab699..2202d653 100644 --- a/.github/workflows/windows-arm-build.yml +++ b/.github/workflows/windows-arm-build.yml @@ -4,8 +4,13 @@ on: push: branches: [main] pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] workflow_dispatch: - + inputs: + candidate_sha: + description: Exact 40-character commit SHA to validate + required: true + type: string # One live run per workflow per ref: a new push to a branch or PR cancels the # superseded run instead of queuing behind it in the shared account-wide runner # pool. Runs on main get a unique group, so they are never cancelled or coalesced. @@ -15,7 +20,9 @@ concurrency: jobs: call: + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ci-full')) uses: ./.github/workflows/_build.yml with: + source-sha: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} runs-on: windows-2025 rust-target: aarch64-pc-windows-msvc diff --git a/.github/workflows/windows-arm-integration-test.yml b/.github/workflows/windows-arm-integration-test.yml new file mode 100644 index 00000000..26329387 --- /dev/null +++ b/.github/workflows/windows-arm-integration-test.yml @@ -0,0 +1,27 @@ +name: Windows ARM Integration Test + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] + workflow_dispatch: + inputs: + candidate_sha: + description: Exact 40-character commit SHA to validate + required: true + type: string +# One live run per workflow per ref: a new push to a branch or PR cancels the +# superseded run instead of queuing behind it in the shared account-wide runner +# pool. Runs on main get a unique group, so they are never cancelled or coalesced. +concurrency: + group: ${{ github.workflow }}-${{ github.ref == 'refs/heads/main' && github.run_id || github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + call: + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ci-full')) + uses: ./.github/workflows/_integration-test.yml + with: + source-sha: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + runs-on: windows-11-arm diff --git a/.github/workflows/windows-arm-unit-test.yml b/.github/workflows/windows-arm-unit-test.yml new file mode 100644 index 00000000..cb072f29 --- /dev/null +++ b/.github/workflows/windows-arm-unit-test.yml @@ -0,0 +1,27 @@ +name: Windows ARM Unit Test + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] + workflow_dispatch: + inputs: + candidate_sha: + description: Exact 40-character commit SHA to validate + required: true + type: string +# One live run per workflow per ref: a new push to a branch or PR cancels the +# superseded run instead of queuing behind it in the shared account-wide runner +# pool. Runs on main get a unique group, so they are never cancelled or coalesced. +concurrency: + group: ${{ github.workflow }}-${{ github.ref == 'refs/heads/main' && github.run_id || github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + call: + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ci-full')) + uses: ./.github/workflows/_unit-test.yml + with: + source-sha: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + runs-on: windows-11-arm diff --git a/.github/workflows/windows-x86-build.yml b/.github/workflows/windows-x86-build.yml index 63dd17c6..1c06014e 100644 --- a/.github/workflows/windows-x86-build.yml +++ b/.github/workflows/windows-x86-build.yml @@ -4,8 +4,13 @@ on: push: branches: [main] pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] workflow_dispatch: - + inputs: + candidate_sha: + description: Exact 40-character commit SHA to validate + required: true + type: string # One live run per workflow per ref: a new push to a branch or PR cancels the # superseded run instead of queuing behind it in the shared account-wide runner # pool. Runs on main get a unique group, so they are never cancelled or coalesced. @@ -15,8 +20,10 @@ concurrency: jobs: call: + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ci-full')) uses: ./.github/workflows/_build.yml with: + source-sha: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} runs-on: windows-2025 # Explicit target so CI wheels/binaries are identical to the release # builds in auto-release.yml (same artifact names, plat-name tags, and diff --git a/.github/workflows/windows-x86-integration-test.yml b/.github/workflows/windows-x86-integration-test.yml index d7f548bb..ea3b7dcc 100644 --- a/.github/workflows/windows-x86-integration-test.yml +++ b/.github/workflows/windows-x86-integration-test.yml @@ -4,8 +4,13 @@ on: push: branches: [main] pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] workflow_dispatch: - + inputs: + candidate_sha: + description: Exact 40-character commit SHA to validate + required: true + type: string # One live run per workflow per ref: a new push to a branch or PR cancels the # superseded run instead of queuing behind it in the shared account-wide runner # pool. Runs on main get a unique group, so they are never cancelled or coalesced. @@ -15,6 +20,8 @@ concurrency: jobs: call: + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ci-full')) uses: ./.github/workflows/_integration-test.yml with: + source-sha: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} runs-on: windows-2025 diff --git a/.github/workflows/windows-x86-lint.yml b/.github/workflows/windows-x86-lint.yml index 9f022734..4d5f5e4c 100644 --- a/.github/workflows/windows-x86-lint.yml +++ b/.github/workflows/windows-x86-lint.yml @@ -4,8 +4,13 @@ on: push: branches: [main] pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] workflow_dispatch: - + inputs: + candidate_sha: + description: Exact 40-character commit SHA to validate + required: true + type: string # One live run per workflow per ref: a new push to a branch or PR cancels the # superseded run instead of queuing behind it in the shared account-wide runner # pool. Runs on main get a unique group, so they are never cancelled or coalesced. @@ -15,6 +20,8 @@ concurrency: jobs: call: + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ci-full')) uses: ./.github/workflows/_lint.yml with: + source-sha: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} runs-on: windows-2025 diff --git a/.github/workflows/windows-x86-terminal-test.yml b/.github/workflows/windows-x86-terminal-test.yml index 70dde39b..f72c7629 100644 --- a/.github/workflows/windows-x86-terminal-test.yml +++ b/.github/workflows/windows-x86-terminal-test.yml @@ -4,8 +4,13 @@ on: push: branches: [main] pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] workflow_dispatch: - + inputs: + candidate_sha: + description: Exact 40-character commit SHA to validate + required: true + type: string # One live run per workflow per ref: a new push to a branch or PR cancels the # superseded run instead of queuing behind it in the shared account-wide runner # pool. Runs on main get a unique group, so they are never cancelled or coalesced. @@ -15,8 +20,10 @@ concurrency: jobs: call: + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ci-full')) uses: ./.github/workflows/_terminal-test.yml with: + source-sha: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} runs-on: windows-2025 # The interactive test drives a POSIX shell; ConPTY runs the slot-release # regression only, selected here so the job reports no skipped tests. diff --git a/.github/workflows/windows-x86-unit-test.yml b/.github/workflows/windows-x86-unit-test.yml index a11640f4..76a5cf44 100644 --- a/.github/workflows/windows-x86-unit-test.yml +++ b/.github/workflows/windows-x86-unit-test.yml @@ -4,8 +4,13 @@ on: push: branches: [main] pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] workflow_dispatch: - + inputs: + candidate_sha: + description: Exact 40-character commit SHA to validate + required: true + type: string # One live run per workflow per ref: a new push to a branch or PR cancels the # superseded run instead of queuing behind it in the shared account-wide runner # pool. Runs on main get a unique group, so they are never cancelled or coalesced. @@ -15,6 +20,8 @@ concurrency: jobs: call: + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ci-full')) uses: ./.github/workflows/_unit-test.yml with: + source-sha: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} runs-on: windows-2025 diff --git a/CLAUDE.md b/CLAUDE.md index 25080d25..d0c1c34d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,11 @@ If `soldr` is not on PATH, install it with `uv tool install soldr`. ## Tests +Ordinary PR/main CI is minimal. The literal `ci-test` PR label adds Linux x86 +unit and integration tests; `ci-full` runs the platform matrix. Full validation +must pass on the exact release SHA before tagging or publishing. A version bump +on `main` no longer starts a release. See [fractional CI and release gates](docs/FRACTIONAL_CI.md). + `bash test` runs the Python API smoke tests plus the Rust workspace tests. End-to-end WASM compiles should be run only when the change touches the native build backend. For iterative work: diff --git a/ci/check_full_coverage.py b/ci/check_full_coverage.py new file mode 100644 index 00000000..3f1d6c78 --- /dev/null +++ b/ci/check_full_coverage.py @@ -0,0 +1,192 @@ +"""Fail closed unless every declared platform workflow passed on one SHA.""" + +import argparse +import datetime as dt +import json +import os +import sys +import time +import urllib.parse +import urllib.request +from pathlib import Path + +MANIFEST = Path(__file__).with_name("full_coverage.json") +UTC = dt.timezone.utc +PLATFORMS = { + "linux-x86", + "linux-arm", + "windows-x86", + "windows-arm", + "macos-x86", + "macos-arm", +} +POLL_SECONDS = 120 # 29 workflows × runs/jobs endpoints; stay well below API limits. + + +def parse_time(value): + return dt.datetime.fromisoformat(value.replace("Z", "+00:00")) + + +def cell_result(run, jobs, sha, event, earliest): + if run["head_sha"] != sha or run["event"] != event: + return "wrong-sha-or-event" + if parse_time(run["created_at"]) < earliest: + return "stale" + if run["status"] != "completed": + return "pending" + if run["conclusion"] != "success": + return run["conclusion"] or "failed" + if not jobs: + return "missing-jobs" + if any(job["conclusion"] != "success" for job in jobs): + return "skipped-or-failed-job" + return "passed" + + +def test_execution_gaps(cells, platform_tests): + """Return every platform/step lacking a successful executed test step.""" + gaps = [] + for platform, checks in platform_tests.items(): + for check in checks: + cell = cells.get(check["workflow"], {}) + executed = any( + step.get("name") == check["step"] + and step.get("conclusion") == "success" + for job in cell.get("jobs", []) + if job.get("conclusion") == "success" + for step in job.get("steps", []) + ) + if not executed: + gaps.append(f"{platform}: {check['workflow']} / {check['step']}") + return gaps + + +def api(path): + url = "https://api.github.com/" + path.lstrip("/") + request = urllib.request.Request( + url, + headers={ + "Authorization": f"Bearer {os.environ['GH_TOKEN']}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + ) + with urllib.request.urlopen(request, timeout=30) as response: + return json.load(response) + + +def pages(path, key): + result = [] + page = 1 + while True: + separator = "&" if "?" in path else "?" + batch = api(f"{path}{separator}per_page=100&page={page}")[key] + result.extend(batch) + if len(batch) < 100: + return result + page += 1 + + +def collect(repo, sha, event, earliest, workflows): + report = {} + for workflow in workflows: + path = urllib.parse.quote(workflow) + runs = pages( + f"repos/{repo}/actions/workflows/{path}/runs?head_sha={sha}&event={event}", + "workflow_runs", + ) + eligible = [run for run in runs if parse_time(run["created_at"]) >= earliest] + if not eligible: + report[workflow] = {"result": "missing-run"} + continue + run = max(eligible, key=lambda item: (item["created_at"], item["id"])) + jobs = [] + if run["status"] == "completed": + jobs = pages(f"repos/{repo}/actions/runs/{run['id']}/jobs", "jobs") + report[workflow] = { + "result": cell_result(run, jobs, sha, event, earliest), + "run_id": run["id"], + "url": run["html_url"], + "jobs": [ + { + "name": job["name"], + "conclusion": job["conclusion"], + "steps": [ + {"name": step["name"], "conclusion": step["conclusion"]} + for step in job.get("steps", []) + ], + } + for job in jobs + ], + } + return report + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--sha", required=True) + parser.add_argument( + "--event", choices=("pull_request", "workflow_dispatch"), required=True + ) + parser.add_argument("--coverage-run-id", type=int, required=True) + parser.add_argument("--report", type=Path, required=True) + parser.add_argument("--timeout-seconds", type=int, default=5400) + args = parser.parse_args() + if len(args.sha) != 40 or any(c not in "0123456789abcdef" for c in args.sha): + parser.error("--sha must be a lowercase 40-character commit SHA") + repo = os.environ["GITHUB_REPOSITORY"] + manifest = json.loads(MANIFEST.read_text()) + if manifest["schema_version"] != 2 or not manifest["workflows"]: + parser.error("empty or unsupported coverage manifest") + if set(manifest.get("platform_tests", {})) != PLATFORMS: + parser.error("coverage manifest omits a supported platform test contract") + for checks in manifest["platform_tests"].values(): + if not checks or any( + check["workflow"] not in manifest["workflows"] for check in checks + ): + parser.error( + "coverage manifest contains an empty or undeclared platform test" + ) + for platform, checks in manifest["platform_tests"].items(): + if any(not check["workflow"].startswith(platform + "-") for check in checks): + parser.error("coverage manifest maps a platform to another platform's test") + own_run = api(f"repos/{repo}/actions/runs/{args.coverage_run_id}") + if own_run["head_sha"] != args.sha: + parser.error("coverage workflow itself is not running on the requested SHA") + # PR labels require a fresh run; dispatch may aggregate platform runs + # started earlier for the same immutable candidate SHA. + earliest = ( + parse_time(own_run["created_at"]) - dt.timedelta(minutes=2) + if args.event == "pull_request" + else dt.datetime.min.replace(tzinfo=UTC) + ) + deadline = time.monotonic() + args.timeout_seconds + while True: + report = collect(repo, args.sha, args.event, earliest, manifest["workflows"]) + gaps = test_execution_gaps(report, manifest["platform_tests"]) + payload = { + "schema_version": 2, + "sha": args.sha, + "event": args.event, + "manifest": manifest["workflows"], + "platform_tests": manifest["platform_tests"], + "test_execution_gaps": gaps, + "cells": report, + } + args.report.write_text(json.dumps(payload, indent=2) + "\n") + results = {name: cell["result"] for name, cell in report.items()} + print(json.dumps(results, sort_keys=True), flush=True) + if all(value == "passed" for value in results.values()) and not gaps: + return 0 + terminal = set(results.values()) - {"passed", "pending", "missing-run"} + if ( + terminal + or (all(value == "passed" for value in results.values()) and gaps) + or time.monotonic() >= deadline + ): + return 1 + time.sleep(POLL_SECONDS) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/ci/full_coverage.json b/ci/full_coverage.json new file mode 100644 index 00000000..ee791938 --- /dev/null +++ b/ci/full_coverage.json @@ -0,0 +1,61 @@ +{ + "schema_version": 2, + "workflows": [ + "linux-arm-build.yml", + "linux-arm-integration-test.yml", + "linux-arm-lint.yml", + "linux-arm-unit-test.yml", + "linux-x86-build.yml", + "linux-x86-dwarf-smoke.yml", + "linux-x86-integration-test.yml", + "linux-x86-lint.yml", + "linux-x86-render-smoke.yml", + "linux-x86-terminal-test.yml", + "linux-x86-unit-test.yml", + "macos-arm-build.yml", + "macos-arm-integration-test.yml", + "macos-arm-lint.yml", + "macos-arm-live-test.yml", + "macos-arm-unit-test.yml", + "macos-x64-guest-webkit-probe.yml", + "macos-x86-build.yml", + "macos-x86-integration-test.yml", + "macos-x86-lint.yml", + "macos-x86-unit-test.yml", + "windows-arm-build.yml", + "windows-arm-integration-test.yml", + "windows-arm-unit-test.yml", + "windows-x86-build.yml", + "windows-x86-integration-test.yml", + "windows-x86-lint.yml", + "windows-x86-terminal-test.yml", + "windows-x86-unit-test.yml" + ], + "platform_tests": { + "linux-x86": [ + {"workflow": "linux-x86-unit-test.yml", "step": "Unit Tests"}, + {"workflow": "linux-x86-integration-test.yml", "step": "Native Test Suite"} + ], + "linux-arm": [ + {"workflow": "linux-arm-unit-test.yml", "step": "Unit Tests"}, + {"workflow": "linux-arm-integration-test.yml", "step": "Native Test Suite"} + ], + "windows-x86": [ + {"workflow": "windows-x86-unit-test.yml", "step": "Unit Tests"}, + {"workflow": "windows-x86-integration-test.yml", "step": "Native Test Suite"} + ], + "windows-arm": [ + {"workflow": "windows-arm-unit-test.yml", "step": "Unit Tests"}, + {"workflow": "windows-arm-integration-test.yml", "step": "Native Test Suite"} + ], + "macos-x86": [ + {"workflow": "macos-x86-unit-test.yml", "step": "Unit Tests"}, + {"workflow": "macos-x86-integration-test.yml", "step": "Native Test Suite"} + ], + "macos-arm": [ + {"workflow": "macos-arm-unit-test.yml", "step": "Unit Tests"}, + {"workflow": "macos-arm-integration-test.yml", "step": "Native Test Suite"}, + {"workflow": "macos-arm-live-test.yml", "step": "Run the cross-built Rust tests"} + ] + } +} diff --git a/ci/release_artifact_lint.py b/ci/release_artifact_lint.py new file mode 100644 index 00000000..14074edd --- /dev/null +++ b/ci/release_artifact_lint.py @@ -0,0 +1,101 @@ +"""Offline release-artifact checks before any tag or registry upload. + +This is a preflight, not a PyPI upload reservation or a project-quota check. +""" + +import argparse +import json +import zipfile +from pathlib import Path + +TARGET_PLATFORMS = { + "x86_64-unknown-linux-gnu": "manylinux_2_28_x86_64", + "aarch64-unknown-linux-gnu": "manylinux_2_28_aarch64", + "x86_64-pc-windows-msvc": "win_amd64", + "aarch64-pc-windows-msvc": "win_arm64", + "x86_64-apple-darwin": "macosx_10_12_x86_64", + "aarch64-apple-darwin": "macosx_11_0_arm64", +} +MAX_WHEEL_BYTES = 100_000_000 # Conservative PyPI per-file limit. + + +def check(wheel_root: Path, binary_root: Path, version: str) -> dict: + if not version or not all(char.isdigit() or char == "." for char in version): + raise ValueError(f"invalid release version: {version!r}") + wheel_files = sorted(path for path in wheel_root.rglob("*") if path.is_file()) + expected_wheels = { + f"fastled-{version}-py3-none-{platform}.whl": target + for target, platform in TARGET_PLATFORMS.items() + } + if {path.name for path in wheel_files} != set(expected_wheels) or len( + wheel_files + ) != len(expected_wheels): + raise ValueError( + f"expected exactly the six versioned platform wheels {sorted(expected_wheels)}; found {[path.name for path in wheel_files]}" + ) + wheels = [] + for path in wheel_files: + size = path.stat().st_size + if not 0 < size < MAX_WHEEL_BYTES: + raise ValueError( + f"wheel is empty or at/above the {MAX_WHEEL_BYTES}-byte limit: {path} ({size} bytes)" + ) + with zipfile.ZipFile(path) as archive: + bad_member = archive.testzip() + binary_name = ( + "fastled.exe" if "windows" in expected_wheels[path.name] else "fastled" + ) + binary_path = f"fastled/bin/{binary_name}" + if ( + bad_member + or not any( + name.endswith(".dist-info/WHEEL") for name in archive.namelist() + ) + or binary_path not in archive.namelist() + or not archive.getinfo(binary_path).file_size + ): + raise ValueError( + f"invalid wheel archive: {path} (bad member: {bad_member})" + ) + wheels.append({"name": path.name, "bytes": size}) + + binary_dirs = ( + sorted(path for path in binary_root.iterdir() if path.is_dir()) + if binary_root.exists() + else [] + ) + if len(binary_dirs) != len(TARGET_PLATFORMS): + raise ValueError( + f"expected {len(TARGET_PLATFORMS)} binary artifacts; found {[path.name for path in binary_dirs]}" + ) + for target in TARGET_PLATFORMS: + matches = [ + path + for path in binary_dirs + if path.name.startswith("binary-") and path.name.endswith("-" + target) + ] + if len(matches) != 1: + raise ValueError(f"missing or duplicate binary artifact for {target}") + executable = matches[0] / ("fastled.exe" if "windows" in target else "fastled") + if not executable.is_file() or executable.stat().st_size == 0: + raise ValueError(f"missing or empty release binary: {executable}") + return { + "schema_version": 1, + "version": version, + "wheels": wheels, + "binary_targets": list(TARGET_PLATFORMS), + "pypi_project_quota_verified": False, + } + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--wheels", type=Path, required=True) + parser.add_argument("--binaries", type=Path, required=True) + parser.add_argument("--version", required=True) + args = parser.parse_args() + print(json.dumps(check(args.wheels, args.binaries, args.version), indent=2)) + + +if __name__ == "__main__": + main() diff --git a/ci/verify_full_coverage.py b/ci/verify_full_coverage.py new file mode 100644 index 00000000..baeeee4d --- /dev/null +++ b/ci/verify_full_coverage.py @@ -0,0 +1,98 @@ +"""Require a successful GitHub full-coverage dispatch at the release SHA.""" + +import argparse +import io +import json +import os +import sys +import urllib.request +import zipfile +from pathlib import Path + +MANIFEST = Path(__file__).with_name("full_coverage.json") +PLATFORMS = {"linux-x86", "linux-arm", "windows-x86", "windows-arm", "macos-x86", "macos-arm"} + + +def get_json(path): + request = urllib.request.Request( + "https://api.github.com/" + path, + headers={ + "Authorization": f"Bearer {os.environ['GH_TOKEN']}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + ) + with urllib.request.urlopen(request, timeout=30) as response: + return json.load(response) + + +def download(url): + request = urllib.request.Request( + url, + headers={"Authorization": f"Bearer {os.environ['GH_TOKEN']}"}, + ) + with urllib.request.urlopen(request, timeout=60) as response: + return response.read() + + +def validate_report(report, manifest, sha): + if report.get("schema_version") != 2 or report.get("sha") != sha: + return False + if report.get("event") != "workflow_dispatch": + return False + names = manifest.get("workflows", []) + if not names or report.get("manifest") != names: + return False + tests = manifest.get("platform_tests", {}) + if set(tests) != PLATFORMS or report.get("platform_tests") != tests: + return False + if any(not checks for checks in tests.values()): + return False + if any(not check["workflow"].startswith(platform + "-") for platform, checks in tests.items() for check in checks): + return False + cells = report.get("cells", {}) + if set(cells) != set(names): + return False + if not all(cell.get("result") == "passed" and isinstance(cell.get("run_id"), int) for cell in cells.values()): + return False + if report.get("test_execution_gaps") != []: + return False + for checks in tests.values(): + for check in checks: + if check["workflow"] not in cells: + return False + jobs = cells[check["workflow"]].get("jobs", []) + if not any(step.get("name") == check["step"] and step.get("conclusion") == "success" for job in jobs if job.get("conclusion") == "success" for step in job.get("steps", [])): + return False + return True + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--sha", required=True) + args = parser.parse_args() + if len(args.sha) != 40 or any(c not in "0123456789abcdef" for c in args.sha): + parser.error("--sha must be a lowercase 40-character commit SHA") + manifest = json.loads(MANIFEST.read_text()) + if manifest.get("schema_version") != 2: + parser.error("unsupported coverage manifest") + repo = os.environ["GITHUB_REPOSITORY"] + runs = get_json(f"repos/{repo}/actions/workflows/full-coverage.yml/runs?head_sha={args.sha}&event=workflow_dispatch&per_page=100")["workflow_runs"] + for run in runs: + if run["head_sha"] != args.sha or run["conclusion"] != "success": + continue + artifacts = get_json(f"repos/{repo}/actions/runs/{run['id']}/artifacts?per_page=100")["artifacts"] + for artifact in artifacts: + if artifact["name"] != f"full-coverage-{run['id']}" or artifact["expired"]: + continue + with zipfile.ZipFile(io.BytesIO(download(artifact["archive_download_url"]))) as archive: + report = json.loads(archive.read("full-coverage.json")) + if validate_report(report, manifest, args.sha): + print(f"Verified all {len(manifest['workflows'])} platform workflows: {run['html_url']}") + return 0 + print("No passing exact-SHA release full-coverage report found", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docs/FRACTIONAL_CI.md b/docs/FRACTIONAL_CI.md new file mode 100644 index 00000000..bb432ed9 --- /dev/null +++ b/docs/FRACTIONAL_CI.md @@ -0,0 +1,52 @@ +# Fractional CI and release gate + +Ordinary pull requests and `main` updates run the Linux x86 lint gate. The +literal `ci-test` PR label adds Linux x86 unit and integration tests. The +`ci-full` label runs every declared platform workflow. Adding or removing a +label retriggers the workflows; `ci-full` includes the `ci-test` cells. + +The versioned platform manifest is [`ci/full_coverage.json`](../ci/full_coverage.json). +[`full-coverage.yml`](../.github/workflows/full-coverage.yml) polls the real +GitHub runs for all 29 workflows on one SHA and publishes a JSON report. Each +of the six release platforms must also show successful execution of the +declared unit and integration test steps. macOS ARM additionally requires its +cross-built Rust test step. A build-only success cannot satisfy release +coverage. A missing, failed, cancelled, or skipped required job or test step +fails the report. This is the +aggregate proof for the CLI platform matrix; local YAML or unit tests do not +replace it. + +For a release candidate, dispatch each platform workflow at a branch whose +head is the candidate SHA, supplying its required `candidate_sha` input. The +jobs refuse a different SHA. Then dispatch `full-coverage.yml` with that SHA +and wait for its report to pass. `auto-release.yml` is manual-only and checks +the successful GitHub full-coverage report before any build or tag. To exercise +its six release builds and artifact preflight without tagging or publishing, +run `gh workflow run auto-release.yml --ref -f +candidate_sha=<40-hex-sha> -f dry_run=true`. The branch tip must be that SHA. +The preflight rejects missing/corrupt wheels, missing binaries, and wheels at +or above 100 MB; it cannot reserve a PyPI upload or verify the project's +remaining storage quota. A conflicting tag is rejected before the six builds. +The VS Code tag-push publication workflow checks the same report before +building or publishing. + +The normalized issue-driven release front door and automated dispatch of the +29 full workflows are still pending. A candidate must currently be the tip of +the dispatched branch; the workflows reject a different SHA rather than +substituting the branch head. Do not create a release tag from a version bump +or invoke a direct publisher without the full-coverage report. Live PR-label +and release-candidate runs, their URLs, and runner-minute measurements are +required before claiming rollout completion. + +Windows ARM previously had only a cross-build on `windows-2025`. Full CI now +includes native unit and integration jobs on `windows-11-arm`, which is already +used by the VS Code extension matrix. These new jobs have not yet passed a +real GitHub run. Until both execute and pass on the candidate SHA, the +coverage report fails and release tagging is blocked. The same real-run +requirement applies to every platform test step. + +The Python wheel is tagged `py3-none-` by `setup.py`; it bundles a +native CLI executable but has no CPython extension ABI. Platform/architecture +execution is therefore the release ABI boundary here. The current test entry +points install Python 3.11.9 and do not prove the full `requires-python >=3.10` +interpreter range; that compatibility check remains a separate release gap. diff --git a/setup.py b/setup.py index 3387259b..a0147eb4 100644 --- a/setup.py +++ b/setup.py @@ -42,8 +42,8 @@ def _workspace_package_version() -> str: def _candidate_binaries(root: Path) -> list[Path]: candidates: list[Path] = [] - target = os.environ.get("CARGO_BUILD_TARGET") or os.environ.get( - "FASTLED_RUST_TARGET" + target = os.environ.get("FASTLED_RUST_TARGET") or os.environ.get( + "CARGO_BUILD_TARGET" ) if target: candidates.append(root / "target" / target / "release" / EXE_NAME) diff --git a/tests/unit/test_ci_modes.py b/tests/unit/test_ci_modes.py new file mode 100644 index 00000000..b9a1d1fc --- /dev/null +++ b/tests/unit/test_ci_modes.py @@ -0,0 +1,311 @@ +"""Guard the event-to-CI-tier wiring across every platform workflow.""" + +import importlib.util +import json +import zipfile +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +WORKFLOWS = ROOT / ".github" / "workflows" +SPEC = importlib.util.spec_from_file_location( + "check_full_coverage", ROOT / "ci" / "check_full_coverage.py" +) +assert SPEC and SPEC.loader +coverage = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(coverage) +VERIFY_SPEC = importlib.util.spec_from_file_location( + "verify_full_coverage", ROOT / "ci" / "verify_full_coverage.py" +) +assert VERIFY_SPEC and VERIFY_SPEC.loader +verify = importlib.util.module_from_spec(VERIFY_SPEC) +VERIFY_SPEC.loader.exec_module(verify) +ARTIFACT_SPEC = importlib.util.spec_from_file_location( + "release_artifact_lint", ROOT / "ci" / "release_artifact_lint.py" +) +assert ARTIFACT_SPEC and ARTIFACT_SPEC.loader +artifacts = importlib.util.module_from_spec(ARTIFACT_SPEC) +ARTIFACT_SPEC.loader.exec_module(artifacts) + + +def test_platform_workflows_have_normalized_tiers(): + seen = set() + for path in WORKFLOWS.glob("*.yml"): + if not path.name.startswith(("linux-", "windows-", "macos-")): + continue + seen.add(path.name) + workflow = path.read_text() + assert " pull_request:" in workflow, path.name + assert ( + "types: [opened, synchronize, reopened, labeled, unlabeled]" in workflow + ), path.name + if path.name == "linux-x86-lint.yml": + assert " if: " not in workflow, path.name + else: + assert ( + " if: github.event_name == 'workflow_dispatch'" in workflow + ), path.name + assert "'ci-full'" in workflow, path.name + if path.name in { + "linux-x86-unit-test.yml", + "linux-x86-integration-test.yml", + }: + assert "'ci-test'" in workflow, path.name + else: + assert "'ci-test'" not in workflow, path.name + assert len(seen) == 29 + + +def test_vscode_matrix_only_runs_for_full_pr_or_tag_release(): + workflow = (WORKFLOWS / "vscode-extension.yml").read_text() + assert "release_build:" in workflow + assert workflow.count("inputs.release_build") == 2 + assert workflow.count("'ci-full'") == 2 + release = (WORKFLOWS / "vscode-extension-release.yml").read_text() + assert "release_build: true" in release + + +def test_release_does_not_wait_for_removed_routine_build_artifacts(): + workflow = (WORKFLOWS / "auto-release.yml").read_text() + assert "collect-artifacts:" not in workflow + assert "build-linux-x86:" in workflow + assert "build-macos-arm:" in workflow + assert "needs.build-linux-x86.result == 'success'" in workflow + assert "needs.build-macos-arm.result == 'success'" in workflow + build = (WORKFLOWS / "_build.yml").read_text() + assert build.count("if-no-files-found: error") >= 2 + assert "name: smoke-wheel-screenshot-" in build + assert "name: smoke-wheel-log-" in build + assert "name: wheel-smoke-" not in build + + +def test_platform_jobs_checkout_exact_requested_sha(): + for path in WORKFLOWS.glob("*.yml"): + if not path.name.startswith(("linux-", "windows-", "macos-")): + continue + workflow = path.read_text() + assert "candidate_sha:" in workflow, path.name + assert "required: true" in workflow, path.name + assert "github.event.pull_request.head.sha" in workflow, path.name + if "uses: ./.github/workflows/_" in workflow: + assert "source-sha:" in workflow, path.name + else: + assert "ref: ${{" in workflow, path.name + assert "git rev-parse HEAD" in workflow, path.name + for path in WORKFLOWS.glob("_*.yml"): + workflow = path.read_text() + assert "source-sha:" in workflow, path.name + assert "ref: ${{ inputs.source-sha || github.sha }}" in workflow, path.name + assert "git rev-parse HEAD" in workflow, path.name + + +def test_full_coverage_manifest_and_fail_closed_results(): + manifest = json.loads((ROOT / "ci" / "full_coverage.json").read_text()) + actual = sorted( + path.name + for path in WORKFLOWS.glob("*.yml") + if path.name.startswith(("linux-", "windows-", "macos-")) + ) + assert manifest["schema_version"] == 2 + assert manifest["workflows"] == actual + workflow = (WORKFLOWS / "full-coverage.yml").read_text() + assert "ci-full" in workflow + assert "check_full_coverage.py" in workflow + sha = "a" * 40 + run = { + "head_sha": sha, + "event": "pull_request", + "created_at": "2026-09-23T00:00:00Z", + "status": "completed", + "conclusion": "success", + } + earliest = coverage.parse_time("2026-09-22T23:59:00Z") + assert ( + coverage.cell_result( + run, [{"conclusion": "success"}], sha, "pull_request", earliest + ) + == "passed" + ) + assert ( + coverage.cell_result(run, [], sha, "pull_request", earliest) == "missing-jobs" + ) + assert ( + coverage.cell_result( + run, [{"conclusion": "skipped"}], sha, "pull_request", earliest + ) + == "skipped-or-failed-job" + ) + assert ( + coverage.cell_result( + run, [{"conclusion": "success"}], "b" * 40, "pull_request", earliest + ) + == "wrong-sha-or-event" + ) + assert ( + coverage.cell_result( + run, [{"conclusion": "success"}], sha, "workflow_dispatch", earliest + ) + == "wrong-sha-or-event" + ) + + +def test_publication_requires_exact_sha_full_coverage(): + release = (WORKFLOWS / "auto-release.yml").read_text() + vscode = (WORKFLOWS / "vscode-extension-release.yml").read_text() + assert " push:\n branches:\n - main" not in release + assert "candidate_sha:" in release + assert "needs.full-coverage.result == 'success'" in release + assert "verify_full_coverage.py" in release + assert "verify_full_coverage.py" in vscode + assert "needs.full-coverage.result == 'success'" in vscode + + +def test_release_tag_uses_verified_candidate_checkout(): + release = (WORKFLOWS / "auto-release.yml").read_text() + tag_job = release.split(" create-tag:\n", 1)[1].split(" publish-pypi:\n", 1)[0] + assert "ref: ${{ inputs.candidate_sha }}" in tag_job + assert 'test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"' in tag_job + + +def test_release_dry_run_and_artifact_preflight_block_tagging(): + release = (WORKFLOWS / "auto-release.yml").read_text() + assert "dry_run:" in release + assert "artifact-preflight:" in release + assert ( + 'python ci/release_artifact_lint.py --wheels dist --binaries binaries --version "$VERSION"' + in release + ) + tag_job = release.split(" create-tag:\n", 1)[1].split(" publish-pypi:\n", 1)[0] + assert "needs.artifact-preflight.result == 'success'" in tag_job + assert "inputs.dry_run != true" in tag_job + for job in ("publish-pypi", "create-release"): + section = release.split(f" {job}:\n", 1)[1].split(" steps:\n", 1)[0] + assert "inputs.dry_run != true" in section + + +def test_release_artifact_lint_checks_exact_six_wheels_and_binaries(tmp_path): + wheels = tmp_path / "dist" + binaries = tmp_path / "binaries" + wheels.mkdir() + binaries.mkdir() + for target, platform in artifacts.TARGET_PLATFORMS.items(): + with zipfile.ZipFile( + wheels / f"fastled-2.0.23-py3-none-{platform}.whl", "w" + ) as archive: + archive.writestr("fastled-1.0.dist-info/WHEEL", "Wheel-Version: 1.0\n") + archive.writestr( + ( + "fastled/bin/fastled.exe" + if "windows" in target + else "fastled/bin/fastled" + ), + "binary", + ) + destination = binaries / f"binary-runner-{target}" + destination.mkdir() + ( + destination / ("fastled.exe" if "windows" in target else "fastled") + ).write_bytes(b"binary") + assert len(artifacts.check(wheels, binaries, "2.0.23")["wheels"]) == 6 + with pytest.raises(ValueError, match="versioned platform wheels"): + artifacts.check(wheels, binaries, "2.0.24") + (wheels / "stray.log").write_text("not a wheel") + with pytest.raises(ValueError, match="versioned platform wheels"): + artifacts.check(wheels, binaries, "2.0.23") + + +def test_cross_target_wheel_build_preserves_target_for_setup_py(): + build = (WORKFLOWS / "_build.yml").read_text() + wheel_step = build.split(" - name: Build wheel\n", 1)[1].split( + " - name: Smoke test", 1 + )[0] + assert "FASTLED_RUST_TARGET: ${{ inputs.rust-target }}" in wheel_step + + +def test_release_report_validation_rejects_missing_or_wrong_sha_cells(): + sha = "a" * 40 + platforms = ( + "linux-x86", + "linux-arm", + "windows-x86", + "windows-arm", + "macos-x86", + "macos-arm", + ) + names = [f"{platform}-unit-test.yml" for platform in platforms] + tests = { + platform: [{"workflow": f"{platform}-unit-test.yml", "step": "Unit Tests"}] + for platform in platforms + } + manifest = {"workflows": names, "platform_tests": tests} + report = { + "schema_version": 2, + "sha": sha, + "event": "workflow_dispatch", + "manifest": names, + "platform_tests": tests, + "test_execution_gaps": [], + "cells": { + name: { + "result": "passed", + "run_id": index, + "jobs": [ + { + "conclusion": "success", + "steps": [{"name": "Unit Tests", "conclusion": "success"}], + } + ], + } + for index, name in enumerate(names, 1) + }, + } + assert verify.validate_report(report, manifest, sha) + assert not verify.validate_report(report, manifest, "b" * 40) + report["cells"]["windows-arm-unit-test.yml"]["jobs"] = [ + {"conclusion": "success", "steps": []} + ] + assert coverage.test_execution_gaps(report["cells"], tests) == [ + "windows-arm: windows-arm-unit-test.yml / Unit Tests" + ] + assert not verify.validate_report(report, manifest, sha) + report["cells"]["windows-arm-unit-test.yml"]["jobs"] = [ + { + "conclusion": "success", + "steps": [{"name": "Unit Tests", "conclusion": "success"}], + } + ] + report["cells"]["macos-arm-unit-test.yml"]["jobs"][0]["steps"][0][ + "conclusion" + ] = "skipped" + assert not verify.validate_report(report, manifest, sha) + report["cells"]["macos-arm-unit-test.yml"]["jobs"][0]["steps"][0][ + "conclusion" + ] = "success" + del report["cells"]["macos-arm-unit-test.yml"] + assert not verify.validate_report(report, manifest, sha) + + +def test_every_release_platform_has_executed_test_step(): + manifest = json.loads((ROOT / "ci" / "full_coverage.json").read_text()) + required = { + "linux-x86", + "linux-arm", + "windows-x86", + "windows-arm", + "macos-x86", + "macos-arm", + } + assert set(manifest["platform_tests"]) == required + for platform, checks in manifest["platform_tests"].items(): + assert checks, platform + for check in checks: + assert check["workflow"] in manifest["workflows"] + workflow = (WORKFLOWS / check["workflow"]).read_text() + if "_unit-test.yml" in workflow: + step_source = (WORKFLOWS / "_unit-test.yml").read_text() + elif "_integration-test.yml" in workflow: + step_source = (WORKFLOWS / "_integration-test.yml").read_text() + else: + step_source = workflow + assert f"- name: {check['step']}\n" in step_source, check diff --git a/tests/unit/test_setup_packaging.py b/tests/unit/test_setup_packaging.py index da76d25d..d7e592c0 100644 --- a/tests/unit/test_setup_packaging.py +++ b/tests/unit/test_setup_packaging.py @@ -86,6 +86,21 @@ def test_candidate_binaries_never_harvest_cargo_home( ), f"candidate {candidate} escapes the workspace" +def test_explicit_release_target_beats_ambient_cargo_default( + setup_module, tmp_path, monkeypatch +): + monkeypatch.setenv("CARGO_BUILD_TARGET", "x86_64-pc-windows-msvc") + monkeypatch.setenv("FASTLED_RUST_TARGET", "aarch64-pc-windows-msvc") + candidates = setup_module._candidate_binaries(tmp_path) + assert candidates[0] == ( + tmp_path + / "target" + / "aarch64-pc-windows-msvc" + / "release" + / setup_module.EXE_NAME + ) + + def test_fails_loud_without_cargo_toml_or_binary(setup_module, tmp_path): with pytest.raises(RuntimeError, match="Cargo.toml"): setup_module.ensure_bundled_fastled_binary(tmp_path) From 48cc8ca2a8a38ec9e77098de5ca2497b0c6f9c90 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Wed, 23 Sep 2026 05:49:49 -0700 Subject: [PATCH 2/5] ci: capture bounded Windows ARM Soldr failure diagnostics --- .github/workflows/_integration-test.yml | 14 +++ .github/workflows/_unit-test.yml | 14 +++ .../windows-arm-integration-test.yml | 2 +- .github/workflows/windows-arm-unit-test.yml | 2 +- ci/capture_soldr_failure.ps1 | 107 ++++++++++++++++++ docs/FRACTIONAL_CI.md | 2 + tests/unit/test_ci_modes.py | 41 +++++++ 7 files changed, 180 insertions(+), 2 deletions(-) create mode 100644 ci/capture_soldr_failure.ps1 diff --git a/.github/workflows/_integration-test.yml b/.github/workflows/_integration-test.yml index d94aa349..ca5b4641 100644 --- a/.github/workflows/_integration-test.yml +++ b/.github/workflows/_integration-test.yml @@ -79,6 +79,20 @@ jobs: - name: Native Test Suite run: ./test + - name: Capture Soldr failure diagnostics + if: failure() && inputs.runs-on == 'windows-11-arm' + continue-on-error: true + shell: pwsh + run: ./ci/capture_soldr_failure.ps1 + + - name: Upload Soldr failure diagnostics + if: failure() && inputs.runs-on == 'windows-11-arm' + uses: actions/upload-artifact@v4 + with: + name: soldr-failure-diagnostics-integration-test-${{ inputs.runs-on }}-${{ github.run_attempt }} + path: soldr-failure-diagnostics/** + if-no-files-found: error + - name: Upload failure logs if: failure() uses: actions/upload-artifact@v4 diff --git a/.github/workflows/_unit-test.yml b/.github/workflows/_unit-test.yml index fd40c693..db71f94d 100644 --- a/.github/workflows/_unit-test.yml +++ b/.github/workflows/_unit-test.yml @@ -79,6 +79,20 @@ jobs: - name: Unit Tests run: ./test + - name: Capture Soldr failure diagnostics + if: failure() && inputs.runs-on == 'windows-11-arm' + continue-on-error: true + shell: pwsh + run: ./ci/capture_soldr_failure.ps1 + + - name: Upload Soldr failure diagnostics + if: failure() && inputs.runs-on == 'windows-11-arm' + uses: actions/upload-artifact@v4 + with: + name: soldr-failure-diagnostics-unit-test-${{ inputs.runs-on }}-${{ github.run_attempt }} + path: soldr-failure-diagnostics/** + if-no-files-found: error + - name: Upload failure logs if: failure() uses: actions/upload-artifact@v4 diff --git a/.github/workflows/windows-arm-integration-test.yml b/.github/workflows/windows-arm-integration-test.yml index 26329387..fa5ff297 100644 --- a/.github/workflows/windows-arm-integration-test.yml +++ b/.github/workflows/windows-arm-integration-test.yml @@ -20,7 +20,7 @@ concurrency: jobs: call: - if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ci-full')) + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && (contains(github.event.pull_request.labels.*.name, 'ci-full') || contains(github.event.pull_request.labels.*.name, 'ci-test:windows-arm'))) uses: ./.github/workflows/_integration-test.yml with: source-sha: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} diff --git a/.github/workflows/windows-arm-unit-test.yml b/.github/workflows/windows-arm-unit-test.yml index cb072f29..6d5fa39c 100644 --- a/.github/workflows/windows-arm-unit-test.yml +++ b/.github/workflows/windows-arm-unit-test.yml @@ -20,7 +20,7 @@ concurrency: jobs: call: - if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ci-full')) + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && (contains(github.event.pull_request.labels.*.name, 'ci-full') || contains(github.event.pull_request.labels.*.name, 'ci-test:windows-arm'))) uses: ./.github/workflows/_unit-test.yml with: source-sha: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_sha || github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} diff --git a/ci/capture_soldr_failure.ps1 b/ci/capture_soldr_failure.ps1 new file mode 100644 index 00000000..7810f3c1 --- /dev/null +++ b/ci/capture_soldr_failure.ps1 @@ -0,0 +1,107 @@ +# Windows ARM failure evidence for the pinned Soldr v0.7.51/zccache 1.11.8. +# `soldr logs paths` was added later; enumerate only its known log locations. +param([string]$OutputDir = "soldr-failure-diagnostics") + +$ErrorActionPreference = "Continue" +$ProbeTimeoutMs = 15000 +$MaxLogFiles = 24 +$MaxLogBytes = 65536 +$MaxPrivateDirs = 8 +$captured = 0 +New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null +$inventory = Join-Path $OutputDir "logs-paths.txt" +"Soldr v0.7.51 log paths and bounded captures:" | Out-File -FilePath $inventory -Encoding utf8 + +function Save-LogTail { + param([string]$Source) + if ($script:captured -ge $MaxLogFiles -or -not (Test-Path -LiteralPath $Source -PathType Leaf)) { + return + } + try { + $stream = [System.IO.File]::Open($Source, 'Open', 'Read', 'ReadWrite') + try { + $bytesToRead = [int][Math]::Min($stream.Length, $MaxLogBytes) + $null = $stream.Seek(-$bytesToRead, 'End') + $bytes = New-Object byte[] $bytesToRead + $read = $stream.Read($bytes, 0, $bytesToRead) + $script:captured++ + $target = Join-Path $OutputDir ("log-{0:D2}-{1}" -f $script:captured, [IO.Path]::GetFileName($Source)) + if ($read -gt 0) { + $capturedBytes = New-Object byte[] $read + [Array]::Copy($bytes, $capturedBytes, $read) + [System.IO.File]::WriteAllBytes($target, $capturedBytes) + } else { + [System.IO.File]::WriteAllBytes($target, [byte[]]@()) + } + "captured=$target source=$Source bytes=$read original_bytes=$($stream.Length)" | + Add-Content -Path $inventory + } finally { + $stream.Dispose() + } + } catch { + "unreadable=$Source reason=$_" | Add-Content -Path $inventory + } +} + +function Save-KnownLogDirectory { + param([string]$Directory) + if (-not $Directory -or -not (Test-Path -LiteralPath $Directory -PathType Container)) { + return + } + "checked=$Directory" | Add-Content -Path $inventory + Get-ChildItem -LiteralPath $Directory -File -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '^(daemon.*\.log(\..*)?|last-session\.(log|jsonl)|lifecycle\.jsonl|embedded-.*\.warn\.log.*)$' } | + Sort-Object LastWriteTime -Descending | + Select-Object -First $MaxLogFiles | + ForEach-Object { Save-LogTail $_.FullName } +} + +$soldr = $env:SOLDR_BINARY +if (-not $soldr -or -not (Test-Path -LiteralPath $soldr)) { + $resolved = Get-Command soldr -ErrorAction SilentlyContinue + if ($resolved) { $soldr = $resolved.Source } +} +if ($soldr -and (Test-Path -LiteralPath $soldr)) { + foreach ($probe in @('doctor', 'status')) { + $stdout = Join-Path $env:RUNNER_TEMP "soldr-$probe-stdout.txt" + $stderr = Join-Path $env:RUNNER_TEMP "soldr-$probe-stderr.txt" + try { + $process = Start-Process -FilePath $soldr -ArgumentList $probe -PassThru -NoNewWindow ` + -RedirectStandardOutput $stdout -RedirectStandardError $stderr + if (-not $process.WaitForExit($ProbeTimeoutMs)) { + $process.Kill($true) + $null = $process.WaitForExit(2000) + "probe=$probe timed_out_ms=$ProbeTimeoutMs" | Add-Content -Path $inventory + } else { + "probe=$probe exit_code=$($process.ExitCode)" | Add-Content -Path $inventory + } + } catch { + "probe=$probe error=$_" | Add-Content -Path $inventory + } + Save-LogTail $stdout + Save-LogTail $stderr + } +} else { + "soldr binary unavailable; SOLDR_BINARY=$($env:SOLDR_BINARY)" | Add-Content -Path $inventory +} + +$setupRoot = Join-Path $env:RUNNER_TEMP 'setup-soldr-soldr' +$zccacheRoot = Join-Path $setupRoot 'cache\zccache' +Save-KnownLogDirectory (Join-Path $zccacheRoot 'logs') +Save-KnownLogDirectory (Join-Path $setupRoot 'cache\soldr-daemon') +Save-LogTail (Join-Path $setupRoot 'daemon-spawn.log') + +# The pinned Soldr puts private zccache daemons under private/. +$privateRoot = Join-Path $zccacheRoot 'private' +if (Test-Path -LiteralPath $privateRoot -PathType Container) { + Get-ChildItem -LiteralPath $privateRoot -Directory -ErrorAction SilentlyContinue | + Select-Object -First $MaxPrivateDirs | + ForEach-Object { Save-KnownLogDirectory (Join-Path $_.FullName 'logs') } +} + +# The runner's Soldr home may hold a separate daemon lifecycle log. +$homeRoot = Join-Path $env:USERPROFILE '.soldr' +Save-KnownLogDirectory (Join-Path $homeRoot 'cache\soldr-daemon') +Save-LogTail (Join-Path $homeRoot 'daemon-spawn.log') +"captured_files=$captured max_files=$MaxLogFiles max_bytes_per_file=$MaxLogBytes" | + Add-Content -Path $inventory diff --git a/docs/FRACTIONAL_CI.md b/docs/FRACTIONAL_CI.md index bb432ed9..193258c8 100644 --- a/docs/FRACTIONAL_CI.md +++ b/docs/FRACTIONAL_CI.md @@ -4,6 +4,8 @@ Ordinary pull requests and `main` updates run the Linux x86 lint gate. The literal `ci-test` PR label adds Linux x86 unit and integration tests. The `ci-full` label runs every declared platform workflow. Adding or removing a label retriggers the workflows; `ci-full` includes the `ci-test` cells. +The `ci-test:windows-arm` label runs only Windows ARM unit and integration +tests for a targeted diagnosis. It does not satisfy the full-coverage gate. The versioned platform manifest is [`ci/full_coverage.json`](../ci/full_coverage.json). [`full-coverage.yml`](../.github/workflows/full-coverage.yml) polls the real diff --git a/tests/unit/test_ci_modes.py b/tests/unit/test_ci_modes.py index b9a1d1fc..635085e0 100644 --- a/tests/unit/test_ci_modes.py +++ b/tests/unit/test_ci_modes.py @@ -100,6 +100,47 @@ def test_platform_jobs_checkout_exact_requested_sha(): assert "git rev-parse HEAD" in workflow, path.name +def test_windows_arm_test_failures_capture_soldr_diagnostics(): + diagnostic = ROOT / "ci" / "capture_soldr_failure.ps1" + assert diagnostic.exists() + script = diagnostic.read_text() + for command in ("doctor", "status", "logs paths"): + assert command in script + assert "WriteAllBytes" in script + assert "logs-paths.txt" in script + assert "RUNNER_TEMP" in script + assert ".soldr" in script + assert "cache\\zccache" in script + assert "private" in script + assert "-Recurse" not in script + assert "$ProbeTimeoutMs = 15000" in script + assert "$MaxLogFiles = 24" in script + assert "$MaxLogBytes = 65536" in script + assert "$MaxPrivateDirs = 8" in script + assert "WaitForExit($ProbeTimeoutMs)" in script + assert "Seek(-$bytesToRead, 'End')" in script + for name in ("_unit-test.yml", "_integration-test.yml"): + workflow = (WORKFLOWS / name).read_text() + assert "if: failure() && inputs.runs-on == 'windows-11-arm'" in workflow + assert "continue-on-error: true" in workflow + assert "./ci/capture_soldr_failure.ps1" in workflow + assert "soldr-failure-diagnostics/**" in workflow + + +def test_windows_arm_targeted_label_runs_only_its_two_test_workflows(): + selected = {"windows-arm-unit-test.yml", "windows-arm-integration-test.yml"} + for path in WORKFLOWS.glob("*.yml"): + workflow = path.read_text() + if path.name in selected: + assert "'ci-test:windows-arm'" in workflow, path.name + assert "'ci-full'" in workflow, path.name + assert ( + "types: [opened, synchronize, reopened, labeled, unlabeled]" in workflow + ) + else: + assert "'ci-test:windows-arm'" not in workflow, path.name + + def test_full_coverage_manifest_and_fail_closed_results(): manifest = json.loads((ROOT / "ci" / "full_coverage.json").read_text()) actual = sorted( From 5453141fe2701ad7654f000c4d14b9563d5f4e73 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Wed, 23 Sep 2026 06:00:59 -0700 Subject: [PATCH 3/5] ci: use adaptive Soldr startup on Windows ARM tests --- .github/workflows/_integration-test.yml | 7 ++++--- .github/workflows/_unit-test.yml | 7 ++++--- tests/unit/test_ci_modes.py | 17 +++++++++++++++++ 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/.github/workflows/_integration-test.yml b/.github/workflows/_integration-test.yml index ca5b4641..bde800c6 100644 --- a/.github/workflows/_integration-test.yml +++ b/.github/workflows/_integration-test.yml @@ -55,9 +55,10 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} with: - # No soldr version pin: keep in lockstep with _build.yml so all jobs - # resolve the same soldr/zccache version and share warm caches - # (see the pin rationale comment in _build.yml and #158). + # Windows ARM needs 0.7.53's adaptive daemon-spawn wait; the 0.7.51 + # default times out before its slow listener starts. Keep other test + # runners and cross-builds on 0.7.51 (see #158). + version: ${{ inputs.runs-on == 'windows-11-arm' && '0.7.53' || '0.7.51' }} # cache-preset: foundation expands to build-cache: true + # target-cache: false + cargo-registry-cache: false + # prebuild-deps: soldr-cook (setup-soldr v0.9.17+, #251). diff --git a/.github/workflows/_unit-test.yml b/.github/workflows/_unit-test.yml index db71f94d..b9783397 100644 --- a/.github/workflows/_unit-test.yml +++ b/.github/workflows/_unit-test.yml @@ -55,9 +55,10 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} with: - # No soldr version pin: keep in lockstep with _build.yml so all jobs - # resolve the same soldr/zccache version and share warm caches - # (see the pin rationale comment in _build.yml and #158). + # Windows ARM needs 0.7.53's adaptive daemon-spawn wait; the 0.7.51 + # default times out before its slow listener starts. Keep other test + # runners and cross-builds on 0.7.51 (see #158). + version: ${{ inputs.runs-on == 'windows-11-arm' && '0.7.53' || '0.7.51' }} # cache-preset: foundation expands to build-cache: true + # target-cache: false + cargo-registry-cache: false + # prebuild-deps: soldr-cook (setup-soldr v0.9.17+, #251). diff --git a/tests/unit/test_ci_modes.py b/tests/unit/test_ci_modes.py index 635085e0..df969d8b 100644 --- a/tests/unit/test_ci_modes.py +++ b/tests/unit/test_ci_modes.py @@ -127,6 +127,23 @@ def test_windows_arm_test_failures_capture_soldr_diagnostics(): assert "soldr-failure-diagnostics/**" in workflow +def test_windows_arm_tests_select_soldr_with_adaptive_daemon_wait(): + version_selector = ( + "version: ${{ inputs.runs-on == 'windows-11-arm' && '0.7.53' || '0.7.51' }}" + ) + for name in ("_unit-test.yml", "_integration-test.yml"): + workflow = (WORKFLOWS / name).read_text() + setup = workflow.split("- uses: zackees/setup-soldr@v0.9.62", 1)[1] + setup = setup.split(" - name: Sync Python deps", 1)[0] + assert version_selector in setup, name + assert setup.count("version:") == 1, name + + build = (WORKFLOWS / "_build.yml").read_text() + setup = build.split("- uses: zackees/setup-soldr@v0.9.62", 1)[1] + setup = setup.split(" - name: Build fastled CLI binary", 1)[0] + assert "version:" not in setup + + def test_windows_arm_targeted_label_runs_only_its_two_test_workflows(): selected = {"windows-arm-unit-test.yml", "windows-arm-integration-test.yml"} for path in WORKFLOWS.glob("*.yml"): From 951cb05a9162e9150d45c7c8d9170169270ccd69 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Wed, 23 Sep 2026 06:09:14 -0700 Subject: [PATCH 4/5] ci: test embedded Soldr on Windows ARM --- .github/workflows/_integration-test.yml | 8 ++++---- .github/workflows/_unit-test.yml | 8 ++++---- tests/unit/test_ci_modes.py | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/_integration-test.yml b/.github/workflows/_integration-test.yml index bde800c6..90f27bb1 100644 --- a/.github/workflows/_integration-test.yml +++ b/.github/workflows/_integration-test.yml @@ -55,10 +55,10 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} with: - # Windows ARM needs 0.7.53's adaptive daemon-spawn wait; the 0.7.51 - # default times out before its slow listener starts. Keep other test - # runners and cross-builds on 0.7.51 (see #158). - version: ${{ inputs.runs-on == 'windows-11-arm' && '0.7.53' || '0.7.51' }} + # Windows ARM native tests hit 0.7.51's listener timeout and + # 0.7.53's pre-lock timeout. Try 0.9.21's embedded daemon; keep + # other runners and cross-builds on 0.7.51 (see #158). + version: ${{ inputs.runs-on == 'windows-11-arm' && '0.9.21' || '0.7.51' }} # cache-preset: foundation expands to build-cache: true + # target-cache: false + cargo-registry-cache: false + # prebuild-deps: soldr-cook (setup-soldr v0.9.17+, #251). diff --git a/.github/workflows/_unit-test.yml b/.github/workflows/_unit-test.yml index b9783397..a92a5ad2 100644 --- a/.github/workflows/_unit-test.yml +++ b/.github/workflows/_unit-test.yml @@ -55,10 +55,10 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} with: - # Windows ARM needs 0.7.53's adaptive daemon-spawn wait; the 0.7.51 - # default times out before its slow listener starts. Keep other test - # runners and cross-builds on 0.7.51 (see #158). - version: ${{ inputs.runs-on == 'windows-11-arm' && '0.7.53' || '0.7.51' }} + # Windows ARM native tests hit 0.7.51's listener timeout and + # 0.7.53's pre-lock timeout. Try 0.9.21's embedded daemon; keep + # other runners and cross-builds on 0.7.51 (see #158). + version: ${{ inputs.runs-on == 'windows-11-arm' && '0.9.21' || '0.7.51' }} # cache-preset: foundation expands to build-cache: true + # target-cache: false + cargo-registry-cache: false + # prebuild-deps: soldr-cook (setup-soldr v0.9.17+, #251). diff --git a/tests/unit/test_ci_modes.py b/tests/unit/test_ci_modes.py index df969d8b..484fb0e5 100644 --- a/tests/unit/test_ci_modes.py +++ b/tests/unit/test_ci_modes.py @@ -127,9 +127,9 @@ def test_windows_arm_test_failures_capture_soldr_diagnostics(): assert "soldr-failure-diagnostics/**" in workflow -def test_windows_arm_tests_select_soldr_with_adaptive_daemon_wait(): +def test_windows_arm_tests_select_soldr_with_embedded_daemon(): version_selector = ( - "version: ${{ inputs.runs-on == 'windows-11-arm' && '0.7.53' || '0.7.51' }}" + "version: ${{ inputs.runs-on == 'windows-11-arm' && '0.9.21' || '0.7.51' }}" ) for name in ("_unit-test.yml", "_integration-test.yml"): workflow = (WORKFLOWS / name).read_text() From 0b38e4e1e4eb6629d86f0b64ed20d65c35bee8d4 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Wed, 23 Sep 2026 14:30:51 -0700 Subject: [PATCH 5/5] ci: gate VS Code tags on exact-SHA release preflight --- .../workflows/vscode-extension-release.yml | 135 +++++++++++++++--- .github/workflows/vscode-extension.yml | 2 +- CLAUDE.md | 4 +- ci/vscode_release_artifact_lint.py | 54 +++++++ docs/FRACTIONAL_CI.md | 9 +- tests/unit/test_ci_modes.py | 47 ++++++ 6 files changed, 231 insertions(+), 20 deletions(-) create mode 100644 ci/vscode_release_artifact_lint.py diff --git a/.github/workflows/vscode-extension-release.yml b/.github/workflows/vscode-extension-release.yml index eb5b2f1e..7626f870 100644 --- a/.github/workflows/vscode-extension-release.yml +++ b/.github/workflows/vscode-extension-release.yml @@ -1,55 +1,156 @@ name: Release VS Code extension on: - push: - tags: ["vscode-v*"] + workflow_dispatch: + inputs: + candidate_sha: + description: Exact 40-character branch-head commit SHA to release + required: true + type: string + dry_run: + description: Validate full CI and VSIX artifacts without tagging or publishing + required: false + default: false + type: boolean -# Never cancelled and never coalesced: every publish runs to completion. concurrency: group: ${{ github.workflow }}-${{ github.run_id }} cancel-in-progress: false +permissions: + contents: read + actions: read + jobs: full-coverage: runs-on: ubuntu-24.04 - permissions: - actions: read - contents: read steps: - uses: actions/checkout@v4 - - name: Require exact-SHA full release CI before publication + with: + ref: ${{ inputs.candidate_sha }} + - name: Require exact-SHA full release CI before tagging env: GH_TOKEN: ${{ github.token }} - run: python ci/verify_full_coverage.py --sha "$GITHUB_SHA" + EXPECTED_SHA: ${{ inputs.candidate_sha }} + run: | + [[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]] + [[ "$GITHUB_REF" == refs/heads/* ]] + test "$EXPECTED_SHA" = "$GITHUB_SHA" + test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" + python ci/verify_full_coverage.py --sha "$EXPECTED_SHA" - validate-tag: + validate-version: + needs: [full-coverage] runs-on: ubuntu-24.04 outputs: version: ${{ steps.version.outputs.version }} + tag: ${{ steps.version.outputs.tag }} steps: - uses: actions/checkout@v4 + with: + ref: ${{ inputs.candidate_sha }} - id: version + env: + EXPECTED_SHA: ${{ inputs.candidate_sha }} run: | + test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" VERSION=$(node -p "require('./vscode-plugin/package.json').version") - test "${GITHUB_REF_NAME}" = "vscode-v${VERSION}" + TAG="vscode-v${VERSION}" + if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then + git fetch --no-tags origin "refs/tags/${TAG}:refs/tags/${TAG}" + test "$(git rev-parse "${TAG}^{commit}")" = "$EXPECTED_SHA" || { + echo "Existing tag ${TAG} points to another commit" >&2 + exit 1 + } + fi echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + build: - needs: [validate-tag, full-coverage] - if: needs.full-coverage.result == 'success' + needs: [full-coverage, validate-version] + if: needs.full-coverage.result == 'success' && needs.validate-version.result == 'success' uses: ./.github/workflows/vscode-extension.yml with: release_build: true + + artifact-preflight: + needs: [full-coverage, validate-version, build] + if: needs.full-coverage.result == 'success' && needs.validate-version.result == 'success' && needs.build.result == 'success' + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.candidate_sha }} + - uses: actions/download-artifact@v4 + with: { pattern: vscode-*, path: artifacts, merge-multiple: true } + - name: Validate the seven VSIX packages before tagging + env: + VERSION: ${{ needs.validate-version.outputs.version }} + EXPECTED_SHA: ${{ inputs.candidate_sha }} + run: | + test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" + python ci/vscode_release_artifact_lint.py --artifacts artifacts --version "$VERSION" + + create-tag: + needs: [full-coverage, validate-version, artifact-preflight] + if: >- + !cancelled() && + inputs.dry_run != true && + needs.full-coverage.result == 'success' && + needs.validate-version.result == 'success' && + needs.artifact-preflight.result == 'success' + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.candidate_sha }} + - name: Create release tag from the verified commit + env: + EXPECTED_SHA: ${{ inputs.candidate_sha }} + TAG: ${{ needs.validate-version.outputs.tag }} + run: | + test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then + git fetch --no-tags origin "refs/tags/${TAG}:refs/tags/${TAG}" + test "$(git rev-parse "${TAG}^{commit}")" = "$EXPECTED_SHA" || { + echo "Existing tag ${TAG} points to another commit" >&2 + exit 1 + } + else + git tag -a "$TAG" -m "Release VS Code ${{ needs.validate-version.outputs.version }}" + git push origin "refs/tags/${TAG}" + fi + publish: - needs: [validate-tag, full-coverage, build] - if: needs.full-coverage.result == 'success' && needs.build.result == 'success' + needs: [full-coverage, validate-version, artifact-preflight, create-tag] + if: >- + !cancelled() && + inputs.dry_run != true && + needs.full-coverage.result == 'success' && + needs.artifact-preflight.result == 'success' && + needs.create-tag.result == 'success' runs-on: ubuntu-24.04 permissions: contents: write id-token: write attestations: write steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.candidate_sha }} - uses: actions/download-artifact@v4 - with: { path: artifacts, merge-multiple: true } + with: { pattern: vscode-*, path: artifacts, merge-multiple: true } + - name: Verify release artifacts again + env: + VERSION: ${{ needs.validate-version.outputs.version }} + EXPECTED_SHA: ${{ inputs.candidate_sha }} + run: | + test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" + python ci/vscode_release_artifact_lint.py --artifacts artifacts --version "$VERSION" - name: Produce checksums run: | cd artifacts @@ -60,7 +161,9 @@ jobs: - name: Create GitHub release from the verified package bytes env: GH_TOKEN: ${{ github.token }} - run: gh release create "$GITHUB_REF_NAME" artifacts/* --title "FastLED VS Code ${{ needs.validate-tag.outputs.version }}" --generate-notes + TAG: ${{ needs.validate-version.outputs.tag }} + VERSION: ${{ needs.validate-version.outputs.version }} + run: gh release create "$TAG" artifacts/* --title "FastLED VS Code $VERSION" --generate-notes - name: Publish those same prebuilt VSIX files to Marketplace env: VSCE_PAT: ${{ secrets.VSCE_PAT }} diff --git a/.github/workflows/vscode-extension.yml b/.github/workflows/vscode-extension.yml index 0ca40260..0610df2e 100644 --- a/.github/workflows/vscode-extension.yml +++ b/.github/workflows/vscode-extension.yml @@ -4,7 +4,7 @@ on: workflow_call: inputs: release_build: - description: Build VSIX artifacts for the tag-triggered release caller + description: Build VSIX artifacts for the exact-SHA release caller required: false type: boolean default: false diff --git a/CLAUDE.md b/CLAUDE.md index d0c1c34d..fbfbf090 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,9 @@ If `soldr` is not on PATH, install it with `uv tool install soldr`. Ordinary PR/main CI is minimal. The literal `ci-test` PR label adds Linux x86 unit and integration tests; `ci-full` runs the platform matrix. Full validation must pass on the exact release SHA before tagging or publishing. A version bump -on `main` no longer starts a release. See [fractional CI and release gates](docs/FRACTIONAL_CI.md). +on `main` or a pushed VS Code tag no longer starts a release. Both CLI and +VS Code releases require manual exact-SHA dispatch and artifact preflight. +See [fractional CI and release gates](docs/FRACTIONAL_CI.md). `bash test` runs the Python API smoke tests plus the Rust workspace tests. End-to-end WASM compiles should be run only when the change touches the native build backend. diff --git a/ci/vscode_release_artifact_lint.py b/ci/vscode_release_artifact_lint.py new file mode 100644 index 00000000..154aa60c --- /dev/null +++ b/ci/vscode_release_artifact_lint.py @@ -0,0 +1,54 @@ +"""Validate the complete VS Code release payload before creating its tag.""" + +import argparse +import json +import zipfile +from pathlib import Path + +TARGETS = frozenset( + { + "win32-x64", + "win32-arm64", + "linux-x64", + "linux-arm64", + "darwin-x64", + "darwin-arm64", + "universal", + } +) + + +def check(artifacts: Path, version: str) -> list[Path]: + expected = {f"fastled-wasm-{version}-{target}.vsix" for target in TARGETS} + found = {path.name for path in artifacts.iterdir() if path.is_file()} + if found != expected: + raise ValueError( + f"expected exact seven versioned VSIX files: {sorted(expected)}; got {sorted(found)}" + ) + packages = sorted(artifacts / name for name in expected) + for package in packages: + if package.stat().st_size == 0: + raise ValueError(f"empty VSIX: {package.name}") + try: + with zipfile.ZipFile(package) as archive: + if archive.testzip() is not None: + raise ValueError(f"corrupt VSIX: {package.name}") + manifest = json.loads(archive.read("extension/package.json")) + except (zipfile.BadZipFile, KeyError, json.JSONDecodeError) as error: + raise ValueError(f"invalid VSIX: {package.name}: {error}") from error + if manifest.get("name") != "fastled-wasm" or manifest.get("version") != version: + raise ValueError(f"VSIX manifest mismatch: {package.name}") + return packages + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--artifacts", type=Path, required=True) + parser.add_argument("--version", required=True) + args = parser.parse_args() + packages = check(args.artifacts, args.version) + print(f"Validated {len(packages)} VSIX packages for version {args.version}") + + +if __name__ == "__main__": + main() diff --git a/docs/FRACTIONAL_CI.md b/docs/FRACTIONAL_CI.md index 193258c8..b4d80608 100644 --- a/docs/FRACTIONAL_CI.md +++ b/docs/FRACTIONAL_CI.md @@ -29,8 +29,13 @@ candidate_sha=<40-hex-sha> -f dry_run=true`. The branch tip must be that SHA. The preflight rejects missing/corrupt wheels, missing binaries, and wheels at or above 100 MB; it cannot reserve a PyPI upload or verify the project's remaining storage quota. A conflicting tag is rejected before the six builds. -The VS Code tag-push publication workflow checks the same report before -building or publishing. +The VS Code extension release is also manual-only. Dispatch +`vscode-extension-release.yml` at the candidate branch with the same +`candidate_sha`; `dry_run=true` exercises its seven-platform VSIX build and +artifact preflight without creating `vscode-v` or publishing. A real +attempt checks the exact-SHA full-coverage report, builds and validates all +seven version-matched VSIX files, and only then creates the VS Code tag and +publishes. Pushing a `vscode-v*` tag does not start a release workflow. The normalized issue-driven release front door and automated dispatch of the 29 full workflows are still pending. A candidate must currently be the tip of diff --git a/tests/unit/test_ci_modes.py b/tests/unit/test_ci_modes.py index 484fb0e5..c927817d 100644 --- a/tests/unit/test_ci_modes.py +++ b/tests/unit/test_ci_modes.py @@ -66,6 +66,53 @@ def test_vscode_matrix_only_runs_for_full_pr_or_tag_release(): assert "release_build: true" in release +def test_vscode_release_cannot_create_tag_before_full_coverage_and_preflight(): + release = (WORKFLOWS / "vscode-extension-release.yml").read_text() + assert " push:\n tags:" not in release + assert " workflow_dispatch:" in release + assert "candidate_sha:" in release + assert "dry_run:" in release + assert "verify_full_coverage.py" in release + assert "vscode_release_artifact_lint.py" in release + assert " create-tag:" in release + tag = release.split(" create-tag:\n", 1)[1].split(" publish:\n", 1)[0] + assert "needs.full-coverage.result == 'success'" in tag + assert "needs.artifact-preflight.result == 'success'" in tag + assert "inputs.dry_run != true" in tag + assert 'test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"' in tag + assert "git push origin" in tag + publish = release.split(" publish:\n", 1)[1] + assert "needs.create-tag.result == 'success'" in publish + + +def test_vscode_release_artifact_lint_rejects_missing_corrupt_and_wrong_version( + tmp_path, +): + spec = importlib.util.spec_from_file_location( + "vscode_release_artifact_lint", ROOT / "ci" / "vscode_release_artifact_lint.py" + ) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + targets = { + "win32-x64", "win32-arm64", "linux-x64", "linux-arm64", + "darwin-x64", "darwin-arm64", "universal", + } + for target in targets: + with zipfile.ZipFile( + tmp_path / f"fastled-wasm-1.0.1-{target}.vsix", "w" + ) as archive: + archive.writestr( + "extension/package.json", json.dumps({"name": "fastled-wasm", "version": "1.0.1"}) + ) + assert len(module.check(tmp_path, "1.0.1")) == 7 + with pytest.raises(ValueError): + module.check(tmp_path, "1.0.2") + (tmp_path / "fastled-wasm-1.0.1-universal.vsix").write_bytes(b"corrupt") + with pytest.raises(ValueError): + module.check(tmp_path, "1.0.1") + + def test_release_does_not_wait_for_removed_routine_build_artifacts(): workflow = (WORKFLOWS / "auto-release.yml").read_text() assert "collect-artifacts:" not in workflow