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."