From 79f417b85565688eac8ab0a6b69bc1b113ab2229 Mon Sep 17 00:00:00 2001 From: Chen Date: Mon, 11 May 2026 13:25:09 +0800 Subject: [PATCH] =?UTF-8?q?ci(scripts):=20=E6=94=B6=E6=95=9B=20CI=20?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=E9=97=A8=E7=A6=81=E4=B8=8E=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E9=97=AD=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 拆分 static 门禁为 shell workflow project 三段 - 集中维护 bootstrap profile 工具声明 - 固定 PR 基础门禁并校验 profile 依赖闭包 --- .github/workflows/ci.yml | 1 + scripts/ci/classify.sh | 21 +- scripts/ci/release_smoke.sh | 1 + scripts/ci/static.sh | 291 +------------------------- scripts/ci/static_project.sh | 150 +++++++++++++ scripts/ci/static_shell.sh | 80 +++++++ scripts/ci/static_workflows.sh | 130 ++++++++++++ scripts/ci/test_bootstrap_profiles.sh | 100 +++++++++ scripts/ci/test_classify.sh | 14 +- scripts/ci/ui_smoke.sh | 2 +- scripts/ci/unit.sh | 2 +- scripts/ci/xcode.sh | 2 +- scripts/dev/bootstrap.sh | 79 +++---- scripts/dev/bootstrap_profiles.sh | 62 ++++++ scripts/dev/doctor.sh | 3 +- 15 files changed, 581 insertions(+), 357 deletions(-) create mode 100755 scripts/ci/static_project.sh create mode 100755 scripts/ci/static_shell.sh create mode 100755 scripts/ci/static_workflows.sh create mode 100755 scripts/ci/test_bootstrap_profiles.sh create mode 100755 scripts/dev/bootstrap_profiles.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a72192d..33817f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -525,6 +525,7 @@ jobs: `xcode=${value('REQUIRES_XCODE_BUILD')}`, `ui=${value('REQUIRES_UI_SMOKE')}`, `release=${value('REQUIRES_RELEASE_SMOKE')}`, + 'PRs always run static, unit, and xcode build gates.', ].join('
'); const rows = [ ['Change Scope', value('CHANGE_SCOPE', 'unknown'), classificationDetails], diff --git a/scripts/ci/classify.sh b/scripts/ci/classify.sh index 0319048..1e9f73e 100755 --- a/scripts/ci/classify.sh +++ b/scripts/ci/classify.sh @@ -240,26 +240,23 @@ set_flags false "${requirement_flags[@]}" if [[ "$EVENT_NAME" == "push" ]]; then set_flags true "${push_required_flags[@]}" -elif [[ "$docs_only" != "true" ]]; then - if [[ "$code_relevant" == "true" || "$unknown_relevant" == "true" ]]; then - requires_static="true" - fi +elif [[ "$EVENT_NAME" == "pull_request" ]]; then + requires_static="true" + requires_unit="true" + requires_xcode_build="true" if [[ "$dependency_manifest_relevant" == "true" ]]; then requires_dependency_review="true" fi - if [[ "$product_code_relevant" == "true" || - "$test_code_relevant" == "true" || - "$dependency_manifest_relevant" == "true" || - "$unknown_relevant" == "true" ]]; then - requires_unit="true" - requires_xcode_build="true" - fi - if [[ "$ui_relevant" == "true" ]]; then + if [[ "$ui_relevant" == "true" || "$unknown_relevant" == "true" ]]; then requires_ui_smoke="true" fi if [[ "$release_relevant" == "true" && "$BASE_REF" == "main" ]]; then requires_release_smoke="true" fi +elif [[ "$docs_only" != "true" ]]; then + requires_static="true" + requires_unit="true" + requires_xcode_build="true" fi for field in "${output_fields[@]}"; do diff --git a/scripts/ci/release_smoke.sh b/scripts/ci/release_smoke.sh index 5752f5f..c4c4f05 100755 --- a/scripts/ci/release_smoke.sh +++ b/scripts/ci/release_smoke.sh @@ -60,6 +60,7 @@ done validate_release_arch "$ARCH" LABEL="${LABEL:-$(release_label_for_arch "$ARCH")}" require_release_label_for_arch "$ARCH" "$LABEL" +require_command go jq rg xcodebuild lipo codesign mkdir -p "$OUT_DIR" DERIVED_DATA_PATH="${DERIVED_DATA_PATH:-$OUT_DIR/DerivedData}" APP_OUTPUT_FILE="${APP_OUTPUT_FILE:-$OUT_DIR/app-path.txt}" diff --git a/scripts/ci/static.sh b/scripts/ci/static.sh index 0263a27..b821b81 100755 --- a/scripts/ci/static.sh +++ b/scripts/ci/static.sh @@ -8,293 +8,8 @@ source "$TOOL_ROOT/scripts/lib/common.sh" cd "$ROOT_DIR" -require_command actionlint jq shellcheck shfmt swiftformat swiftlint rg xcrun - -validate_runner_labels() { - 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() { - local failures=() - local line - local file - local line_number - local value - local action_ref - - while IFS= read -r line; do - file="${line%%:*}" - line="${line#*:}" - line_number="${line%%:*}" - value="${line#*:}" - value="${value#*uses:}" - value="${value%%#*}" - value="$(printf '%s' "$value" | tr -d "'\"" | xargs)" - - [[ -n "$value" ]] || continue - [[ "$value" == ./* ]] && continue - [[ "$value" == docker://* ]] && continue - - action_ref="${value##*@}" - if [[ ! "$value" =~ @ || ! "$action_ref" =~ ^[0-9a-f]{40}$ ]]; then - failures+=("$file:$line_number uses unpinned action reference: $value") - fi - done < <(rg -n '^[[:space:]]*uses:[[:space:]]*[^[:space:]]+' .github/workflows .github/actions) - - if [[ "${#failures[@]}" -gt 0 ]]; then - printf '%s\n' "${failures[@]}" >&2 - die "All external GitHub Actions must be pinned to a 40-character commit SHA." - fi -} - -validate_shell_scripts() { - local bash_scripts=() - local zsh_scripts=() - local script_path - local first_line - - while IFS= read -r script_path; do - [[ -f "$script_path" ]] || continue - first_line="$(head -n 1 "$script_path" || true)" - case "$first_line" in - *zsh*) zsh_scripts+=("$script_path") ;; - *bash* | *'/sh'* | *' sh') bash_scripts+=("$script_path") ;; - *) - case "$script_path" in - *.sh) bash_scripts+=("$script_path") ;; - esac - ;; - esac - done < <(find scripts -type f -not -name ".DS_Store" -print | sort) - - if [[ "${#bash_scripts[@]}" -gt 0 ]]; then - shellcheck -x -e SC2016 "${bash_scripts[@]}" - shfmt -d "${bash_scripts[@]}" - bash -n "${bash_scripts[@]}" - fi - - if [[ "${#zsh_scripts[@]}" -gt 0 ]]; then - zsh -n "${zsh_scripts[@]}" - fi -} - -validate_script_contract() { - local invalid - - 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|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)" - 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' -} - -fail_on_output() { - local message="$1" - local output="$2" - - if [[ -n "$output" ]]; then - printf '%s\n' "$output" >&2 - die "$message" - fi -} - -assert_no_match() { - local message="$1" - shift - - fail_on_output "$message" "$(rg -n "$@" || true)" -} - -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 -} - -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) - - assert_no_match "Workflow script invocations must execute scripts through TOOL_ROOT." \ - '\$GITHUB_WORKSPACE/scripts/' .github/workflows .github/actions - - invalid="$(awk '/scripts\// && $0 !~ /^[[:space:]]*- '\''scripts\// && $0 !~ /"\$tool_root\/scripts\// { print FILENAME ":" FNR ":" $0 }' "${workflow_files[@]}" || true)" - 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)" - 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 (checkout_line && !saw_persist_credentials) { - print current_file ":" checkout_line ":actions/checkout must set persist-credentials: false" - } - } - FNR == 1 { - finish_checkout() - current_file = FILENAME - saw_persist_credentials = 0 - checkout_line = 0 - } - /^[[:space:]]*-[[:space:]]+(name|uses):/ { - finish_checkout() - saw_persist_credentials = 0 - checkout_line = 0 - } - /uses:[[:space:]]*actions\/checkout@/ { - saw_persist_credentials = 0 - checkout_line = FNR - } - checkout_line && /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" -} - -validate_xcode_shell_build_phase() { - local project_file="Apps/VoidDisplay/VoidDisplay.xcodeproj/project.pbxproj" - local shell_phase_count - local invalid_inputs - local root_setting_count - local tool_setting_count - - extract_pbx_array_values() { - local key="$1" - awk -v key="$key" ' - $0 ~ "^[[:space:]]*" key " = \\(" { inside = 1; next } - inside && /^[[:space:]]*\);/ { inside = 0; next } - inside { - line = $0 - sub(/^[[:space:]]*"/, "", line) - sub(/",[[:space:]]*$/, "", line) - print line - } - ' "$project_file" - } - - assert_pbx_array_exact() { - local key="$1" - local label="$2" - shift 2 - - if ! diff -u <(printf '%s\n' "$@") <(extract_pbx_array_values "$key") >&2; then - die "$label is not frozen to the expected values." - fi - } - - 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." - - assert_file_contains_all "$project_file" "Build Relay phase is missing required line" \ - 'name = "Build Relay";' \ - 'shellPath = /bin/bash;' \ - '"cd \"$SRCROOT/../..\"",' - - 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",' - - 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:]')" - [[ "$root_setting_count" == "2" ]] || die "ROOT_DIR build setting must be present in Debug and Release." - [[ "$tool_setting_count" == "2" ]] || die "TOOL_ROOT build setting must be present in Debug and Release." - - assert_pbx_array_exact shellScript "Build Relay shellScript" \ - 'cd \"$SRCROOT/../..\"' \ - 'export ROOT_DIR=\"${ROOT_DIR:-$PWD}\"' \ - 'export TOOL_ROOT=\"${TOOL_ROOT:-$ROOT_DIR}\"' \ - '\"$TOOL_ROOT/scripts/build-relay.sh\"' \ - '' - - assert_pbx_array_exact outputPaths "Build Relay outputPaths" \ - '$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/voiddisplay-relay' - - invalid_inputs="$( - extract_pbx_array_values inputPaths | - rg -v '^\$\(TOOL_ROOT\)/scripts/(build-relay\.sh|lib/(contract|common|architecture|release_binaries)\.sh)$|^\$\(ROOT_DIR\)/Tools/VoidDisplayRelay/' || true - )" - fail_on_output "Build Relay input paths must stay under allowed prefixes." "$invalid_inputs" -} - -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 ("$scanner" "$label log fixture" "$positive_fixture" >/dev/null 2>&1); then - die "$label log scanner missed fixture: $positive_fixture" - fi - done - - "$scanner" "$label negative log fixture" "$fixture_dir/negative-ordinary-text.fixture" -} - -validate_xcode_log_scanner() { - validate_log_scanner scan_xcode_log_for_diagnostics Xcode "$TOOL_ROOT/scripts/ci/fixtures/xcode-log-scanner" -} - -validate_swiftpm_log_scanner() { - validate_log_scanner scan_build_log_for_diagnostics SwiftPM "$TOOL_ROOT/scripts/ci/fixtures/swiftpm-log-scanner" -} - -validate_classify_fixtures() { - env ROOT_DIR="$ROOT_DIR" TOOL_ROOT="$TOOL_ROOT" "$TOOL_ROOT/scripts/ci/test_classify.sh" -} - -validate_swift_style() { - swiftformat --lint --config "$ROOT_DIR/.swiftformat" Sources Tests UITests Apps Package.swift scripts/release/render_dmg_background.swift - swiftlint lint --config "$ROOT_DIR/.swiftlint.yml" --quiet -} - -validate_swift_scripts() { - xcrun swiftc -typecheck "$ROOT_DIR/scripts/release/render_dmg_background.swift" -} - -actionlint -validate_runner_labels -validate_action_pinning -validate_shell_scripts -validate_script_contract -validate_workflow_script_contract -validate_xcode_shell_build_phase -validate_xcode_log_scanner -validate_swiftpm_log_scanner -validate_classify_fixtures -validate_swift_style -validate_swift_scripts +env ROOT_DIR="$ROOT_DIR" TOOL_ROOT="$TOOL_ROOT" "$TOOL_ROOT/scripts/ci/static_shell.sh" +env ROOT_DIR="$ROOT_DIR" TOOL_ROOT="$TOOL_ROOT" "$TOOL_ROOT/scripts/ci/static_workflows.sh" +env ROOT_DIR="$ROOT_DIR" TOOL_ROOT="$TOOL_ROOT" "$TOOL_ROOT/scripts/ci/static_project.sh" info "Static gate passed." diff --git a/scripts/ci/static_project.sh b/scripts/ci/static_project.sh new file mode 100755 index 0000000..7661b1a --- /dev/null +++ b/scripts/ci/static_project.sh @@ -0,0 +1,150 @@ +#!/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" + +cd "$ROOT_DIR" + +require_command git jq swiftformat swiftlint xcrun rg awk diff wc tr + +fail_on_output() { + local message="$1" + local output="$2" + + if [[ -n "$output" ]]; then + printf '%s\n' "$output" >&2 + die "$message" + fi +} + +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 +} + +validate_xcode_shell_build_phase() { + local project_file="Apps/VoidDisplay/VoidDisplay.xcodeproj/project.pbxproj" + local shell_phase_count + local invalid_inputs + local root_setting_count + local tool_setting_count + + extract_pbx_array_values() { + local key="$1" + awk -v key="$key" ' + $0 ~ "^[[:space:]]*" key " = \\(" { inside = 1; next } + inside && /^[[:space:]]*\);/ { inside = 0; next } + inside { + line = $0 + sub(/^[[:space:]]*"/, "", line) + sub(/",[[:space:]]*$/, "", line) + print line + } + ' "$project_file" + } + + assert_pbx_array_exact() { + local key="$1" + local label="$2" + shift 2 + + if ! diff -u <(printf '%s\n' "$@") <(extract_pbx_array_values "$key") >&2; then + die "$label is not frozen to the expected values." + fi + } + + 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." + + assert_file_contains_all "$project_file" "Build Relay phase is missing required line" \ + 'name = "Build Relay";' \ + 'shellPath = /bin/bash;' \ + '"cd \"$SRCROOT/../..\"",' + + 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",' + + 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:]')" + [[ "$root_setting_count" == "2" ]] || die "ROOT_DIR build setting must be present in Debug and Release." + [[ "$tool_setting_count" == "2" ]] || die "TOOL_ROOT build setting must be present in Debug and Release." + + assert_pbx_array_exact shellScript "Build Relay shellScript" \ + 'cd \"$SRCROOT/../..\"' \ + 'export ROOT_DIR=\"${ROOT_DIR:-$PWD}\"' \ + 'export TOOL_ROOT=\"${TOOL_ROOT:-$ROOT_DIR}\"' \ + '\"$TOOL_ROOT/scripts/build-relay.sh\"' \ + '' + + assert_pbx_array_exact outputPaths "Build Relay outputPaths" \ + '$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/voiddisplay-relay' + + invalid_inputs="$( + extract_pbx_array_values inputPaths | + rg -v '^\$\(TOOL_ROOT\)/scripts/(build-relay\.sh|lib/(contract|common|architecture|release_binaries)\.sh)$|^\$\(ROOT_DIR\)/Tools/VoidDisplayRelay/' || true + )" + fail_on_output "Build Relay input paths must stay under allowed prefixes." "$invalid_inputs" +} + +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 ("$scanner" "$label log fixture" "$positive_fixture" >/dev/null 2>&1); then + die "$label log scanner missed fixture: $positive_fixture" + fi + done + + "$scanner" "$label negative log fixture" "$fixture_dir/negative-ordinary-text.fixture" +} + +validate_xcode_log_scanner() { + validate_log_scanner scan_xcode_log_for_diagnostics Xcode "$TOOL_ROOT/scripts/ci/fixtures/xcode-log-scanner" +} + +validate_swiftpm_log_scanner() { + validate_log_scanner scan_build_log_for_diagnostics SwiftPM "$TOOL_ROOT/scripts/ci/fixtures/swiftpm-log-scanner" +} + +validate_bootstrap_profile_fixtures() { + env ROOT_DIR="$ROOT_DIR" TOOL_ROOT="$TOOL_ROOT" "$TOOL_ROOT/scripts/ci/test_bootstrap_profiles.sh" +} + +validate_classify_fixtures() { + env ROOT_DIR="$ROOT_DIR" TOOL_ROOT="$TOOL_ROOT" "$TOOL_ROOT/scripts/ci/test_classify.sh" +} + +validate_swift_style() { + swiftformat --lint --config "$ROOT_DIR/.swiftformat" Sources Tests UITests Apps Package.swift scripts/release/render_dmg_background.swift + swiftlint lint --config "$ROOT_DIR/.swiftlint.yml" --quiet +} + +validate_swift_scripts() { + xcrun swiftc -typecheck "$ROOT_DIR/scripts/release/render_dmg_background.swift" +} + +validate_xcode_shell_build_phase +validate_xcode_log_scanner +validate_swiftpm_log_scanner +validate_bootstrap_profile_fixtures +validate_classify_fixtures +validate_swift_style +validate_swift_scripts + +info "Static project gate passed." diff --git a/scripts/ci/static_shell.sh b/scripts/ci/static_shell.sh new file mode 100755 index 0000000..8fb7e8a --- /dev/null +++ b/scripts/ci/static_shell.sh @@ -0,0 +1,80 @@ +#!/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" + +cd "$ROOT_DIR" + +require_command shellcheck shfmt bash zsh rg + +fail_on_output() { + local message="$1" + local output="$2" + + if [[ -n "$output" ]]; then + printf '%s\n' "$output" >&2 + die "$message" + fi +} + +assert_no_match() { + local message="$1" + shift + + fail_on_output "$message" "$(rg -n "$@" || true)" +} + +validate_shell_scripts() { + local bash_scripts=() + local zsh_scripts=() + local script_path + local first_line + + while IFS= read -r script_path; do + [[ -f "$script_path" ]] || continue + first_line="$(head -n 1 "$script_path" || true)" + case "$first_line" in + *zsh*) zsh_scripts+=("$script_path") ;; + *bash* | *'/sh'* | *' sh') bash_scripts+=("$script_path") ;; + *) + case "$script_path" in + *.sh) bash_scripts+=("$script_path") ;; + esac + ;; + esac + done < <(find scripts -type f -not -name ".DS_Store" -print | sort) + + if [[ "${#bash_scripts[@]}" -gt 0 ]]; then + shellcheck -x -e SC2016 "${bash_scripts[@]}" + shfmt -d "${bash_scripts[@]}" + bash -n "${bash_scripts[@]}" + fi + + if [[ "${#zsh_scripts[@]}" -gt 0 ]]; then + zsh -n "${zsh_scripts[@]}" + fi +} + +validate_script_contract() { + local invalid + + 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_shell.sh' + + invalid="$( + 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_shell.sh' || true + )" + invalid="$(printf '%s\n' "$invalid" | rg -v 'source "\$TOOL_ROOT/scripts/lib/' || true)" + 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_shell.sh' +} + +validate_shell_scripts +validate_script_contract + +info "Static shell gate passed." diff --git a/scripts/ci/static_workflows.sh b/scripts/ci/static_workflows.sh new file mode 100755 index 0000000..de50c51 --- /dev/null +++ b/scripts/ci/static_workflows.sh @@ -0,0 +1,130 @@ +#!/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" + +cd "$ROOT_DIR" + +require_command actionlint rg awk + +fail_on_output() { + local message="$1" + local output="$2" + + if [[ -n "$output" ]]; then + printf '%s\n' "$output" >&2 + die "$message" + fi +} + +assert_no_match() { + local message="$1" + shift + + fail_on_output "$message" "$(rg -n "$@" || true)" +} + +validate_runner_labels() { + 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() { + local failures=() + local line + local file + local line_number + local value + local action_ref + + while IFS= read -r line; do + file="${line%%:*}" + line="${line#*:}" + line_number="${line%%:*}" + value="${line#*:}" + value="${value#*uses:}" + value="${value%%#*}" + value="$(printf '%s' "$value" | tr -d "'\"" | xargs)" + + [[ -n "$value" ]] || continue + [[ "$value" == ./* ]] && continue + [[ "$value" == docker://* ]] && continue + + action_ref="${value##*@}" + if [[ ! "$value" =~ @ || ! "$action_ref" =~ ^[0-9a-f]{40}$ ]]; then + failures+=("$file:$line_number uses unpinned action reference: $value") + fi + done < <(rg -n '^[[:space:]]*uses:[[:space:]]*[^[:space:]]+' .github/workflows .github/actions) + + if [[ "${#failures[@]}" -gt 0 ]]; then + printf '%s\n' "${failures[@]}" >&2 + die "All external GitHub Actions must be pinned to a 40-character commit SHA." + fi +} + +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) + + assert_no_match "Workflow script invocations must execute scripts through TOOL_ROOT." \ + '\$GITHUB_WORKSPACE/scripts/' .github/workflows .github/actions + + invalid="$(awk '/scripts\// && $0 !~ /^[[:space:]]*- '\''scripts\// && $0 !~ /"\$tool_root\/scripts\// { print FILENAME ":" FNR ":" $0 }' "${workflow_files[@]}" || true)" + 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)" + 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 (checkout_line && !saw_persist_credentials) { + print current_file ":" checkout_line ":actions/checkout must set persist-credentials: false" + } + } + FNR == 1 { + finish_checkout() + current_file = FILENAME + saw_persist_credentials = 0 + checkout_line = 0 + } + /^[[:space:]]*-[[:space:]]+(name|uses):/ { + finish_checkout() + saw_persist_credentials = 0 + checkout_line = 0 + } + /uses:[[:space:]]*actions\/checkout@/ { + saw_persist_credentials = 0 + checkout_line = FNR + } + checkout_line && /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" +} + +actionlint +validate_runner_labels +validate_action_pinning +validate_workflow_script_contract + +info "Static workflow gate passed." diff --git a/scripts/ci/test_bootstrap_profiles.sh b/scripts/ci/test_bootstrap_profiles.sh new file mode 100755 index 0000000..97a7d2d --- /dev/null +++ b/scripts/ci/test_bootstrap_profiles.sh @@ -0,0 +1,100 @@ +#!/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" + +cd "$ROOT_DIR" + +require_command awk grep sort + +profile_commands() { + local profile="$1" + + "$TOOL_ROOT/scripts/dev/bootstrap.sh" --profile "$profile" --print-required-commands | sort -u +} + +script_required_commands() { + local script_path="$1" + + awk ' + /^[[:space:]]*require_command[[:space:]]/ { + for (i = 2; i <= NF; i += 1) { + value = $i + gsub(/["'\'';]/, "", value) + if (value != "" && value !~ /\$/) { + print value + } + } + } + ' "$script_path" | sort -u +} + +doctor_required_commands() { + local script_path="$1" + + awk ' + /^[[:space:]]*DOCTOR_REQUIRED_COMMANDS=\(/ { inside = 1 } + inside { line = line " " $0 } + inside && /\)/ { + gsub(/.*DOCTOR_REQUIRED_COMMANDS=\(/, "", line) + gsub(/\).*/, "", line) + gsub(/["'\'';]/, "", line) + split(line, values, /[[:space:]]+/) + for (i in values) { + if (values[i] != "") { + print values[i] + } + } + inside = 0 + line = "" + } + ' "$script_path" | sort -u +} + +script_command_contract() { + local script_path="$1" + + { + script_required_commands "$script_path" + doctor_required_commands "$script_path" + } | sort -u +} + +assert_profile_covers_scripts() { + local profile="$1" + local profile_command_file="$WORK_DIR/$profile.commands" + local script_path + local command_name + shift + + profile_commands "$profile" >"$profile_command_file" + + for script_path in "$@"; do + while IFS= read -r command_name; do + [[ -n "$command_name" ]] || continue + if ! grep -Fx "$command_name" "$profile_command_file" >/dev/null; then + die "$profile profile does not cover $command_name required by $script_path." + fi + done < <(script_command_contract "$script_path") + done +} + +WORK_DIR="${WORK_DIR:-$(make_artifact_dir bootstrap-profile-tests)}" +mkdir -p "$WORK_DIR" + +assert_profile_covers_scripts static \ + scripts/ci/static_shell.sh \ + scripts/ci/static_workflows.sh \ + scripts/ci/static_project.sh \ + scripts/ci/test_bootstrap_profiles.sh \ + scripts/ci/test_classify.sh + +assert_profile_covers_scripts unit scripts/ci/unit.sh +assert_profile_covers_scripts xcode scripts/dev/doctor.sh scripts/ci/xcode.sh +assert_profile_covers_scripts ui-smoke scripts/ci/ui_smoke.sh +assert_profile_covers_scripts release-smoke scripts/ci/release_smoke.sh + +info "Bootstrap profile fixtures passed." diff --git a/scripts/ci/test_classify.sh b/scripts/ci/test_classify.sh index b0e0ff1..1b85f24 100755 --- a/scripts/ci/test_classify.sh +++ b/scripts/ci/test_classify.sh @@ -118,16 +118,16 @@ run_rename_case() { } run_file_case docs_only pull_request main \ - "docs_only=true code_relevant=false requires_static=false requires_unit=false unknown_relevant=false" \ + "docs_only=true code_relevant=false requires_static=true requires_unit=true requires_xcode_build=true 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_unit=false requires_xcode_build=false" \ + "ci_config_relevant=true script_relevant=true code_relevant=true requires_static=true requires_unit=true requires_xcode_build=true" \ .github/workflows/codeql.yml run_file_case mise_config pull_request main \ - "tooling_config_relevant=true ci_config_relevant=true requires_static=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=true requires_xcode_build=true" \ mise.toml run_file_case mise_lock pull_request main \ - "tooling_config_relevant=true ci_config_relevant=true requires_static=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=true requires_xcode_build=true" \ mise.lock 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" \ @@ -154,16 +154,16 @@ 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_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=true requires_xcode_build=true" \ 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" \ + "unknown_relevant=true docs_only=false code_relevant=false requires_static=true requires_unit=true requires_xcode_build=true requires_ui_smoke=true" \ Config/new.yml 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" \ + "docs_only=true code_relevant=false requires_static=true requires_unit=true requires_xcode_build=true unknown_relevant=false" \ LICENSE_THIRD_PARTY run_rename_case rename_docs_to_code docs/old.md Sources/VoidDisplayFoundation/Renamed.swift \ diff --git a/scripts/ci/ui_smoke.sh b/scripts/ci/ui_smoke.sh index 8b69506..83ca313 100755 --- a/scripts/ci/ui_smoke.sh +++ b/scripts/ci/ui_smoke.sh @@ -100,7 +100,7 @@ write_summary() { } select_required_xcode -require_command jq go rg +require_command jq go rg xcodebuild grep xcrun awk tr tail go_mod_download_with_retry "$ROOT_DIR/Tools/VoidDisplayRelay" last_reason="not_run" diff --git a/scripts/ci/unit.sh b/scripts/ci/unit.sh index e33c937..3ff6958 100755 --- a/scripts/ci/unit.sh +++ b/scripts/ci/unit.sh @@ -12,7 +12,7 @@ source "$TOOL_ROOT/scripts/lib/artifacts.sh" cd "$ROOT_DIR" -require_command go jq +require_command go jq rg xcodebuild swift awk select_required_xcode OUT_DIR="${OUT_DIR:-$(make_artifact_dir ci-unit)}" diff --git a/scripts/ci/xcode.sh b/scripts/ci/xcode.sh index 7845f7c..fae18f6 100755 --- a/scripts/ci/xcode.sh +++ b/scripts/ci/xcode.sh @@ -16,7 +16,7 @@ source "$TOOL_ROOT/scripts/lib/artifacts.sh" cd "$ROOT_DIR" -require_command jq +require_command go jq rg xcodebuild xcrun awk tr tail ACTION="build" CONFIGURATION="Debug" diff --git a/scripts/dev/bootstrap.sh b/scripts/dev/bootstrap.sh index b6bc673..a008b92 100755 --- a/scripts/dev/bootstrap.sh +++ b/scripts/dev/bootstrap.sh @@ -5,22 +5,14 @@ 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/dev/bootstrap_profiles.sh +source "$TOOL_ROOT/scripts/dev/bootstrap_profiles.sh" CI_REQUIRES_MISE="${CI_REQUIRES_MISE:-${GITHUB_ACTIONS:-false}}" PROFILE="full" - -required_commands=( - actionlint - shellcheck - shfmt - swiftformat - swiftlint - go - jq - rg - syft - gh -) +PRINT_REQUIRED_COMMANDS="false" +PRINT_MISE_TARGETS="false" +required_commands=() mise_targets=() while [[ $# -gt 0 ]]; do @@ -30,46 +22,41 @@ while [[ $# -gt 0 ]]; do PROFILE="$2" shift 2 ;; + --print-required-commands) + PRINT_REQUIRED_COMMANDS="true" + shift + ;; + --print-mise-targets) + PRINT_MISE_TARGETS="true" + shift + ;; *) die "Unknown argument: $1" ;; esac done -case "$PROFILE" in -full) ;; -static) - required_commands=(actionlint shellcheck shfmt swiftformat swiftlint jq rg) - mise_targets=( - aqua:rhysd/actionlint - aqua:koalaman/shellcheck - aqua:mvdan/sh - swiftformat - aqua:realm/SwiftLint - aqua:jqlang/jq - aqua:BurntSushi/ripgrep - ) - ;; -unit) - required_commands=(go jq) - mise_targets=(go aqua:jqlang/jq) - ;; -ui-smoke) - required_commands=(go jq rg) - mise_targets=(go aqua:jqlang/jq aqua:BurntSushi/ripgrep) - ;; -xcode) - required_commands=(go jq rg) - mise_targets=(go aqua:jqlang/jq aqua:BurntSushi/ripgrep) - ;; -release-smoke) - required_commands=(go jq rg) - mise_targets=(go aqua:jqlang/jq aqua:BurntSushi/ripgrep) - ;; -*) +if ! bootstrap_profile_exists "$PROFILE"; then die "Unsupported bootstrap profile: $PROFILE" - ;; -esac +fi + +while IFS= read -r command_name; do + [[ -n "$command_name" ]] && required_commands+=("$command_name") +done < <(bootstrap_profile_commands "$PROFILE") + +while IFS= read -r target_name; do + [[ -n "$target_name" ]] && mise_targets+=("$target_name") +done < <(bootstrap_profile_mise_targets "$PROFILE") + +if [[ "$PRINT_REQUIRED_COMMANDS" == "true" ]]; then + printf '%s\n' "${required_commands[@]}" + exit 0 +fi + +if [[ "$PRINT_MISE_TARGETS" == "true" ]]; then + printf '%s\n' "${mise_targets[@]}" + exit 0 +fi activate_mise_shims() { local mise_data_dir="${MISE_DATA_DIR:-$HOME/.local/share/mise}" diff --git a/scripts/dev/bootstrap_profiles.sh b/scripts/dev/bootstrap_profiles.sh new file mode 100755 index 0000000..9c776f0 --- /dev/null +++ b/scripts/dev/bootstrap_profiles.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash + +if [[ -z "${VOIDDISPLAY_BOOTSTRAP_PROFILES_SH_SOURCED:-}" ]]; then + VOIDDISPLAY_BOOTSTRAP_PROFILES_SH_SOURCED=1 + + bootstrap_profile_exists() { + case "$1" in + full | static | unit | ui-smoke | xcode | release-smoke) return 0 ;; + *) return 1 ;; + esac + } + + bootstrap_profile_commands() { + case "$1" in + full) + printf '%s\n' actionlint shellcheck shfmt swiftformat swiftlint go jq rg syft gh git xcrun xcodebuild swift bash zsh awk diff lipo codesign + ;; + static) + printf '%s\n' actionlint shellcheck shfmt swiftformat swiftlint jq rg git xcrun bash zsh awk diff grep sort wc tr + ;; + unit) + printf '%s\n' git go jq rg xcodebuild swift awk + ;; + ui-smoke) + printf '%s\n' go jq rg xcodebuild grep xcrun awk tr tail + ;; + xcode) + printf '%s\n' git go jq rg xcodebuild swift xcrun awk tr tail + ;; + release-smoke) + printf '%s\n' go jq rg xcodebuild lipo codesign xcrun + ;; + *) + return 1 + ;; + esac + } + + bootstrap_profile_mise_targets() { + case "$1" in + full) + return 0 + ;; + static) + printf '%s\n' \ + aqua:rhysd/actionlint \ + aqua:koalaman/shellcheck \ + aqua:mvdan/sh \ + swiftformat \ + aqua:realm/SwiftLint \ + aqua:jqlang/jq \ + aqua:BurntSushi/ripgrep + ;; + unit | ui-smoke | xcode | release-smoke) + printf '%s\n' go aqua:jqlang/jq aqua:BurntSushi/ripgrep + ;; + *) + return 1 + ;; + esac + } +fi diff --git a/scripts/dev/doctor.sh b/scripts/dev/doctor.sh index ec99250..1c73727 100755 --- a/scripts/dev/doctor.sh +++ b/scripts/dev/doctor.sh @@ -31,7 +31,8 @@ done mkdir -p "$OUT_DIR" missing=() -for command_name in git xcodebuild swift go jq rg; do +DOCTOR_REQUIRED_COMMANDS=(git xcodebuild swift go jq rg) +for command_name in "${DOCTOR_REQUIRED_COMMANDS[@]}"; do if ! command -v "$command_name" >/dev/null 2>&1; then missing+=("$command_name") fi