diff --git a/.github/workflows/_reusable-ui-smoke-tests.yml b/.github/workflows/_reusable-ui-smoke-tests.yml index 7341ec1..0c44ff5 100644 --- a/.github/workflows/_reusable-ui-smoke-tests.yml +++ b/.github/workflows/_reusable-ui-smoke-tests.yml @@ -62,42 +62,14 @@ jobs: steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - - name: Checkout trusted scripts - if: ${{ github.event_name == 'pull_request' }} - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: - ref: ${{ github.event.pull_request.base.sha }} - path: .ai-tmp/trusted-ci persist-credentials: false - name: Bootstrap tools shell: bash - env: - GITHUB_TOKEN: ${{ github.token }} run: | set -euo pipefail tool_root="$GITHUB_WORKSPACE" - if [ "${{ github.event_name }}" = "pull_request" ]; then - tool_root="$GITHUB_WORKSPACE/.ai-tmp/trusted-ci" - for required in \ - scripts/build-relay.sh \ - scripts/lib/contract.sh \ - scripts/lib/common.sh \ - scripts/lib/artifacts.sh \ - scripts/lib/xcode.sh \ - scripts/lib/xcresult.sh \ - scripts/lib/architecture.sh \ - scripts/lib/release_binaries.sh \ - scripts/dev/bootstrap.sh \ - scripts/ci/download_relay_modules.sh \ - scripts/ci/ui_smoke.sh; do - if [ ! -e "$tool_root/$required" ]; then - echo "Missing trusted CI script in base checkout: $required. Merge the seed PR into the base branch first." >&2 - exit 1 - fi - done - fi ROOT_DIR="$GITHUB_WORKSPACE" TOOL_ROOT="$tool_root" "$tool_root/scripts/dev/bootstrap.sh" @@ -118,9 +90,6 @@ jobs: run: | set -euo pipefail tool_root="$GITHUB_WORKSPACE" - if [ "${{ github.event_name }}" = "pull_request" ]; then - tool_root="$GITHUB_WORKSPACE/.ai-tmp/trusted-ci" - fi ROOT_DIR="$GITHUB_WORKSPACE" TOOL_ROOT="$tool_root" "$tool_root/scripts/ci/ui_smoke.sh" \ --only-testing '${{ inputs.only_testing }}' \ diff --git a/.github/workflows/_reusable-unit-tests.yml b/.github/workflows/_reusable-unit-tests.yml index fb2a06b..ace5c5c 100644 --- a/.github/workflows/_reusable-unit-tests.yml +++ b/.github/workflows/_reusable-unit-tests.yml @@ -42,37 +42,14 @@ jobs: steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - - name: Checkout trusted scripts - if: ${{ github.event_name == 'pull_request' }} - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: - ref: ${{ github.event.pull_request.base.sha }} - path: .ai-tmp/trusted-ci persist-credentials: false - name: Bootstrap tools shell: bash - env: - GITHUB_TOKEN: ${{ github.token }} run: | set -euo pipefail tool_root="$GITHUB_WORKSPACE" - if [ "${{ github.event_name }}" = "pull_request" ]; then - tool_root="$GITHUB_WORKSPACE/.ai-tmp/trusted-ci" - for required in \ - scripts/lib/contract.sh \ - scripts/lib/common.sh \ - scripts/lib/xcode.sh \ - scripts/lib/artifacts.sh \ - scripts/dev/bootstrap.sh \ - scripts/ci/unit.sh; do - if [ ! -e "$tool_root/$required" ]; then - echo "Missing trusted CI script in base checkout: $required. Merge the seed PR into the base branch first." >&2 - exit 1 - fi - done - fi ROOT_DIR="$GITHUB_WORKSPACE" TOOL_ROOT="$tool_root" "$tool_root/scripts/dev/bootstrap.sh" @@ -90,9 +67,6 @@ jobs: run: | set -euo pipefail tool_root="$GITHUB_WORKSPACE" - if [ "${{ github.event_name }}" = "pull_request" ]; then - tool_root="$GITHUB_WORKSPACE/.ai-tmp/trusted-ci" - fi ROOT_DIR="$GITHUB_WORKSPACE" TOOL_ROOT="$tool_root" "$tool_root/scripts/ci/unit.sh" --out-dir .ai-tmp/unit-gate diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5cd32b5..84461a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,7 +52,6 @@ jobs: docs_only: ${{ steps.classify.outputs.docs_only }} unknown_relevant: ${{ steps.classify.outputs.unknown_relevant }} requires_static: ${{ steps.classify.outputs.requires_static }} - requires_head_script_self_test: ${{ steps.classify.outputs.requires_head_script_self_test }} requires_dependency_review: ${{ steps.classify.outputs.requires_dependency_review }} requires_unit: ${{ steps.classify.outputs.requires_unit }} requires_xcode_build: ${{ steps.classify.outputs.requires_xcode_build }} @@ -63,13 +62,6 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 - - - name: Checkout trusted classification scripts - if: ${{ github.event_name == 'pull_request' }} - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - ref: ${{ github.event.pull_request.base.sha }} - path: .ai-tmp/trusted-ci persist-credentials: false - name: Classify changed files @@ -83,26 +75,6 @@ jobs: run: | set -euo pipefail tool_root="$GITHUB_WORKSPACE" - if [ "$EVENT_NAME" = "pull_request" ]; then - tool_root="$GITHUB_WORKSPACE/.ai-tmp/trusted-ci" - for required in \ - scripts/build-relay.sh \ - scripts/lib/contract.sh \ - scripts/lib/common.sh \ - scripts/lib/artifacts.sh \ - scripts/lib/xcode.sh \ - scripts/lib/xcresult.sh \ - scripts/lib/architecture.sh \ - scripts/lib/release_binaries.sh \ - scripts/dev/bootstrap.sh \ - scripts/dev/doctor.sh \ - scripts/ci/classify.sh; do - if [ ! -e "$tool_root/$required" ]; then - echo "Missing trusted CI script in base checkout: $required. Merge the seed PR into the base branch first." >&2 - exit 1 - fi - done - fi ROOT_DIR="$GITHUB_WORKSPACE" TOOL_ROOT="$tool_root" "$tool_root/scripts/ci/classify.sh" \ --base "$BASE_SHA" \ @@ -148,50 +120,14 @@ jobs: steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - - name: Checkout trusted scripts - if: ${{ github.event_name == 'pull_request' }} - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: - ref: ${{ github.event.pull_request.base.sha }} - path: .ai-tmp/trusted-ci persist-credentials: false - name: Bootstrap static tools shell: bash - env: - GITHUB_TOKEN: ${{ github.token }} run: | set -euo pipefail tool_root="$GITHUB_WORKSPACE" - if [ "${{ github.event_name }}" = "pull_request" ]; then - tool_root="$GITHUB_WORKSPACE/.ai-tmp/trusted-ci" - for required in \ - scripts/build-relay.sh \ - scripts/lib/contract.sh \ - scripts/lib/common.sh \ - scripts/lib/artifacts.sh \ - scripts/lib/xcode.sh \ - scripts/lib/xcresult.sh \ - scripts/lib/architecture.sh \ - scripts/lib/release_binaries.sh \ - scripts/dev/bootstrap.sh \ - scripts/dev/doctor.sh \ - scripts/ci/classify.sh \ - scripts/ci/static.sh \ - scripts/ci/unit.sh \ - scripts/ci/xcode.sh \ - scripts/ci/ui_smoke.sh \ - scripts/ci/release_smoke.sh \ - scripts/ci/release_arch_check.sh \ - scripts/ci/download_relay_modules.sh \ - scripts/release/thin_webrtc_and_sign.sh; do - if [ ! -e "$tool_root/$required" ]; then - echo "Missing trusted CI script in base checkout: $required. Merge the seed PR into the base branch first." >&2 - exit 1 - fi - done - fi ROOT_DIR="$GITHUB_WORKSPACE" TOOL_ROOT="$tool_root" "$tool_root/scripts/dev/bootstrap.sh" @@ -200,55 +136,9 @@ jobs: run: | set -euo pipefail tool_root="$GITHUB_WORKSPACE" - if [ "${{ github.event_name }}" = "pull_request" ]; then - tool_root="$GITHUB_WORKSPACE/.ai-tmp/trusted-ci" - fi ROOT_DIR="$GITHUB_WORKSPACE" TOOL_ROOT="$tool_root" "$tool_root/scripts/ci/static.sh" - head_script_self_test: - name: head-script-self-test - needs: - - classify_changes - if: ${{ github.event_name == 'pull_request' && needs.classify_changes.outputs.requires_head_script_self_test == 'true' }} - runs-on: macos-26 - timeout-minutes: 15 - permissions: - contents: read - steps: - - name: Checkout head without credentials - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - persist-credentials: false - - - name: Checkout trusted bootstrap scripts - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - ref: ${{ github.event.pull_request.base.sha }} - path: .ai-tmp/trusted-ci - persist-credentials: false - - - name: Bootstrap trusted head-test tools - shell: bash - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - tool_root="$GITHUB_WORKSPACE/.ai-tmp/trusted-ci" - ROOT_DIR="$GITHUB_WORKSPACE" TOOL_ROOT="$tool_root" "$tool_root/scripts/dev/bootstrap.sh" - - - name: Validate head scripts - shell: bash - env: - GITHUB_TOKEN: '' - GH_TOKEN: '' - MISE_GITHUB_TOKEN: '' - AQUA_GITHUB_TOKEN: '' - run: | - set -euo pipefail - tool_root="$GITHUB_WORKSPACE" - ROOT_DIR="$GITHUB_WORKSPACE" TOOL_ROOT="$tool_root" "$tool_root/scripts/ci/static.sh" - unit_tests: name: unit-tests needs: @@ -272,13 +162,7 @@ jobs: steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - - name: Checkout trusted scripts - if: ${{ github.event_name == 'pull_request' }} - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: - ref: ${{ github.event.pull_request.base.sha }} - path: .ai-tmp/trusted-ci persist-credentials: false - name: Select Xcode @@ -286,32 +170,9 @@ jobs: - name: Bootstrap tools shell: bash - env: - GITHUB_TOKEN: ${{ github.token }} run: | set -euo pipefail tool_root="$GITHUB_WORKSPACE" - if [ "${{ github.event_name }}" = "pull_request" ]; then - tool_root="$GITHUB_WORKSPACE/.ai-tmp/trusted-ci" - for required in \ - scripts/build-relay.sh \ - scripts/lib/contract.sh \ - scripts/lib/common.sh \ - scripts/lib/artifacts.sh \ - scripts/lib/xcode.sh \ - scripts/lib/xcresult.sh \ - scripts/lib/architecture.sh \ - scripts/lib/release_binaries.sh \ - scripts/dev/bootstrap.sh \ - scripts/dev/doctor.sh \ - scripts/ci/download_relay_modules.sh \ - scripts/ci/xcode.sh; do - if [ ! -e "$tool_root/$required" ]; then - echo "Missing trusted CI script in base checkout: $required. Merge the seed PR into the base branch first." >&2 - exit 1 - fi - done - fi ROOT_DIR="$GITHUB_WORKSPACE" TOOL_ROOT="$tool_root" "$tool_root/scripts/dev/bootstrap.sh" @@ -320,9 +181,6 @@ jobs: run: | set -euo pipefail tool_root="$GITHUB_WORKSPACE" - if [ "${{ github.event_name }}" = "pull_request" ]; then - tool_root="$GITHUB_WORKSPACE/.ai-tmp/trusted-ci" - fi ROOT_DIR="$GITHUB_WORKSPACE" TOOL_ROOT="$tool_root" "$tool_root/scripts/dev/doctor.sh" \ --out-dir .ai-tmp/xcode-build/doctor @@ -332,9 +190,6 @@ jobs: run: | set -euo pipefail tool_root="$GITHUB_WORKSPACE" - if [ "${{ github.event_name }}" = "pull_request" ]; then - tool_root="$GITHUB_WORKSPACE/.ai-tmp/trusted-ci" - fi ROOT_DIR="$GITHUB_WORKSPACE" TOOL_ROOT="$tool_root" "$tool_root/scripts/ci/xcode.sh" \ --action build \ @@ -373,89 +228,91 @@ jobs: artifact_name_suffix: ${{ matrix.case_name }} enforce_failure: true - release_build_check: - name: release-build-check (${{ matrix.label }}) + release_build_check_arm64: + name: release-build-check (arm64) needs: - classify_changes - script_static_checks if: ${{ needs.classify_changes.outputs.requires_release_smoke == 'true' }} - runs-on: ${{ matrix.runs_on }} + runs-on: macos-26 timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - include: - - label: arm64 - arch: arm64 - runs_on: macos-26 - - label: intel64 - arch: x86_64 - runs_on: macos-26-intel permissions: contents: read steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Bootstrap tools + shell: bash + run: | + set -euo pipefail + tool_root="$GITHUB_WORKSPACE" + + ROOT_DIR="$GITHUB_WORKSPACE" TOOL_ROOT="$tool_root" "$tool_root/scripts/dev/bootstrap.sh" --profile release-smoke + + - name: Release smoke + shell: bash + run: | + set -euo pipefail + tool_root="$GITHUB_WORKSPACE" + + ROOT_DIR="$GITHUB_WORKSPACE" TOOL_ROOT="$tool_root" "$tool_root/scripts/ci/release_smoke.sh" \ + --arch arm64 \ + --label arm64 \ + --out-dir .ai-tmp/release-check-arm64 - - name: Checkout trusted scripts - if: ${{ github.event_name == 'pull_request' }} + - name: Upload release smoke artifacts + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: release-smoke-arm64 + path: .ai-tmp/release-check-arm64 + if-no-files-found: warn + retention-days: 7 + + release_build_check_intel64: + name: release-build-check (intel64) + needs: + - classify_changes + - script_static_checks + if: ${{ github.event_name == 'push' && needs.classify_changes.outputs.requires_release_smoke == 'true' }} + runs-on: macos-26-intel + timeout-minutes: 30 + permissions: + contents: read + steps: + - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: - ref: ${{ github.event.pull_request.base.sha }} - path: .ai-tmp/trusted-ci persist-credentials: false - name: Bootstrap tools shell: bash - env: - GITHUB_TOKEN: ${{ github.token }} run: | set -euo pipefail tool_root="$GITHUB_WORKSPACE" - if [ "${{ github.event_name }}" = "pull_request" ]; then - tool_root="$GITHUB_WORKSPACE/.ai-tmp/trusted-ci" - for required in \ - scripts/build-relay.sh \ - scripts/lib/contract.sh \ - scripts/lib/common.sh \ - scripts/lib/artifacts.sh \ - scripts/lib/xcode.sh \ - scripts/lib/architecture.sh \ - scripts/lib/release_binaries.sh \ - scripts/dev/bootstrap.sh \ - scripts/ci/download_relay_modules.sh \ - scripts/ci/release_arch_check.sh \ - scripts/release/thin_webrtc_and_sign.sh \ - scripts/ci/release_smoke.sh; do - if [ ! -e "$tool_root/$required" ]; then - echo "Missing trusted CI script in base checkout: $required. Merge the seed PR into the base branch first." >&2 - exit 1 - fi - done - fi - ROOT_DIR="$GITHUB_WORKSPACE" TOOL_ROOT="$tool_root" "$tool_root/scripts/dev/bootstrap.sh" + ROOT_DIR="$GITHUB_WORKSPACE" TOOL_ROOT="$tool_root" "$tool_root/scripts/dev/bootstrap.sh" --profile release-smoke - name: Release smoke shell: bash run: | set -euo pipefail tool_root="$GITHUB_WORKSPACE" - if [ "${{ github.event_name }}" = "pull_request" ]; then - tool_root="$GITHUB_WORKSPACE/.ai-tmp/trusted-ci" - fi ROOT_DIR="$GITHUB_WORKSPACE" TOOL_ROOT="$tool_root" "$tool_root/scripts/ci/release_smoke.sh" \ - --arch '${{ matrix.arch }}' \ - --label '${{ matrix.label }}' \ - --out-dir '.ai-tmp/release-check-${{ matrix.label }}' + --arch x86_64 \ + --label intel64 \ + --out-dir .ai-tmp/release-check-intel64 - name: Upload release smoke artifacts if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: release-smoke-${{ matrix.label }} - path: .ai-tmp/release-check-${{ matrix.label }} + name: release-smoke-intel64 + path: .ai-tmp/release-check-intel64 if-no-files-found: warn retention-days: 7 @@ -466,11 +323,11 @@ jobs: - classify_changes - dependency_review - script_static_checks - - head_script_self_test - unit_tests - xcode_build - ui_smoke_tests - - release_build_check + - release_build_check_arm64 + - release_build_check_intel64 runs-on: macos-26 timeout-minutes: 5 permissions: @@ -494,7 +351,6 @@ jobs: DOCS_ONLY: ${{ needs.classify_changes.outputs.docs_only }} UNKNOWN_RELEVANT: ${{ needs.classify_changes.outputs.unknown_relevant }} REQUIRES_STATIC: ${{ needs.classify_changes.outputs.requires_static }} - REQUIRES_HEAD_SCRIPT_SELF_TEST: ${{ needs.classify_changes.outputs.requires_head_script_self_test }} REQUIRES_DEPENDENCY_REVIEW: ${{ needs.classify_changes.outputs.requires_dependency_review }} REQUIRES_UNIT: ${{ needs.classify_changes.outputs.requires_unit }} REQUIRES_XCODE_BUILD: ${{ needs.classify_changes.outputs.requires_xcode_build }} @@ -502,11 +358,11 @@ jobs: REQUIRES_RELEASE_SMOKE: ${{ needs.classify_changes.outputs.requires_release_smoke }} DEPENDENCY_RESULT: ${{ needs.dependency_review.result }} SCRIPT_RESULT: ${{ needs.script_static_checks.result }} - HEAD_SCRIPT_RESULT: ${{ needs.head_script_self_test.result }} UNIT_RESULT: ${{ needs.unit_tests.result }} XCODE_RESULT: ${{ needs.xcode_build.result }} UI_RESULT: ${{ needs.ui_smoke_tests.result }} - RELEASE_RESULT: ${{ needs.release_build_check.result }} + RELEASE_ARM64_RESULT: ${{ needs.release_build_check_arm64.result }} + RELEASE_INTEL64_RESULT: ${{ needs.release_build_check_intel64.result }} run: | set -euo pipefail @@ -515,14 +371,14 @@ jobs: echo "Base ref: ${BASE_REF:-n/a}" echo "Change scope: ${CHANGE_SCOPE}" echo "Relevant: code=${CODE_RELEVANT} ui=${UI_RELEVANT} script=${SCRIPT_RELEVANT} product=${PRODUCT_CODE_RELEVANT} test=${TEST_CODE_RELEVANT} ci=${CI_CONFIG_RELEVANT} release=${RELEASE_RELEVANT} dependency=${DEPENDENCY_MANIFEST_RELEVANT} tooling=${TOOLING_CONFIG_RELEVANT} docs_only=${DOCS_ONLY} unknown=${UNKNOWN_RELEVANT}" - echo "Required: static=${REQUIRES_STATIC} head_script=${REQUIRES_HEAD_SCRIPT_SELF_TEST} dependency=${REQUIRES_DEPENDENCY_REVIEW} unit=${REQUIRES_UNIT} xcode=${REQUIRES_XCODE_BUILD} ui=${REQUIRES_UI_SMOKE} release=${REQUIRES_RELEASE_SMOKE}" + echo "Required: static=${REQUIRES_STATIC} dependency=${REQUIRES_DEPENDENCY_REVIEW} unit=${REQUIRES_UNIT} xcode=${REQUIRES_XCODE_BUILD} ui=${REQUIRES_UI_SMOKE} release=${REQUIRES_RELEASE_SMOKE}" echo "Dependency review: ${DEPENDENCY_RESULT}" echo "Static: ${SCRIPT_RESULT}" - echo "Head script self-test: ${HEAD_SCRIPT_RESULT}" echo "Unit: ${UNIT_RESULT}" echo "Xcode build: ${XCODE_RESULT}" echo "UI smoke: ${UI_RESULT}" - echo "Release smoke: ${RELEASE_RESULT}" + echo "Release smoke arm64: ${RELEASE_ARM64_RESULT}" + echo "Release smoke intel64: ${RELEASE_INTEL64_RESULT}" if [ "$CLASSIFY_RESULT" != "success" ]; then echo "Gate failed because classify-changes result is ${CLASSIFY_RESULT}." @@ -547,11 +403,15 @@ jobs: check_required "$REQUIRES_DEPENDENCY_REVIEW" "$DEPENDENCY_RESULT" "dependency-review" check_required "$REQUIRES_STATIC" "$SCRIPT_RESULT" "script-static-checks" - check_required "$REQUIRES_HEAD_SCRIPT_SELF_TEST" "$HEAD_SCRIPT_RESULT" "head-script-self-test" check_required "$REQUIRES_UNIT" "$UNIT_RESULT" "unit-tests" check_required "$REQUIRES_XCODE_BUILD" "$XCODE_RESULT" "xcode-build" check_required "$REQUIRES_UI_SMOKE" "$UI_RESULT" "ui-smoke-tests" - check_required "$REQUIRES_RELEASE_SMOKE" "$RELEASE_RESULT" "release-build-check" + check_required "$REQUIRES_RELEASE_SMOKE" "$RELEASE_ARM64_RESULT" "release-build-check-arm64" + if [ "$EVENT_NAME" = "push" ]; then + check_required "$REQUIRES_RELEASE_SMOKE" "$RELEASE_INTEL64_RESULT" "release-build-check-intel64" + else + check_required "false" "$RELEASE_INTEL64_RESULT" "release-build-check-intel64" + fi exit 0 @@ -562,11 +422,11 @@ jobs: - classify_changes - dependency_review - script_static_checks - - head_script_self_test - unit_tests - xcode_build - ui_smoke_tests - - release_build_check + - release_build_check_arm64 + - release_build_check_intel64 - ci_gate runs-on: macos-26 timeout-minutes: 5 @@ -593,7 +453,6 @@ jobs: DOCS_ONLY: ${{ needs.classify_changes.outputs.docs_only }} UNKNOWN_RELEVANT: ${{ needs.classify_changes.outputs.unknown_relevant }} REQUIRES_STATIC: ${{ needs.classify_changes.outputs.requires_static }} - REQUIRES_HEAD_SCRIPT_SELF_TEST: ${{ needs.classify_changes.outputs.requires_head_script_self_test }} REQUIRES_DEPENDENCY_REVIEW: ${{ needs.classify_changes.outputs.requires_dependency_review }} REQUIRES_UNIT: ${{ needs.classify_changes.outputs.requires_unit }} REQUIRES_XCODE_BUILD: ${{ needs.classify_changes.outputs.requires_xcode_build }} @@ -601,7 +460,6 @@ jobs: REQUIRES_RELEASE_SMOKE: ${{ needs.classify_changes.outputs.requires_release_smoke }} DEPENDENCY_RESULT: ${{ needs.dependency_review.result }} SCRIPT_RESULT: ${{ needs.script_static_checks.result }} - HEAD_SCRIPT_RESULT: ${{ needs.head_script_self_test.result }} UNIT_RESULT: ${{ needs.unit_tests.result }} UNIT_ARTIFACT: ${{ needs.unit_tests.outputs.artifact_name }} UNIT_SWIFT_TEST_COUNT: ${{ needs.unit_tests.outputs.swift_test_count }} @@ -609,7 +467,8 @@ jobs: UNIT_REASON: ${{ needs.unit_tests.outputs.unit_reason }} XCODE_RESULT: ${{ needs.xcode_build.result }} UI_RESULT: ${{ needs.ui_smoke_tests.result }} - RELEASE_RESULT: ${{ needs.release_build_check.result }} + RELEASE_ARM64_RESULT: ${{ needs.release_build_check_arm64.result }} + RELEASE_INTEL64_RESULT: ${{ needs.release_build_check_intel64.result }} GATE_RESULT: ${{ needs.ci_gate.result }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} with: @@ -631,8 +490,18 @@ jobs: 'Failure classification is written by ui-smoke-summary.json in each artifact.', 'Artifacts: ui-smoke-baseline, ui-smoke-permissionDenied, ui-smoke-rebuildFailed', ].join('
'); + const releaseStatus = context.eventName === 'pull_request' + ? value('RELEASE_ARM64_RESULT', 'unknown') + : `arm64=${value('RELEASE_ARM64_RESULT', 'unknown')}
intel64=${value('RELEASE_INTEL64_RESULT', 'unknown')}`; const releaseDetails = [ - 'Artifacts: release-smoke-arm64, release-smoke-intel64', + `arm64=${value('RELEASE_ARM64_RESULT', 'unknown')}`, + `intel64=${value('RELEASE_INTEL64_RESULT', 'unknown')}`, + context.eventName === 'pull_request' + ? 'PR release smoke requires arm64 only.' + : 'Push release smoke requires arm64 and intel64.', + context.eventName === 'pull_request' + ? 'Artifacts: release-smoke-arm64' + : 'Artifacts: release-smoke-arm64, release-smoke-intel64', 'Release verify summaries are produced by release.yml before publishing.', ].join('
'); const classificationDetails = [ @@ -650,7 +519,6 @@ jobs: ].join('
'); const requirementDetails = [ `static=${value('REQUIRES_STATIC')}`, - `head_script=${value('REQUIRES_HEAD_SCRIPT_SELF_TEST')}`, `dependency=${value('REQUIRES_DEPENDENCY_REVIEW')}`, `unit=${value('REQUIRES_UNIT')}`, `xcode=${value('REQUIRES_XCODE_BUILD')}`, @@ -662,11 +530,10 @@ jobs: ['Required Gates', 'derived', requirementDetails], ['Dependency Review', value('DEPENDENCY_RESULT', 'unknown'), `required=${value('REQUIRES_DEPENDENCY_REVIEW')}
high and critical vulnerabilities block`], ['Static Checks', value('SCRIPT_RESULT', 'unknown'), `required=${value('REQUIRES_STATIC')}
actionlint, shellcheck, shfmt, SwiftFormat, SwiftLint, action pinning`], - ['Head Script Self-Test', value('HEAD_SCRIPT_RESULT', 'unknown'), `required=${value('REQUIRES_HEAD_SCRIPT_SELF_TEST')}`], ['Unit Tests', value('UNIT_RESULT', 'unknown'), `required=${value('REQUIRES_UNIT')}
${unitDetails}`], ['Xcode Build', value('XCODE_RESULT', 'unknown'), `required=${value('REQUIRES_XCODE_BUILD')}
Debug build with zero warning scan
Artifact: xcode-build`], ['UI Smoke', value('UI_RESULT', 'unknown'), `required=${value('REQUIRES_UI_SMOKE')}
${uiDetails}`], - ['Release Smoke', value('RELEASE_RESULT', 'unknown'), `required=${value('REQUIRES_RELEASE_SMOKE')}
${releaseDetails}`], + ['Release Smoke', releaseStatus, `required=${value('REQUIRES_RELEASE_SMOKE')}
${releaseDetails}`], ['Artifacts', 'link', `[Open artifacts](${artifactsUrl})`], ['CI Gate', value('GATE_RESULT', 'unknown'), 'single branch protection check'], ]; diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3e73b1d..d15bcf4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,8 +36,77 @@ concurrency: cancel-in-progress: false jobs: + resolve_release_target: + name: resolve-release-target + runs-on: macos-26 + timeout-minutes: 5 + outputs: + target_sha: ${{ steps.resolve.outputs.target_sha }} + permissions: + contents: read + steps: + - name: Resolve release target + id: resolve + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + EVENT_NAME: ${{ github.event_name }} + WORKFLOW_REF: ${{ github.ref }} + TARGET_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.target_ref || github.sha }} + SUMMARY_PATH: .ai-tmp/release-target/target-summary.json + with: + script: | + const fs = require('fs/promises'); + const path = require('path'); + + const owner = context.repo.owner; + const repo = context.repo.repo; + const eventName = process.env.EVENT_NAME; + const workflowRef = process.env.WORKFLOW_REF; + const targetRef = process.env.TARGET_REF; + const summaryPath = process.env.SUMMARY_PATH; + + if (eventName === 'workflow_dispatch' && workflowRef !== 'refs/heads/main') { + throw new Error('Manual release dispatch must run from main. Use target_ref to select the release target.'); + } + if (!targetRef) { + throw new Error('TARGET_REF is required.'); + } + + const response = await github.rest.repos.getCommit({ + owner, + repo, + ref: targetRef, + }); + const targetSha = response.data.sha; + if (!/^[0-9a-f]{40}$/i.test(targetSha)) { + throw new Error(`Resolved target SHA is invalid: ${targetSha}`); + } + + core.setOutput('target_sha', targetSha); + await fs.mkdir(path.dirname(summaryPath), { recursive: true }); + await fs.writeFile(summaryPath, `${JSON.stringify({ + status: 'passed', + repository: `${owner}/${repo}`, + event_name: eventName, + workflow_ref: workflowRef, + target_ref: targetRef, + target_sha: targetSha, + }, null, 2)}\n`); + + - name: Upload target summary + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: release-target-summary + path: .ai-tmp/release-target + if-no-files-found: warn + retention-days: 14 + prepare_release: name: prepare-release + needs: + - resolve_release_target + - require_ci_gate runs-on: macos-26 timeout-minutes: 10 outputs: @@ -49,30 +118,12 @@ jobs: permissions: contents: read steps: - - name: Validate workflow ref - shell: bash - env: - EVENT_NAME: ${{ github.event_name }} - WORKFLOW_REF: ${{ github.ref }} - run: | - set -euo pipefail - if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "$WORKFLOW_REF" != "refs/heads/main" ]; then - echo "Manual release dispatch must run from main. Use target_ref to select the release target." - exit 1 - fi - - - name: Checkout trusted release scripts - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - ref: ${{ github.sha }} - fetch-depth: 0 - - name: Checkout release target uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: - ref: ${{ github.event_name == 'workflow_dispatch' && inputs.target_ref || github.sha }} - path: target + ref: ${{ needs.resolve_release_target.outputs.target_sha }} fetch-depth: 0 + persist-credentials: false - name: Bootstrap prepare tools shell: bash @@ -81,7 +132,7 @@ jobs: run: | set -euo pipefail tool_root="$GITHUB_WORKSPACE" - ROOT_DIR="$GITHUB_WORKSPACE/target" TOOL_ROOT="$tool_root" "$tool_root/scripts/dev/bootstrap.sh" + ROOT_DIR="$GITHUB_WORKSPACE" TOOL_ROOT="$tool_root" "$tool_root/scripts/dev/bootstrap.sh" - name: Prepare release metadata id: prepare @@ -95,22 +146,22 @@ jobs: run: | set -euo pipefail tool_root="$GITHUB_WORKSPACE" - ROOT_DIR="$GITHUB_WORKSPACE/target" TOOL_ROOT="$tool_root" "$tool_root/scripts/release/prepare.sh" \ + ROOT_DIR="$GITHUB_WORKSPACE" TOOL_ROOT="$tool_root" "$tool_root/scripts/release/prepare.sh" \ --event-name "$EVENT_NAME" \ - --target-repo-dir "$GITHUB_WORKSPACE/target" \ + --target-repo-dir "$GITHUB_WORKSPACE" \ --before-sha "$BEFORE_SHA" \ --input-tag "$INPUT_TAG" \ --ref-name "$REF_NAME" \ --ref-type "$REF_TYPE" \ --github-output "$GITHUB_OUTPUT" \ - --summary "$GITHUB_WORKSPACE/target/.ai-tmp/release-prepare/prepare-summary.json" + --summary "$GITHUB_WORKSPACE/.ai-tmp/release-prepare/prepare-summary.json" - name: Upload prepare summary if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: release-prepare-summary - path: target/.ai-tmp/release-prepare + path: .ai-tmp/release-prepare if-no-files-found: warn retention-days: 14 @@ -137,18 +188,12 @@ jobs: arch: x86_64 runs_on: macos-26-intel steps: - - name: Checkout trusted release scripts - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - ref: ${{ github.sha }} - fetch-depth: 0 - - name: Checkout release target uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: ref: ${{ needs.prepare_release.outputs.target_sha }} - path: target fetch-depth: 0 + persist-credentials: false - name: Select Xcode uses: ./.github/actions/xcode-select @@ -160,26 +205,26 @@ jobs: run: | set -euo pipefail tool_root="$GITHUB_WORKSPACE" - ROOT_DIR="$GITHUB_WORKSPACE/target" TOOL_ROOT="$tool_root" "$tool_root/scripts/dev/bootstrap.sh" + ROOT_DIR="$GITHUB_WORKSPACE" TOOL_ROOT="$tool_root" "$tool_root/scripts/dev/bootstrap.sh" - name: Build ad hoc DMG and SBOM shell: bash run: | set -euo pipefail tool_root="$GITHUB_WORKSPACE" - ROOT_DIR="$GITHUB_WORKSPACE/target" TOOL_ROOT="$tool_root" "$tool_root/scripts/release/build.sh" \ + ROOT_DIR="$GITHUB_WORKSPACE" TOOL_ROOT="$tool_root" "$tool_root/scripts/release/build.sh" \ --tag '${{ needs.prepare_release.outputs.tag }}' \ --arch '${{ matrix.arch }}' \ --label '${{ matrix.label }}' \ - --out-dir "$GITHUB_WORKSPACE/target/.ai-tmp/release-${{ matrix.label }}" + --out-dir "$GITHUB_WORKSPACE/.ai-tmp/release-${{ matrix.label }}" - name: Verify local release asset shell: bash run: | set -euo pipefail tool_root="$GITHUB_WORKSPACE" - ROOT_DIR="$GITHUB_WORKSPACE/target" TOOL_ROOT="$tool_root" "$tool_root/scripts/release/verify.sh" \ - --assets-dir "$GITHUB_WORKSPACE/target/.ai-tmp/release-${{ matrix.label }}/release-assets" \ + ROOT_DIR="$GITHUB_WORKSPACE" TOOL_ROOT="$tool_root" "$tool_root/scripts/release/verify.sh" \ + --assets-dir "$GITHUB_WORKSPACE/.ai-tmp/release-${{ matrix.label }}/release-assets" \ --tag '${{ needs.prepare_release.outputs.tag }}' \ --label '${{ matrix.label }}' \ --arch '${{ matrix.arch }}' @@ -187,30 +232,30 @@ jobs: - name: Attest DMG provenance uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: - subject-path: target/.ai-tmp/release-${{ matrix.label }}/release-assets/*.dmg + subject-path: .ai-tmp/release-${{ matrix.label }}/release-assets/*.dmg - name: Attest checksum provenance uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: - subject-path: target/.ai-tmp/release-${{ matrix.label }}/release-assets/*.sha256 + subject-path: .ai-tmp/release-${{ matrix.label }}/release-assets/*.sha256 - name: Attest SBOM provenance uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: - subject-path: target/.ai-tmp/release-${{ matrix.label }}/release-assets/*.spdx.json + subject-path: .ai-tmp/release-${{ matrix.label }}/release-assets/*.spdx.json - name: Attest SBOM uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0 with: - subject-path: target/.ai-tmp/release-${{ matrix.label }}/release-assets/*.dmg - sbom-path: target/.ai-tmp/release-${{ matrix.label }}/release-assets/*.spdx.json + subject-path: .ai-tmp/release-${{ matrix.label }}/release-assets/*.dmg + sbom-path: .ai-tmp/release-${{ matrix.label }}/release-assets/*.spdx.json - name: Upload release summaries if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: release-summaries-${{ matrix.label }} - path: target/.ai-tmp/release-${{ matrix.label }}/**/*summary.json + path: .ai-tmp/release-${{ matrix.label }}/**/*summary.json if-no-files-found: error retention-days: 14 @@ -219,10 +264,10 @@ jobs: with: name: release-assets-${{ matrix.label }} path: | - target/.ai-tmp/release-${{ matrix.label }}/release-assets/*.dmg - target/.ai-tmp/release-${{ matrix.label }}/release-assets/*.sha256 - target/.ai-tmp/release-${{ matrix.label }}/release-assets/*.spdx.json - target/.ai-tmp/release-${{ matrix.label }}/release-assets/*.json + .ai-tmp/release-${{ matrix.label }}/release-assets/*.dmg + .ai-tmp/release-${{ matrix.label }}/release-assets/*.sha256 + .ai-tmp/release-${{ matrix.label }}/release-assets/*.spdx.json + .ai-tmp/release-${{ matrix.label }}/release-assets/*.json if-no-files-found: error retention-days: 14 @@ -239,18 +284,12 @@ jobs: contents: write attestations: read steps: - - name: Checkout trusted release scripts - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - ref: ${{ github.sha }} - fetch-depth: 0 - - name: Checkout release target uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: ref: ${{ needs.prepare_release.outputs.target_sha }} - path: target fetch-depth: 0 + persist-credentials: false - name: Bootstrap publish tools shell: bash @@ -259,7 +298,7 @@ jobs: run: | set -euo pipefail tool_root="$GITHUB_WORKSPACE" - ROOT_DIR="$GITHUB_WORKSPACE/target" TOOL_ROOT="$tool_root" "$tool_root/scripts/dev/bootstrap.sh" + ROOT_DIR="$GITHUB_WORKSPACE" TOOL_ROOT="$tool_root" "$tool_root/scripts/dev/bootstrap.sh" - name: Download release artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 @@ -277,7 +316,7 @@ jobs: ls -lah release-assets tool_root="$GITHUB_WORKSPACE" - ROOT_DIR="$GITHUB_WORKSPACE/target" TOOL_ROOT="$tool_root" "$tool_root/scripts/release/verify.sh" \ + ROOT_DIR="$GITHUB_WORKSPACE" TOOL_ROOT="$tool_root" "$tool_root/scripts/release/verify.sh" \ --assets-dir "$GITHUB_WORKSPACE/release-assets" \ --tag '${{ needs.prepare_release.outputs.tag }}' \ --label arm64 \ @@ -285,7 +324,7 @@ jobs: --repository '${{ github.repository }}' \ --require-attestation true - ROOT_DIR="$GITHUB_WORKSPACE/target" TOOL_ROOT="$tool_root" "$tool_root/scripts/release/verify.sh" \ + ROOT_DIR="$GITHUB_WORKSPACE" TOOL_ROOT="$tool_root" "$tool_root/scripts/release/verify.sh" \ --assets-dir "$GITHUB_WORKSPACE/release-assets" \ --tag '${{ needs.prepare_release.outputs.tag }}' \ --label intel64 \ @@ -300,19 +339,19 @@ jobs: run: | set -euo pipefail tool_root="$GITHUB_WORKSPACE" - ROOT_DIR="$GITHUB_WORKSPACE/target" TOOL_ROOT="$tool_root" "$tool_root/scripts/release/publish.sh" \ + ROOT_DIR="$GITHUB_WORKSPACE" TOOL_ROOT="$tool_root" "$tool_root/scripts/release/publish.sh" \ --assets-dir "$GITHUB_WORKSPACE/release-assets" \ --tag '${{ needs.prepare_release.outputs.tag }}' \ --target-sha '${{ needs.prepare_release.outputs.target_sha }}' \ --repository '${{ github.repository }}' \ - --out-dir "$GITHUB_WORKSPACE/target/.ai-tmp/release-publish" + --out-dir "$GITHUB_WORKSPACE/.ai-tmp/release-publish" - name: Upload publish summary if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: release-publish-summary - path: target/.ai-tmp/release-publish + path: .ai-tmp/release-publish if-no-files-found: error retention-days: 14 @@ -328,8 +367,8 @@ jobs: require_ci_gate: name: require-ci-gate needs: - - prepare_release - if: ${{ needs.prepare_release.outputs.should_release == 'true' }} + - resolve_release_target + if: ${{ needs.resolve_release_target.outputs.target_sha != '' }} runs-on: macos-26 timeout-minutes: 20 permissions: @@ -337,35 +376,181 @@ jobs: checks: read statuses: read steps: - - name: Checkout trusted release scripts - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - ref: ${{ github.sha }} - fetch-depth: 0 - - - name: Bootstrap release gate tools - shell: bash - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - tool_root="$GITHUB_WORKSPACE" - ROOT_DIR="$GITHUB_WORKSPACE" TOOL_ROOT="$tool_root" "$tool_root/scripts/dev/bootstrap.sh" - - name: Require target ci-gate - shell: bash + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - tool_root="$GITHUB_WORKSPACE" - ROOT_DIR="$GITHUB_WORKSPACE" TOOL_ROOT="$tool_root" "$tool_root/scripts/release/require_ci_gate.sh" \ - --repository '${{ github.repository }}' \ - --sha '${{ needs.prepare_release.outputs.target_sha }}' \ - --check-name ci-gate \ - --timeout-seconds 900 \ - --poll-interval-seconds 20 \ - --summary '.ai-tmp/release-ci-gate/require-ci-gate-summary.json' + TARGET_SHA: ${{ needs.resolve_release_target.outputs.target_sha }} + CHECK_NAME: ci-gate + TIMEOUT_SECONDS: '900' + POLL_INTERVAL_SECONDS: '20' + SUMMARY_PATH: .ai-tmp/release-ci-gate/require-ci-gate-summary.json + with: + script: | + const fs = require('fs/promises'); + const path = require('path'); + + const owner = context.repo.owner; + const repo = context.repo.repo; + const repository = `${owner}/${repo}`; + const targetSha = process.env.TARGET_SHA; + const checkName = process.env.CHECK_NAME; + const timeoutSeconds = Number(process.env.TIMEOUT_SECONDS); + const pollIntervalSeconds = Number(process.env.POLL_INTERVAL_SECONDS); + const summaryPath = process.env.SUMMARY_PATH; + + if (!targetSha) { + throw new Error('TARGET_SHA is required.'); + } + if (!checkName) { + throw new Error('CHECK_NAME is required.'); + } + if (!Number.isInteger(timeoutSeconds) || timeoutSeconds <= 0) { + throw new Error('TIMEOUT_SECONDS must be a positive integer.'); + } + if (!Number.isInteger(pollIntervalSeconds) || pollIntervalSeconds <= 0) { + throw new Error('POLL_INTERVAL_SECONDS must be a positive integer.'); + } + + let snapshot = { + gate_state: 'missing', + source: 'none', + detail: '', + }; + + const writeSummary = async (status, reason, elapsedSeconds) => { + await fs.mkdir(path.dirname(summaryPath), { recursive: true }); + const summary = { + status, + reason, + repository, + target_sha: targetSha, + check_name: checkName, + gate_state: snapshot.gate_state, + source: snapshot.source, + detail: snapshot.detail, + elapsed_seconds: elapsedSeconds, + }; + await fs.writeFile(summaryPath, `${JSON.stringify(summary, null, 2)}\n`); + }; + + const listCheckRuns = async () => { + const checkRuns = []; + for (let page = 1; ; page += 1) { + const response = await github.rest.checks.listForRef({ + owner, + repo, + ref: targetSha, + check_name: checkName, + per_page: 100, + page, + }); + const pageRuns = response.data.check_runs || []; + checkRuns.push(...pageRuns); + if (pageRuns.length < 100) { + break; + } + } + return checkRuns; + }; + + const loadGateSnapshot = async () => { + snapshot = { + gate_state: 'missing', + source: 'checks', + detail: 'No matching check run found.', + }; + + try { + const checkRuns = await listCheckRuns(); + const latestCheckRun = checkRuns + .filter((checkRun) => checkRun.name === checkName) + .sort((left, right) => { + const leftTime = Date.parse(left.started_at || left.completed_at || left.created_at || '') || 0; + const rightTime = Date.parse(right.started_at || right.completed_at || right.created_at || '') || 0; + return leftTime - rightTime; + }) + .at(-1); + + if (latestCheckRun) { + const checkStatus = latestCheckRun.status || 'unknown'; + const checkConclusion = latestCheckRun.conclusion || ''; + snapshot.detail = `check_status=${checkStatus} conclusion=${checkConclusion || 'none'}`; + if (checkStatus === 'completed' && checkConclusion === 'success') { + snapshot.gate_state = 'success'; + } else if (checkStatus === 'completed') { + snapshot.gate_state = 'failure'; + } else { + snapshot.gate_state = 'pending'; + } + return; + } + + snapshot.source = 'statuses'; + const statusResponse = await github.rest.repos.getCombinedStatusForRef({ + owner, + repo, + ref: targetSha, + per_page: 100, + }); + const latestStatus = (statusResponse.data.statuses || []) + .filter((status) => status.context === checkName) + .sort((left, right) => { + const leftTime = Date.parse(left.created_at || '') || 0; + const rightTime = Date.parse(right.created_at || '') || 0; + return leftTime - rightTime; + }) + .at(-1); + + if (!latestStatus) { + snapshot.gate_state = 'missing'; + snapshot.detail = 'No matching commit status found.'; + return; + } + + const statusState = latestStatus.state || 'unknown'; + snapshot.detail = `status_state=${statusState}`; + if (statusState === 'success') { + snapshot.gate_state = 'success'; + } else if (statusState === 'pending') { + snapshot.gate_state = 'pending'; + } else { + snapshot.gate_state = 'failure'; + } + } catch (error) { + snapshot = { + gate_state: 'api_error', + source: snapshot.source, + detail: error.message, + }; + } + }; + + const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + const startTime = Date.now(); + + for (;;) { + await loadGateSnapshot(); + const elapsedSeconds = Math.floor((Date.now() - startTime) / 1000); + + if (snapshot.gate_state === 'success') { + await writeSummary('passed', 'ci_gate_success', elapsedSeconds); + core.info(`Required target check passed: ${checkName} on ${targetSha}`); + return; + } + + if (snapshot.gate_state === 'failure' || snapshot.gate_state === 'api_error') { + await writeSummary('failed', `ci_gate_${snapshot.gate_state}`, elapsedSeconds); + throw new Error(`Required target check ${checkName} is ${snapshot.gate_state} for ${targetSha}. ${snapshot.detail}`); + } + + if (elapsedSeconds >= timeoutSeconds) { + await writeSummary('failed', 'ci_gate_timeout', elapsedSeconds); + throw new Error(`Timed out waiting for ${checkName} on ${targetSha}. Last state: ${snapshot.gate_state}. ${snapshot.detail}`); + } + + core.info(`Waiting for ${checkName} on ${targetSha}: ${snapshot.gate_state}. ${snapshot.detail}`); + await sleep(pollIntervalSeconds * 1000); + } - name: Upload release ci-gate summary if: always() diff --git a/docs/ci-final-plan.md b/docs/ci-final-plan.md index 3b5db5e..a1ba63b 100644 --- a/docs/ci-final-plan.md +++ b/docs/ci-final-plan.md @@ -56,7 +56,7 @@ CI 和脚本体系的核心目标是可复现、可审计、低误报、低人 `scripts/release/verify.sh` 校验 DMG 可挂载、app 存在、bundle id 正确、版本匹配、架构匹配、checksum 有效、SBOM 有效、ad hoc codesign 可验证、attestation 可选有效。 -`scripts/release/require_ci_gate.sh` 在发布前验证目标 commit 的 `ci-gate` 已成功,防止绕过分支保护或手动 dispatch 发布未验证 commit。 +Release workflow 在发布前用内联 GitHub API 逻辑验证目标 commit 的 `ci-gate` 已成功,防止绕过分支保护或手动 dispatch 发布未验证 commit。该验证不执行目标 checkout 中的脚本。 `scripts/release/publish.sh` 只封装发布前最终校验和 GitHub Release 上传。本地默认不调用,CI release job 调用。 @@ -97,9 +97,9 @@ PR workflow 使用一个稳定的 required check:`ci-gate`。其他 job 都是 UI 相关 PR 额外运行 UI smoke matrix。初始矩阵覆盖首页导航、权限拒绝、虚拟显示 rebuild 失败行。后续可以按风险扩展。 -目标分支是 `main` 的代码 PR 额外运行双架构 release smoke,覆盖 `arm64` 和 `x86_64`。 +目标分支是 `main` 的代码 PR 额外运行 arm64 release smoke。x86_64 release smoke 留给 main push、nightly 和 release workflow 承担。 -脚本和 workflow 改动采用 trusted scripts 策略。关键 job 优先 checkout base commit 中的脚本执行。脚本或 workflow 相关 PR 额外运行低权限 `head-script-self-test`,该 job 不导出 `GITHUB_TOKEN`、不使用 checkout credentials、不发布产物,只验证 head 脚本的静态门禁。 +PR CI 执行当前 checkout 的脚本。脚本和 workflow 完整性由 review 和 `script-static-checks` 承担,`ci-gate` 继续作为唯一 required check。PR CI checkout 不持久化凭据,bootstrap 不向已 checkout 的仓库脚本暴露 `GITHUB_TOKEN`。 ## 6. Main CI @@ -136,7 +136,7 @@ Nightly 失败不应直接阻断普通 PR,但必须在仓库首页、issue 或 ## 8. Release 触发规则 -Release workflow 支持两种入口。workflow 只负责 checkout、runner 权限和 job 编排,版本判断、tag 判断、build number 规则都由 `scripts/release/prepare.sh` 执行。`prepare` 输出 `should_release=true` 后,workflow 先调用 `scripts/release/require_ci_gate.sh` 验证目标 SHA 的 `ci-gate`,成功后才允许 build 和 publish。 +Release workflow 支持两种入口。workflow 先不 checkout 目标代码,只用 GitHub API 解析目标 SHA,并用内联 GitHub API 逻辑验证目标 SHA 的 `ci-gate`。验证成功后,workflow 才 checkout 目标 commit 并执行 release 脚本。版本判断、tag 判断、build number 规则都由 `scripts/release/prepare.sh` 执行。`prepare` 输出 `should_release=true` 后才允许 build 和 publish。 第一种是 push 到 `main`。当 `Apps/VoidDisplay/VoidDisplay.xcodeproj/project.pbxproj` 中的版本字段变化时触发 release 判断。 @@ -255,7 +255,7 @@ scripts/release/verify.sh --assets-dir .ai-tmp/release-arm64/release-assets --ta 第一阶段收敛脚本入口,完成 bootstrap、static、unit、xcode、ui smoke、release smoke、release build、release verify。 -第二阶段改造 PR CI,建立 `ci-gate`、trusted scripts、artifact summary、docs-only fast path。 +第二阶段改造 PR CI,建立 `ci-gate`、artifact summary、docs-only fast path。 第三阶段改造 release workflow,完成版本号触发、双架构 ad hoc DMG、checksum、SBOM、attestation、下载后 verify、GitHub Release 发布。 @@ -271,7 +271,7 @@ scripts/release/verify.sh --assets-dir .ai-tmp/release-arm64/release-assets --ta UI 相关 PR 能跑最小 smoke matrix。 -目标 `main` 的代码 PR 能覆盖双架构 release smoke。 +目标 `main` 的代码 PR 能覆盖 arm64 release smoke;main push、nightly 和 release workflow 能覆盖双架构 release smoke。 推送到 `main` 后,`MARKETING_VERSION` 与 `CURRENT_PROJECT_VERSION` 能自动驱动 release 判断。 diff --git a/docs/testing/ci-workflows.md b/docs/testing/ci-workflows.md index 7e01c9c..0fd9321 100644 --- a/docs/testing/ci-workflows.md +++ b/docs/testing/ci-workflows.md @@ -29,11 +29,10 @@ Gate behavior: - Non-code PRs pass through the fast path. - Code-relevant PRs run static checks, SwiftPM unit tests, Go unit tests, and Xcode Debug build. - UI-relevant PRs run the UI smoke matrix. -- Code-relevant PRs targeting `main` also run arm64 and x86_64 release smoke. +- Code-relevant PRs targeting `main` also run arm64 release smoke. Main push, nightly, and release workflows cover x86_64 release smoke. - Code PRs run Dependency Review and block high or critical dependency vulnerabilities. -- Script or workflow PRs also run `head-script-self-test` without checkout credentials or exported `GITHUB_TOKEN`. -Candidate workflow and script changes are validated from the PR head, while critical gate execution uses trusted base checkout scripts to build or test the PR head source. `ci-gate` remains the single required branch protection check and requires the head self-test only when the change is script/tooling relevant. +PR CI executes scripts from the checked-out PR head. Script and workflow integrity is enforced by code review plus `script-static-checks`; `ci-gate` remains the single required branch protection check. PR CI checkouts do not persist credentials, and bootstrap steps do not expose `GITHUB_TOKEN` to checked-out repository scripts. ## Local Entrypoints @@ -87,9 +86,9 @@ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 Release builds are ad hoc signed only. They are not Developer ID signed, notarized, stapled, or certified by Apple. -Release decision logic lives in `scripts/release/prepare.sh`. The workflow passes event metadata and the target checkout path into that script, then uses its JSON summary and outputs to decide whether build and publish jobs should run. +Release workflow first resolves the target SHA without checking out target code, then verifies that SHA has a successful `ci-gate`. After that gate passes, release jobs execute scripts from the checked-out target commit and use JSON summaries and outputs to decide whether build and publish jobs should run. -Before build or publish jobs run, `scripts/release/require_ci_gate.sh` verifies that the target commit has a successful `ci-gate` check. Missing, pending, failed, cancelled, or inaccessible gate state stops the release and writes a JSON summary. +Before build or publish jobs run, the release workflow verifies the target commit has a successful `ci-gate` check with inline GitHub API logic that does not execute target checkout scripts. Missing, pending, failed, cancelled, or inaccessible gate state stops the release and writes a JSON summary. Release assets per architecture: diff --git a/scripts/ci/classification-rules.json b/scripts/ci/classification-rules.json new file mode 100644 index 0000000..7881b42 --- /dev/null +++ b/scripts/ci/classification-rules.json @@ -0,0 +1,54 @@ +{ + "categories": { + "code": { + "exact": ["Package.swift", "Package.resolved", "mise.toml", "Brewfile", ".swiftformat", ".swiftlint.yml", ".github/dependabot.yml"], + "prefixes": ["Sources/", "Tests/", "UITests/", "Apps/VoidDisplay/", "Tools/VoidDisplayRelay/", "scripts/", ".github/workflows/", ".github/actions/"], + "globs": ["*/Package.resolved"] + }, + "ui": { + "exact": [], + "prefixes": ["UITests/", "Apps/VoidDisplay/", "Sources/VoidDisplayApp/", "Sources/VoidDisplayDesignSystem/", "Sources/VoidDisplayCapture/", "Sources/VoidDisplaySharing/", "Sources/VoidDisplaySupport/", "Sources/VoidDisplayVirtualDisplay/"], + "globs": [] + }, + "script": { + "exact": ["mise.toml", "Brewfile", ".swiftformat", ".swiftlint.yml", ".github/dependabot.yml"], + "prefixes": ["scripts/", ".github/workflows/", ".github/actions/"], + "globs": [] + }, + "product_code": { + "exact": ["Package.swift", "Package.resolved"], + "prefixes": ["Sources/", "Apps/VoidDisplay/", "Tools/VoidDisplayRelay/"], + "globs": ["*/Package.resolved"] + }, + "test_code": { + "exact": [], + "prefixes": ["Tests/", "UITests/"], + "globs": [] + }, + "ci_config": { + "exact": [".swiftformat", ".swiftlint.yml", "mise.toml", "Brewfile", ".github/dependabot.yml"], + "prefixes": [".github/workflows/", ".github/actions/", "scripts/ci/", "scripts/dev/"], + "globs": [] + }, + "release": { + "exact": [".github/workflows/release.yml", "scripts/lib/architecture.sh", "scripts/lib/release_binaries.sh"], + "prefixes": ["Apps/VoidDisplay/", "Tools/VoidDisplayRelay/", "scripts/release/"], + "globs": ["scripts/ci/release_*.sh"] + }, + "dependency_manifest": { + "exact": ["Package.swift", "Package.resolved", "Tools/VoidDisplayRelay/go.mod", "Tools/VoidDisplayRelay/go.sum"], + "prefixes": [], + "globs": ["*/Package.resolved"] + }, + "tooling_config": { + "exact": ["mise.toml", "Brewfile", ".swiftformat", ".swiftlint.yml", ".github/dependabot.yml"], + "prefixes": [], + "globs": [] + }, + "docs": { + "exact": ["AGENTS.md", "LICENSE", "Readme.md", "README.md"], + "prefixes": ["docs/", ".github/PULL_REQUEST_TEMPLATE/"], + "globs": ["LICENSE_*", "*.md"] + } + } +} diff --git a/scripts/ci/classify.sh b/scripts/ci/classify.sh index 2e69367..707bc43 100755 --- a/scripts/ci/classify.sh +++ b/scripts/ci/classify.sh @@ -45,216 +45,120 @@ done [[ -n "$HEAD_SHA" ]] || die "--head is required." SUMMARY_PATH="${SUMMARY_PATH:-$(make_artifact_dir classify)/classify-summary.json}" -legacy_code_prefixes=( - Sources/ - Tests/ - UITests/ - Apps/VoidDisplay/ - Tools/VoidDisplayRelay/ - scripts/ - .github/workflows/ - .github/actions/ -) -ui_prefixes=( - UITests/ - Apps/VoidDisplay/ - Sources/VoidDisplayApp/ - Sources/VoidDisplayDesignSystem/ - Sources/VoidDisplayCapture/ - Sources/VoidDisplaySharing/ - Sources/VoidDisplaySupport/ - Sources/VoidDisplayVirtualDisplay/ -) -legacy_script_prefixes=( - scripts/ - .github/workflows/ - .github/actions/ -) -legacy_exact_matches=( - Package.swift - Package.resolved - mise.toml - Brewfile - .swiftformat - .swiftlint.yml - .github/dependabot.yml -) -legacy_script_exact_matches=( - mise.toml - Brewfile - .swiftformat - .swiftlint.yml - .github/dependabot.yml -) -product_code_prefixes=( - Sources/ - Apps/VoidDisplay/ - Tools/VoidDisplayRelay/ -) -test_code_prefixes=( - Tests/ - UITests/ -) -ci_config_prefixes=( - .github/workflows/ - .github/actions/ - scripts/ci/ - scripts/dev/ -) -release_prefixes=( - Apps/VoidDisplay/ - Tools/VoidDisplayRelay/ - scripts/release/ -) -docs_prefixes=( - docs/ - .github/PULL_REQUEST_TEMPLATE/ -) -product_code_exact_matches=( - Package.swift - Package.resolved -) -ci_config_exact_matches=( - .swiftformat - .swiftlint.yml - mise.toml - Brewfile - .github/dependabot.yml -) -release_exact_matches=( - .github/workflows/release.yml - scripts/lib/architecture.sh - scripts/lib/release_binaries.sh -) -dependency_manifest_exact_matches=( - Package.swift - Package.resolved - Tools/VoidDisplayRelay/go.mod - Tools/VoidDisplayRelay/go.sum -) -tooling_config_exact_matches=( - mise.toml - Brewfile - .swiftformat - .swiftlint.yml - .github/dependabot.yml -) -docs_exact_matches=( - AGENTS.md - LICENSE - Readme.md - README.md +require_command git jq + +RULES_PATH="$TOOL_ROOT/scripts/ci/classification-rules.json" +classification_categories=( + code + ui + script + product_code + test_code + ci_config + release + dependency_manifest + tooling_config + docs ) +[[ -f "$RULES_PATH" ]] || die "Missing classification rules: $RULES_PATH" +jq -e '.categories | type == "object"' "$RULES_PATH" >/dev/null || die "Invalid classification rules: $RULES_PATH" + +for category in "${classification_categories[@]}"; do + for key in exact prefixes globs; do + var_name="CLASSIFY_RULES_${category}_${key}" + values="$(jq -r --arg category "$category" --arg key "$key" '.categories[$category][$key][]?' "$RULES_PATH")" + printf -v "$var_name" '%s' "$values" + done +done + is_zero_sha() { [[ "$1" =~ ^0+$ ]] } -path_in_list() { - local needle="$1" - shift +rule_values() { + local category="$1" + local key="$2" + local var_name="CLASSIFY_RULES_${category}_${key}" + + [[ -n "${!var_name:-}" ]] || return 0 + printf '%s\n' "${!var_name}" +} + +path_matches_category() { + local category="$1" + local file_path="$2" local value - for value in "$@"; do - if [[ "$needle" == "$value" ]]; then + + while IFS= read -r value; do + if [[ "$file_path" == "$value" ]]; then return 0 fi - done - return 1 -} + done < <(rule_values "$category" exact) -path_has_prefix() { - local file_path="$1" - shift - local prefix - for prefix in "$@"; do - if [[ "$file_path" == "$prefix"* ]]; then + while IFS= read -r value; do + if [[ "$file_path" == "$value"* ]]; then return 0 fi - done + done < <(rule_values "$category" prefixes) + + while IFS= read -r value; do + # shellcheck disable=SC2053 + if [[ "$file_path" == $value ]]; then + return 0 + fi + done < <(rule_values "$category" globs) + return 1 } is_code_path() { - local file_path="$1" - path_in_list "$file_path" "${legacy_exact_matches[@]}" && return 0 - path_has_prefix "$file_path" "${legacy_code_prefixes[@]}" && return 0 - [[ "$file_path" == */Package.resolved ]] && return 0 - return 1 + path_matches_category code "$1" } is_ui_path() { - local file_path="$1" - path_has_prefix "$file_path" "${ui_prefixes[@]}" + path_matches_category ui "$1" } is_script_path() { - local file_path="$1" - path_in_list "$file_path" "${legacy_script_exact_matches[@]}" && return 0 - path_has_prefix "$file_path" "${legacy_script_prefixes[@]}" && return 0 - return 1 + path_matches_category script "$1" } is_product_code_path() { - local file_path="$1" - path_in_list "$file_path" "${product_code_exact_matches[@]}" && return 0 - path_has_prefix "$file_path" "${product_code_prefixes[@]}" && return 0 - [[ "$file_path" == */Package.resolved ]] && return 0 - return 1 + path_matches_category product_code "$1" } is_test_code_path() { - local file_path="$1" - path_has_prefix "$file_path" "${test_code_prefixes[@]}" + path_matches_category test_code "$1" } is_ci_config_path() { - local file_path="$1" - path_in_list "$file_path" "${ci_config_exact_matches[@]}" && return 0 - path_has_prefix "$file_path" "${ci_config_prefixes[@]}" && return 0 - return 1 + path_matches_category ci_config "$1" } is_release_path() { - local file_path="$1" - path_in_list "$file_path" "${release_exact_matches[@]}" && return 0 - path_has_prefix "$file_path" "${release_prefixes[@]}" && return 0 - [[ "$file_path" == scripts/ci/release_*.sh ]] && return 0 - return 1 + path_matches_category release "$1" } is_dependency_manifest_path() { - local file_path="$1" - path_in_list "$file_path" "${dependency_manifest_exact_matches[@]}" && return 0 - [[ "$file_path" == */Package.resolved ]] && return 0 - return 1 + path_matches_category dependency_manifest "$1" } is_tooling_config_path() { - local file_path="$1" - path_in_list "$file_path" "${tooling_config_exact_matches[@]}" + path_matches_category tooling_config "$1" } is_docs_path() { - local file_path="$1" - path_in_list "$file_path" "${docs_exact_matches[@]}" && return 0 - path_has_prefix "$file_path" "${docs_prefixes[@]}" && return 0 - [[ "$file_path" == LICENSE_* ]] && return 0 - [[ "$file_path" == *.md ]] && return 0 - return 1 + path_matches_category docs "$1" } is_known_path() { local file_path="$1" - is_code_path "$file_path" && return 0 - is_ui_path "$file_path" && return 0 - is_script_path "$file_path" && return 0 - is_product_code_path "$file_path" && return 0 - is_test_code_path "$file_path" && return 0 - is_ci_config_path "$file_path" && return 0 - is_release_path "$file_path" && return 0 - is_dependency_manifest_path "$file_path" && return 0 - is_tooling_config_path "$file_path" && return 0 - is_docs_path "$file_path" && return 0 + local category + + for category in "${classification_categories[@]}"; do + path_matches_category "$category" "$file_path" && return 0 + done return 1 } @@ -397,7 +301,6 @@ elif [[ "$code_relevant" == "true" ]]; then fi requires_static="false" -requires_head_script_self_test="false" requires_dependency_review="false" requires_unit="false" requires_xcode_build="false" @@ -414,9 +317,6 @@ elif [[ "$docs_only" != "true" ]]; then if [[ "$code_relevant" == "true" || "$unknown_relevant" == "true" ]]; then requires_static="true" fi - if [[ "$script_relevant" == "true" ]]; then - requires_head_script_self_test="true" - fi if [[ "$dependency_manifest_relevant" == "true" ]]; then requires_dependency_review="true" fi @@ -448,7 +348,6 @@ append_github_output tooling_config_relevant "$tooling_config_relevant" "$GITHUB append_github_output docs_only "$docs_only" "$GITHUB_OUTPUT_PATH" append_github_output unknown_relevant "$unknown_relevant" "$GITHUB_OUTPUT_PATH" append_github_output requires_static "$requires_static" "$GITHUB_OUTPUT_PATH" -append_github_output requires_head_script_self_test "$requires_head_script_self_test" "$GITHUB_OUTPUT_PATH" append_github_output requires_dependency_review "$requires_dependency_review" "$GITHUB_OUTPUT_PATH" append_github_output requires_unit "$requires_unit" "$GITHUB_OUTPUT_PATH" append_github_output requires_xcode_build "$requires_xcode_build" "$GITHUB_OUTPUT_PATH" @@ -490,7 +389,6 @@ write_json_file "$SUMMARY_PATH" \ --arg docs_only "$docs_only" \ --arg unknown_relevant "$unknown_relevant" \ --arg requires_static "$requires_static" \ - --arg requires_head_script_self_test "$requires_head_script_self_test" \ --arg requires_dependency_review "$requires_dependency_review" \ --arg requires_unit "$requires_unit" \ --arg requires_xcode_build "$requires_xcode_build" \ @@ -515,7 +413,6 @@ write_json_file "$SUMMARY_PATH" \ docs_only: ($docs_only == "true"), unknown_relevant: ($unknown_relevant == "true"), requires_static: ($requires_static == "true"), - requires_head_script_self_test: ($requires_head_script_self_test == "true"), requires_dependency_review: ($requires_dependency_review == "true"), requires_unit: ($requires_unit == "true"), requires_xcode_build: ($requires_xcode_build == "true"), diff --git a/scripts/ci/static.sh b/scripts/ci/static.sh index c17a889..050c0b9 100755 --- a/scripts/ci/static.sh +++ b/scripts/ci/static.sh @@ -11,12 +11,8 @@ cd "$ROOT_DIR" require_command actionlint jq shellcheck shfmt shasum swift swiftformat swiftlint rg xcrun validate_runner_labels() { - local invalid - invalid="$(rg -n '(runs-on|runs_on):[[:space:]]*(ubuntu-|windows-|macos-latest-large|.*-large)' .github/workflows .github/actions || true)" - if [[ -n "$invalid" ]]; then - printf '%s\n' "$invalid" >&2 - die "Workflow uses a non-macOS, paid, or larger runner label." - fi + assert_no_match "Workflow uses a non-macOS, paid, or larger runner label." \ + '(runs-on|runs_on):[[:space:]]*(ubuntu-|windows-|macos-latest-large|.*-large)' .github/workflows .github/actions } validate_action_pinning() { @@ -86,123 +82,115 @@ validate_shell_scripts() { validate_script_contract() { local invalid - invalid="$(rg -n 'SCRIPT_ROOT=|SCRIPT_LIB_DIR=' scripts --glob '!scripts/ci/static.sh' || true)" - if [[ -n "$invalid" ]]; then - printf '%s\n' "$invalid" >&2 - die "Scripts must use ROOT_DIR/TOOL_ROOT contract instead of SCRIPT_ROOT or SCRIPT_LIB_DIR." - fi + assert_no_match "Scripts must use ROOT_DIR/TOOL_ROOT contract instead of SCRIPT_ROOT or SCRIPT_LIB_DIR." \ + 'SCRIPT_ROOT=|SCRIPT_LIB_DIR=' scripts --glob '!scripts/ci/static.sh' invalid="$( - rg -n 'source .*scripts/lib/(common|artifacts|xcode|xcresult|architecture|release_binaries)\.sh|source "\$[A-Z_]+/(common|artifacts|xcode|xcresult|architecture|release_binaries)\.sh' scripts --glob '!scripts/ci/static.sh' || true + rg -n 'source .*scripts/lib/(common|artifacts|xcode|xcresult|architecture|release|release_binaries)\.sh|source "\$[A-Z_]+/(common|artifacts|xcode|xcresult|architecture|release|release_binaries)\.sh' scripts --glob '!scripts/ci/static.sh' || true )" invalid="$(printf '%s\n' "$invalid" | rg -v 'source "\$TOOL_ROOT/scripts/lib/' || true)" - if [[ -n "$invalid" ]]; then - printf '%s\n' "$invalid" >&2 - die "Helper source paths must use TOOL_ROOT." - fi + fail_on_output "Helper source paths must use TOOL_ROOT." "$invalid" + + assert_no_match "Nested script calls must pass ROOT_DIR and TOOL_ROOT explicitly." \ + 'ROOT_DIR="\$ROOT_DIR"(?!.*TOOL_ROOT=)|ROOT_DIR=\$\{ROOT_DIR:-' scripts --pcre2 --glob '!scripts/lib/contract.sh' --glob '!scripts/ci/static.sh' +} - invalid="$(rg -n 'ROOT_DIR="\$ROOT_DIR"(?!.*TOOL_ROOT=)|ROOT_DIR=\$\{ROOT_DIR:-' scripts --pcre2 --glob '!scripts/lib/contract.sh' --glob '!scripts/ci/static.sh' || true)" - if [[ -n "$invalid" ]]; then - printf '%s\n' "$invalid" >&2 - die "Nested script calls must pass ROOT_DIR and TOOL_ROOT explicitly." +fail_on_output() { + local message="$1" + local output="$2" + + if [[ -n "$output" ]]; then + printf '%s\n' "$output" >&2 + die "$message" fi } -validate_workflow_required_file_references() { - local missing - local workflow_files=("$@") +assert_no_match() { + local message="$1" + shift - missing="$( - awk ' - function emit_required_paths(line, tokens, token_count, token_index, token) { - sub(/[[:space:]]*;[[:space:]]*do.*$/, "", line) - gsub(/\\/, " ", line) - token_count = split(line, tokens, /[[:space:]]+/) - for (token_index = 1; token_index <= token_count; token_index += 1) { - token = tokens[token_index] - gsub(/^["\047]+/, "", token) - gsub(/["\047]+$/, "", token) - if (token ~ /^(scripts|\.github)\//) { - print FILENAME ":" FNR ":" token - } - } - } + fail_on_output "$message" "$(rg -n "$@" || true)" +} - /for required in/ { - raw = $0 - sub(/^.*for required in[[:space:]]*/, "", raw) - emit_required_paths(raw) - in_required = 1 - if ($0 ~ /;[[:space:]]*do/) { - in_required = 0 - } - next - } - in_required { - raw = $0 - emit_required_paths(raw) - if (raw ~ /;[[:space:]]*do[[:space:]]*$/) { - in_required = 0 - } - } - ' "${workflow_files[@]}" | - while IFS=: read -r workflow_file line_number required_path; do - [[ -n "$required_path" ]] || continue - if [[ ! -e "$ROOT_DIR/$required_path" ]]; then - printf '%s:%s missing required file: %s\n' "$workflow_file" "$line_number" "$required_path" - fi - done - )" +assert_file_contains_all() { + local file="$1" + local message="$2" + local required + shift 2 + + for required in "$@"; do + rg -F "$required" "$file" >/dev/null || die "$message: $required" + done +} + +assert_paths_exist() { + local message="$1" + local path + shift + + for path in "$@"; do + [[ -e "$path" ]] || die "$message: $path" + done +} + +workflow_job_block() { + local file="$1" + local job="$2" - if [[ -n "$missing" ]]; then - printf '%s\n' "$missing" >&2 - die "Workflow trusted-script required lists must only reference existing repository files." + awk -v job="$job" ' + $0 ~ "^[[:space:]]{2}" job ":" { inside = 1; print; next } + inside && /^[[:space:]]{2}[[:alnum:]_-]+:/ { exit } + inside { print } + ' "$file" +} + +assert_job_contains() { + local file="$1" + local job="$2" + local pattern="$3" + local message="$4" + local block + + block="$(workflow_job_block "$file" "$job")" + [[ -n "$block" ]] || die "$message" + if ! rg -n "$pattern" <<<"$block" >/dev/null; then + die "$message" fi } +assert_job_no_match() { + local file="$1" + local job="$2" + local pattern="$3" + local message="$4" + local block + + block="$(workflow_job_block "$file" "$job")" + [[ -n "$block" ]] || die "$message" + fail_on_output "$message" "$(rg -n "$pattern" <<<"$block" || true)" +} + validate_workflow_script_contract() { local invalid local workflow_files=() + local pr_ci_workflow_files=( + .github/workflows/ci.yml + .github/workflows/_reusable-unit-tests.yml + .github/workflows/_reusable-ui-smoke-tests.yml + ) while IFS= read -r workflow_file; do workflow_files+=("$workflow_file") done < <(find .github/workflows .github/actions -type f \( -name '*.yml' -o -name '*.yaml' \) -print | sort) - validate_workflow_required_file_references "${workflow_files[@]}" - - invalid="$(rg -n 'inline_first_rollout|static-validated head scripts|inline_name_status_fallback|first rollout|steps\.ci_scripts|ci_scripts\.outputs|using head scripts' .github/workflows .github/actions scripts --glob '!scripts/ci/static.sh' || true)" - if [[ -n "$invalid" ]]; then - printf '%s\n' "$invalid" >&2 - die "Seed fallback workflow paths must be removed." - fi - - invalid="$(rg -n '\.ai-tmp/trusted-ci/scripts/' .github/workflows .github/actions scripts --glob '!scripts/ci/static.sh' || true)" - if [[ -n "$invalid" ]]; then - printf '%s\n' "$invalid" >&2 - die "Trusted CI scripts must be invoked through an absolute TOOL_ROOT." - fi - - invalid="$(rg -n '\$GITHUB_WORKSPACE/scripts/' .github/workflows .github/actions || true)" - if [[ -n "$invalid" ]]; then - printf '%s\n' "$invalid" >&2 - die "Workflow script invocations must execute scripts through TOOL_ROOT." - fi + assert_no_match "Trusted/base CI script model must be removed." \ + '\.ai-tmp/trusted-ci|head-script-self-test|requires_head_script_self_test|trusted_files|test_trusted_files|Checkout trusted' \ + .github/workflows .github/actions scripts --glob '!scripts/ci/static.sh' + assert_no_match "Workflow script invocations must execute scripts through TOOL_ROOT." \ + '\$GITHUB_WORKSPACE/scripts/' .github/workflows .github/actions invalid="$( awk ' - /for required in[[:space:]]*\\/ { - in_required = 1 - next - } - in_required && /^[[:space:]]*scripts\/[^[:space:]]+([[:space:]]*\\|; do)[[:space:]]*$/ { - if ($0 ~ /; do[[:space:]]*$/) { - in_required = 0 - } - next - } - in_required && /; do[[:space:]]*$/ { - in_required = 0 - } /scripts\// { if ($0 ~ /^[[:space:]]*- '\''scripts\//) { next @@ -214,31 +202,77 @@ validate_workflow_script_contract() { } ' "${workflow_files[@]}" || true )" - if [[ -n "$invalid" ]]; then - printf '%s\n' "$invalid" >&2 - die "Workflow script references must be path filters, trusted-script checks, or TOOL_ROOT executions." - fi + fail_on_output "Workflow script references must be path filters or TOOL_ROOT executions." "$invalid" invalid="$(rg -n 'ROOT_DIR=.*scripts/' .github/workflows .github/actions | rg -v 'TOOL_ROOT=.*"\$tool_root/scripts/' || true)" - if [[ -n "$invalid" ]]; then - printf '%s\n' "$invalid" >&2 - die "Workflow script invocations must pass ROOT_DIR and TOOL_ROOT and execute through TOOL_ROOT." - fi + fail_on_output "Workflow script invocations must pass ROOT_DIR and TOOL_ROOT and execute through TOOL_ROOT." "$invalid" + assert_no_match "PR CI workflows must not expose GITHUB_TOKEN to checked-out repository scripts." \ + 'GITHUB_TOKEN:[[:space:]]*\$\{\{[[:space:]]*github\.token[[:space:]]*\}\}' "${pr_ci_workflow_files[@]}" + + invalid="$( + awk ' + function finish_checkout() { + if (in_checkout && !saw_persist_credentials) { + print current_file ":" checkout_line ":actions/checkout must set persist-credentials: false" + } + } + FNR == 1 { + finish_checkout() + current_file = FILENAME + in_checkout = 0 + saw_persist_credentials = 0 + checkout_line = 0 + } + /^[[:space:]]*-[[:space:]]+name:/ { + finish_checkout() + in_checkout = 0 + saw_persist_credentials = 0 + checkout_line = 0 + } + /uses:[[:space:]]*actions\/checkout@/ { + in_checkout = 1 + saw_persist_credentials = 0 + checkout_line = FNR + } + in_checkout && /persist-credentials:[[:space:]]*false/ { + saw_persist_credentials = 1 + } + END { + finish_checkout() + } + ' "${pr_ci_workflow_files[@]}" || true + )" + fail_on_output "PR CI checkouts must disable persisted credentials." "$invalid" + assert_no_match "Release smoke must stay split into arm64 and intel64 jobs." \ + '^[[:space:]]{2}release_build_check:' .github/workflows/ci.yml + + assert_job_contains .github/workflows/ci.yml release_build_check_arm64 \ + 'if:.*requires_release_smoke.*true' \ + "arm64 release smoke must run whenever release smoke is required" + assert_job_contains .github/workflows/ci.yml release_build_check_intel64 \ + 'if:.*github\.event_name.*push.*requires_release_smoke.*true' \ + "intel64 release smoke must be push-only" + + assert_job_no_match .github/workflows/release.yml require_ci_gate \ + 'actions\/checkout@|scripts\/release\/require_ci_gate\.sh|scripts\/dev\/bootstrap\.sh|prepare_release|needs\.prepare_release' \ + "Release ci-gate verification must stay workflow-owned and must not execute target checkout scripts." + assert_job_no_match .github/workflows/release.yml resolve_release_target \ + 'actions\/checkout@|scripts\/' \ + "resolve-release-target must not checkout or execute repository scripts" + assert_job_contains .github/workflows/release.yml prepare_release \ + '^[[:space:]]*-[[:space:]]*require_ci_gate[[:space:]]*$' \ + "prepare-release must depend on require-ci-gate" + assert_job_contains .github/workflows/release.yml prepare_release \ + 'ref:[[:space:]]*\$\{\{[[:space:]]*needs\.resolve_release_target\.outputs\.target_sha[[:space:]]*\}\}' \ + "prepare-release must checkout the resolved target SHA" } validate_xcode_shell_build_phase() { local project_file="Apps/VoidDisplay/VoidDisplay.xcodeproj/project.pbxproj" local shell_phase_count local invalid_inputs - local required local root_setting_count local tool_setting_count - local tool_build_input='$(TOOL_ROOT)/scripts/build-relay.sh' - local tool_contract_input='$(TOOL_ROOT)/scripts/lib/contract.sh' - local tool_common_input='$(TOOL_ROOT)/scripts/lib/common.sh' - local tool_architecture_input='$(TOOL_ROOT)/scripts/lib/architecture.sh' - local tool_release_binaries_input='$(TOOL_ROOT)/scripts/lib/release_binaries.sh' - local root_relay_input_prefix='$(ROOT_DIR)/Tools/VoidDisplayRelay/' extract_pbx_array_values() { local key="$1" @@ -267,25 +301,17 @@ validate_xcode_shell_build_phase() { shell_phase_count="$(awk '/isa = PBXShellScriptBuildPhase;/{count += 1} END{print count + 0}' "$project_file")" [[ "$shell_phase_count" == "1" ]] || die "Xcode project must contain exactly one shell build phase." - for required in \ + assert_file_contains_all "$project_file" "Build Relay phase is missing required line" \ 'name = "Build Relay";' \ 'shellPath = /bin/bash;' \ - '"cd \"$SRCROOT/../..\"",'; do - if ! rg -F "$required" "$project_file" >/dev/null; then - die "Build Relay phase is missing required line: $required" - fi - done + '"cd \"$SRCROOT/../..\"",' - for required in \ + assert_file_contains_all "$project_file" "Build Relay phase is missing required tool input or build setting" \ '"$(TOOL_ROOT)/scripts/build-relay.sh",' \ '"$(TOOL_ROOT)/scripts/lib/contract.sh",' \ '"$(TOOL_ROOT)/scripts/lib/common.sh",' \ '"$(TOOL_ROOT)/scripts/lib/architecture.sh",' \ - '"$(TOOL_ROOT)/scripts/lib/release_binaries.sh",'; do - if ! rg -F "$required" "$project_file" >/dev/null; then - die "Build Relay phase is missing required trusted tool input or build setting: $required" - fi - done + '"$(TOOL_ROOT)/scripts/lib/release_binaries.sh",' root_setting_count="$(rg -F 'ROOT_DIR = "$(SRCROOT)/../..";' "$project_file" | wc -l | tr -d '[:space:]')" tool_setting_count="$(rg -F 'TOOL_ROOT = "$(ROOT_DIR)";' "$project_file" | wc -l | tr -d '[:space:]')" @@ -304,48 +330,32 @@ validate_xcode_shell_build_phase() { invalid_inputs="$( extract_pbx_array_values inputPaths | - while IFS= read -r input_path; do - if [[ "$input_path" == "$tool_build_input" || - "$input_path" == "$tool_contract_input" || - "$input_path" == "$tool_common_input" || - "$input_path" == "$tool_architecture_input" || - "$input_path" == "$tool_release_binaries_input" || - "$input_path" == "$root_relay_input_prefix"* ]]; then - continue - fi - printf '%s\n' "$input_path" - done + rg -v '^\$\(TOOL_ROOT\)/scripts/(build-relay\.sh|lib/(contract|common|architecture|release_binaries)\.sh)$|^\$\(ROOT_DIR\)/Tools/VoidDisplayRelay/' || true )" - if [[ -n "$invalid_inputs" ]]; then - printf '%s\n' "$invalid_inputs" >&2 - die "Build Relay input paths must stay under allowed prefixes." - fi + fail_on_output "Build Relay input paths must stay under allowed prefixes." "$invalid_inputs" } -validate_xcode_log_scanner() { - local fixture_dir="$TOOL_ROOT/scripts/ci/fixtures/xcode-log-scanner" +validate_log_scanner() { + local scanner="$1" + local label="$2" + local fixture_dir="$3" local positive_fixture for positive_fixture in "$fixture_dir"/positive-*.fixture; do - if (scan_xcode_log_for_diagnostics "Xcode log fixture" "$positive_fixture" >/dev/null 2>&1); then - die "Xcode log scanner missed fixture: $positive_fixture" + if ("$scanner" "$label log fixture" "$positive_fixture" >/dev/null 2>&1); then + die "$label log scanner missed fixture: $positive_fixture" fi done - scan_xcode_log_for_diagnostics "Xcode negative log fixture" "$fixture_dir/negative-ordinary-text.fixture" + "$scanner" "$label negative log fixture" "$fixture_dir/negative-ordinary-text.fixture" } -validate_swiftpm_log_scanner() { - local fixture_dir="$TOOL_ROOT/scripts/ci/fixtures/swiftpm-log-scanner" - local positive_fixture - - for positive_fixture in "$fixture_dir"/positive-*.fixture; do - if (scan_build_log_for_diagnostics "SwiftPM log fixture" "$positive_fixture" >/dev/null 2>&1); then - die "SwiftPM log scanner missed fixture: $positive_fixture" - fi - done +validate_xcode_log_scanner() { + validate_log_scanner scan_xcode_log_for_diagnostics Xcode "$TOOL_ROOT/scripts/ci/fixtures/xcode-log-scanner" +} - scan_build_log_for_diagnostics "SwiftPM negative log fixture" "$fixture_dir/negative-ordinary-text.fixture" +validate_swiftpm_log_scanner() { + validate_log_scanner scan_build_log_for_diagnostics SwiftPM "$TOOL_ROOT/scripts/ci/fixtures/swiftpm-log-scanner" } validate_classify_fixtures() { @@ -360,7 +370,6 @@ validate_webrtc_header_overlay() { local invalid local expected_paths local actual_paths - local required_source local manifest_json if ! manifest_json="$(swift package dump-package 2>/dev/null)"; then @@ -370,51 +379,36 @@ validate_webrtc_header_overlay() { if ! jq -e \ --arg url 'https://github.com/stasel/WebRTC/releases/download/147.0.0/WebRTC-M147.xcframework.zip' \ --arg checksum '49f9b1713432c19f408e3218fc8526c7692fafca5869f7ec5f5991614276ed40' \ - '.targets[] | select(.name == "WebRTCBinary" and .type == "binary" and .url == $url and .checksum == $checksum)' \ + '( + any(.targets[]; .name == "WebRTCBinary" and .type == "binary" and .url == $url and .checksum == $checksum) and + any(.targets[]; .name == "WebRTC" and .type == "regular" and .path == "Vendor/WebRTCHeaders/M147" and .publicHeadersPath == "include" and any(.dependencies[]?; .byName[0] == "WebRTCBinary")) and + any(.targets[]; .name == "VoidDisplaySharing" and any(.dependencies[]?; .byName[0] == "WebRTC")) and + ((.dependencies // []) | length == 0) + )' \ <<<"$manifest_json" >/dev/null; then - die "Package.swift must define WebRTCBinary from the stasel/WebRTC 147.0.0 asset." - fi - if ! jq -e \ - '.targets[] | select(.name == "WebRTC" and .type == "regular" and .path == "Vendor/WebRTCHeaders/M147" and .publicHeadersPath == "include" and any(.dependencies[]?; .byName[0] == "WebRTCBinary"))' \ - <<<"$manifest_json" >/dev/null; then - die "Package.swift must expose the WebRTC M147 overlay through the local WebRTC target." - fi - if ! jq -e \ - '.targets[] | select(.name == "VoidDisplaySharing" and any(.dependencies[]?; .byName[0] == "WebRTC"))' \ - <<<"$manifest_json" >/dev/null; then - die "VoidDisplaySharing must depend on the local WebRTC wrapper target." - fi - if ! jq -e '(.dependencies // []) | length == 0' <<<"$manifest_json" >/dev/null; then - die "Package.swift must not retain remote source package dependencies." - fi - if rg -F 'https://github.com/stasel/WebRTC.git' "$ROOT_DIR/Package.swift" >/dev/null; then - die "Package.swift must use the local WebRTC wrapper target instead of the remote stasel package." + die "Package.swift must use the local WebRTC M147 wrapper target and no remote source package dependencies." fi + assert_no_match "Package.swift must use the local WebRTC wrapper target instead of the remote stasel package." \ + 'https://github.com/stasel/WebRTC.git' "$ROOT_DIR/Package.swift" invalid="$(rg -n 'https://github.com/stasel/WebRTC.git|\"identity\"[[:space:]]*:[[:space:]]*\"webrtc\"' \ "$ROOT_DIR/Package.resolved" \ "$ROOT_DIR/Apps/VoidDisplay/VoidDisplay.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved" \ "$ROOT_DIR/VoidDisplay.xcworkspace/xcshareddata/swiftpm/Package.resolved" || true)" - if [[ -n "$invalid" ]]; then - printf '%s\n' "$invalid" >&2 - die "Package.resolved files must not retain stale stasel/WebRTC source pins." - fi + fail_on_output "Package.resolved files must not retain stale stasel/WebRTC source pins." "$invalid" - [[ -f "$overlay_root/SOURCES.md" ]] || die "WebRTC M147 header overlay must document sources." - [[ -f "$checksum_file" ]] || die "WebRTC M147 header overlay must include SHA256SUMS." - [[ -f "$overlay_root/WebRTCHeaderOverlayAnchor.c" ]] || die "WebRTC M147 header overlay target must include its anchor C file." - [[ -f "$include_dir/WebRTC.h" ]] || die "WebRTC M147 header overlay must include WebRTC.h." - [[ -f "$include_dir/RTCMTLNSVideoView.h" ]] || die "WebRTC M147 header overlay must include RTCMTLNSVideoView.h." + assert_paths_exist "WebRTC M147 header overlay is missing required file" \ + "$overlay_root/SOURCES.md" \ + "$checksum_file" \ + "$overlay_root/WebRTCHeaderOverlayAnchor.c" \ + "$include_dir/WebRTC.h" \ + "$include_dir/RTCMTLNSVideoView.h" - for required_source in \ + assert_file_contains_all "$overlay_root/SOURCES.md" "WebRTC M147 SOURCES.md is missing required source detail" \ 'https://github.com/stasel/WebRTC/releases/download/147.0.0/WebRTC-M147.xcframework.zip' \ '49f9b1713432c19f408e3218fc8526c7692fafca5869f7ec5f5991614276ed40' \ 'refs/branch-heads/7727' \ 'macos-x86_64_arm64/WebRTC.framework/Versions/A/Headers/WebRTC.h' \ - 'RTCMTLNSVideoView.h'; do - if ! rg -F "$required_source" "$overlay_root/SOURCES.md" >/dev/null; then - die "WebRTC M147 SOURCES.md is missing required source detail: $required_source" - fi - done + 'RTCMTLNSVideoView.h' invalid="$( find "$overlay_root" -type f \ @@ -424,24 +418,18 @@ validate_webrtc_header_overlay() { ! -path "$overlay_root/WebRTCHeaderOverlayAnchor.c" \ -print )" - if [[ -n "$invalid" ]]; then - printf '%s\n' "$invalid" >&2 - die "WebRTC M147 overlay may only contain headers, source metadata, checksums, and the anchor C file." - fi + fail_on_output "WebRTC M147 overlay may only contain headers, source metadata, checksums, and the anchor C file." "$invalid" - if ! rg -F '#import ' "$include_dir/RTCMTLNSVideoView.h" >/dev/null; then - die "RTCMTLNSVideoView.h must import RTCVideoRenderer.h through the WebRTC framework path." - fi + assert_file_contains_all "$include_dir/RTCMTLNSVideoView.h" \ + "RTCMTLNSVideoView.h must import RTCVideoRenderer.h through the WebRTC framework path" \ + '#import ' for forbidden_header in RTCEAGLVideoView.h RTCCameraPreviewView.h UIDevice+RTCDevice.h; do [[ ! -e "$include_dir/$forbidden_header" ]] || die "WebRTC M147 overlay must not include iOS-only header: $forbidden_header" done - invalid="$(rg -n '^[[:space:]]*#(import|include)[[:space:]]+"[^"]+"' "$include_dir" || true)" - if [[ -n "$invalid" ]]; then - printf '%s\n' "$invalid" >&2 - die "WebRTC M147 overlay must not use WebRTC-local quoted imports." - fi + assert_no_match "WebRTC M147 overlay must not use WebRTC-local quoted imports." \ + '^[[:space:]]*#(import|include)[[:space:]]+"[^"]+"' "$include_dir" invalid="$( while IFS=: read -r file line_number import_path; do @@ -451,10 +439,7 @@ validate_webrtc_header_overlay() { [[ -f "$include_dir/$import_path" ]] || printf '%s:%s missing <%s>\n' "$file" "$line_number" "WebRTC/$import_path" done < <(rg -n -o ']+>' "$include_dir" || true) )" - if [[ -n "$invalid" ]]; then - printf '%s\n' "$invalid" >&2 - die "WebRTC M147 overlay has unresolved recursive WebRTC imports." - fi + fail_on_output "WebRTC M147 overlay has unresolved recursive WebRTC imports." "$invalid" invalid="$( awk ' @@ -481,10 +466,7 @@ validate_webrtc_header_overlay() { } ' "$include_dir"/*.h )" - if [[ -n "$invalid" ]]; then - printf '%s\n' "$invalid" >&2 - die "WebRTC M147 overlay may only reference UIKit inside TARGET_OS_IPHONE guards." - fi + fail_on_output "WebRTC M147 overlay may only reference UIKit inside TARGET_OS_IPHONE guards." "$invalid" if ! (cd "$overlay_root" && shasum -a 256 -c SHA256SUMS >/dev/null); then (cd "$overlay_root" && shasum -a 256 -c SHA256SUMS) >&2 || true @@ -511,23 +493,19 @@ validate_create_dmg_failure_summary() { local out_dir local summary_path local missing_app_path - local status out_dir="$AI_TMP_DIR/static-dmg-summary/$(timestamp)" summary_path="$out_dir/create-dmg-summary.json" missing_app_path="$out_dir/Missing.app" mkdir -p "$out_dir" - set +e - env ROOT_DIR="$ROOT_DIR" TOOL_ROOT="$TOOL_ROOT" "$TOOL_ROOT/scripts/release/create_dmg.sh" \ + if env ROOT_DIR="$ROOT_DIR" TOOL_ROOT="$TOOL_ROOT" "$TOOL_ROOT/scripts/release/create_dmg.sh" \ --summary "$summary_path" \ "$missing_app_path" \ "$out_dir/Missing.dmg" \ - "VoidDisplay" >/dev/null 2>&1 - status="$?" - set -e - - [[ "$status" -ne 0 ]] || die "create_dmg missing-app fixture unexpectedly passed." + "VoidDisplay" >/dev/null 2>&1; then + die "create_dmg missing-app fixture unexpectedly passed." + fi jq -e '.status == "failed" and .reason == "missing_app" and .stage == "argument_validation"' "$summary_path" >/dev/null || die "create_dmg missing-app fixture did not write the expected summary." } diff --git a/scripts/ci/test_classify.sh b/scripts/ci/test_classify.sh index 08dae9d..7cd0148 100755 --- a/scripts/ci/test_classify.sh +++ b/scripts/ci/test_classify.sh @@ -121,14 +121,17 @@ run_file_case docs_only pull_request main \ "docs_only=true code_relevant=false requires_static=false requires_unit=false unknown_relevant=false" \ docs/change.md run_file_case codeql_workflow pull_request main \ - "ci_config_relevant=true script_relevant=true code_relevant=true requires_static=true requires_head_script_self_test=true requires_unit=false requires_xcode_build=false" \ + "ci_config_relevant=true script_relevant=true code_relevant=true requires_static=true requires_unit=false requires_xcode_build=false" \ .github/workflows/codeql.yml run_file_case mise_config pull_request main \ - "tooling_config_relevant=true ci_config_relevant=true requires_static=true requires_head_script_self_test=true requires_dependency_review=false requires_unit=false" \ + "tooling_config_relevant=true ci_config_relevant=true requires_static=true requires_dependency_review=false requires_unit=false" \ mise.toml run_file_case package_manifest pull_request main \ "product_code_relevant=true dependency_manifest_relevant=true requires_dependency_review=true requires_unit=true requires_xcode_build=true" \ Package.swift +run_file_case nested_package_resolved pull_request main \ + "code_relevant=true product_code_relevant=true dependency_manifest_relevant=true requires_dependency_review=true requires_unit=true requires_xcode_build=true unknown_relevant=false" \ + Apps/VoidDisplay/VoidDisplay.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved run_file_case go_manifest pull_request main \ "product_code_relevant=true dependency_manifest_relevant=true release_relevant=true requires_release_smoke=true requires_unit=true requires_xcode_build=true" \ Tools/VoidDisplayRelay/go.mod @@ -148,7 +151,7 @@ run_file_case app_resource pull_request main \ "product_code_relevant=true ui_relevant=true release_relevant=true requires_release_smoke=true requires_ui_smoke=true" \ Apps/VoidDisplay/Resources/Localizable.xcstrings run_file_case release_script pull_request main \ - "ci_config_relevant=true script_relevant=true release_relevant=true requires_static=true requires_head_script_self_test=true requires_release_smoke=true requires_unit=false" \ + "ci_config_relevant=true script_relevant=true release_relevant=true requires_static=true requires_release_smoke=true requires_unit=false" \ scripts/ci/release_smoke.sh run_file_case unknown_path pull_request main \ "unknown_relevant=true docs_only=false code_relevant=false requires_static=true requires_unit=true requires_xcode_build=true" \ @@ -156,6 +159,9 @@ run_file_case unknown_path pull_request main \ run_file_case main_push_docs push "" \ "docs_only=true requires_static=true requires_unit=true requires_xcode_build=true requires_ui_smoke=true requires_release_smoke=true" \ docs/push.md +run_file_case license_variant_docs pull_request main \ + "docs_only=true code_relevant=false requires_static=false requires_unit=false unknown_relevant=false" \ + LICENSE_THIRD_PARTY run_rename_case rename_docs_to_code docs/old.md Sources/VoidDisplayFoundation/Renamed.swift \ "docs_only=false product_code_relevant=true code_relevant=true requires_unit=true requires_xcode_build=true" diff --git a/scripts/dev/bootstrap.sh b/scripts/dev/bootstrap.sh index 6ab94d3..a6f09c7 100755 --- a/scripts/dev/bootstrap.sh +++ b/scripts/dev/bootstrap.sh @@ -7,6 +7,7 @@ source "${BASH_SOURCE[0]%/*}/../lib/contract.sh" source "$TOOL_ROOT/scripts/lib/common.sh" CI_REQUIRES_MISE="${CI_REQUIRES_MISE:-${GITHUB_ACTIONS:-false}}" +PROFILE="full" required_commands=( actionlint @@ -20,6 +21,31 @@ required_commands=( syft gh ) +mise_targets=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --profile) + [[ $# -ge 2 && -n "${2:-}" ]] || die "--profile requires a value." + PROFILE="$2" + shift 2 + ;; + *) + die "Unknown argument: $1" + ;; + esac +done + +case "$PROFILE" in +full) ;; +release-smoke) + required_commands=(go jq rg) + mise_targets=(go aqua:jqlang/jq aqua:BurntSushi/ripgrep) + ;; +*) + die "Unsupported bootstrap profile: $PROFILE" + ;; +esac activate_mise_shims() { local mise_data_dir="${MISE_DATA_DIR:-$HOME/.local/share/mise}" @@ -37,7 +63,11 @@ install_pinned_tools_with_mise() { cd "$TOOL_ROOT" export MISE_YES=1 export MISE_TRUSTED_CONFIG_PATHS="$TOOL_ROOT" - mise install + if ((${#mise_targets[@]})); then + mise install "${mise_targets[@]}" + else + mise install + fi mise reshim >/dev/null 2>&1 || true activate_mise_shims } diff --git a/scripts/lib/release.sh b/scripts/lib/release.sh new file mode 100644 index 0000000..0ef20a5 --- /dev/null +++ b/scripts/lib/release.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash + +if [[ -z "${VOIDDISPLAY_RELEASE_SH_SOURCED:-}" ]]; then + VOIDDISPLAY_RELEASE_SH_SOURCED=1 + + # shellcheck source=scripts/lib/common.sh + source "$TOOL_ROOT/scripts/lib/common.sh" + # shellcheck source=scripts/lib/architecture.sh + source "$TOOL_ROOT/scripts/lib/architecture.sh" + + release_project_file() { + printf '%s\n' "$ROOT_DIR/Apps/VoidDisplay/VoidDisplay.xcodeproj/project.pbxproj" + } + + release_read_project_value_stream() { + local key="$1" + local context="${2:-}" + local values + local count + values="$(awk -v key="$key" '$1 == key && $2 == "=" { value = $3; sub(/;$/, "", value); print value }' | sort -u)" + count="$(printf '%s\n' "$values" | sed '/^$/d' | wc -l | tr -d ' ')" + if [[ "$count" -ne 1 ]]; then + die "Expected exactly one unique $key${context:+ $context}; found: ${values:-}" + fi + printf '%s\n' "$values" + } + + release_read_project_value() { + local key="$1" + local source="${2:-$(release_project_file)}" + release_read_project_value_stream "$key" <"$source" + } + + release_read_project_value_from_git() { + local key="$1" + local commit="$2" + local path="${3:-Apps/VoidDisplay/VoidDisplay.xcodeproj/project.pbxproj}" + + git cat-file -e "${commit}:${path}" 2>/dev/null || die "Unable to read $path at $commit." + git show "${commit}:${path}" | release_read_project_value_stream "$key" "at $commit" + } + + release_require_semver() { + local version="$1" + local detail="${2:-Invalid version: $version. Expected MAJOR.MINOR.PATCH.}" + [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "$detail" + } + + release_require_tag() { + local tag="$1" + local detail="${2:---tag must match vMAJOR.MINOR.PATCH.}" + [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "$detail" + } + + release_require_positive_integer() { + local value="$1" + local detail="${2:-Expected a positive integer: $value}" + [[ "$value" =~ ^[1-9][0-9]*$ ]] || die "$detail" + } + + release_arch_label() { + local arch="$1" + local label="${2:-}" + [[ -n "$arch" ]] || die "--arch is required." + validate_release_arch "$arch" + label="${label:-$(release_label_for_arch "$arch")}" + require_release_label_for_arch "$arch" "$label" + printf '%s\n' "$label" + } + + release_dmg_name() { + local tag="$1" + local label="$2" + printf 'VoidDisplay-%s-%s.dmg\n' "$tag" "$label" + } + + release_stage_failure_once() { + local exit_code="$1" + local summary_written="$2" + local writer="$3" + local stage="$4" + local detail="$5" + + if [[ "$exit_code" -ne 0 && "$summary_written" != "true" ]]; then + "$writer" "failed" "${stage}_failed" "$detail" + fi + } + + release_fail() { + local writer="$1" + local reason="$2" + local detail="$3" + + "$writer" "failed" "$reason" "$detail" + die "$detail" + } + + release_parse_attach_device() { + awk '/^\/dev\// {print $1; exit}' + } + + release_parse_attach_mount_path() { + sed -n 's#.*\(/Volumes/.*\)$#\1#p' | tail -n 1 + } + + release_detach_device() { + local target_device="$1" + [[ -n "$target_device" ]] || return 0 + + for _ in 1 2 3 4 5; do + hdiutil detach "$target_device" -quiet >/dev/null 2>&1 && return 0 + sleep 1 + done + hdiutil detach "$target_device" -force -quiet >/dev/null 2>&1 || true + } + + release_cleanup_device() { + local target_device="$1" + [[ -n "$target_device" ]] || return 0 + hdiutil detach "$target_device" -quiet >/dev/null 2>&1 || + hdiutil detach "$target_device" -force -quiet >/dev/null 2>&1 || + true + } + + release_run_with_timeout() { + local timeout_seconds="$1" + shift + + python3 - "$timeout_seconds" "$@" <<'PY' +import subprocess +import sys + +timeout_seconds = int(sys.argv[1]) +command = sys.argv[2:] + +try: + completed = subprocess.run(command, check=False, timeout=timeout_seconds) + sys.exit(completed.returncode) +except subprocess.TimeoutExpired: + sys.exit(124) +PY + } +fi diff --git a/scripts/release/build.sh b/scripts/release/build.sh index 438bd78..f834e3c 100755 --- a/scripts/release/build.sh +++ b/scripts/release/build.sh @@ -5,10 +5,10 @@ set -euo pipefail source "${BASH_SOURCE[0]%/*}/../lib/contract.sh" # shellcheck source=scripts/lib/common.sh source "$TOOL_ROOT/scripts/lib/common.sh" -# shellcheck source=scripts/lib/architecture.sh -source "$TOOL_ROOT/scripts/lib/architecture.sh" # shellcheck source=scripts/lib/artifacts.sh source "$TOOL_ROOT/scripts/lib/artifacts.sh" +# shellcheck source=scripts/lib/release.sh +source "$TOOL_ROOT/scripts/lib/release.sh" cd "$ROOT_DIR" @@ -41,18 +41,15 @@ while [[ $# -gt 0 ]]; do esac done -[[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "--tag must match vMAJOR.MINOR.PATCH." -[[ -n "$ARCH" ]] || die "--arch is required." -validate_release_arch "$ARCH" -LABEL="${LABEL:-$(release_label_for_arch "$ARCH")}" -require_release_label_for_arch "$ARCH" "$LABEL" +release_require_tag "$TAG" +LABEL="$(release_arch_label "$ARCH" "$LABEL")" OUT_DIR="${OUT_DIR:-$ROOT_DIR/.ai-tmp/release-$LABEL}" require_command syft jq mkdir -p "$OUT_DIR/release-assets" -dmg_name="VoidDisplay-${TAG}-${LABEL}.dmg" +dmg_name="$(release_dmg_name "$TAG" "$LABEL")" dmg_path="$OUT_DIR/release-assets/$dmg_name" sbom_path="$OUT_DIR/release-assets/$dmg_name.spdx.json" summary_path="$OUT_DIR/release-assets/$dmg_name.summary.json" @@ -96,9 +93,8 @@ handle_unexpected_release_error() { handle_release_exit() { local exit_code="$?" - if [[ "$exit_code" -ne 0 && "$release_summary_written" != "true" ]]; then - write_release_build_summary "failed" "${release_stage}_failed" "Release build failed in stage ${release_stage}." - fi + release_stage_failure_once "$exit_code" "$release_summary_written" \ + write_release_build_summary "$release_stage" "Release build failed in stage ${release_stage}." } trap 'handle_unexpected_release_error $? $LINENO' ERR @@ -132,13 +128,11 @@ if ! ( cd "$OUT_DIR/release-assets" shasum -a 256 "$dmg_name" >"$dmg_name.sha256" ); then - write_release_build_summary "failed" "checksum_failed" "Failed to write SHA256 checksum." - die "Failed to write SHA256 checksum." + release_fail write_release_build_summary "checksum_failed" "Failed to write SHA256 checksum." fi if ! checksum="$(sha256_digest "$dmg_path")"; then - write_release_build_summary "failed" "checksum_failed" "Failed to compute SHA256 checksum." - die "Failed to compute SHA256 checksum." + release_fail write_release_build_summary "checksum_failed" "Failed to compute SHA256 checksum." fi release_stage="sbom" @@ -146,8 +140,7 @@ if ! SYFT_CHECK_FOR_APP_UPDATE=false syft "$app_path" \ --source-name "VoidDisplay.app" \ --source-version "${TAG#v}" \ -o "spdx-json=$sbom_path"; then - write_release_build_summary "failed" "sbom_failed" "Failed to generate SPDX SBOM." - die "Failed to generate SPDX SBOM." + release_fail write_release_build_summary "sbom_failed" "Failed to generate SPDX SBOM." fi jq -n \ diff --git a/scripts/release/create_dmg.sh b/scripts/release/create_dmg.sh index ac92cdf..e7d43b7 100755 --- a/scripts/release/create_dmg.sh +++ b/scripts/release/create_dmg.sh @@ -7,6 +7,8 @@ source "${BASH_SOURCE[0]%/*}/../lib/contract.sh" source "$TOOL_ROOT/scripts/lib/common.sh" # shellcheck source=scripts/lib/artifacts.sh source "$TOOL_ROOT/scripts/lib/artifacts.sh" +# shellcheck source=scripts/lib/release.sh +source "$TOOL_ROOT/scripts/lib/release.sh" usage() { cat <<'EOF' @@ -102,50 +104,12 @@ mount_path="" device="" cleanup() { - if [ -n "${device}" ]; then - hdiutil detach "${device}" -quiet >/dev/null 2>&1 || hdiutil detach "${device}" -force -quiet >/dev/null 2>&1 || true - fi + release_cleanup_device "$device" rm -rf "${work_dir}" } trap cleanup EXIT -detach_volume() { - local target_device="$1" - - if [ -z "${target_device}" ]; then - return - fi - - for _ in 1 2 3 4 5; do - if hdiutil detach "${target_device}" -quiet >/dev/null 2>&1; then - return - fi - sleep 1 - done - - hdiutil detach "${target_device}" -force -quiet >/dev/null 2>&1 || true -} - -run_with_timeout() { - local timeout_seconds="$1" - shift - - python3 - "$timeout_seconds" "$@" <<'PY' -import subprocess -import sys - -timeout_seconds = int(sys.argv[1]) -command = sys.argv[2:] - -try: - completed = subprocess.run(command, check=False, timeout=timeout_seconds) - sys.exit(completed.returncode) -except subprocess.TimeoutExpired: - sys.exit(124) -PY -} - mkdir -p "${background_dir}" # Render the DMG background at build time from a fixed template. stage="render_background" @@ -194,8 +158,8 @@ set -e if [ "$attach_status" -ne 0 ]; then fail_dmg "hdiutil_failed" "Failed to attach writable DMG: ${attach_output}" fi -device="$(printf '%s\n' "${attach_output}" | awk '/^\/dev\// {print $1; exit}')" -mount_path="$(printf '%s\n' "${attach_output}" | sed -n 's#.*\(/Volumes/.*\)$#\1#p' | tail -n 1)" +device="$(printf '%s\n' "${attach_output}" | release_parse_attach_device)" +mount_path="$(printf '%s\n' "${attach_output}" | release_parse_attach_mount_path)" if [ -z "${device}" ] || [ -z "${mount_path}" ]; then fail_dmg "hdiutil_failed" "Failed to locate mounted DMG device or mount path: ${attach_output}" @@ -205,7 +169,7 @@ mounted_volume_name="$(basename "${mount_path}")" stage="dmg_layout" layout_exit_code=0 -run_with_timeout 45 osascript "$TOOL_ROOT/scripts/release/apply_dmg_layout.applescript" "${mounted_volume_name}" "${app_name}" || layout_exit_code="$?" +release_run_with_timeout 45 osascript "$TOOL_ROOT/scripts/release/apply_dmg_layout.applescript" "${mounted_volume_name}" "${app_name}" || layout_exit_code="$?" if [ "${layout_exit_code}" -ne 0 ]; then if [ "${layout_exit_code}" -eq 124 ]; then fail_dmg "dmg_layout_timeout" "DMG layout timed out after 45 seconds." 124 @@ -218,7 +182,7 @@ SetFile -a V "${mount_path}/.background" || true bless --folder "${mount_path}" --openfolder "${mount_path}" >/dev/null 2>&1 || true stage="hdiutil_detach" -detach_volume "${device}" +release_detach_device "${device}" device="" rm -f "${output_dmg}" diff --git a/scripts/release/prepare.sh b/scripts/release/prepare.sh index 96aea5e..f7c31a7 100755 --- a/scripts/release/prepare.sh +++ b/scripts/release/prepare.sh @@ -7,6 +7,8 @@ source "${BASH_SOURCE[0]%/*}/../lib/contract.sh" source "$TOOL_ROOT/scripts/lib/common.sh" # shellcheck source=scripts/lib/artifacts.sh source "$TOOL_ROOT/scripts/lib/artifacts.sh" +# shellcheck source=scripts/lib/release.sh +source "$TOOL_ROOT/scripts/lib/release.sh" EVENT_NAME="" TARGET_REPO_DIR="" @@ -94,42 +96,6 @@ cd "$TARGET_REPO_DIR" project_file="Apps/VoidDisplay/VoidDisplay.xcodeproj/project.pbxproj" -read_single_value() { - local key="$1" - local source="$2" - local values - local count - values="$( - awk -v key="$key" '$1 == key && $2 == "=" { value = $3; sub(/;$/, "", value); print value }' "$source" | sort -u - )" - count="$(printf '%s\n' "$values" | sed '/^$/d' | wc -l | tr -d ' ')" - if [[ "$count" -ne 1 ]]; then - die "Expected exactly one unique $key; found: ${values:-}" - fi - printf '%s\n' "$values" -} - -read_single_value_from_git() { - local key="$1" - local commit="$2" - local path="$3" - local values - local count - if ! git cat-file -e "${commit}:${path}" 2>/dev/null; then - die "Unable to read $path at $commit." - fi - values="$( - git show "${commit}:${path}" | - awk -v key="$key" '$1 == key && $2 == "=" { value = $3; sub(/;$/, "", value); print value }' | - sort -u - )" - count="$(printf '%s\n' "$values" | sed '/^$/d' | wc -l | tr -d ' ')" - if [[ "$count" -ne 1 ]]; then - die "Expected exactly one unique $key at $commit; found: ${values:-}" - fi - printf '%s\n' "$values" -} - find_existing_tag_sha() { local release_tag="$1" git fetch --tags --force >/dev/null 2>&1 @@ -184,17 +150,17 @@ emit_result() { } target_sha="$(git rev-parse HEAD)" -version="$(read_single_value MARKETING_VERSION "$project_file")" -build_number="$(read_single_value CURRENT_PROJECT_VERSION "$project_file")" +version="$(release_read_project_value MARKETING_VERSION "$project_file")" +build_number="$(release_read_project_value CURRENT_PROJECT_VERSION "$project_file")" -[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "Invalid MARKETING_VERSION: $version. Expected MAJOR.MINOR.PATCH." -[[ "$build_number" =~ ^[1-9][0-9]*$ ]] || die "Invalid CURRENT_PROJECT_VERSION: $build_number. Expected a positive integer." +release_require_semver "$version" "Invalid MARKETING_VERSION: $version. Expected MAJOR.MINOR.PATCH." +release_require_positive_integer "$build_number" "Invalid CURRENT_PROJECT_VERSION: $build_number. Expected a positive integer." release_tag="v$version" case "$EVENT_NAME" in workflow_dispatch) - [[ "$INPUT_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "Invalid input tag: $INPUT_TAG. Expected vMAJOR.MINOR.PATCH." + release_require_tag "$INPUT_TAG" "Invalid input tag: $INPUT_TAG. Expected vMAJOR.MINOR.PATCH." [[ "$INPUT_TAG" == "$release_tag" ]] || die "Input tag $INPUT_TAG does not match MARKETING_VERSION $version." ensure_target_is_on_main "$target_sha" ensure_tag_matches_target_if_present "$release_tag" "$target_sha" @@ -207,9 +173,9 @@ push) if [[ -z "$BEFORE_SHA" || "$BEFORE_SHA" =~ ^0+$ ]]; then die "Unable to compare against the previous main commit." fi - previous_version="$(read_single_value_from_git MARKETING_VERSION "$BEFORE_SHA" "$project_file")" - previous_build_number="$(read_single_value_from_git CURRENT_PROJECT_VERSION "$BEFORE_SHA" "$project_file")" - [[ "$previous_build_number" =~ ^[1-9][0-9]*$ ]] || die "Previous CURRENT_PROJECT_VERSION is invalid: $previous_build_number." + previous_version="$(release_read_project_value_from_git MARKETING_VERSION "$BEFORE_SHA" "$project_file")" + previous_build_number="$(release_read_project_value_from_git CURRENT_PROJECT_VERSION "$BEFORE_SHA" "$project_file")" + release_require_positive_integer "$previous_build_number" "Previous CURRENT_PROJECT_VERSION is invalid: $previous_build_number." if [[ "$previous_version" == "$version" ]]; then existing_tag_sha="$(find_existing_tag_sha "$release_tag")" diff --git a/scripts/release/publish.sh b/scripts/release/publish.sh index 7aac1df..765ffc1 100755 --- a/scripts/release/publish.sh +++ b/scripts/release/publish.sh @@ -5,10 +5,10 @@ set -euo pipefail source "${BASH_SOURCE[0]%/*}/../lib/contract.sh" # shellcheck source=scripts/lib/common.sh source "$TOOL_ROOT/scripts/lib/common.sh" -# shellcheck source=scripts/lib/architecture.sh -source "$TOOL_ROOT/scripts/lib/architecture.sh" # shellcheck source=scripts/lib/artifacts.sh source "$TOOL_ROOT/scripts/lib/artifacts.sh" +# shellcheck source=scripts/lib/release.sh +source "$TOOL_ROOT/scripts/lib/release.sh" cd "$ROOT_DIR" @@ -79,16 +79,14 @@ fail_publish() { local reason="$1" local detail="$2" - write_publish_summary "failed" "$reason" "$detail" - die "$detail" + release_fail write_publish_summary "$reason" "$detail" } handle_publish_exit() { local exit_code="$?" - if [[ "$exit_code" -ne 0 && "$publish_summary_written" != "true" ]]; then - write_publish_summary "failed" "${publish_stage}_failed" "Release publish failed in stage ${publish_stage}." - fi + release_stage_failure_once "$exit_code" "$publish_summary_written" \ + write_publish_summary "$publish_stage" "Release publish failed in stage ${publish_stage}." } trap handle_publish_exit EXIT @@ -96,10 +94,9 @@ trap handle_publish_exit EXIT [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail_publish "invalid_tag" "--tag must match vMAJOR.MINOR.PATCH." [[ -n "$TARGET_SHA" ]] || fail_publish "missing_target_sha" "--target-sha is required." -project_file="Apps/VoidDisplay/VoidDisplay.xcodeproj/project.pbxproj" publish_stage="version_metadata" -version="$(sed -nE 's/^[[:space:]]*MARKETING_VERSION = ([^;]+);/\1/p' "$project_file" | sort -u)" -build_number="$(sed -nE 's/^[[:space:]]*CURRENT_PROJECT_VERSION = ([^;]+);/\1/p' "$project_file" | sort -u)" +version="$(release_read_project_value MARKETING_VERSION)" +build_number="$(release_read_project_value CURRENT_PROJECT_VERSION)" arm64_label="$(release_label_for_arch arm64)" x86_64_label="$(release_label_for_arch x86_64)" diff --git a/scripts/release/require_ci_gate.sh b/scripts/release/require_ci_gate.sh deleted file mode 100755 index 1b3df35..0000000 --- a/scripts/release/require_ci_gate.sh +++ /dev/null @@ -1,188 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# shellcheck source=scripts/lib/contract.sh -source "${BASH_SOURCE[0]%/*}/../lib/contract.sh" -# shellcheck source=scripts/lib/common.sh -source "$TOOL_ROOT/scripts/lib/common.sh" -# shellcheck source=scripts/lib/artifacts.sh -source "$TOOL_ROOT/scripts/lib/artifacts.sh" - -cd "$ROOT_DIR" - -REPOSITORY="${GITHUB_REPOSITORY:-}" -TARGET_SHA="" -CHECK_NAME="ci-gate" -TIMEOUT_SECONDS="${TIMEOUT_SECONDS:-900}" -POLL_INTERVAL_SECONDS="${POLL_INTERVAL_SECONDS:-20}" -SUMMARY_PATH="" - -while [[ $# -gt 0 ]]; do - case "$1" in - --repository) - REPOSITORY="$2" - shift 2 - ;; - --sha) - TARGET_SHA="$2" - shift 2 - ;; - --check-name) - CHECK_NAME="$2" - shift 2 - ;; - --timeout-seconds) - TIMEOUT_SECONDS="$2" - shift 2 - ;; - --poll-interval-seconds) - POLL_INTERVAL_SECONDS="$2" - shift 2 - ;; - --summary) - SUMMARY_PATH="$(normalize_path "$2")" - shift 2 - ;; - *) - die "Unknown argument: $1" - ;; - esac -done - -[[ -n "$REPOSITORY" ]] || die "--repository is required." -[[ -n "$TARGET_SHA" ]] || die "--sha is required." -[[ -n "$CHECK_NAME" ]] || die "--check-name is required." -[[ "$TIMEOUT_SECONDS" =~ ^[0-9]+$ ]] || die "--timeout-seconds must be numeric." -[[ "$POLL_INTERVAL_SECONDS" =~ ^[0-9]+$ ]] || die "--poll-interval-seconds must be numeric." -[[ "$POLL_INTERVAL_SECONDS" -gt 0 ]] || die "--poll-interval-seconds must be greater than 0." - -SUMMARY_PATH="${SUMMARY_PATH:-$(make_artifact_dir release-ci-gate)/require-ci-gate-summary.json}" -require_command gh jq - -snapshot_state="missing" -snapshot_source="none" -snapshot_detail="" - -write_gate_summary() { - local status="$1" - local reason="$2" - local elapsed_seconds="$3" - - write_json_file "$SUMMARY_PATH" \ - --arg status "$status" \ - --arg reason "$reason" \ - --arg repository "$REPOSITORY" \ - --arg target_sha "$TARGET_SHA" \ - --arg check_name "$CHECK_NAME" \ - --arg gate_state "$snapshot_state" \ - --arg source "$snapshot_source" \ - --arg detail "$snapshot_detail" \ - --argjson elapsed_seconds "$elapsed_seconds" \ - '{ - status: $status, - reason: $reason, - repository: $repository, - target_sha: $target_sha, - check_name: $check_name, - gate_state: $gate_state, - source: $source, - detail: $detail, - elapsed_seconds: $elapsed_seconds - }' -} - -load_gate_snapshot() { - local response - local latest - local check_status - local check_conclusion - local status_state - - snapshot_state="missing" - snapshot_source="checks" - snapshot_detail="No matching check run found." - - if ! response="$(gh api -X GET "repos/$REPOSITORY/commits/$TARGET_SHA/check-runs" -f "check_name=$CHECK_NAME" -f "per_page=100" 2>&1)"; then - snapshot_state="api_error" - snapshot_detail="$response" - return - fi - - latest="$( - jq -c --arg name "$CHECK_NAME" ' - [.check_runs[]? | select(.name == $name)] - | sort_by(.started_at // .completed_at // .created_at // "") - | last // empty - ' <<<"$response" - )" - if [[ -n "$latest" ]]; then - check_status="$(jq -r '.status // "unknown"' <<<"$latest")" - check_conclusion="$(jq -r '.conclusion // ""' <<<"$latest")" - snapshot_detail="check_status=$check_status conclusion=${check_conclusion:-none}" - if [[ "$check_status" == "completed" && "$check_conclusion" == "success" ]]; then - snapshot_state="success" - elif [[ "$check_status" == "completed" ]]; then - snapshot_state="failure" - else - snapshot_state="pending" - fi - return - fi - - snapshot_source="statuses" - if ! response="$(gh api -X GET "repos/$REPOSITORY/commits/$TARGET_SHA/status" -f "per_page=100" 2>&1)"; then - snapshot_state="api_error" - snapshot_detail="$response" - return - fi - - latest="$( - jq -c --arg name "$CHECK_NAME" ' - [.statuses[]? | select(.context == $name)] - | sort_by(.created_at // "") - | last // empty - ' <<<"$response" - )" - if [[ -z "$latest" ]]; then - snapshot_state="missing" - snapshot_detail="No matching commit status found." - return - fi - - status_state="$(jq -r '.state // "unknown"' <<<"$latest")" - snapshot_detail="status_state=$status_state" - case "$status_state" in - success) snapshot_state="success" ;; - pending) snapshot_state="pending" ;; - *) snapshot_state="failure" ;; - esac -} - -start_seconds="$(date +%s)" - -while true; do - load_gate_snapshot - now_seconds="$(date +%s)" - elapsed_seconds="$((now_seconds - start_seconds))" - - case "$snapshot_state" in - success) - write_gate_summary "passed" "ci_gate_success" "$elapsed_seconds" - info "Required target check passed: $CHECK_NAME on $TARGET_SHA" - info "Summary: $SUMMARY_PATH" - exit 0 - ;; - failure | api_error) - write_gate_summary "failed" "ci_gate_${snapshot_state}" "$elapsed_seconds" - die "Required target check $CHECK_NAME is $snapshot_state for $TARGET_SHA. $snapshot_detail" - ;; - esac - - if [[ "$elapsed_seconds" -ge "$TIMEOUT_SECONDS" ]]; then - write_gate_summary "failed" "ci_gate_${snapshot_state}" "$elapsed_seconds" - die "Timed out waiting for target check $CHECK_NAME on $TARGET_SHA. state=$snapshot_state detail=$snapshot_detail" - fi - - info "Waiting for $CHECK_NAME on $TARGET_SHA. state=$snapshot_state detail=$snapshot_detail" - sleep "$POLL_INTERVAL_SECONDS" -done diff --git a/scripts/release/verify.sh b/scripts/release/verify.sh index 0f3e415..521b982 100755 --- a/scripts/release/verify.sh +++ b/scripts/release/verify.sh @@ -5,12 +5,12 @@ set -euo pipefail source "${BASH_SOURCE[0]%/*}/../lib/contract.sh" # shellcheck source=scripts/lib/common.sh source "$TOOL_ROOT/scripts/lib/common.sh" -# shellcheck source=scripts/lib/architecture.sh -source "$TOOL_ROOT/scripts/lib/architecture.sh" # shellcheck source=scripts/lib/release_binaries.sh source "$TOOL_ROOT/scripts/lib/release_binaries.sh" # shellcheck source=scripts/lib/artifacts.sh source "$TOOL_ROOT/scripts/lib/artifacts.sh" +# shellcheck source=scripts/lib/release.sh +source "$TOOL_ROOT/scripts/lib/release.sh" ASSETS_DIR="" TAG="" @@ -52,15 +52,13 @@ while [[ $# -gt 0 ]]; do done [[ -n "$ASSETS_DIR" ]] || die "--assets-dir is required." -[[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "--tag must match vMAJOR.MINOR.PATCH." -[[ -n "$ARCH" ]] || die "--arch is required." -validate_release_arch "$ARCH" -LABEL="${LABEL:-$(release_label_for_arch "$ARCH")}" -require_release_label_for_arch "$ARCH" "$LABEL" +release_require_tag "$TAG" +LABEL="$(release_arch_label "$ARCH" "$LABEL")" require_command jq -dmg_path="$ASSETS_DIR/VoidDisplay-${TAG}-${LABEL}.dmg" +dmg_name="$(release_dmg_name "$TAG" "$LABEL")" +dmg_path="$ASSETS_DIR/$dmg_name" sha_path="$dmg_path.sha256" sbom_path="$dmg_path.spdx.json" summary_path="$ASSETS_DIR/VoidDisplay-${TAG}-${LABEL}.verify-summary.json" @@ -72,9 +70,7 @@ verify_stage="input_validation" verify_summary_written="false" cleanup() { - if [[ -n "$device" ]]; then - hdiutil detach "$device" -quiet >/dev/null 2>&1 || hdiutil detach "$device" -force -quiet >/dev/null 2>&1 || true - fi + release_cleanup_device "$device" } write_verify_summary() { @@ -120,16 +116,14 @@ fail_verify() { local reason="$1" local detail="$2" - write_verify_summary "failed" "$reason" "$detail" - die "$detail" + release_fail write_verify_summary "$reason" "$detail" } handle_verify_exit() { local exit_code="$?" - if [[ "$exit_code" -ne 0 && "$verify_summary_written" != "true" ]]; then - write_verify_summary "failed" "${verify_stage}_failed" "Release verification failed in stage ${verify_stage}." - fi + release_stage_failure_once "$exit_code" "$verify_summary_written" \ + write_verify_summary "$verify_stage" "Release verification failed in stage ${verify_stage}." cleanup } trap handle_verify_exit EXIT @@ -154,8 +148,8 @@ set -e if [[ "$attach_status" -ne 0 ]]; then fail_verify "hdiutil_failed" "Failed to attach DMG: $attach_output" fi -device="$(printf '%s\n' "$attach_output" | awk '/^\/dev\// {print $1; exit}')" -mount_path="$(printf '%s\n' "$attach_output" | sed -n 's#.*\(/Volumes/.*\)$#\1#p' | tail -n 1)" +device="$(printf '%s\n' "$attach_output" | release_parse_attach_device)" +mount_path="$(printf '%s\n' "$attach_output" | release_parse_attach_mount_path)" [[ -n "$device" && -n "$mount_path" ]] || fail_verify "hdiutil_failed" "Failed to locate mounted DMG device or mount path."