diff --git a/.github/workflows/architecture.md b/.github/workflows/architecture.md new file mode 100644 index 00000000..1291438a --- /dev/null +++ b/.github/workflows/architecture.md @@ -0,0 +1,47 @@ +# Swift Package Test Workflow Architecture + +## Aims + +The Swift Package Test workflow is intended to give maintainers of open-source Swift packages a low-friction way to give contributors confidence in the code they write. This means making testing against a range of Swift versions and platforms as simple as possible, and making the results as accessible as possible. + +This workflow builds on the GitHub Actions workflows from [apple/swift-nio](https://github.com/apple/swift-nio/tree/main/.github/workflows) and [swiftlang/github-workflows](https://github.com/swiftlang/github-workflows/tree/main/.github/workflows), taking from both repositories the key features listed below, which the unified architecture treats as requirements. + +### 1. GitHub-release-based versioning + +Workflows and actions must be versioned using GitHub releases to guarantee stability to adopters. Properly tagged SemVer will allow even breaking changes to be rolled out safely. This method of versioning also lets adopters use Dependabot to automatically open PRs which bump their pinned references. + +### 2. No skipped jobs + +No reusable workflow offered should show skipped jobs. Skipped jobs add visual noise, look confusing to contributors and imply that something is configured incorrectly. + +### 3. Scripts must be cloned not curled + +Some jobs rely on scripts to execute their functionality. This is often preferred over inline scripts in the workflow definition `yml` files since it gives a better developer experience to the maintainers of those scripts. However, one downside of this approach is that these scripts are not present when a reusable workflow is executed from another repository. It is important that a unified solution checks the scripts out explicitly rather than curling them, since curling frequently runs into rate limiting with GitHub's API. + +### 4. Custom matrix builds + +Some packages need additional checks, such as their own integration tests or a custom script. Those checks often need the same matrix of builds that the recommended test workflow, `package_test.yml`, uses. Hence, a unified solution should offer lower level primitives that can execute a matrix. Furthermore, it should provide a workflow with a matrix that is already configured with the recommended Swift versions and platforms that just executes a command across them. + +### 5. Detect minimum version + +Any matrix that is generated should take the tools-version of the package manifest into consideration to automatically remove unsupported Swift versions. This matters most for newly released packages, which often support only the latest Swift version. + +## High level design + +Provide one workflow which can be adopted to run a range of common test and build configurations on a variety of platforms against multiple Swift versions. This workflow sits atop a "matrix generation" layer which takes inputs and produces a canonical work definition (in YAML or JSON). That definition is then expanded into one job per entry, each executing one slice of the work. Benchmarking is a further consumer of the same layer rather than part of the recommended test workflow. + +The design is intended to be layered, so that adopters may use the whole stack or, where the top-level workflow does not offer the customization they need, drop down a level and supply that part themselves: + +* Use the `package_test.yml` workflow for the full suite of conveniences +* Use a custom workflow (perhaps with custom inputs) which calls `toolchain_matrix.yml` for the matrix and `execute_matrix.yml` to run its own command across it +* Generate the work definition in YAML or JSON by some other means, or hard-code it into a workflow, and pass it to `execute_matrix.yml` + +## Components of the design + +* `package_test.yml`: top-level workflow for the full suite of conveniences +* `benchmarks.yml`: top-level workflow for benchmarking, consuming the same matrix layer +* `toolchain_matrix.yml`: produces a matrix of toolchains with no command attached, for callers supplying their own +* `execute_matrix.yml`: expands a work definition into one job per entry and dispatches each to its platform. FreeBSD runs in a VM step here rather than through a job runner +* `generate-matrix.swift`: produces the canonical work definition YAML or JSON. It reads the workflow's inputs from the environment and fails the run rather than emitting a matrix that would silently drop a job the caller asked for +* `job-runner-linux.sh`, `job-runner-macos.sh`, `job-runner-windows.ps1`: helper scripts which execute the defined work. They handle the complexity of running inside/outside Docker and installing Swift if required. + diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml new file mode 100644 index 00000000..9e3c226f --- /dev/null +++ b/.github/workflows/benchmarks.yml @@ -0,0 +1,198 @@ +name: Benchmarks + +permissions: + contents: read + +on: + workflow_call: + inputs: + benchmark_package_path: + type: string + description: "Path to the directory containing the benchmarking package. Used only when benchmark_package_paths is empty." + default: "." + benchmark_package_paths: + type: string + description: 'JSON array of benchmarking package paths to run in sequence on each runner, e.g. ''["Benchmarks/A", "Benchmarks/B"]''. Takes precedence over benchmark_package_path.' + default: "[]" + swift_package_arguments: + type: string + description: "Additional arguments passed to swift package (e.g. --disable-sandbox)." + default: "" + linux_swift_versions: + type: string + description: "Linux Swift version list (JSON array)." + default: '["6.1", "6.2", "6.3", "nightly-release", "nightly-main"]' + enable_linux: + type: boolean + description: "Run the benchmarks on Linux." + default: true + enable_macos: + type: boolean + description: "Run the benchmarks on macOS." + default: false + macos_swift_versions: + type: string + description: "macOS Swift version list (JSON array) for benchmarks. Resolved through the runners' Xcode symlinks. Empty, with macos_xcode_versions also empty, uses the generator's list of release versions." + default: "" + macos_xcode_versions: + type: string + description: "macOS Xcode version list (JSON array) for benchmarks. Combined with macos_swift_versions rather than replaced by it." + default: "" + macos_runner_pool: + type: string + description: "The self-hosted runner pool for macOS benchmark jobs." + default: "general" + macos_repository_owner: + type: string + description: "Owner whose self-hosted macOS runners these are. A repository under any other owner gets no macOS entries, since a fork's jobs would queue until they time out." + default: "" + linux_env_vars: + type: string + description: "Environment variables for Linux jobs as JSON." + default: "{}" + macos_env_vars: + type: string + description: "Environment variables for macOS jobs as JSON." + default: "{}" + minimum_swift_version: + type: string + description: "Minimum Swift version. Empty auto-detects from Package.swift, 'none' disables filtering, or name a version explicitly." + default: "" + name: + type: string + description: "Name used for the concurrency group. Set this when a workflow calls benchmarks.yml more than once, or the calls cancel each other." + default: "benchmarks" + job_timeout: + type: number + description: "Timeout in minutes for each job." + default: 60 + needs_token: + type: boolean + description: "Whether to provide GITHUB_TOKEN to jobs." + default: false + enable_cross_pr_testing: + type: boolean + description: "Whether PRs can be tested together with linked PRs mentioned in the PR description." + default: false + + workflows_repository: + type: string + description: "Repository to take the matrix scripts from. Point this at a fork to test a change to the workflows before it lands; it carries no version, so Dependabot has only the `uses:` line to bump." + default: "swiftlang/github-workflows" + workflows_ref: + type: string + description: "Ref to take the scripts from. Empty uses workflows_repository's default branch, which is correct for a released version; set it when workflows_repository is a fork whose default branch does not carry the change." + default: "" +jobs: + generate-matrix: + name: Generate benchmark matrix + runs-on: ubuntu-latest + outputs: + matrix_yaml: ${{ steps.generate.outputs.matrix_yaml }} + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Resolve the workflows source + id: workflows_source + shell: bash + env: + WORKFLOWS_REPOSITORY: ${{ inputs.workflows_repository }} + WORKFLOWS_REF: ${{ inputs.workflows_ref }} + run: | + set -euo pipefail + + # Empty uses the default branch. + echo "ref=$WORKFLOWS_REF" >> $GITHUB_OUTPUT + + if [ "$GITHUB_REPOSITORY" = "$WORKFLOWS_REPOSITORY" ]; then + echo "needs_checkout=false" >> $GITHUB_OUTPUT + echo "root_directory=$GITHUB_WORKSPACE" >> $GITHUB_OUTPUT + else + echo "needs_checkout=true" >> $GITHUB_OUTPUT + echo "root_directory=$GITHUB_WORKSPACE/github-workflows" >> $GITHUB_OUTPUT + fi + - name: Checkout the workflows repository + if: ${{ steps.workflows_source.outputs.needs_checkout == 'true' }} + uses: actions/checkout@v7 + with: + repository: ${{ inputs.workflows_repository }} + ref: ${{ steps.workflows_source.outputs.ref }} + path: github-workflows + persist-credentials: false + - name: Generate matrix + id: generate + env: + WORKFLOWS_CHECKOUT: ${{ steps.workflows_source.outputs.root_directory }} + LINUX_USER_ENV_VARS: ${{ inputs.linux_env_vars }} + MACOS_USER_ENV_VARS: ${{ inputs.macos_env_vars }} + BENCHMARK_PACKAGE_PATH: ${{ inputs.benchmark_package_path }} + BENCHMARK_PACKAGE_PATHS: ${{ inputs.benchmark_package_paths }} + SWIFT_PACKAGE_ARGUMENTS: ${{ inputs.swift_package_arguments }} + ENABLE_LINUX: ${{ inputs.enable_linux }} + ENABLE_MACOS: ${{ inputs.enable_macos }} + ENABLE_WINDOWS: "false" + LINUX_SWIFT_VERSIONS: ${{ inputs.linux_swift_versions }} + MACOS_SWIFT_VERSIONS: ${{ inputs.macos_swift_versions }} + MACOS_XCODE_VERSIONS: ${{ inputs.macos_xcode_versions }} + MACOS_RUNNER_POOL: ${{ inputs.macos_runner_pool }} + MACOS_REPOSITORY_OWNER: ${{ inputs.macos_repository_owner }} + GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} + MINIMUM_SWIFT_VERSION: ${{ inputs.minimum_swift_version }} + LINUX_SETUP_COMMAND: | + git config --global --add safe.directory "$(pwd)" + # The job runs unprivileged on the runner but as root in a container, + # where sudo is not installed. + if command -v sudo >/dev/null 2>&1; then + sudo apt-get update -y -q && sudo apt-get install -y -q libjemalloc-dev jq + else + apt-get update -y -q && apt-get install -y -q libjemalloc-dev jq + fi + MACOS_SETUP_COMMAND: | + git config --global --add safe.directory "$(pwd)" + brew install jemalloc + run: | + set -euo pipefail + + # The threshold script reads the package paths from the environment, so + # they are added to the caller's own variables rather than replacing + # them. + with_benchmark_paths() { + jq -cn \ + --argjson user "$(echo "${1:-}" | yq -o=json '. // {}')" \ + --arg path "$BENCHMARK_PACKAGE_PATH" \ + --arg paths "$BENCHMARK_PACKAGE_PATHS" \ + '$user + {BENCHMARK_PACKAGE_PATH: $path, BENCHMARK_PACKAGE_PATHS: $paths}' + } + LINUX_ENV_VARS=$(with_benchmark_paths "$LINUX_USER_ENV_VARS") + MACOS_ENV_VARS=$(with_benchmark_paths "$MACOS_USER_ENV_VARS") + export LINUX_ENV_VARS MACOS_ENV_VARS + + # Single-quoted so ${SCRIPTS_ROOT} survives into the matrix entry and is + # expanded on the runner, where the scripts directory is known. + threshold_command='${SCRIPTS_ROOT}/check-benchmark-thresholds.sh' + if [ -n "$SWIFT_PACKAGE_ARGUMENTS" ]; then + threshold_command="$threshold_command $SWIFT_PACKAGE_ARGUMENTS" + fi + LINUX_COMMAND="$threshold_command" + MACOS_COMMAND="$threshold_command" + export LINUX_COMMAND MACOS_COMMAND + + matrix_yaml=$("${WORKFLOWS_CHECKOUT}/.github/workflows/scripts/matrix/generate-matrix.swift") + echo "matrix_yaml<> $GITHUB_OUTPUT + echo "$matrix_yaml" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + benchmarks: + name: Benchmarks + needs: generate-matrix + uses: ./.github/workflows/execute_matrix.yml + with: + name: ${{ inputs.name }} + matrix_yaml_string: ${{ needs.generate-matrix.outputs.matrix_yaml }} + workflows_repository: ${{ inputs.workflows_repository }} + workflows_ref: ${{ inputs.workflows_ref }} + job_timeout: ${{ inputs.job_timeout }} + needs_token: ${{ inputs.needs_token }} + enable_cross_pr_testing: ${{ inputs.enable_cross_pr_testing }} diff --git a/.github/workflows/execute_matrix.yml b/.github/workflows/execute_matrix.yml new file mode 100644 index 00000000..0d1be020 --- /dev/null +++ b/.github/workflows/execute_matrix.yml @@ -0,0 +1,450 @@ +name: Execute matrix + +permissions: + contents: read + +on: + workflow_call: + inputs: + name: + type: string + description: "Name used for the concurrency group. Set this when a workflow calls execute_matrix.yml more than once, or the calls cancel each other." + required: true + matrix_yaml_string: + type: string + description: "The YAML string containing the matrix definition." + required: true + command: + type: string + description: "Command to run for matrix entries that do not specify their own. Lets a caller pair a toolchain matrix with its own command." + default: "" + setup_command: + type: string + description: "Setup command for matrix entries that do not specify their own." + default: "" + command_arguments: + type: string + description: "Space-separated arguments appended to entries that do not specify their own." + default: "" + env: + type: string + description: "Environment variables (JSON or YAML object) for every entry. Per-entry values win on a key-by-key basis." + default: "{}" + job_timeout: + type: number + description: "Timeout in minutes for each job (default: 60)" + default: 60 + windows_job_timeout: + type: number + description: "Timeout in minutes for Windows jobs. 0 uses job_timeout. Windows installs a toolchain and Visual Studio Build Tools before building, so it often needs longer." + default: 0 + freebsd_job_timeout: + type: number + description: "Timeout in minutes for FreeBSD jobs. 0 uses job_timeout. FreeBSD provisions a virtual machine and installs a toolchain into it before building, so it often needs longer." + default: 0 + needs_token: + type: boolean + description: "Whether to provide GITHUB_TOKEN to jobs" + default: false + enable_cross_pr_testing: + type: boolean + description: "Whether PRs can be tested together with linked PRs mentioned in the PR description" + default: false + + workflows_repository: + type: string + description: "Repository to take the matrix scripts from. Point this at a fork to test a change to the workflows before it lands; it carries no version, so Dependabot has only the `uses:` line to bump." + default: "swiftlang/github-workflows" + workflows_ref: + type: string + description: "Ref to take the scripts from. Empty uses workflows_repository's default branch, which is correct for a released version; set it when workflows_repository is a fork whose default branch does not carry the change." + default: "" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.name }} + cancel-in-progress: true + +jobs: + convert-matrix: + name: Convert matrix YAML to JSON + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.convert.outputs.matrix }} + job_count: ${{ steps.convert.outputs.job_count }} + steps: + - name: Convert YAML to JSON + id: convert + env: + MATRIX_YAML: ${{ inputs.matrix_yaml_string }} + DEFAULT_COMMAND: ${{ inputs.command }} + DEFAULT_SETUP_COMMAND: ${{ inputs.setup_command }} + DEFAULT_COMMAND_ARGUMENTS: ${{ inputs.command_arguments }} + DEFAULT_ENV: ${{ inputs.env }} + run: | + set -euo pipefail + + if [ -z "${MATRIX_YAML//[[:space:]]/}" ]; then + echo "::error::matrix_yaml_string is empty. Pass the matrix YAML; a caller that wants no jobs passes 'config: []'." + exit 1 + fi + + if ! parsed=$(echo "$MATRIX_YAML" | yq -o=json '.'); then + echo "::error::matrix_yaml_string is not valid YAML." + exit 1 + fi + + # A matrix with no entries is valid but a missing or misspelled 'config' key is not. + if ! echo "$parsed" | jq -e 'type == "object" and has("config")' > /dev/null; then + echo "::error::matrix_yaml_string has no 'config' key. It must be a map with a 'config' list; pass 'config: []' for no jobs." + exit 1 + fi + if ! echo "$parsed" | jq -e '.config | type == "array"' > /dev/null; then + echo "::error::'config' must be a list of matrix entries." + exit 1 + fi + + default_env=$(echo "$DEFAULT_ENV" | yq -o=json '. // {}') + + # Fill in what an entry leaves out, so a toolchain-only matrix can be paired + # with a command by its caller. + matrix=$(echo "$parsed" | jq -c \ + --arg command "$DEFAULT_COMMAND" \ + --arg setup_command "$DEFAULT_SETUP_COMMAND" \ + --arg command_arguments "$DEFAULT_COMMAND_ARGUMENTS" \ + --argjson default_env "$default_env" \ + '{config: [ + .config[] + | if (.command // "") == "" then .command = $command else . end + | if (.setup_command // "") == "" then .setup_command = $setup_command else . end + | if ((.command_arguments // []) | length) == 0 + then .command_arguments = ($command_arguments | split(" ") | map(select(. != ""))) + else . end + | .env = ($default_env + (.env // {})) + ]}') + + missing=$(echo "$matrix" | jq -r '[.config[] | select((.command // "") == "") | .name] | join(", ")') + if [ -n "$missing" ]; then + echo "::error::No command for matrix entries: $missing. Set it on the entry or pass the workflow's command input." + exit 1 + fi + + # Reject unexpected platforms early. + unsupported=$(echo "$matrix" | jq -r ' + [.config[] + | select((.platform // "") as $p + | ["Linux", "macOS", "Windows", "FreeBSD"] | index($p) == null) + | "\(.name // "") (platform: \(.platform // ""))"] + | join(", ")') + if [ -n "$unsupported" ]; then + echo "::error::Unsupported platform for matrix entries: $unsupported. Must be one of Linux, macOS, Windows, FreeBSD." + exit 1 + fi + + job_count=$(echo "$matrix" | jq -r '.config | length') + if [ "$job_count" -eq 0 ]; then + echo "::notice::No matrix entries; no jobs will run." + else + echo "::notice::Running $job_count job(s): $(echo "$matrix" | jq -r '[.config[].name] | join(", ")')" + fi + echo "job_count=$job_count" >> $GITHUB_OUTPUT + + echo "matrix<> $GITHUB_OUTPUT + echo "$matrix" >> $GITHUB_OUTPUT + echo "MATRIX_EOF" >> $GITHUB_OUTPUT + + execute-matrix: + name: ${{ matrix.config.name }} + needs: convert-matrix + if: ${{ needs.convert-matrix.outputs.job_count != '0' }} + runs-on: ${{ matrix.config.runner }} + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.convert-matrix.outputs.matrix) }} + env: + SWIFT_VERSION: >- + ${{ matrix.config.swift_build.swift_version + || matrix.config.xcode_build.swift_version + || matrix.config.xcode_build.xcode_version + || matrix.config.freebsd.swift_version + || '' }} + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + persist-credentials: false + submodules: true + + - name: Resolve the workflows source + id: workflows_source + shell: bash + env: + WORKFLOWS_REPOSITORY: ${{ inputs.workflows_repository }} + WORKFLOWS_REF: ${{ inputs.workflows_ref }} + run: | + set -euo pipefail + + # Empty uses the default branch. + echo "ref=$WORKFLOWS_REF" >> $GITHUB_OUTPUT + + if [ "$GITHUB_REPOSITORY" = "$WORKFLOWS_REPOSITORY" ]; then + echo "needs_checkout=false" >> $GITHUB_OUTPUT + root="$GITHUB_WORKSPACE" + scripts_relative=".github/workflows/scripts" + else + echo "needs_checkout=true" >> $GITHUB_OUTPUT + root="$GITHUB_WORKSPACE/github-workflows" + scripts_relative="github-workflows/.github/workflows/scripts" + fi + + echo "root_directory=$root" >> $GITHUB_OUTPUT + # The Windows dispatch invokes the runner through PowerShell, which + # needs backslashes. + echo "root_directory_windows=$(echo "$root" | sed 's|/|\\|g')" >> $GITHUB_OUTPUT + # The FreeBSD VM works in its own copy of the workspace, at a path the host + # does not know, so it rebuilds ${SCRIPTS_ROOT} from this relative form. + echo "scripts_root_relative=$scripts_relative" >> $GITHUB_OUTPUT + + - name: Checkout the workflows repository + if: ${{ steps.workflows_source.outputs.needs_checkout == 'true' }} + uses: actions/checkout@v7 + with: + repository: ${{ inputs.workflows_repository }} + ref: ${{ steps.workflows_source.outputs.ref }} + path: github-workflows + persist-credentials: false + + - name: Check out linked PRs (macOS) + if: ${{ inputs.enable_cross_pr_testing && github.event_name == 'pull_request' && matrix.config.platform == 'macOS' }} + shell: bash + env: + SCRIPTS_ROOT: ${{ steps.workflows_source.outputs.root_directory }}/.github/workflows/scripts + PR_REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.number }} + run: | + cp "$SCRIPTS_ROOT/cross-pr-checkout.swift" /tmp/cross-pr-checkout.swift + swift /tmp/cross-pr-checkout.swift "$PR_REPO" "$PR_NUMBER" + + - name: Run matrix job (Linux) + if: ${{ matrix.config.platform == 'Linux' }} + timeout-minutes: ${{ inputs.job_timeout }} + shell: bash + env: + GITHUB_TOKEN: ${{ inputs.needs_token && secrets.GITHUB_TOKEN || '' }} + SCRIPTS_ROOT: ${{ steps.workflows_source.outputs.root_directory }}/.github/workflows/scripts + CONTAINER_JSON: ${{ toJson(matrix.config.swift_build.container) }} + MATRIX_SWIFT_VERSION: ${{ matrix.config.swift_build.swift_version }} + MATRIX_TOOLCHAIN: ${{ matrix.config.swift_build.toolchain }} + MATRIX_SWIFTLY: ${{ matrix.config.swift_build.swiftly }} + MATRIX_SETUP_COMMAND: ${{ matrix.config.setup_command }} + MATRIX_COMMAND: ${{ matrix.config.command }} + MATRIX_COMMAND_ARGUMENTS: ${{ toJson(matrix.config.command_arguments) }} + MATRIX_ENV: ${{ toJson(matrix.config.env) }} + NEEDS_TOKEN: ${{ inputs.needs_token }} + MATRIX_SDK: ${{ toJson(matrix.config.swift_build.sdk) }} + CROSS_PR_TESTING: ${{ inputs.enable_cross_pr_testing && github.event_name == 'pull_request' }} + CROSS_PR_REPO: ${{ github.repository }} + CROSS_PR_NUMBER: ${{ github.event.number }} + run: | + "$SCRIPTS_ROOT/matrix/job-runner-linux.sh" \ + "$MATRIX_SWIFT_VERSION" \ + "$MATRIX_SETUP_COMMAND" \ + "$MATRIX_COMMAND" \ + "$MATRIX_COMMAND_ARGUMENTS" \ + "$MATRIX_ENV" \ + "$NEEDS_TOKEN" \ + "$MATRIX_SDK" + + - name: Enable KVM and free disk space + if: ${{ matrix.config.android_emulator == true && matrix.config.platform == 'Linux' }} + shell: bash + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + sudo rm -rf /opt/microsoft /opt/google /opt/az /opt/ghc /usr/share/dotnet /usr/local/share/boost /opt/hostedtoolcache /usr/local/share/chromium + df -h + + - name: Run Android emulator tests + if: ${{ matrix.config.android_emulator == true && matrix.config.platform == 'Linux' }} + timeout-minutes: ${{ inputs.job_timeout }} + shell: bash + env: + SCRIPTS_ROOT: ${{ steps.workflows_source.outputs.root_directory }}/.github/workflows/scripts + SDK_TRIPLES: ${{ join(matrix.config.swift_build.sdk.triples, ' --android-sdk-triple=') }} + run: | + # The SDK build exported the matched toolchain and SDK bundle to GITHUB_ENV for + # this script. + # Intentional word splitting to pass multiple --android-sdk-triple arguments + # shellcheck disable=SC2086 + "$SCRIPTS_ROOT/android/android-emulator-tests.sh" \ + --android-sdk-triple=$SDK_TRIPLES + + - name: Run matrix job (Windows) + if: ${{ matrix.config.platform == 'Windows' }} + timeout-minutes: ${{ inputs.windows_job_timeout > 0 && inputs.windows_job_timeout || inputs.job_timeout }} + env: + GITHUB_TOKEN: ${{ inputs.needs_token && secrets.GITHUB_TOKEN || '' }} + SCRIPTS_ROOT: ${{ steps.workflows_source.outputs.root_directory_windows }}\.github\workflows\scripts + CONTAINER_IMAGE: ${{ matrix.config.swift_build.container.image }} + CROSS_PR_TESTING: ${{ inputs.enable_cross_pr_testing && github.event_name == 'pull_request' }} + CROSS_PR_REPO: ${{ github.repository }} + CROSS_PR_NUMBER: ${{ github.event.number }} + MATRIX_SWIFT_VERSION: ${{ matrix.config.swift_build.swift_version }} + MATRIX_TOOLCHAIN: ${{ matrix.config.swift_build.toolchain }} + MATRIX_SETUP_COMMAND: ${{ matrix.config.setup_command }} + MATRIX_COMMAND: ${{ matrix.config.command }} + MATRIX_COMMAND_ARGUMENTS: ${{ toJson(matrix.config.command_arguments) }} + MATRIX_ENV: ${{ toJson(matrix.config.env) }} + NEEDS_TOKEN: ${{ inputs.needs_token }} + shell: pwsh + run: | + & "$env:SCRIPTS_ROOT\matrix\job-runner-windows.ps1" ` + -SwiftVersion "$env:MATRIX_SWIFT_VERSION" ` + -SetupCommand "$env:MATRIX_SETUP_COMMAND" ` + -Command "$env:MATRIX_COMMAND" ` + -CommandArguments $env:MATRIX_COMMAND_ARGUMENTS ` + -EnvJson $env:MATRIX_ENV ` + -NeedsToken "$env:NEEDS_TOKEN" + + - name: Run matrix job (macOS) + if: ${{ matrix.config.platform == 'macOS' }} + timeout-minutes: ${{ inputs.job_timeout }} + shell: bash + env: + GITHUB_TOKEN: ${{ inputs.needs_token && secrets.GITHUB_TOKEN || '' }} + SCRIPTS_ROOT: ${{ steps.workflows_source.outputs.root_directory }}/.github/workflows/scripts + XCODE_TARGETS_JSON: ${{ toJson(matrix.config.xcode_build.targets) }} + XCODE_DEBUG_OUTPUT: ${{ matrix.config.xcode_build.debug_output }} + SWIFTLY_TOOLCHAIN: ${{ matrix.config.xcode_build.swiftly_toolchain }} + MATRIX_XCODE_VERSION: ${{ matrix.config.xcode_build.xcode_version }} + MATRIX_SWIFT_VERSION: ${{ matrix.config.xcode_build.swift_version }} + MATRIX_SETUP_COMMAND: ${{ matrix.config.setup_command }} + MATRIX_COMMAND: ${{ matrix.config.command }} + MATRIX_COMMAND_ARGUMENTS: ${{ toJson(matrix.config.command_arguments) }} + MATRIX_ENV: ${{ toJson(matrix.config.env) }} + NEEDS_TOKEN: ${{ inputs.needs_token }} + run: | + "$SCRIPTS_ROOT/matrix/job-runner-macos.sh" \ + "$MATRIX_XCODE_VERSION" \ + "$MATRIX_SWIFT_VERSION" \ + "$MATRIX_SETUP_COMMAND" \ + "$MATRIX_COMMAND" \ + "$MATRIX_COMMAND_ARGUMENTS" \ + "$MATRIX_ENV" \ + "$NEEDS_TOKEN" + + - name: Merge the FreeBSD environment + id: freebsd_env + if: ${{ matrix.config.platform == 'FreeBSD' }} + shell: bash + env: + FREEBSD_ENV_VARS: ${{ matrix.config.freebsd.env_vars }} + MATRIX_ENV: ${{ toJson(matrix.config.env) }} + run: | + set -euo pipefail + + # The VM reads its environment as KEY=VALUE lines, so a value that spans lines + # would arrive cut short. + if echo "$MATRIX_ENV" | jq -e '(. // {}) | any(.[]; tostring | test("\n"))' > /dev/null; then + echo "::error::A FreeBSD entry's env holds a value spanning several lines, which the VM cannot receive." + exit 1 + fi + + # The VM has no jq, so the entry's env is flattened here into the KEY=VALUE + # lines the VM already reads. It is applied last, so an entry's own value wins + # over freebsd.env_vars. + merged=$( { + printf '%s\n' "$FREEBSD_ENV_VARS" + echo "$MATRIX_ENV" | jq -r '(. // {}) | to_entries[] | "\(.key)=\(.value)"' + } | awk 'NF') + + { + echo "env_vars<> $GITHUB_OUTPUT + + - name: Quote the FreeBSD command arguments + id: freebsd_arguments + if: ${{ matrix.config.platform == 'FreeBSD' }} + shell: bash + env: + MATRIX_COMMAND_ARGUMENTS: ${{ toJson(matrix.config.command_arguments) }} + run: | + set -euo pipefail + + # The VM has no jq and eval's the command it runs, so each argument is quoted + # here: an argument containing whitespace would otherwise arrive as several. + # A string, which a hand-written entry may give instead of a list, is already in + # the form the VM receives. + quoted=$(echo "$MATRIX_COMMAND_ARGUMENTS" \ + | jq -r '(. // []) | if type == "array" then map(@sh) | join(" ") else tostring end') + + # An argument may contain a newline, which a KEY=VALUE output line cannot carry. + { + echo "command_arguments<> $GITHUB_OUTPUT + + - name: Run matrix job (FreeBSD) + if: ${{ matrix.config.platform == 'FreeBSD' }} + timeout-minutes: ${{ inputs.freebsd_job_timeout > 0 && inputs.freebsd_job_timeout || inputs.job_timeout }} + uses: vmactions/freebsd-vm@v1 + env: + SWIFT_WEB_URL: ${{ matrix.config.freebsd.swift_url }} + BUILD_FLAGS: ${{ matrix.config.freebsd.build_flags }} + FREEBSD_ENV_VARS: ${{ steps.freebsd_env.outputs.env_vars }} + MATRIX_SETUP_COMMAND: ${{ matrix.config.setup_command }} + MATRIX_COMMAND: ${{ matrix.config.command }} + # Shell-quoted on the host, where jq is available. + MATRIX_COMMAND_ARGUMENTS: ${{ steps.freebsd_arguments.outputs.command_arguments }} + SCRIPTS_ROOT_RELATIVE: ${{ steps.workflows_source.outputs.scripts_root_relative }} + GITHUB_TOKEN: ${{ inputs.needs_token && secrets.GITHUB_TOKEN || '' }} + CROSS_PR_TESTING: ${{ inputs.enable_cross_pr_testing && github.event_name == 'pull_request' }} + CROSS_PR_REPO: ${{ github.repository }} + CROSS_PR_NUMBER: ${{ github.event.number }} + with: + envs: 'SWIFT_WEB_URL BUILD_FLAGS GITHUB_TOKEN FREEBSD_ENV_VARS MATRIX_SETUP_COMMAND MATRIX_COMMAND MATRIX_COMMAND_ARGUMENTS SCRIPTS_ROOT_RELATIVE CROSS_PR_TESTING CROSS_PR_REPO CROSS_PR_NUMBER' + release: "${{ matrix.config.freebsd.os_version }}" + arch: "x86_64" + sync: rsync + copyback: false + usesh: true + prepare: | + fetch -o /tmp/swift.tar.gz "$SWIFT_WEB_URL" + mkdir -p /opt/swift + tar -xzf /tmp/swift.tar.gz -C /opt/swift + pkg install -y git sqlite3 libuuid python3 curl brotli bash + git config --global init.defaultBranch 'main' + /opt/swift/usr/bin/swift --version + run: | + # The VM runs this under sh with no -e, so a failing setup command or + # cross-PR checkout would otherwise be followed by the build anyway, + # testing the wrong tree and reporting success. + set -e + export PATH="/opt/swift/usr/bin:$PATH" + swift --version + # The command runs in the VM's copy of the workspace, so ${SCRIPTS_ROOT} is + # this copy's path, not the host's. + SCRIPTS_ROOT="$(pwd)/$SCRIPTS_ROOT_RELATIVE" + export SCRIPTS_ROOT + if [ -n "$FREEBSD_ENV_VARS" ]; then + printf '%s\n' "$FREEBSD_ENV_VARS" > /tmp/.env_vars + while IFS= read -r _line || [ -n "$_line" ]; do + if [ -n "$_line" ]; then + export "$_line" + fi + done < /tmp/.env_vars + fi + if [ "$CROSS_PR_TESTING" = "true" ]; then + cat "$SCRIPTS_ROOT/cross-pr-checkout.swift" > /tmp/cross-pr-checkout.swift + swift /tmp/cross-pr-checkout.swift "$CROSS_PR_REPO" "$CROSS_PR_NUMBER" + fi + if [ -n "$MATRIX_SETUP_COMMAND" ]; then + eval "$MATRIX_SETUP_COMMAND" + fi + eval "$MATRIX_COMMAND $BUILD_FLAGS $MATRIX_COMMAND_ARGUMENTS" diff --git a/.github/workflows/package_test.yml b/.github/workflows/package_test.yml new file mode 100644 index 00000000..27a7946c --- /dev/null +++ b/.github/workflows/package_test.yml @@ -0,0 +1,510 @@ +name: Package Test + +permissions: + contents: read + +on: + workflow_call: + inputs: + # ----- Platform enables ----- + enable_linux: + type: boolean + description: "Run the tests on Linux." + default: true + enable_macos: + type: boolean + description: "Run the tests on macOS." + default: false + enable_macos_swiftly: + type: boolean + description: "Run the tests on macOS with swiftly-managed toolchains, for a nightly snapshot no Xcode carries." + default: false + enable_windows: + type: boolean + description: "Run the tests on Windows." + default: true + enable_linux_static_sdk_build: + type: boolean + description: "Boolean to enable building with the Static Linux Swift SDK. Defaults to false." + default: false + enable_wasm_sdk_build: + type: boolean + description: "Boolean to enable building with the Swift SDK for Wasm. Defaults to false." + default: false + enable_embedded_wasm_sdk_build: + type: boolean + description: "Boolean to enable building with the Embedded Swift SDK for Wasm. Defaults to false." + default: false + enable_android_sdk_build: + type: boolean + description: "Boolean to enable building with the Swift SDK for Android. Defaults to false." + default: false + enable_android_emulator_tests: + type: boolean + description: "Boolean to enable Android emulator testing after SDK builds. Defaults to false." + default: false + enable_cxx_interop: + type: boolean + description: "Boolean to enable Cxx interoperability check. Defaults to false." + default: false + cxx_interop_swift_versions: + type: string + description: "Swift version list (JSON array) for the Cxx interop check. Empty uses the newest release version in linux_swift_versions." + default: "" + + # ----- Version lists (JSON arrays) ----- + linux_swift_versions: + type: string + description: "Linux Swift version list (JSON array)." + default: '["6.1", "6.2", "6.3", "nightly-release", "nightly-main"]' + linux_host_archs: + type: string + description: "Linux host architecture list (JSON array, e.g. '[\"x86_64\", \"aarch64\"]')." + default: '["x86_64"]' + macos_xcode_versions: + type: string + description: "macOS Xcode version list (JSON array). Combined with macos_swift_versions rather than replaced by it; use \"latest-beta\" for whichever beta the runners carry." + default: "" + macos_swift_versions: + type: string + description: "macOS Swift version list (JSON array), resolved through the runners' Xcode symlinks. Combined with macos_xcode_versions rather than replacing it. Empty, with macos_xcode_versions also empty, uses the generator's list of release versions." + default: "" + windows_swift_versions: + type: string + description: "Windows Swift version list (JSON array)." + default: '["6.1", "6.2", "6.3", "nightly-release", "nightly-main"]' + windows_os: + type: string + description: "Windows runner label, or a list of them (JSON/YAML array) to run one job per label." + default: "windows-2022" + windows_use_docker: + type: boolean + description: "Run Windows builds inside Docker containers. A container carries a Windows SDK matched to its toolchain, which the runner image does not carry for Swift releases before 6.1." + default: false + windows_job_timeout: + type: number + description: "Timeout in minutes for Windows jobs. 0 uses job_timeout. Windows installs a toolchain and Visual Studio Build Tools before building, so it often needs longer." + default: 0 + linux_static_sdk_versions: + type: string + description: "Static Linux Swift SDK version list (JSON array)." + default: '["6.3", "nightly-release", "nightly-main"]' + wasm_sdk_versions: + type: string + description: "Wasm Swift SDK version list (JSON array)." + default: '["6.3", "nightly-release", "nightly-main"]' + embedded_wasm_sdk_versions: + type: string + description: "Embedded Wasm Swift SDK version list (JSON array)." + default: '["6.3", "nightly-release", "nightly-main"]' + android_sdk_versions: + type: string + description: "Android Swift SDK version list (JSON array)." + default: '["6.3", "nightly-release", "nightly-main"]' + android_ndk_versions: + type: string + description: "Android NDK version list (JSON array)." + default: '["r27d", "r28c"]' + android_sdk_triples: + type: string + description: "Android SDK triples (JSON array)." + default: '["aarch64-unknown-linux-android28", "x86_64-unknown-linux-android28"]' + + # ----- Commands & flags ----- + linux_command: + type: string + description: >- + Linux command to execute (default: swift test). Takes one command, or a map of label to + command to run a job per label, each name carrying its label; a label may take a map of + command and versions to run on part of the version list. + default: "swift test" + linux_setup_command: + type: string + description: "Linux command to execute before building." + default: "" + macos_command: + type: string + description: >- + macOS command to execute (default: xcrun swift test). Takes one command, or a map of label + to command to run a job per label, each name carrying its label; a label may take a map of + command and versions to run on part of the version list. + default: "xcrun swift test" + macos_setup_command: + type: string + description: "macOS command to execute before building." + default: "" + macos_swiftly_toolchains: + type: string + description: 'macOS swiftly toolchain list (JSON/YAML array of objects with xcode_version, swiftly_toolchain, and optionally os_version and arch).' + default: '[{"xcode_version": "swift_6.3", "swiftly_toolchain": "main-snapshot"}]' + macos_swiftly_command: + type: string + description: >- + macOS command to execute for swiftly toolchains. Runs through swiftly, so it should not use + xcrun. Takes one command, or a map of label to command to run a job per label, each name + carrying its label; the toolchains come from macos_swiftly_toolchains, so a label takes no + versions. + default: "swiftly run swift test" + xcode_scheme: + type: string + description: "Xcode scheme every xcode_targets target builds, unless a target names its own. Required unless every target does." + default: "" + xcode_targets: + type: string + description: >- + Platforms to build and test through xcodebuild on the macOS runners, as a map of platform to + settings ('iOS: {build: true, test: true}') or a list of platforms taking every default + ('[iOS, watchOS]'). The platforms are macOS, Catalyst, iOS, watchOS, tvOS and visionOS. A + target takes build (default true), test (default false), scheme, build_destination and + test_destination; the destinations default to the newest device of each kind. + default: "" + windows_command: + type: string + description: >- + Windows command to execute (default: swift test). Takes one command, or a map of label to + command to run a job per label, each name carrying its label; a label may take a map of + command and versions to run on part of the version list. + default: "swift test" + windows_setup_command: + type: string + description: "Windows command to execute before building." + default: "" + linux_static_sdk_command: + type: string + description: >- + Command to use when building with the Static Linux Swift SDK. Takes one command, or a map + of label to command to run a job per label, each name carrying its label; a label may take + a map of command and versions to run on part of the version list. + default: "swift build" + linux_static_sdk_setup_command: + type: string + description: "Command to execute before building with the Static Linux Swift SDK." + default: "" + wasm_sdk_command: + type: string + description: >- + Command to use when building with the Swift SDK for Wasm. Takes one command, or a map of + label to command to run a job per label, each name carrying its label; a label may take a + map of command and versions to run on part of the version list. + default: "swift build" + wasm_sdk_setup_command: + type: string + description: "Command to execute before building with the Swift SDK for Wasm." + default: "" + embedded_wasm_sdk_setup_command: + type: string + description: "Command to execute before building with the Embedded Wasm SDK." + default: "" + embedded_wasm_sdk_command: + type: string + description: >- + Command to use when building with the Embedded Swift SDK for Wasm. Takes one command, or a + map of label to command to run a job per label, each name carrying its label; a label may + take a map of command and versions to run on part of the version list. + default: "swift build" + android_sdk_command: + type: string + description: >- + Command to use when building with the Swift SDK for Android. Takes one command, or a map + of label to command to run a job per label, each name carrying its label; a label may take + a map of command and versions to run on part of the version list. + default: "swift build" + android_sdk_setup_command: + type: string + description: "Command to execute before building with the Swift SDK for Android." + default: "" + swift_flags: + type: string + description: "Swift flags appended to release version builds." + default: "" + swift_nightly_flags: + type: string + description: "Swift flags appended to nightly version builds." + default: "" + + # ----- Per-version overrides (JSON objects) ----- + linux_version_overrides: + type: string + description: >- + Per-Linux-version overrides (JSON/YAML object). A string value adds arguments, which reach + every Linux job kind; an object with `arguments` and/or `command` can also replace the + command for that version. Replacing needs a single command configured for the kinds that + run it, and the release build and the Cxx interop check refuse it, since their command is + the check itself. + default: "{}" + windows_version_overrides: + type: string + description: >- + Per-Windows-version overrides (JSON/YAML object). A string value adds arguments; an object + with `arguments` and/or `command` can also replace the command for that version, which + needs windows_command holding a single command. + default: "{}" + macos_version_overrides: + type: string + description: >- + Per-macOS-version overrides (JSON/YAML object), keyed by Swift or Xcode version. A string + value adds arguments; an object with `arguments` and/or `command` can also replace the + command for that version, which needs macos_command holding a single command. + default: "{}" + + # ----- Environment variables (JSON objects) ----- + linux_env_vars: + type: string + description: "Environment variables for Linux jobs as JSON." + default: "{}" + macos_env_vars: + type: string + description: "Environment variables for macOS jobs as JSON." + default: "{}" + windows_env_vars: + type: string + description: "Environment variables for Windows jobs as JSON." + default: "{}" + + # ----- Docker/container mode ----- + linux_use_docker: + type: boolean + description: "Run Linux jobs inside Docker containers instead of natively with swiftly." + default: false + linux_os: + type: string + description: "Linux distribution the container image is tagged for (e.g. noble, jammy), or a list of them (JSON/YAML array) to run one job per distribution. A distribution other than the default, or more than one, implies linux_use_docker." + default: "noble" + linux_dockerfile: + type: string + description: "Path to a Dockerfile to build the Linux container from. The base image is available as the SWIFT_IMAGE build argument. Implies linux_use_docker." + default: "" + linux_docker_capabilities: + type: string + description: 'Docker --cap-add capabilities for Linux containers (JSON/YAML array, e.g. ''["CAP_BPF"]'').' + default: "[]" + linux_docker_security_options: + type: string + description: 'Docker --security-opt options for Linux containers (JSON/YAML array, e.g. ''["apparmor=unconfined"]'').' + default: "[]" + + # ----- Minimum version detection ----- + minimum_swift_version: + type: string + description: "Minimum Swift version. Empty auto-detects from Package.swift, 'none' disables filtering, or name a version explicitly." + default: "" + find_subdirectory_manifests: + type: boolean + description: "Check subdirectory Package.swift files for minimum version detection." + default: false + + # ----- macOS runner configuration ----- + macos_os: + type: string + description: "macOS runner label, or a list of them (JSON/YAML array) to run one job per label." + default: "tahoe" + macos_arch: + type: string + description: "macOS runner architecture label." + default: "ARM64" + macos_runner_pool: + type: string + description: "macOS self-hosted runner pool label." + default: "general" + macos_repository_owner: + type: string + description: "Owner whose self-hosted macOS pools the macOS entries need. When set and the running repository has a different owner, those entries are not generated, so a fork gets no jobs it cannot start rather than jobs that queue forever." + default: "" + xcode_debug_output: + type: boolean + description: "Drop -quiet from the xcodebuild target invocations." + default: false + + # ----- Miscellaneous ----- + name: + type: string + description: "Name used for the concurrency group. Set this when a workflow calls package_test.yml more than once, or the calls cancel each other." + default: "package-test" + job_timeout: + type: number + description: "Timeout in minutes for each job." + default: 60 + needs_token: + type: boolean + description: "Whether to provide GITHUB_TOKEN to jobs." + default: false + enable_cross_pr_testing: + type: boolean + description: "Whether PRs can be tested together with linked PRs mentioned in the PR description." + default: false + + # ----- FreeBSD ----- + enable_freebsd: + type: boolean + description: "Boolean to enable FreeBSD testing. Defaults to false." + default: false + freebsd_swift_versions: + type: string + description: "FreeBSD Swift version list (JSON array)." + default: '["nightly-main"]' + freebsd_os_versions: + type: string + description: "FreeBSD OS version list (JSON array)." + default: '["14.3"]' + freebsd_setup_command: + type: string + description: "FreeBSD command to execute before building." + default: "" + freebsd_command: + type: string + description: >- + FreeBSD command to execute (default: swift test). Takes one command, or a map of label to + command to run a job per label, each name carrying its label; a label may take a map of + command and versions to run on part of the version list. + default: "swift test" + freebsd_env_vars: + type: string + description: "Environment variables for FreeBSD jobs (newline-separated key=value)." + default: "" + freebsd_job_timeout: + type: number + description: "Timeout in minutes for FreeBSD jobs. 0 uses job_timeout. FreeBSD provisions a virtual machine and installs a toolchain into it before building, so it often needs longer." + default: 0 + + workflows_repository: + type: string + description: "Repository to take the matrix scripts from. Point this at a fork to test a change to the workflows before it lands; it carries no version, so Dependabot has only the `uses:` line to bump." + default: "swiftlang/github-workflows" + workflows_ref: + type: string + description: "Ref to take the scripts from. Empty uses workflows_repository's default branch, which is correct for a released version; set it when workflows_repository is a fork whose default branch does not carry the change." + default: "" +jobs: + generate-matrix: + name: Generate test matrix + runs-on: ubuntu-latest + outputs: + matrix_yaml: ${{ steps.generate.outputs.matrix_yaml }} + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Resolve the workflows source + id: workflows_source + shell: bash + env: + WORKFLOWS_REPOSITORY: ${{ inputs.workflows_repository }} + WORKFLOWS_REF: ${{ inputs.workflows_ref }} + run: | + set -euo pipefail + + # Empty uses the default branch. + echo "ref=$WORKFLOWS_REF" >> $GITHUB_OUTPUT + + if [ "$GITHUB_REPOSITORY" = "$WORKFLOWS_REPOSITORY" ]; then + echo "needs_checkout=false" >> $GITHUB_OUTPUT + echo "root_directory=$GITHUB_WORKSPACE" >> $GITHUB_OUTPUT + else + echo "needs_checkout=true" >> $GITHUB_OUTPUT + echo "root_directory=$GITHUB_WORKSPACE/github-workflows" >> $GITHUB_OUTPUT + fi + + - name: Checkout the workflows repository + if: ${{ steps.workflows_source.outputs.needs_checkout == 'true' }} + uses: actions/checkout@v7 + with: + repository: ${{ inputs.workflows_repository }} + ref: ${{ steps.workflows_source.outputs.ref }} + path: github-workflows + persist-credentials: false + + - name: Generate matrix + id: generate + run: | + matrix_yaml=$("${WORKFLOWS_CHECKOUT}/.github/workflows/scripts/matrix/generate-matrix.swift") + echo "matrix_yaml<> $GITHUB_OUTPUT + echo "$matrix_yaml" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + env: + WORKFLOWS_CHECKOUT: ${{ steps.workflows_source.outputs.root_directory }} + ENABLE_LINUX: ${{ inputs.enable_linux }} + ENABLE_MACOS: ${{ inputs.enable_macos }} + ENABLE_MACOS_SWIFTLY: ${{ inputs.enable_macos_swiftly }} + MACOS_SWIFTLY_TOOLCHAINS: ${{ inputs.macos_swiftly_toolchains }} + MACOS_SWIFTLY_COMMAND: ${{ inputs.macos_swiftly_command }} + ENABLE_WINDOWS: ${{ inputs.enable_windows }} + ENABLE_LINUX_STATIC_SDK_BUILD: ${{ inputs.enable_linux_static_sdk_build }} + ENABLE_WASM_SDK_BUILD: ${{ inputs.enable_wasm_sdk_build }} + ENABLE_EMBEDDED_WASM_SDK_BUILD: ${{ inputs.enable_embedded_wasm_sdk_build }} + ENABLE_ANDROID_SDK_BUILD: ${{ inputs.enable_android_sdk_build }} + ENABLE_ANDROID_EMULATOR_TESTS: ${{ inputs.enable_android_emulator_tests }} + ENABLE_CXX_INTEROP: ${{ inputs.enable_cxx_interop }} + CXX_INTEROP_SWIFT_VERSIONS: ${{ inputs.cxx_interop_swift_versions }} + LINUX_SWIFT_VERSIONS: ${{ inputs.linux_swift_versions }} + LINUX_HOST_ARCHS: ${{ inputs.linux_host_archs }} + MACOS_XCODE_VERSIONS: ${{ inputs.macos_xcode_versions }} + MACOS_SWIFT_VERSIONS: ${{ inputs.macos_swift_versions }} + WINDOWS_SWIFT_VERSIONS: ${{ inputs.windows_swift_versions }} + WINDOWS_OS: ${{ inputs.windows_os }} + WINDOWS_USE_DOCKER: ${{ inputs.windows_use_docker }} + ENABLE_FREEBSD: ${{ inputs.enable_freebsd }} + FREEBSD_SWIFT_VERSIONS: ${{ inputs.freebsd_swift_versions }} + FREEBSD_OS_VERSIONS: ${{ inputs.freebsd_os_versions }} + FREEBSD_COMMAND: ${{ inputs.freebsd_command }} + FREEBSD_SETUP_COMMAND: ${{ inputs.freebsd_setup_command }} + FREEBSD_ENV_VARS: ${{ inputs.freebsd_env_vars }} + LINUX_STATIC_SDK_VERSIONS: ${{ inputs.linux_static_sdk_versions }} + WASM_SDK_VERSIONS: ${{ inputs.wasm_sdk_versions }} + EMBEDDED_WASM_SDK_VERSIONS: ${{ inputs.embedded_wasm_sdk_versions }} + ANDROID_SDK_VERSIONS: ${{ inputs.android_sdk_versions }} + ANDROID_NDK_VERSIONS: ${{ inputs.android_ndk_versions }} + ANDROID_SDK_TRIPLES: ${{ inputs.android_sdk_triples }} + LINUX_COMMAND: ${{ inputs.linux_command }} + LINUX_SETUP_COMMAND: ${{ inputs.linux_setup_command }} + MACOS_COMMAND: ${{ inputs.macos_command }} + MACOS_SETUP_COMMAND: ${{ inputs.macos_setup_command }} + XCODE_SCHEME: ${{ inputs.xcode_scheme }} + XCODE_TARGETS: ${{ inputs.xcode_targets }} + WINDOWS_COMMAND: ${{ inputs.windows_command }} + WINDOWS_SETUP_COMMAND: ${{ inputs.windows_setup_command }} + LINUX_STATIC_SDK_COMMAND: ${{ inputs.linux_static_sdk_command }} + LINUX_STATIC_SDK_SETUP_COMMAND: ${{ inputs.linux_static_sdk_setup_command }} + WASM_SDK_COMMAND: ${{ inputs.wasm_sdk_command }} + WASM_SDK_SETUP_COMMAND: ${{ inputs.wasm_sdk_setup_command }} + EMBEDDED_WASM_SDK_COMMAND: ${{ inputs.embedded_wasm_sdk_command }} + EMBEDDED_WASM_SDK_SETUP_COMMAND: ${{ inputs.embedded_wasm_sdk_setup_command }} + ANDROID_SDK_COMMAND: ${{ inputs.android_sdk_command }} + ANDROID_SDK_SETUP_COMMAND: ${{ inputs.android_sdk_setup_command }} + SWIFT_FLAGS: ${{ inputs.swift_flags }} + SWIFT_NIGHTLY_FLAGS: ${{ inputs.swift_nightly_flags }} + LINUX_VERSION_OVERRIDES: ${{ inputs.linux_version_overrides }} + WINDOWS_VERSION_OVERRIDES: ${{ inputs.windows_version_overrides }} + MACOS_VERSION_OVERRIDES: ${{ inputs.macos_version_overrides }} + LINUX_ENV_VARS: ${{ inputs.linux_env_vars }} + MACOS_ENV_VARS: ${{ inputs.macos_env_vars }} + WINDOWS_ENV_VARS: ${{ inputs.windows_env_vars }} + LINUX_USE_DOCKER: ${{ inputs.linux_use_docker }} + LINUX_OS: ${{ inputs.linux_os }} + LINUX_DOCKERFILE: ${{ inputs.linux_dockerfile }} + LINUX_DOCKER_CAPABILITIES: ${{ inputs.linux_docker_capabilities }} + LINUX_DOCKER_SECURITY_OPTIONS: ${{ inputs.linux_docker_security_options }} + MINIMUM_SWIFT_VERSION: ${{ inputs.minimum_swift_version }} + ENABLE_SUBDIRECTORY_MANIFEST_SEARCH: ${{ inputs.find_subdirectory_manifests }} + MACOS_OS: ${{ inputs.macos_os }} + MACOS_ARCH: ${{ inputs.macos_arch }} + MACOS_RUNNER_POOL: ${{ inputs.macos_runner_pool }} + MACOS_REPOSITORY_OWNER: ${{ inputs.macos_repository_owner }} + GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} + XCODE_DEBUG_OUTPUT: ${{ inputs.xcode_debug_output }} + + test: + name: Test + needs: generate-matrix + uses: ./.github/workflows/execute_matrix.yml + with: + name: ${{ inputs.name }} + matrix_yaml_string: ${{ needs.generate-matrix.outputs.matrix_yaml }} + workflows_repository: ${{ inputs.workflows_repository }} + workflows_ref: ${{ inputs.workflows_ref }} + job_timeout: ${{ inputs.job_timeout }} + windows_job_timeout: ${{ inputs.windows_job_timeout }} + freebsd_job_timeout: ${{ inputs.freebsd_job_timeout }} + needs_token: ${{ inputs.needs_token }} + enable_cross_pr_testing: ${{ inputs.enable_cross_pr_testing }} diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 48582c9d..2601ffc3 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -94,7 +94,7 @@ jobs: enable_ios_checks: true ios_build_command: | cd tests/TestPackage - xcodebuild -quiet -scheme TestPackage-Package -destination "generic/platform=ios" build + xcodebuild -quiet -scheme TestPackage -destination "generic/platform=ios" build soundness: name: Soundness @@ -103,6 +103,151 @@ jobs: api_breakage_check_enabled: false license_header_check_project_name: "Swift.org" + matrix_generator_tests: + name: Matrix generator tests + # The runner image ships both Swift and yq, so nothing needs installing. It + # has to be mikefarah's yq, which is what the image carries: generate-matrix.swift + # invokes yq with no filter argument, and python-yq is a jq wrapper that + # requires one. + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Run generator tests + working-directory: tests/MatrixGeneratorValidator + run: swift test + + runner_exit_code_tests: + name: Runner exit-code tests + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-latest + test: ./tests/runner-exit-code-tests.sh + shell: bash + - runner: macos-latest + test: ./tests/runner-exit-code-tests-macos.sh + shell: bash + - runner: windows-latest + test: ./tests/invoke-program-tests.ps1 + shell: pwsh + runs-on: ${{ matrix.runner }} + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Assert failures are not reported as success + shell: ${{ matrix.shell }} + run: ${{ matrix.test }} + + unified_package_test: + name: Unified package test + uses: ./.github/workflows/package_test.yml + with: + linux_swift_versions: '["6.3", "nightly-main"]' + linux_setup_command: cd tests/TestPackage + windows_swift_versions: '["6.3"]' + windows_setup_command: cd tests/TestPackage + windows_command: Invoke-Program swift build + enable_linux_static_sdk_build: true + linux_static_sdk_versions: '["6.3"]' + # The SDK script builds in the working directory, so the setup command is + # the only way to reach a package below the repository root. + linux_static_sdk_setup_command: cd tests/TestPackage + # The Cxx interop check takes linux_setup_command as its setup command, so + # it enters tests/TestPackage too. + enable_cxx_interop: true + # Two labeled commands, so a run exercises the label suffix. + linux_command: | + Debug-Build: swift build + Release-Build: + command: swift build -c release + versions: ["6.3"] + + unified_package_test_container: + name: Unified package test (container) + uses: ./.github/workflows/package_test.yml + with: + # Named so this does not share a concurrency group with the native call. + name: unified-package-test-container + # A container runs as root, has no sudo, and carries neither jq nor the tools + # a hosted runner provides, none of which the native default exercises. + linux_use_docker: true + linux_swift_versions: '["6.3"]' + # Runs the privilege-agnostic form the migration guide documents, so the + # advice is checked rather than only written down. + linux_setup_command: | + if command -v sudo >/dev/null 2>&1; then + sudo apt-get update -y -q && sudo apt-get install -y -q jq + else + apt-get update -y -q && apt-get install -y -q jq + fi + cd tests/TestPackage + linux_command: swift build + # Windows containers too: this is the only coverage of that path, and an + # adopter testing a Swift release before 6.1 depends on it. + windows_swift_versions: '["6.3"]' + windows_use_docker: true + windows_setup_command: cd tests/TestPackage + windows_command: Invoke-Program swift build + + unified_package_test_commands: + name: Unified package test (commands) + uses: ./.github/workflows/package_test.yml + with: + # Named so this does not share a concurrency group with the other calls. + name: unified-package-test-commands + enable_windows: false + linux_swift_versions: '["6.2", "6.3"]' + linux_setup_command: cd tests/TestPackage + # Two commands on one toolchain list, the second narrowed to one version, so + # a run proves the axis fans out and that the label's versions select from + # the list rather than being ignored. Three jobs, each carrying its label. + linux_command: | + Debug-Build: swift build + Release-Build: + command: swift build -c release + versions: ["6.3"] + + unified_toolchain_matrix: + name: Unified toolchain matrix + uses: ./.github/workflows/toolchain_matrix.yml + with: + linux_swift_versions: '["6.3"]' + + unified_toolchain_matrix_execute: + name: Unified toolchain matrix executor + needs: unified_toolchain_matrix + uses: ./.github/workflows/execute_matrix.yml + with: + name: toolchain-matrix-self-test + matrix_yaml_string: ${{ needs.unified_toolchain_matrix.outputs.matrix_yaml }} + setup_command: cd tests/TestPackage + command: swift build + + unified_execute_matrix: + name: Unified matrix executor + uses: ./.github/workflows/execute_matrix.yml + with: + name: execute-matrix-self-test + matrix_yaml_string: | + config: + - platform: Linux + name: Hand-written matrix entry + runner: + - ubuntu-24.04 + swift_build: + swift_version: "6.3" + command: | + cd tests/TestPackage + swift build + command_arguments: [] + env: {} + proposal_validation: name: Proposal Validation uses: ./.github/workflows/proposal_validation.yml diff --git a/.github/workflows/pull_request_label.yml b/.github/workflows/pull_request_label.yml new file mode 100644 index 00000000..8142e2a5 --- /dev/null +++ b/.github/workflows/pull_request_label.yml @@ -0,0 +1,24 @@ +name: PR Semver Label Check + +permissions: + contents: read + # `gh pr view --json labels` reads pull-request metadata, which is a scope of its + # own: without it the call 403s on a private repository. + pull-requests: read + +on: + workflow_call: + +jobs: + semver-label-check: + name: Semantic version label check + runs-on: ubuntu-latest + timeout-minutes: 1 + steps: + - name: Check for Semantic Version label + if: ${{ !env.ACT }} + env: + GH_TOKEN: ${{ github.token }} + run: | + gh pr view ${{ github.event.number }} --repo ${{ github.repository }} --json labels \ + | jq -e '[.labels[].name] | any(. == "⚠️ semver/major" or . == "🆕 semver/minor" or . == "🔨 semver/patch" or . == "semver/none")' diff --git a/.github/workflows/scripts/check-benchmark-thresholds.sh b/.github/workflows/scripts/check-benchmark-thresholds.sh new file mode 100755 index 00000000..35eab9aa --- /dev/null +++ b/.github/workflows/scripts/check-benchmark-thresholds.sh @@ -0,0 +1,119 @@ +#!/bin/bash +##===----------------------------------------------------------------------===## +## +## This source file is part of the Swift.org open source project +## +## Copyright (c) 2026 Apple Inc. and the Swift project authors +## Licensed under Apache License v2.0 with Runtime Library Exception +## +## See https://swift.org/LICENSE.txt for license information +## See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +## +##===----------------------------------------------------------------------===## + +set -uo pipefail + +log() { printf -- "** %s\n" "$*" >&2; } +error() { printf -- "** ERROR: %s\n" "$*" >&2; } +fatal() { error "$@"; exit 1; } + +if [ -z "${SWIFT_VERSION:-}" ]; then + fatal "SWIFT_VERSION must be specified." +fi + +swift_version="$SWIFT_VERSION" + +# Any arguments to this script are passed through to SwiftPM. +swift_package_arguments=("$@") + +# bash 3.2, which is what macOS runners provide, treats "${array[@]}" on an empty +# array as an unbound variable under `set -u`, so the expansion is guarded. The +# arguments are empty by default, and macOS benchmarks run on that default. +swift_package() { + local package_path="$1" + shift + swift package --package-path "$package_path" ${swift_package_arguments[@]+"${swift_package_arguments[@]}"} "$@" +} + +# BENCHMARK_PACKAGE_PATHS is a JSON array of strings or a newline-separated +# list. It takes precedence over the singular BENCHMARK_PACKAGE_PATH, which +# remains supported so a caller naming one path needs nothing else. +plural_paths="${BENCHMARK_PACKAGE_PATHS:-}" +singular_path="${BENCHMARK_PACKAGE_PATH:-.}" + +# Recognize an empty array without jq: the workflow passes "[]" when a caller +# named no paths, and the container images do not carry jq. +trimmed="${plural_paths#"${plural_paths%%[![:space:]]*}"}" +trimmed="${trimmed%"${trimmed##*[![:space:]]}"}" +if [[ "$trimmed" == "[]" ]]; then + trimmed="" + plural_paths="" +fi + +if [[ "$trimmed" == \[* ]]; then + command -v jq >/dev/null 2>&1 || fatal "BENCHMARK_PACKAGE_PATHS is a JSON array but jq is not installed. Install it in the pre-build command, or pass BENCHMARK_PACKAGE_PATH instead." + jq empty <<< "$plural_paths" 2>/dev/null || fatal "BENCHMARK_PACKAGE_PATHS is not valid JSON." + jq -e 'type == "array" and all(.[]; type == "string")' >/dev/null <<< "$plural_paths" \ + || fatal "BENCHMARK_PACKAGE_PATHS must be a JSON array of strings." + if [[ "$(jq 'length' <<< "$plural_paths")" == "0" ]]; then + benchmark_package_paths="$singular_path" + else + benchmark_package_paths=$(jq -r '.[]' <<< "$plural_paths") + fi +elif [[ -n "$plural_paths" ]]; then + benchmark_package_paths="$plural_paths" +else + benchmark_package_paths="$singular_path" +fi + +run_one() { + local benchmark_package_path="$1" + + swift_package "$benchmark_package_path" benchmark thresholds check --format metricP90AbsoluteThresholds --path "${benchmark_package_path}/Thresholds/${swift_version}/" + local rc="$?" + + # The measurements are within their thresholds, so there is nothing to recalculate. + if [[ "$rc" == 0 ]]; then + return 0 + fi + + # A non-zero exit from 'thresholds check' means either that thresholds + # regressed or that the build failed. Try 'thresholds update' to tell them + # apart: if that also fails it was a build error. + log "Recalculating thresholds for ${benchmark_package_path}..." + + swift_package "$benchmark_package_path" benchmark thresholds update --format metricP90AbsoluteThresholds --path "${benchmark_package_path}/Thresholds/${swift_version}/" + local update_rc="$?" + + if [[ "$update_rc" != 0 ]]; then + error "Benchmark in ${benchmark_package_path} failed to run due to build error." + return "$update_rc" + fi + + # Use echo, not log, so the diff is clean for tooling that scrapes it out of + # the job log. The marker carries the package path, so a multi-package run's + # diffs can be told apart. + echo "=== BEGIN DIFF (${benchmark_package_path}) ===" + git add --intent-to-add "${benchmark_package_path}/Thresholds/" + git diff HEAD -- "${benchmark_package_path}/Thresholds/" + return 1 +} + +overall_rc=0 +failed=() +while IFS= read -r path; do + [ -z "$path" ] && continue + echo "::group::Running benchmarks for $path" + run_one "$path" + rc=$? + echo "::endgroup::" + if [[ "$rc" -ne 0 ]]; then + overall_rc=$rc + failed+=("$path") + fi +done <<< "$benchmark_package_paths" + +if [[ "$overall_rc" -ne 0 ]]; then + echo "::error::Benchmark failures in: ${failed[*]}" +fi +exit "$overall_rc" diff --git a/.github/workflows/scripts/check-cxx-interop.sh b/.github/workflows/scripts/check-cxx-interop.sh new file mode 100755 index 00000000..dcd88061 --- /dev/null +++ b/.github/workflows/scripts/check-cxx-interop.sh @@ -0,0 +1,56 @@ +#!/bin/bash +##===----------------------------------------------------------------------===## +## +## This source file is part of the Swift.org open source project +## +## Copyright (c) 2026 Apple Inc. and the Swift project authors +## Licensed under Apache License v2.0 with Runtime Library Exception +## +## See https://swift.org/LICENSE.txt for license information +## See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +## +##===----------------------------------------------------------------------===## + +set -euo pipefail + +log() { printf -- "** %s\n" "$*" >&2; } +error() { printf -- "** ERROR: %s\n" "$*" >&2; } +fatal() { error "$@"; exit 1; } + +log "Checking for Cxx interoperability compatibility..." + +source_dir=$(pwd) +working_dir=$(mktemp -d "/tmp/tmp_swift_package_XXXXXXXXXX") +project_name=$(basename "$working_dir") +source_file="Sources/$project_name/$project_name.swift" +library_products=$(swift package dump-package | jq -r '.products[] | select(.type.library != null) | .name') +package_name=$(swift package dump-package | jq -r '.name') + +# The check works by importing the library products from a package built in Cxx +# interoperability mode. With none to import it would build an empty package and +# report success for a check it never ran. +if [ -z "$library_products" ]; then + fatal "No library products in $package_name, so there is nothing to import in Cxx interoperability mode." +fi + +cd "$working_dir" +swift package init + +{ + echo 'let swiftSettings: [SwiftSetting] = [.interoperabilityMode(.Cxx)]' + echo 'for target in package.targets { target.swiftSettings = (target.swiftSettings ?? []) + swiftSettings }' +} >> Package.swift + +echo "package.dependencies.append(.package(path: \"$source_dir\"))" >> Package.swift +echo >> "$source_file" + +for product in $library_products; do + echo "package.targets.first!.dependencies.append(.product(name: \"$product\", package: \"$package_name\"))" >> Package.swift + echo "import $product" >> "$source_file" +done + +# The matrix entry's command arguments arrive here, so swift_flags and per-version +# overrides reach the compiler that does the interoperability build. +swift build "$@" + +log "Passed the Cxx interoperability tests." diff --git a/.github/workflows/scripts/cross-pr-checkout.swift b/.github/workflows/scripts/cross-pr-checkout.swift index 9553f687..736b71ab 100644 --- a/.github/workflows/scripts/cross-pr-checkout.swift +++ b/.github/workflows/scripts/cross-pr-checkout.swift @@ -195,6 +195,20 @@ func getCrossRepoPrs(repository: String, prNumber: String) async throws -> [Cros return result } +/// The directory linked PRs are cloned into, for a checkout at `checkout` whose parent +/// directory is `parent`. +/// +/// A job that runs in a container has the checkout mounted at the root of the container's +/// own filesystem. A clone in the parent directory is then outside the mount: the host +/// never sees it and it does not outlive the container. Such a checkout takes its clones +/// below itself, where the mount carries them. +func linkedPullRequestsDirectory(checkout: URL, parent: URL) -> URL { + if parent.pathComponents.count > 1 { + return parent + } + return checkout.appendingPathComponent(".linked-pull-requests") +} + func main() async throws { guard ProcessInfo.processInfo.arguments.count >= 3 else { throw GenericError( @@ -224,14 +238,18 @@ func main() async throws { prNumber: crossRepoPr.prNumber ).base.ref - let workspaceDir = URL(fileURLWithPath: "..").resolvingSymlinksInPath() - let repoDir = workspaceDir.appendingPathComponent(crossRepoPr.repositoryName) + let checkoutsDirectory = linkedPullRequestsDirectory( + checkout: URL(fileURLWithPath: FileManager.default.currentDirectoryPath), + parent: URL(fileURLWithPath: "..").resolvingSymlinksInPath() + ) + try FileManager.default.createDirectory(at: checkoutsDirectory, withIntermediateDirectories: true) + let repoDir = checkoutsDirectory.appendingPathComponent(crossRepoPr.repositoryName) try run( git, "clone", "https://github.com/\(crossRepoPr.repositoryOwner)/\(crossRepoPr.repositoryName).git", "\(crossRepoPr.repositoryName)", - workingDirectory: workspaceDir + workingDirectory: checkoutsDirectory ) try run(git, "fetch", "origin", "pull/\(crossRepoPr.prNumber)/merge:pr_merge", workingDirectory: repoDir) try run(git, "checkout", baseBranch, workingDirectory: repoDir) diff --git a/.github/workflows/scripts/install-and-build-with-sdk.sh b/.github/workflows/scripts/install-and-build-with-sdk.sh index fe313d95..4366e1d9 100755 --- a/.github/workflows/scripts/install-and-build-with-sdk.sh +++ b/.github/workflows/scripts/install-and-build-with-sdk.sh @@ -170,6 +170,22 @@ if [[ "$INSTALL_ANDROID" == false && "$INSTALL_STATIC_LINUX" == false && "$INSTA fatal "At least one of --android or --static or --wasm must be specified" fi +# The Android build loops over the triples, so an empty list installs the Swift SDK and +# the NDK, builds nothing and still exits 0. An empty entry is worse: it reaches the +# compiler as a --swift-sdk with nothing after it. A caller joining a JSON array into +# the argument writes one whenever the array is empty. +if [[ "$INSTALL_ANDROID" == true ]]; then + if [[ ${#ANDROID_SDK_TRIPLES[@]} -eq 0 ]]; then + fatal "At least one --android-sdk-triple= must be specified with --android" + fi + + for android_sdk_triple in "${ANDROID_SDK_TRIPLES[@]}"; do + if [[ -z "${android_sdk_triple//[[:space:]]/}" ]]; then + fatal "--android-sdk-triple was given a blank value; each one must name a triple, e.g. aarch64-unknown-linux-android24" + fi + done +fi + log "Requested Swift version: $SWIFT_VERSION_INPUT" log "Install Android Swift SDK: $INSTALL_ANDROID" log "Install Static Linux Swift SDK: $INSTALL_STATIC_LINUX" @@ -296,9 +312,87 @@ find_latest_swift_version() { echo "${latest_version}|${android_sdk_checksum}|${static_linux_sdk_checksum}|${static_linux_sdk_version}|${wasm_sdk_checksum}" } -# Finds the latest Android or Static Linux or Wasm -# Swift SDK development snapshot for the inputted -# Swift version and its checksum. +SWIFT_DOWNLOAD_ROOT="https://download.swift.org" + +OS_NAME="" +OS_NAME_NO_DOT="" +OS_ARCH_SUFFIX="" + +# Detects OS from /etc/os-release and sets global variables +# +# OS_NAME: Lowercased OS name with the version dot included, e.g. ubuntu22.04 +# OS_NAME_NO_DOT: Version dot excluded, e.g. ubuntu2204 +# OS_ARCH_SUFFIX: "-aarch64" for aarch64 platforms, otherwise "" +initialize_os_info() { + if [[ -n "$OS_NAME" ]]; then + log "Already detected OS: $OS_NAME" + return 0 + fi + + if [[ ! -f /etc/os-release ]]; then + fatal "Cannot detect OS: /etc/os-release not found" + fi + + local os_id + os_id=$(grep '^ID=' /etc/os-release | cut -d'=' -f2 | tr -d '"' | tr '[:upper:]' '[:lower:]') + local version_id + version_id=$(grep '^VERSION_ID=' /etc/os-release | cut -d'=' -f2 | tr -d '"') + + if [[ -z "$os_id" || -z "$version_id" ]]; then + fatal "Could not parse OS information from /etc/os-release" + fi + + log "✅ Detected OS from /etc/os-release: ${os_id}${version_id}" + if [[ "$os_id" == "rhel" && "$version_id" == 9* ]]; then + OS_NAME="ubi9" + OS_NAME_NO_DOT="ubi9" + elif [[ "$os_id" == "amzn" && "$version_id" == "2" ]]; then + OS_NAME="amazonlinux2" + OS_NAME_NO_DOT="amazonlinux2" + elif [[ "$os_id" == "amzn" && "$version_id" == "2023" ]]; then + OS_NAME="amazonlinux2023" + OS_NAME_NO_DOT="amazonlinux2023" + else + # Ubuntu, Debian, Fedora + OS_NAME="${os_id}${version_id}" + OS_NAME_NO_DOT="${os_id}$(echo "$version_id" | tr -d '.')" + fi + log "Using OS name: $OS_NAME" + + local arch + arch=$(uname -m) + if [[ "$arch" == "aarch64" ]]; then + OS_ARCH_SUFFIX="-aarch64" + log "Detected aarch64 architecture, using suffix: $OS_ARCH_SUFFIX" + else + OS_ARCH_SUFFIX="" + log "Detected $arch architecture, using no suffix" + fi +} + +# Whether the toolchain tarball for a snapshot tag has been published. +# +# $1 (string): A snapshot tag, e.g. "swift-6.2-DEVELOPMENT-SNAPSHOT-2025-07-29-a" +toolchain_is_published() { + local snapshot_tag="$1" + + initialize_os_info + + local toolchain_url="${SWIFT_DOWNLOAD_ROOT}/${SWIFT_VERSION_BRANCH}/${OS_NAME_NO_DOT}${OS_ARCH_SUFFIX}/${snapshot_tag}/${snapshot_tag}-${OS_NAME}${OS_ARCH_SUFFIX}.tar.gz" + + local http_code + http_code=$(curl_with_retry -sSL --head -w "%{http_code}" -o /dev/null "$toolchain_url") + [[ "$http_code" != "404" ]] +} + +# Finds the newest Android, Static Linux or Wasm Swift SDK development snapshot +# whose matching toolchain has also been published. Echoes the snapshot tag, its +# checksum and its download filename, separated by "|". +# +# An SDK has to be built by the toolchain it is used with, so both halves of a +# snapshot are needed. They are published separately and a snapshot can carry one +# without the other, so this walks the snapshots newest-first and takes the first +# complete pair rather than assuming the newest SDK has a toolchain. # # $1 (string): Nightly Swift version, e.g. "6.2" or "main" # $2 (string): "android" or "static" or "wasm" @@ -308,43 +402,45 @@ find_latest_sdk_snapshot() { local version="$1" local sdk_name="$2" - log "Finding latest ${sdk_name}-sdk for Swift nightly-${version}" + log "Finding newest ${sdk_name}-sdk for Swift nightly-${version} with a matching toolchain" log "Fetching development snapshots from swift.org API..." local sdk_json sdk_json=$(curl_with_retry -fsSL "${SWIFT_API_INSTALL_ROOT}/dev/${version}/${sdk_name}-sdk.json") || fatal "Failed to fetch ${sdk_name}-sdk development snapshots" - # Extract the snapshot tag from the "dir" field of the first (newest) element - local snapshot_tag - snapshot_tag=$(echo "$sdk_json" | jq -r '.[0].dir') - - if [[ -z "$snapshot_tag" || "$snapshot_tag" == "null" ]]; then - fatal "No ${version} snapshot tag found for ${sdk_name}-sdk" + local snapshot_count + snapshot_count=$(echo "$sdk_json" | jq 'length') + if [[ "$snapshot_count" == "0" ]]; then + fatal "No ${version} snapshots listed for ${sdk_name}-sdk" fi - log "Found latest ${version} ${sdk_name}-sdk snapshot: $snapshot_tag" - - # Extract the checksum - local checksum - checksum=$(echo "$sdk_json" | jq -r '.[0].checksum') + local index=0 + while [[ "$index" -lt "$snapshot_count" ]]; do + local entry snapshot_tag checksum download + entry=$(echo "$sdk_json" | jq -c ".[$index]") + snapshot_tag=$(echo "$entry" | jq -r '.dir // empty') + checksum=$(echo "$entry" | jq -r '.checksum // empty') + download=$(echo "$entry" | jq -r '.download // empty') + index=$((index + 1)) - if [[ -z "$checksum" || "$checksum" == "null" ]]; then - fatal "No checksum found for ${sdk_name}-sdk snapshot" - fi - - log "Found ${sdk_name}-sdk checksum: ${checksum:0:12}..." - - # Extract the download filename - local download - download=$(echo "$sdk_json" | jq -r '.[0].download') + if [[ -z "$snapshot_tag" || -z "$checksum" || -z "$download" ]]; then + log "Skipping ${sdk_name}-sdk entry with incomplete metadata" + continue + fi - if [[ -z "$download" || "$download" == "null" ]]; then - fatal "No download filename found for ${sdk_name}-sdk snapshot" - fi + if ! toolchain_is_published "$snapshot_tag"; then + log "Skipping ${snapshot_tag}: the SDK is published but the matching toolchain is not" + continue + fi - log "Found ${sdk_name}-sdk download filename: $download" + log "Using ${sdk_name}-sdk snapshot: $snapshot_tag" + log "Found ${sdk_name}-sdk checksum: ${checksum:0:12}..." + log "Found ${sdk_name}-sdk download filename: $download" + echo "${snapshot_tag}|${checksum}|${download}" + return 0 + done - echo "${snapshot_tag}|${checksum}|${download}" + fatal "No ${version} ${sdk_name}-sdk snapshot has a matching toolchain for ${OS_NAME}${OS_ARCH_SUFFIX}. Checked $snapshot_count snapshot(s)." } SWIFT_VERSION_BRANCH="" @@ -450,65 +546,9 @@ get_installed_swift_tag() { echo "none" } -OS_NAME="" -OS_NAME_NO_DOT="" -OS_ARCH_SUFFIX="" - -# Detects OS from /etc/os-release and sets global variables -# -# OS_NAME: Lowercased OS name with the version dot included, e.g. ubuntu22.04 -# OS_NAME_NO_DOT: Version dot excluded, e.g. ubuntu2204 -# OS_ARCH_SUFFIX: "-aarch64" for aarch64 platforms, otherwise "" -initialize_os_info() { - if [[ -n "$OS_NAME" ]]; then - log "Already detected OS: $OS_NAME" - return 0 - fi - - if [[ ! -f /etc/os-release ]]; then - fatal "Cannot detect OS: /etc/os-release not found" - fi - - local os_id - os_id=$(grep '^ID=' /etc/os-release | cut -d'=' -f2 | tr -d '"' | tr '[:upper:]' '[:lower:]') - local version_id - version_id=$(grep '^VERSION_ID=' /etc/os-release | cut -d'=' -f2 | tr -d '"') - - if [[ -z "$os_id" || -z "$version_id" ]]; then - fatal "Could not parse OS information from /etc/os-release" - fi - - log "✅ Detected OS from /etc/os-release: ${os_id}${version_id}" - if [[ "$os_id" == "rhel" && "$version_id" == 9* ]]; then - OS_NAME="ubi9" - OS_NAME_NO_DOT="ubi9" - elif [[ "$os_id" == "amzn" && "$version_id" == "2" ]]; then - OS_NAME="amazonlinux2" - OS_NAME_NO_DOT="amazonlinux2" - elif [[ "$os_id" == "amzn" && "$version_id" == "2023" ]]; then - OS_NAME="amazonlinux2023" - OS_NAME_NO_DOT="amazonlinux2023" - else - # Ubuntu, Debian, Fedora - OS_NAME="${os_id}${version_id}" - OS_NAME_NO_DOT="${os_id}$(echo "$version_id" | tr -d '.')" - fi - log "Using OS name: $OS_NAME" - - local arch - arch=$(uname -m) - if [[ "$arch" == "aarch64" ]]; then - OS_ARCH_SUFFIX="-aarch64" - log "Detected aarch64 architecture, using suffix: $OS_ARCH_SUFFIX" - else - OS_ARCH_SUFFIX="" - log "Detected $arch architecture, using no suffix" - fi -} # Directory for extracted toolchains (if needed to match the SDKs) TOOLCHAIN_DIR="${HOME}/.swift-toolchains" -SWIFT_DOWNLOAD_ROOT="https://download.swift.org" download_and_verify() { local url="$1" @@ -538,8 +578,6 @@ download_and_verify() { rm -rf "$GNUPGHOME" "$temp_sig" } -readonly EXIT_TOOLCHAIN_NOT_FOUND=44 - # Downloads and extracts the Swift toolchain for the given snapshot tag # # $1 (string): A snapshot tag, e.g. "swift-6.2-DEVELOPMENT-SNAPSHOT-2025-07-29-a" @@ -560,14 +598,12 @@ download_and_extract_toolchain() { local toolchain_url="${snapshot_root}/${toolchain_filename}" local toolchain_sig_url="${snapshot_root}/${toolchain_sig_filename}" - # Check if toolchain is available + # A 404 means no toolchain to fetch: a nightly withdrawn since the snapshot + # search, or a release whose derived tag was never published for this OS. local http_code http_code=$(curl_with_retry -sSL --head -w "%{http_code}" -o /dev/null "$toolchain_url") if [[ "$http_code" == "404" ]]; then - log "Toolchain not found: ${toolchain_filename}" - log "Exiting workflow..." - # Don't fail the workflow if we can't find the right toolchain - exit $EXIT_TOOLCHAIN_NOT_FOUND + fatal "Toolchain not found: ${toolchain_filename}" fi # Create toolchain directory @@ -618,10 +654,6 @@ if [[ "$INSTALL_ANDROID" == true ]]; then log "Installing Swift toolchain to match Android Swift SDK snapshot: $ANDROID_SDK_TAG" initialize_os_info SWIFT_EXECUTABLE_FOR_ANDROID_SDK=$(download_and_extract_toolchain "$ANDROID_SDK_TAG") - if [[ $? -eq $EXIT_TOOLCHAIN_NOT_FOUND ]]; then - # Don't fail the workflow if we can't find the right toolchain - exit 0 - fi fi # Export the resolved Android SDK tag so subsequent workflow steps @@ -644,10 +676,6 @@ if [[ "$INSTALL_STATIC_LINUX" == true ]]; then log "Installing Swift toolchain to match Static Linux Swift SDK snapshot: $STATIC_LINUX_SDK_TAG" initialize_os_info SWIFT_EXECUTABLE_FOR_STATIC_LINUX_SDK=$(download_and_extract_toolchain "$STATIC_LINUX_SDK_TAG") - if [[ $? -eq $EXIT_TOOLCHAIN_NOT_FOUND ]]; then - # Don't fail the workflow if we can't find the right toolchain - exit 0 - fi fi fi @@ -659,10 +687,6 @@ if [[ "$INSTALL_WASM" == true ]]; then log "Installing Swift toolchain to match Wasm Swift SDK snapshot: $WASM_SDK_TAG" initialize_os_info SWIFT_EXECUTABLE_FOR_WASM_SDK=$(download_and_extract_toolchain "$WASM_SDK_TAG") - if [[ $? -eq $EXIT_TOOLCHAIN_NOT_FOUND ]]; then - # Don't fail the workflow if we can't find the right toolchain - exit 0 - fi fi fi @@ -714,7 +738,7 @@ install_android_sdk() { # permit the "--android-ndk" flag to override the default local android_ndk_version="${ANDROID_NDK_VERSION:-r27d}" - log "Checking for Android NDK $android_ndk_version at $ANDROID_NDK_HOME" + log "Checking for Android NDK $android_ndk_version at ${ANDROID_NDK_HOME:-(unset)}" # Download and install the Android NDK. # Note that we could use the system package manager, but it is @@ -805,6 +829,30 @@ install_sdks() { fi } +# Appends the SDK selector to a build command, unless the command already names +# one. +# +# A caller building for several triples in a loop names its own SDK. A second +# --swift-sdk would override the caller's. Worse, a YAML block scalar keeps its +# trailing newline, so the appended flag lands on a line of its own and the +# shell runs it as a command. +# +# $1 (string): The caller's build command +# $2 (string): The selector arguments to append +build_command_with_sdk() { + local command="$1" + local selector="$2" + + if [[ "$command" == *--swift-sdk* ]]; then + log "Build command names its own Swift SDK; not appending: $selector" + printf '%s' "$command" + return 0 + fi + + printf '%s %s' "$command" "$selector" +} + + build() { # Enable alias expansion to use a 'swift' alias for the executable path shopt -s expand_aliases @@ -818,15 +866,22 @@ build() { alias swift='$SWIFT_EXECUTABLE_FOR_ANDROID_SDK' - log "Using NDK at $ANDROID_NDK_HOME" + log "Using NDK at ${ANDROID_NDK_HOME:-(unset)}" # This can become a single invocation in the future when `swift build` supports multiple Android triples at once for android_sdk_triple in "${ANDROID_SDK_TRIPLES[@]}" ; do if [[ "$SWIFT_VERSION_INPUT" == "6.3" || "$SWIFT_VERSION_INPUT" == "nightly-6.3" ]]; then - local build_command="$SWIFT_BUILD_COMMAND --swift-sdk ${android_sdk_triple}" + local build_command + build_command=$(build_command_with_sdk "$SWIFT_BUILD_COMMAND" "--swift-sdk ${android_sdk_triple}") else - local build_command="$SWIFT_BUILD_COMMAND --swift-sdk ${sdk_name} --triple ${android_sdk_triple}" - # Work around swift-build issue with ANDROID_NDK_ROOT overriding ANDROID_NDK_HOME + local build_command + build_command=$(build_command_with_sdk "$SWIFT_BUILD_COMMAND" "--swift-sdk ${sdk_name} --triple ${android_sdk_triple}") + # Work around swift-build issue with ANDROID_NDK_ROOT overriding ANDROID_NDK_HOME. + # Exporting an empty value would point the compiler at a nonexistent NDK, so an + # unset one is a precondition failure rather than something to default. + if [[ -z "${ANDROID_NDK_HOME:-}" ]]; then + fatal "ANDROID_NDK_HOME is not set, and Swift $SWIFT_VERSION_INPUT locates the NDK through it" + fi export ANDROID_NDK_ROOT="${ANDROID_NDK_HOME}" fi if [[ -n "$SWIFT_BUILD_FLAGS" ]]; then @@ -860,7 +915,8 @@ build() { fi alias swift='$SWIFT_EXECUTABLE_FOR_STATIC_LINUX_SDK' - local build_command="$SWIFT_BUILD_COMMAND --swift-sdk $sdk_triple" + local build_command + build_command=$(build_command_with_sdk "$SWIFT_BUILD_COMMAND" "--swift-sdk $sdk_triple") if [[ -n "$SWIFT_BUILD_FLAGS" ]]; then build_command="$build_command $SWIFT_BUILD_FLAGS" fi @@ -884,7 +940,8 @@ build() { fi alias swift='$SWIFT_EXECUTABLE_FOR_WASM_SDK' - local build_command="$SWIFT_BUILD_COMMAND --swift-sdk $sdk_name" + local build_command + build_command=$(build_command_with_sdk "$SWIFT_BUILD_COMMAND" "--swift-sdk $sdk_name") if [[ -n "$SWIFT_BUILD_FLAGS" ]]; then build_command="$build_command $SWIFT_BUILD_FLAGS" fi diff --git a/.github/workflows/scripts/matrix/generate-matrix.swift b/.github/workflows/scripts/matrix/generate-matrix.swift new file mode 100755 index 00000000..36bfe242 --- /dev/null +++ b/.github/workflows/scripts/matrix/generate-matrix.swift @@ -0,0 +1,2313 @@ +#!/usr/bin/env swift +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +// Generates the job matrix the rest of the workflow runs: the workflow's inputs in +// as environment variables, the matrix out on standard output. Executable, so the +// workflows invoke it by path. +// +// yq reads the YAML an input is written in and writes the YAML the matrix comes out +// as, and jq writes the JSON form. Everything between them is typed. + +import Foundation + +// MARK: - Configuration + +/// What the run was configured with, as the caller wrote it. +struct Configuration { + /// The releases a platform tests by default, oldest first. + static let releases = ["6.1", "6.2", "6.3"] + /// The nightlies every platform runs: the next release's branch, and main. + static let nightlies = ["nightly-release", "nightly-main"] + /// The most recent release, which the SDK builds run on rather than every version: + /// bumping a release means adding to `releases` and nothing else. + static var latestRelease: String { + guard let latest = releases.last else { + fatal("Configuration.releases is empty, so no release is left for the supplementary checks to run on.") + } + return latest + } + /// The Swift versions a platform runs by default: every release, plus both nightlies. + static let defaultVersions = releases + nightlies + /// The Swift versions an SDK build runs by default. An SDK ships from the release it was + /// cut for onward, so an older release would build against one never published for it. + static let defaultSDKVersions = [latestRelease] + nightlies + + @Input("ENABLE_LINUX") var linuxEnabled = true + @Input("ENABLE_MACOS") var macOSEnabled = false + @Input("ENABLE_MACOS_SWIFTLY") var swiftlyEnabled = false + @Input("ENABLE_WINDOWS") var windowsEnabled = true + @Input("ENABLE_FREEBSD") var freeBSDEnabled = false + @Input("ENABLE_ANDROID_EMULATOR_TESTS") var androidEmulatorEnabled = false + @Input("ENABLE_CXX_INTEROP") var cxxInteropEnabled = false + + @Input("NIGHTLY_RELEASE_TOKEN") var nightlyReleaseToken = "6.4.x" + @Input("SWIFT_FLAGS") var swiftFlags = "" + @Input("SWIFT_NIGHTLY_FLAGS") var swiftNightlyFlags = "" + @Input("MINIMUM_SWIFT_VERSION") var minimumSwiftVersion = "" + @Input("ENABLE_SUBDIRECTORY_MANIFEST_SEARCH") var searchSubdirectories = false + @Input("MATRIX_MODE") var matrixMode = "jobs" + @Input("MATRIX_FORMAT") var matrixFormat = "yaml" + + @Input("LINUX_SWIFT_VERSIONS") var linuxVersions = Configuration.defaultVersions + @Input("LINUX_OS") var linuxOS = OSList(Runner.ubuntuDistribution) + @Input("LINUX_HOST_ARCHS") var linuxArchitectures = ["x86_64"] + @Input("LINUX_COMMAND") var linuxCommands: Commands = "swift test" + @Input("LINUX_SETUP_COMMAND") var linuxSetupCommand = "" + @Input("LINUX_ENV_VARS") var linuxEnvironment = JSONValue.object([:]) + @Input("LINUX_VERSION_OVERRIDES") var linuxOverrides = VersionOverrides() + @Input("LINUX_USE_DOCKER") var linuxUsesDocker = false + @Input("LINUX_DOCKERFILE") var linuxDockerfile = "" + @Input("LINUX_DOCKER_CAPABILITIES") var linuxCapabilities: [String] = [] + @Input("LINUX_DOCKER_SECURITY_OPTIONS") var linuxSecurityOptions: [String] = [] + + @Input("MACOS_XCODE_VERSIONS") var macOSXcodeVersions: [String] = [] + @Input("MACOS_SWIFT_VERSIONS") var macOSVersions: [String] = [] + @Input("MACOS_OS") var macOSOS: OSList = "tahoe" + @Input("MACOS_ARCH") var macOSArchitecture = "ARM64" + @Input("MACOS_RUNNER_POOL") var macOSPool = "general" + @Input("MACOS_COMMAND") var macOSCommands: Commands = "xcrun swift test" + @Input("MACOS_SETUP_COMMAND") var macOSSetupCommand = "" + @Input("MACOS_ENV_VARS") var macOSEnvironment = JSONValue.object([:]) + @Input("MACOS_VERSION_OVERRIDES") var macOSOverrides = VersionOverrides() + /// The owner whose self-hosted macOS pools these entries need. Empty means no check. + @Input("MACOS_REPOSITORY_OWNER") var macOSRepositoryOwner = "" + @Input("GITHUB_REPOSITORY_OWNER") var repositoryOwner = "" + @Input("XCODE_SCHEME") var xcodeScheme = "" + @Input("XCODE_TARGETS") var xcodeTargets = "" + @Input("XCODE_DEBUG_OUTPUT") var xcodeDebugOutput = false + @Input("MACOS_SWIFTLY_TOOLCHAINS") var swiftlyToolchains = [ + SwiftlyToolchain(xcodeVersion: "swift_6.3", swiftlyToolchain: "main-snapshot") + ] + @Input("MACOS_SWIFTLY_COMMAND") var swiftlyCommands: Commands = "swiftly run swift test" + + @Input("WINDOWS_SWIFT_VERSIONS") var windowsVersions = Configuration.defaultVersions + @Input("WINDOWS_OS") var windowsOS: OSList = "windows-2022" + @Input("WINDOWS_COMMAND") var windowsCommands: Commands = "swift test" + @Input("WINDOWS_SETUP_COMMAND") var windowsSetupCommand = "" + @Input("WINDOWS_ENV_VARS") var windowsEnvironment = JSONValue.object([:]) + @Input("WINDOWS_VERSION_OVERRIDES") var windowsOverrides = VersionOverrides() + @Input("WINDOWS_USE_DOCKER") var windowsUsesDocker = false + + @Input("ANDROID_NDK_VERSIONS") var androidNDKVersions = ["r27d", "r28c"] + @Input("ANDROID_SDK_TRIPLES") var androidTriples = [ + "aarch64-unknown-linux-android28", "x86_64-unknown-linux-android28", + ] + + @Input("CXX_INTEROP_SWIFT_VERSIONS") var cxxInteropVersions: [String] = [] + + @Input("FREEBSD_SWIFT_VERSIONS") var freeBSDVersions = ["nightly-main"] + @Input("FREEBSD_OS_VERSIONS") var freeBSDOSVersions = ["14.3"] + @Input("FREEBSD_COMMAND") var freeBSDCommands: Commands = "swift test" + @Input("FREEBSD_SETUP_COMMAND") var freeBSDSetupCommand = "" + @Input("FREEBSD_ENV_VARS") var freeBSDEnvironmentVariables = "" + + /// The Swift SDK builds, which differ only in the prefix their inputs share, the SDK they + /// build against, and the name their jobs carry. + let sdkBuilds = [ + SDKBuild(prefix: "linux_static_sdk", kind: .staticLinux, name: "Static Linux SDK Swift"), + SDKBuild(prefix: "wasm_sdk", kind: .wasm, name: "Wasm SDK Swift"), + SDKBuild(prefix: "embedded_wasm_sdk", kind: .embeddedWasm, name: "Embedded Wasm SDK Swift"), + SDKBuild(prefix: "android_sdk", kind: .android, name: "Android SDK Swift"), + ] + + /// Every per-version overrides input, so that an input no enabled group reads is still + /// reported. + var allOverrides: [VersionOverrides] { [self.linuxOverrides, self.macOSOverrides, self.windowsOverrides] } + + /// The Android SDK build, whose output the emulator tests run. + var androidSDKBuild: SDKBuild { + guard let build = self.sdkBuilds.first(where: { $0.kind == .android }) else { + fatal("no Android SDK build is configured, so nothing produces what the emulator runs.") + } + return build + } +} + +// MARK: - What the configuration asks for + +extension Configuration { + /// Fails on a pair of inputs that cannot both be honored. Each would otherwise produce a + /// matrix without the jobs the caller asked for. + func validatePairings(in mode: Matrix.Mode) { + // Toolchains mode emits neither the emulator nor the build it runs, so the pairing only + // has to hold where both could appear. + if mode == .jobs && self.androidEmulatorEnabled && !self.androidSDKBuild.enabled { + fatal( + "enable_android_emulator_tests needs enable_android_sdk_build; the emulator runs what that build produces." + ) + } + // An Apple-platform target rides on a macOS entry, so asking for one without enabling + // macOS produces no jobs at all. + if !self.xcodeTargets.isEmpty && !self.macOSEnabled { + fatal("xcode_targets is set but enable_macos is false; xcodebuild targets run on macOS entries.") + } + // A Windows container shares the host's kernel, so an image built for another Windows + // release does not start on it. ltsc2022 is the only Swift Windows image this repository + // names, so any other label fails rather than being paired with an image that cannot run there. + if self.windowsEnabled && self.windowsUsesDocker { + for os in self.windowsOS.names where os != ContainerImage.windowsRunner { + fatal( + """ + No Swift Windows container image is known for \(os); windows_use_docker supports \ + windows-2022. Other labels have to run natively. + """ + ) + } + } + } + + /// The images the Linux entries fan out over, or a single pass with none when they run on + /// the runner itself. + /// + /// A distribution the runner does not itself run needs an image: left native, the job would + /// test the runner's own distribution and pass. It logs the switch, since the caller asked + /// for a distribution rather than for a container. + func linuxImages() -> [ContainerImage?] { + let names = self.linuxOS.names + if !self.linuxUsesDocker && self.linuxDockerfile.isEmpty { + if names == [Runner.ubuntuDistribution] { return [nil] } + if names.count == 1 { + log("linux_os is \(names[0]) rather than \(Runner.ubuntuDistribution), so Linux runs in a container") + } else { + log("linux_os names \(names.count) distributions, so Linux runs in a container") + } + } + return names.map { + ContainerImage( + distribution: $0, + dockerfile: self.linuxDockerfile.isEmpty ? nil : self.linuxDockerfile, + capabilities: self.linuxCapabilities.isEmpty ? nil : self.linuxCapabilities, + securityOptions: self.linuxSecurityOptions.isEmpty ? nil : self.linuxSecurityOptions + ) + } + } + + /// The Ubuntu runners the Linux entries fan out over, one per architecture. + var linuxRunners: [Runner] { self.linuxArchitectures.map(Runner.ubuntu(architecture:)) } + + /// The one runner a group that does not fan out over architecture runs on: it follows the + /// first architecture configured rather than defaulting to one the tests do not use. + var primaryLinuxRunner: Runner { + Runner.ubuntu(architecture: self.linuxArchitectures.first ?? "x86_64") + } + + /// The self-hosted machines the macOS entries run on. + var macOSMachines: MacOSMachines { + MacOSMachines( + operatingSystems: self.macOSOS.names, + architecture: self.macOSArchitecture, + pool: self.macOSPool + ) + } + + /// The Swift versions the macOS entries run. + /// + /// The two macOS lists are different ways of naming a toolchain, not competing spellings + /// of one, so they combine; the release list is the default only when neither is set. + var macOSSwiftVersions: [String] { + if self.macOSVersions.isEmpty && self.macOSXcodeVersions.isEmpty { + return Configuration.releases + } + return self.macOSVersions + } + + /// The Swift versions the Cxx interop check runs. + /// + /// The check is supplementary rather than a full compatibility check, so it runs on the + /// newest release in the Linux list unless a caller names more. + var cxxInteropSwiftVersions: [String] { + if self.cxxInteropVersions.isEmpty { + return [SwiftVersion.newestRelease(in: self.linuxVersions)] + } + return self.cxxInteropVersions + } + + /// Whether the macOS entries are withheld from this repository, saying so when they are. + /// + /// They run on self-hosted pools a fork cannot reach, where its jobs would queue until they + /// time out. Withholding them produces no jobs rather than jobs that cannot start, and the + /// checks then treat macOS as a group this repository did not ask for. + func withholdsMacOS() -> Bool { + let owner = self.macOSRepositoryOwner + if owner.isEmpty || self.repositoryOwner.isEmpty || owner == self.repositoryOwner { + return false + } + log("Skipping macOS entries: this repository's owner (\(self.repositoryOwner)) is not \(owner)") + return true + } + + /// The flags an entry's command runs with, and what a version's override adds to them. + func flags(with overrides: VersionOverrides = VersionOverrides()) -> SwiftFlags { + SwiftFlags(release: self.swiftFlags, nightly: self.swiftNightlyFlags, overrides: overrides) + } +} + +// MARK: - Generating + +struct Generator { + private let configuration: Configuration + private let mode: Matrix.Mode + + /// Derived once when the run starts rather than at each point one is read: deriving one + /// logs what it resolved to, and reading it twice would log it twice. + private let linuxImages: [ContainerImage?] + private let minimum: MinimumVersion + private let macOSIsWithheld: Bool + /// Derived before the owner check, so a fork - which gets no macOS entries at all - still + /// reports a target the caller got wrong. + private let xcodeTargets: [XcodeTarget] + + /// Everything else a group needs is read from the configuration as that group is built. + init(_ configuration: Configuration, mode: Matrix.Mode) { + self.configuration = configuration + self.mode = mode + self.linuxImages = configuration.linuxImages() + self.minimum = MinimumVersion(for: configuration) + self.xcodeTargets = XcodeTarget.list(in: configuration) + self.macOSIsWithheld = configuration.withholdsMacOS() + } +} + +// MARK: - Assembling the job groups + +extension Generator { + private var linuxJobs: SwiftBuildJobs { + SwiftBuildJobs( + settings: JobGroupSettings( + enableInput: "enable_linux", + versionAxis: .list(input: "linux_swift_versions"), + versions: self.configuration.linuxVersions, + commandSource: .input(name: "linux_command"), + commands: self.configuration.linuxCommands, + overrides: self.configuration.linuxOverrides, + namePrefix: "Linux Swift" + ), + platform: .linux, + minimum: self.minimum, + releaseToken: self.configuration.nightlyReleaseToken, + flags: self.configuration.flags(with: self.configuration.linuxOverrides), + setupCommand: self.configuration.linuxSetupCommand, + environment: self.configuration.linuxEnvironment, + runners: self.configuration.linuxRunners, + images: self.linuxImages + ) + } + + private var macOSJobs: MacOSJobs { + MacOSJobs( + settings: JobGroupSettings( + enableInput: "enable_macos", + versionAxis: .list(input: "macos_swift_versions"), + versions: self.configuration.macOSSwiftVersions, + commandSource: .input(name: "macos_command"), + commands: self.configuration.macOSCommands, + overrides: self.configuration.macOSOverrides, + versionsExemptFromMinimum: self.configuration.macOSXcodeVersions, + namePrefix: "macOS Swift" + ), + minimum: self.minimum, + flags: self.configuration.flags(with: self.configuration.macOSOverrides), + setupCommand: self.configuration.macOSSetupCommand, + environment: self.configuration.macOSEnvironment, + machines: self.configuration.macOSMachines, + targets: self.xcodeTargets, + debugOutput: self.configuration.xcodeDebugOutput + ) + } + + private var macOSSwiftlyJobs: MacOSSwiftlyJobs { + MacOSSwiftlyJobs( + settings: JobGroupSettings( + enableInput: "enable_macos_swiftly", + versionAxis: .toolchains(input: "macos_swiftly_toolchains"), + versions: [], + commandSource: .input(name: "macos_swiftly_command"), + commands: self.configuration.swiftlyCommands, + namePrefix: "macOS Swiftly" + ), + flags: self.configuration.flags(), + setupCommand: self.configuration.macOSSetupCommand, + environment: self.configuration.macOSEnvironment, + machines: self.configuration.macOSMachines, + toolchains: self.configuration.swiftlyToolchains + ) + } + + private var windowsJobs: SwiftBuildJobs { + SwiftBuildJobs( + settings: JobGroupSettings( + enableInput: "enable_windows", + versionAxis: .list(input: "windows_swift_versions"), + versions: self.configuration.windowsVersions, + commandSource: .input(name: "windows_command"), + commands: self.configuration.windowsCommands, + overrides: self.configuration.windowsOverrides, + namePrefix: "Windows Swift" + ), + platform: .windows, + minimum: self.minimum, + releaseToken: self.configuration.nightlyReleaseToken, + flags: self.configuration.flags(with: self.configuration.windowsOverrides), + setupCommand: self.configuration.windowsSetupCommand, + environment: self.configuration.windowsEnvironment, + runners: self.configuration.windowsOS.names.map(Runner.windows(label:)), + images: self.configuration.windowsUsesDocker ? [.windows] : [nil] + ) + } + + private var cxxInteropJobs: SwiftBuildJobs { + SwiftBuildJobs( + settings: JobGroupSettings( + enableInput: "enable_cxx_interop", + versionAxis: .list(input: "cxx_interop_swift_versions"), + versions: self.configuration.cxxInteropSwiftVersions, + // The check is the command rather than a place to run one, so it takes none as input. + // The runner expands SCRIPTS_ROOT, so that reference stays literal here. + commandSource: .fixed, + commands: "${SCRIPTS_ROOT}/check-cxx-interop.sh", + overrides: self.configuration.linuxOverrides, + namePrefix: "Cxx interop Swift" + ), + platform: .linux, + minimum: self.minimum, + releaseToken: self.configuration.nightlyReleaseToken, + flags: self.configuration.flags(with: self.configuration.linuxOverrides), + setupCommand: self.configuration.linuxSetupCommand, + environment: self.configuration.linuxEnvironment, + runners: [self.configuration.primaryLinuxRunner], + images: self.linuxImages + ) + } + + private var freeBSDJobs: FreeBSDJobs { + FreeBSDJobs( + settings: JobGroupSettings( + enableInput: "enable_freebsd", + versionAxis: .list(input: "freebsd_swift_versions"), + versions: self.configuration.freeBSDVersions, + commandSource: .input(name: "freebsd_command"), + commands: self.configuration.freeBSDCommands, + namePrefix: "FreeBSD" + ), + setupCommand: self.configuration.freeBSDSetupCommand, + osVersions: self.configuration.freeBSDOSVersions, + buildFlags: self.configuration.swiftNightlyFlags, + environmentVariables: self.configuration.freeBSDEnvironmentVariables + ) + } + + /// A group that builds against a Swift SDK. Its inputs all share one prefix, and it fans out + /// over neither the distribution nor the architecture: install-and-build-with-sdk.sh + /// fetches a toolchain matched to the SDK, and job-runner-linux.sh refuses an entry + /// carrying both an sdk and a container. + private func sdkJobs(_ build: SDKBuild) -> SwiftBuildJobs { + var jobs = SwiftBuildJobs( + settings: JobGroupSettings( + enableInput: build.enableInput, + versionAxis: .list(input: build.versionsInput), + versions: build.versions, + commandSource: .input(name: build.commandInput), + commands: build.commands, + overrides: self.configuration.linuxOverrides, + namePrefix: build.name + ), + platform: .linux, + minimum: self.minimum, + releaseToken: self.configuration.nightlyReleaseToken, + flags: self.configuration.flags(with: self.configuration.linuxOverrides), + setupCommand: build.setupCommand, + environment: self.configuration.linuxEnvironment, + runners: [self.configuration.primaryLinuxRunner], + sdk: MatrixEntry.SwiftBuild.SDK(kind: build.kind) + ) + guard build.kind == .android else { return jobs } + jobs.sdk?.triples = self.configuration.androidTriples + jobs.ndkVersions = self.configuration.androidNDKVersions + jobs.androidEmulator = self.configuration.androidEmulatorEnabled + // The emulator runs what the build produced, so the build is told to make test binaries + // and takes none of the flags inputs. + jobs.flags = .always(self.configuration.androidEmulatorEnabled ? "--build-tests" : "") + return jobs + } + + /// The groups this run produces entries for, in the order the jobs come out in. + /// + /// A group nobody asked for is not built, so nothing it carries is read and nothing it + /// carries can fail the run. This is the only place that decides whether a group is built. + private var jobGroups: [any JobGroup] { + var groups: [any JobGroup] = [] + if self.configuration.linuxEnabled { groups.append(self.linuxJobs) } + // The macOS pools are self-hosted, and a fork cannot reach them. + if self.configuration.macOSEnabled && !self.macOSIsWithheld { groups.append(self.macOSJobs) } + if self.configuration.swiftlyEnabled && !self.macOSIsWithheld { groups.append(self.macOSSwiftlyJobs) } + if self.configuration.windowsEnabled { groups.append(self.windowsJobs) } + // A group that exists only to run a particular command has no meaning where the caller + // supplies the command instead, so toolchains mode does not emit those. + if self.mode == .toolchains { return groups } + groups += self.configuration.sdkBuilds.filter(\.enabled).map(self.sdkJobs) + if self.configuration.cxxInteropEnabled { groups.append(self.cxxInteropJobs) } + if self.configuration.freeBSDEnabled { groups.append(self.freeBSDJobs) } + return groups + } +} + +// MARK: - Producing the matrix + +extension Generator { + func generate() -> Matrix { + let groups = self.jobGroups + + // An overrides key is valid if it names a version in any enabled group that reads it: the + // lists are independent, so an SDK build can name a version the Linux test list does not. + var readable: [String: [String]] = [:] + for group in groups { + readable[group.settings.overrides.name, default: []] += group.settings.selectableVersions + } + for overrides in self.configuration.allOverrides { + overrides.validateKeys(against: Set(readable[overrides.name] ?? []).sorted()) + } + + for group in groups { group.validate(against: self.minimum) } + + // A group the caller asked for that produces nothing is a job missing from a run that + // still reports success, whatever the other groups produced. A deliberate skip - the + // fork guard, or toolchains mode - leaves the group unbuilt, so it is not such a group. + var entries: [MatrixEntry] = [] + for group in groups { + let produced = group.entries + if produced.isEmpty { + fatal( + """ + \(group.settings.enableInput) is set, but produces no jobs: one of the lists it fans out over \ + is empty, so the run would report success without them. + """ + ) + } + entries += produced + } + + if groups.isEmpty { + log("No matrix entries: nothing is enabled") + } else { + log("Generated \(entries.count) matrix entries") + } + + switch self.mode { + case .jobs: return Matrix(config: entries) + case .toolchains: return Matrix(config: entries.map(\.withoutCommands)) + } + } +} + +// MARK: - Xcodebuild targets + +/// A platform built and tested through xcodebuild. It is a step inside a macOS job rather +/// than a job of its own. +struct XcodeTarget: Encodable { + var platform: String + var scheme: String + var buildDestination: String + var testDestination: String + var build: Bool + var test: Bool + + enum CodingKeys: String, CodingKey { + case platform, scheme, build, test + case buildDestination = "build_destination" + case testDestination = "test_destination" + } + + /// The destinations a target takes when it names none of its own. + /// + /// These name the newest device of each kind, which ages with every Xcode release, so a + /// target can give its own instead. + static let destinations: [String: (build: String, test: String)] = [ + "macOS": (build: "generic/platform=macos,variant=macos", test: "name=My Mac,variant=macos"), + "Catalyst": ( + build: "generic/platform=macos,variant=Mac Catalyst", test: "name=My Mac,variant=Mac Catalyst" + ), + "iOS": (build: "generic/platform=ios", test: "name=iPhone Air"), + "watchOS": (build: "generic/platform=watchos", test: "name=Apple Watch Ultra 3 (49mm)"), + "tvOS": (build: "generic/platform=tvos", test: "name=Apple TV 4K (3rd generation)"), + "visionOS": (build: "generic/platform=visionos", test: "name=Apple Vision Pro"), + ] +} + +extension XcodeTarget { + /// The platforms to build and test through xcodebuild, carried by every macOS entry: a map + /// of platform to that target's settings, or a list of platforms taking the defaults. + /// + /// Reading them runs yq over what the caller wrote. + static func list(in configuration: Configuration) -> [XcodeTarget] { + let text = configuration.xcodeTargets + if text.isEmpty { return [] } + // A scalar is rejected rather than read as one platform: the parse is here only to tell a + // map from a list, and the keys it yields have to match one in `destinations`, so YAML + // rewriting one cannot pass unnoticed. + guard let parsed = Parsed(text) else { + fatal("xcode_targets is not valid JSON or YAML: \(text)") + } + guard parsed.isCollection else { + fatal( + """ + xcode_targets takes a map, such as {iOS: {build: true}}, or a list, such as [iOS, watchOS], \ + but got: \(text) + """ + ) + } + + // A list asks for each platform with every setting left at its default. + var members = parsed.mapMembers.map { (platform: $0.key, settings: $0.value) } + if let listed = parsed.value.asArray { + guard listed.allSatisfy({ $0.asString != nil }) else { + fatal("xcode_targets as a list takes platform names, such as [iOS, watchOS], but got: \(text)") + } + members = listed.map { (platform: $0.text, settings: JSONValue.object([:])) } + } + + return members.map { platform, value in + guard let defaults = XcodeTarget.destinations[platform] else { + fatal( + """ + xcode_targets names an unknown platform '\(platform)'; the platforms are macOS, Catalyst, iOS, \ + watchOS, tvOS and visionOS. + """ + ) + } + // A platform named with no settings is written `iOS:`, which parses as null. + guard case .object(let settings) = (value.isNull ? .object([:]) : value) else { + fatal( + "xcode_targets settings for \(platform) take the form {build: true, test: true}, but got: \(value)" + ) + } + // A misspelled setting would otherwise be dropped and its default left in place: a + // target carrying `sheme` would build the default scheme, or fail for lack of one. + let unknown = Set(settings.keys) + .subtracting(["build", "test", "scheme", "build_destination", "test_destination"]).sorted() + guard unknown.isEmpty else { + fatal( + """ + xcode_targets settings for \(platform) include unknown keys: \(unknown.joined(separator: ", ")). \ + A target takes build, test, scheme, build_destination and test_destination. + """ + ) + } + + func flag(_ key: String, or fallback: Bool) -> Bool { + guard let setting = settings[key]?.nonNull else { return fallback } + guard let value = setting.asBool else { + fatal( + "xcode_targets settings for \(platform) take build and test as true or false, but got: \(value)" + ) + } + return value + } + // Building is the default because a package can be built for every platform, while + // testing needs a simulator and takes far longer. + let build = flag("build", or: true) + let test = flag("test", or: false) + guard build || test else { + fatal( + "xcode_targets asks for \(platform) with build and test both false, so the target would do nothing." + ) + } + + let scheme = settings["scheme"]?.text ?? configuration.xcodeScheme + if scheme.isEmpty { + fatal( + """ + xcode_targets names \(platform) but no scheme reaches it; set xcode_scheme, or give the target \ + its own. xcodebuild builds nothing without a scheme. + """ + ) + } + return XcodeTarget( + platform: platform, + scheme: scheme, + buildDestination: settings["build_destination"]?.text ?? defaults.build, + testDestination: settings["test_destination"]?.text ?? defaults.test, + build: build, + test: test + ) + } + } +} + +// MARK: - Job groups + +/// One group of jobs the matrix can hold: the entries one enable turns on. +protocol JobGroup { + var settings: JobGroupSettings { get } + /// In the order the jobs come out in. + var entries: [MatrixEntry] { get } + /// Fails on a configuration that would drop a job the caller asked for from this group. + func validate(against minimum: MinimumVersion) +} + +extension JobGroup { + /// The checks every group makes. + func validateSettings(against minimum: MinimumVersion) { + self.settings.validateCommandVersions() + self.settings.validateReplaceableCommand() + self.settings.validateRunnableVersions(minimum) + } + + func validate(against minimum: MinimumVersion) { + self.validateSettings(against: minimum) + } +} + +/// What a job group fans out over. +enum VersionAxis { + /// A Swift version list, named by this input. + case list(input: String) + /// Swiftly-managed toolchains rather than a version list, named by this input, so a label + /// has no versions to select from. + case toolchains(input: String) + + /// The input a message names, so the caller knows which knob to turn. + var inputName: String { + switch self { + case .list(let input): return input + case .toolchains(let input): return input + } + } +} + +/// Where a job group's command comes from. +enum CommandSource { + /// The caller names it, in the input given, so a per-version `command:` override can replace + /// it. + case input(name: String) + /// The command is the check itself rather than a place to run one, so an override replacing + /// it would leave the job named for something it no longer does. + case fixed + + /// The input a message names, or nil when the command is the check itself. + var inputName: String? { + switch self { + case .input(let name): return name + case .fixed: return nil + } + } +} + +/// What a job group runs, and the inputs a message about it has to name. +struct JobGroupSettings { + var enableInput: String + var versionAxis: VersionAxis + var versions: [String] + var commandSource: CommandSource + var commands: Commands + var overrides = VersionOverrides() + /// Versions a label may select that the minimum-version filter never sees. Only macOS has + /// any: its Xcode list names Xcodes rather than Swift versions. + var versionsExemptFromMinimum: [String] = [] + var namePrefix: String + + var selectableVersions: [String] { + self.versionsExemptFromMinimum.isEmpty + ? self.versions + : Set(self.versions + self.versionsExemptFromMinimum).sorted() + } + + /// An entry's name: what distinguishes it, led by its command's label. + /// + /// An axis with one value contributes nothing, and the label leads rather than trailing the + /// version: entry names are required status checks in adopting repositories, and a label + /// after the version would read as part of it. + func name(_ base: String, _ suffixes: String?..., for variant: Commands.Variant) -> String { + let name = ([base] + suffixes.compactMap { $0 }).joined(separator: " ") + guard let label = self.commands.nameLabel(for: variant) else { return name } + return "\(label) \(name)" + } +} + +extension JobGroupSettings { + /// Fails when a label selects a version the group does not run: the label contributes no + /// entries, so the command the caller named is missing from a run that reports success. + func validateCommandVersions() { + guard let commandsInput = self.commandSource.inputName else { return } + switch self.versionAxis { + case .toolchains(let toolchainsInput): + // Fanning out over toolchains leaves a label nothing to select from, so the versions it + // names carry nothing. + if self.commands.contains(where: { $0.swiftVersions != nil }) { + fatal("\(commandsInput) takes no versions; its toolchains come from \(toolchainsInput).") + } + case .list: + let selectable = self.selectableVersions + let unmatched = self.commands.flatMap { variant in + (variant.swiftVersions ?? []).filter { !selectable.contains($0) }.map { "\(variant.label): \($0)" } + } + guard unmatched.isEmpty else { + fatal( + """ + \(commandsInput) selects versions the matrix does not hold: \(unmatched.joined(separator: ", ")). \ + Valid versions: \(selectable.joined(separator: " ")) + """ + ) + } + } + } + + /// Fails when a per-version `command:` override has nothing to replace: with more than one + /// command, honoring it would give every label the same one and leave jobs that differ only + /// in name. + func validateReplaceableCommand() { + let replaced = self.overrides.versionsReplacingTheCommand(among: self.versions) + if replaced.isEmpty { return } + guard let commandsInput = self.commandSource.inputName else { + fatal( + """ + \(self.overrides.name) replaces the command for \(replaced.joined(separator: ", ")), which \ + "\(self.namePrefix)" also runs. Its command is the check itself, so replacing it would leave the \ + job named for something it no longer does. Drop the command from the override, or take that \ + version out of this group's version list. + """ + ) + } + guard self.commands.count > 1 else { return } + fatal( + """ + \(self.overrides.name) replaces the command for \(replaced.joined(separator: ", ")), but \ + \(commandsInput) has more than one command configured, so there is no single command to replace. \ + Give that label its own versions instead. + """ + ) + } + + /// Fails when the minimum-version filter leaves the group, or one of its labels, nothing to + /// run. Dropping some versions is the filter working; dropping all of them is a job the + /// caller asked for and did not get, in a run that still reports success. + func validateRunnableVersions(_ minimum: MinimumVersion) { + // An empty list is a group given no versions rather than one the filter emptied; the + // whole-matrix guard reports that against the enables. + if self.versions.isEmpty { return } + let runnable = self.versions.filter(minimum.admits) + if runnable.isEmpty { + fatal( + """ + \(self.enableInput) is set, but the minimum Swift version \(minimum.text) removes every version in \ + \(self.versionAxis.inputName) (\(self.versions.joined(separator: " "))), so it would produce no \ + jobs. \(MinimumVersion.remedy) + """ + ) + } + guard let commandsInput = self.commandSource.inputName else { return } + for variant in self.commands { + // A label naming no versions of its own runs the group's whole list, which the check + // above covers. + guard let swiftVersions = variant.swiftVersions else { continue } + guard variant.versions(among: self.versionsExemptFromMinimum + runnable).isEmpty else { continue } + fatal( + """ + \(commandsInput) label '\(variant.label)' runs only on \(swiftVersions.joined(separator: " ")), which \ + minimum Swift version \(minimum.text) removes, so that label would produce no jobs while the others \ + still run. \(MinimumVersion.remedy) + """ + ) + } + } +} + +// MARK: - Where a job runs + +/// A machine an entry runs on: the labels that select it, and what it contributes to an +/// entry's name when its group runs on more than one. +struct Runner { + /// The distribution GitHub's Ubuntu runners run, which is what `linux_os` defaults to: a + /// job on any other one needs a container image. + static let ubuntuDistribution = "noble" + + var labels: [String] + var name: String + + /// The GitHub Ubuntu runner for an architecture. + static func ubuntu(architecture: String) -> Runner { + Runner(labels: [architecture == "aarch64" ? "ubuntu-24.04-arm" : "ubuntu-24.04"], name: architecture) + } + + /// A Windows runner, which the label that selects it also names. + static func windows(label: String) -> Runner { + Runner(labels: [label], name: label) + } + + /// A machine from one of the self-hosted macOS pools. + static func macOS(os: String, architecture: String, pool: String) -> Runner { + Runner(labels: ["self-hosted", "macos", os, architecture, pool], name: os) + } +} + +/// The self-hosted macOS machines a group's entries run on. +struct MacOSMachines { + var operatingSystems: [String] + var architecture: String + var pool: String + + /// The machines the entries fan out over. + var runners: [Runner] { + self.operatingSystems.map { Runner.macOS(os: $0, architecture: self.architecture, pool: self.pool) } + } + + /// The machines one swiftly toolchain runs on: an entry naming its own OS runs there alone, + /// and on the architecture it names. + func runners(for toolchain: SwiftlyToolchain) -> [Runner] { + let operatingSystems = toolchain.osVersion.map { [$0] } ?? self.operatingSystems + return operatingSystems.map { + Runner.macOS(os: $0, architecture: toolchain.architecture ?? self.architecture, pool: self.pool) + } + } +} + +/// A Swift container image an entry runs in, and what the Docker inputs add to it. +struct ContainerImage { + /// The only Windows runner a Swift container image is published for. + static let windowsRunner = "windows-2022" + + /// The Swift Windows Server image, which is tagged like a distribution. + static let windows = ContainerImage(distribution: "windowsservercore-ltsc2022") + + /// The part of the image tag that follows the toolchain, such as `noble`. + var distribution: String + /// A Dockerfile that extends the image, when the caller named one. + var dockerfile: String? + var capabilities: [String]? + var securityOptions: [String]? + + /// The image one toolchain runs in. + func container(_ toolchain: Toolchain) -> MatrixEntry.SwiftBuild.Container { + MatrixEntry.SwiftBuild.Container( + image: toolchain.image(distribution: self.distribution), + dockerfile: self.dockerfile, + capabilities: self.capabilities, + securityOptions: self.securityOptions + ) + } +} + +/// The flags an entry's command runs with, by the kind of toolchain it runs on. +struct SwiftFlags { + /// What a released toolchain's command takes. + var release: String + /// What a nightly toolchain's command takes. + var nightly: String + /// What a particular version adds to those. + var overrides = VersionOverrides() + + /// The same arguments on every toolchain, for a build that takes none of the flags inputs. + static func always(_ arguments: String) -> SwiftFlags { + SwiftFlags(release: arguments, nightly: arguments) + } + + /// The arguments one version's command runs with. + func arguments(for version: String) -> [String] { + let base = self.flags(nightly: version.hasPrefix("nightly-")) + return self.split("\(base) \(self.overrides.arguments(for: version) ?? "")") + } + + /// The arguments for a toolchain that has no version to look an override up by; this is + /// how a swiftly snapshot takes the nightly flags. + func arguments(nightly: Bool) -> [String] { + self.split(self.flags(nightly: nightly)) + } + + private func flags(nightly: Bool) -> String { + nightly ? self.nightly : self.release + } + + /// A flags input as the arguments it names. Globbing never happens, so a wildcard reaches + /// the runner as the argument the caller wrote. + private func split(_ flags: String) -> [String] { + flags.split(whereSeparator: \.isWhitespace).map(String.init) + } +} + +// MARK: - The groups + +/// A group whose entries carry a `swift_build`: the Linux tests, the SDK builds, the Cxx +/// interop check, and Windows. They differ in the axes they fan out over, and in the machines +/// and images those axes name. +struct SwiftBuildJobs: JobGroup { + var settings: JobGroupSettings + var platform: MatrixEntry.Platform + var minimum: MinimumVersion + var releaseToken: String + var flags: SwiftFlags + var setupCommand: String + var environment: JSONValue + /// The machines the entries fan out over. + var runners: [Runner] + /// The images the entries fan out over, or a single pass with none for entries that run on + /// the runner itself. + var images: [ContainerImage?] = [nil] + /// The SDK the entries build against, which the NDK axis completes. + var sdk: MatrixEntry.SwiftBuild.SDK? + /// The NDK releases the entries fan out over, which only the Android SDK build has. + var ndkVersions: [String]? + /// Carried by the Android SDK build, telling the executor whether the emulator runs what + /// the build produced. + var androidEmulator: Bool? + + /// One entry's value from every axis. + private struct Combination { + var runner: Runner + var image: ContainerImage? + var ndkVersion: String? + var variant: Commands.Variant + var version: String + } + + /// One pass per NDK release, or a single pass for a build with no NDK to name. + private var ndkPasses: [String?] { self.ndkVersions?.map(Optional.some) ?? [nil] } + + /// In the order the jobs come out in. + private var combinations: [Combination] { + self.runners.flatMap { runner in + self.images.flatMap { image in + self.ndkPasses.flatMap { ndkVersion in + self.settings.commands.flatMap { variant in + variant.versions(among: self.settings.versions).filter(self.minimum.admits).map { + Combination( + runner: runner, + image: image, + ndkVersion: ndkVersion, + variant: variant, + version: $0 + ) + } + } + } + } + } + } + + var entries: [MatrixEntry] { + self.combinations.map { combination in + let toolchain = Toolchain(version: combination.version, releaseToken: self.releaseToken) + var sdk = self.sdk + sdk?.ndkVersion = combination.ndkVersion + return MatrixEntry( + platform: self.platform, + name: self.settings.name( + "\(self.settings.namePrefix) \(combination.version)", + combination.ndkVersion.map { "NDK \($0)" }, + self.images.count > 1 ? combination.image?.distribution : nil, + self.runners.count > 1 ? combination.runner.name : nil, + for: combination.variant + ), + runner: combination.runner.labels, + swiftBuild: MatrixEntry.SwiftBuild( + toolchain, + sdk: sdk, + container: combination.image?.container(toolchain) + ), + setupCommand: self.setupCommand, + command: self.command(for: combination), + commandArguments: self.flags.arguments(for: combination.version), + env: self.environment, + androidEmulator: self.androidEmulator + ) + } + } + + /// The command one entry runs. A group whose command is the check itself takes no + /// per-version replacement. + private func command(for combination: Combination) -> String { + switch self.settings.commandSource { + case .fixed: + return combination.variant.command + case .input: + return self.settings.overrides.command(for: combination.version) ?? combination.variant.command + } + } +} + +/// The macOS entries: one pass over the Xcode list, which names Xcodes, and one over the +/// Swift list, which names Swift versions. A label's versions select from whichever list holds +/// them, so a label naming an Xcode contributes nothing to the Swift pass. +struct MacOSJobs: JobGroup { + var settings: JobGroupSettings + var minimum: MinimumVersion + var flags: SwiftFlags + var setupCommand: String + var environment: JSONValue + var machines: MacOSMachines + var targets: [XcodeTarget] + var debugOutput: Bool + /// What the Xcode pass's entry names carry before the version. The Swift pass takes the + /// group's own prefix. + let xcodeNamePrefix = "macOS Xcode" + + /// The Xcodes a label may select, which the group's Xcode list names. + private var xcodeVersions: [String] { self.settings.versionsExemptFromMinimum } + + var entries: [MatrixEntry] { + self.machines.runners.flatMap { runner in + self.pass(self.xcodeVersions, on: runner, namePrefix: self.xcodeNamePrefix, namesXcode: true) + + self.pass( + self.settings.versions, + on: runner, + namePrefix: self.settings.namePrefix, + namesXcode: false + ) + } + } + + private func pass( + _ versions: [String], + on runner: Runner, + namePrefix: String, + namesXcode: Bool + ) -> [MatrixEntry] { + if versions.isEmpty { return [] } + return self.settings.commands.flatMap { variant in + // The Xcode list names Xcodes, which the minimum Swift version does not order. + variant.versions(among: versions).filter { namesXcode || self.minimum.admits($0) }.map { version in + MatrixEntry( + platform: .macOS, + name: self.settings.name( + "\(namePrefix) \(version)", + self.machines.runners.count > 1 ? runner.name : nil, + for: variant + ), + runner: runner.labels, + xcodeBuild: MatrixEntry.XcodeBuild( + swiftVersion: namesXcode ? nil : version, + xcodeVersion: namesXcode ? version : nil, + targets: self.targets, + debugOutput: self.debugOutput + ), + setupCommand: self.setupCommand, + command: self.settings.overrides.command(for: version) ?? variant.command, + commandArguments: self.flags.arguments(for: version), + env: self.environment + ) + } + } + } +} + +/// The macOS entries driven by a swiftly-managed toolchain, which fan out over the toolchains +/// rather than a version list. +struct MacOSSwiftlyJobs: JobGroup { + var settings: JobGroupSettings + var flags: SwiftFlags + var setupCommand: String + var environment: JSONValue + var machines: MacOSMachines + var toolchains: [SwiftlyToolchain] + + func validate(against minimum: MinimumVersion) { + self.validateSettings(against: minimum) + // Skipping the entry would drop a job from a run that still reports success, which is how + // a misspelled key goes unnoticed. + for toolchain in self.toolchains + where toolchain.xcodeVersion.isEmpty || toolchain.swiftlyToolchain.isEmpty { + fatal( + """ + macos_swiftly_toolchains entry needs both xcode_version and swiftly_toolchain: \ + xcode_version "\(toolchain.xcodeVersion)", swiftly_toolchain "\(toolchain.swiftlyToolchain)" + """ + ) + } + } + + var entries: [MatrixEntry] { + self.toolchains.flatMap { toolchain in + self.machines.runners(for: toolchain).flatMap { runner in + self.settings.commands.map { variant in + MatrixEntry( + platform: .macOS, + name: self.settings.name( + "\(self.settings.namePrefix) \(toolchain.swiftlyToolchain) " + + "(Xcode \(toolchain.xcodeVersion))", + self.machines.operatingSystems.count > 1 ? runner.name : nil, + for: variant + ), + runner: runner.labels, + xcodeBuild: MatrixEntry.XcodeBuild( + xcodeVersion: toolchain.xcodeVersion, + swiftlyToolchain: toolchain.swiftlyToolchain + ), + setupCommand: self.setupCommand, + command: variant.command, + // A snapshot takes the nightly flags, as a "nightly-" prefix does elsewhere. + commandArguments: self.flags.arguments( + nightly: toolchain.swiftlyToolchain.contains("snapshot") + ), + env: self.environment + ) + } + } + } + } +} + +/// The FreeBSD entries, which carry a virtual machine and a toolchain URL rather than a +/// `swift_build`. +struct FreeBSDJobs: JobGroup { + /// The one toolchain published for FreeBSD. + private static let toolchainURL = + "https://download.swift.org/tmp-ci-nightly/development/freebsd-14_ci_latest.tar.gz" + + var settings: JobGroupSettings + var setupCommand: String + var osVersions: [String] + var buildFlags: String + var environmentVariables: String + + func validate(against minimum: MinimumVersion) { + self.validateSettings(against: minimum) + // One FreeBSD toolchain is published, so a version naming anything else would produce a + // job labeled for a toolchain it does not install. + for version in self.settings.versions where version != "nightly-main" { + fatal("FreeBSD supports only the nightly-main Swift version, not '\(version)'.") + } + // The published tarballs are named by major release and only 14 has one, so any other OS + // version would install a toolchain built for a release the job is not labeled for. + for osVersion in self.osVersions where osVersion != "14" && !osVersion.hasPrefix("14.") { + fatal( + "No Swift toolchain is published for FreeBSD \(osVersion); freebsd_os_versions supports 14 releases." + ) + } + } + + var entries: [MatrixEntry] { + self.osVersions.flatMap { osVersion in + self.settings.commands.flatMap { variant in + variant.versions(among: self.settings.versions).map { version in + MatrixEntry( + platform: .freeBSD, + name: self.settings.name( + "\(self.settings.namePrefix) \(version) - \(osVersion) - x86_64", + for: variant + ), + runner: ["ubuntu-24.04"], + freeBSDBuild: MatrixEntry.FreeBSDBuild( + osVersion: osVersion, + swiftVersion: version, + swiftURL: FreeBSDJobs.toolchainURL, + buildFlags: self.buildFlags, + envVars: self.environmentVariables + ), + setupCommand: self.setupCommand, + command: variant.command, + commandArguments: [], + env: .object([:]) + ) + } + } + } + } +} + +// MARK: - The matrix + +/// The matrix, as the rest of the workflow reads it. +struct Matrix: Encodable { + var config: [MatrixEntry] + + /// What the matrix is for. In toolchains mode the caller supplies the command, so an entry + /// carries none of its own. + enum Mode: String { + case jobs + case toolchains + } + + /// The form the rest of the workflow parses the matrix with. + enum Format: String { + case yaml + case json + } + + /// The matrix as the rest of the workflow reads it, which jq and yq write. + func encoded(as format: Format) -> String { + let encoder = JSONEncoder() + // Two runs of the same configuration have to produce the same matrix. + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + let json: Data + do { + json = try encoder.encode(self) + } catch { + fatal("Could not encode the matrix: \(error)") + } + switch format { + case .json: return jq.format(json, ["."]) + case .yaml: return yq.format(json, ["-P"]) + } + } +} + +/// One job the matrix holds. +struct MatrixEntry: Encodable { + var platform: Platform + var name: String + var runner: [String] + var swiftBuild: SwiftBuild? + var xcodeBuild: XcodeBuild? + var freeBSDBuild: FreeBSDBuild? + /// Absent in toolchains mode, where the caller supplies these instead. `env` stays: it + /// describes what the toolchain needs rather than the work run on it. + var setupCommand: String? + var command: String? + var commandArguments: [String]? + var env: JSONValue + var androidEmulator: Bool? + + enum CodingKeys: String, CodingKey { + case platform, name, runner, command, env + case swiftBuild = "swift_build" + case xcodeBuild = "xcode_build" + case freeBSDBuild = "freebsd" + case setupCommand = "setup_command" + case commandArguments = "command_arguments" + case androidEmulator = "android_emulator" + } + + /// The entry as toolchains mode emits it: a machine and a toolchain, and no work of its own. + var withoutCommands: MatrixEntry { + var entry = self + entry.setupCommand = nil + entry.command = nil + entry.commandArguments = nil + return entry + } + + /// What an entry runs on, which the executor dispatches on. + enum Platform: String, Encodable { + case linux = "Linux" + case macOS = "macOS" + case windows = "Windows" + case freeBSD = "FreeBSD" + } +} + +extension MatrixEntry { + /// The toolchain a Linux or Windows entry runs. The resolved forms are carried only when + /// they differ from the label, so a hand-written matrix needs only `swift_version`. + struct SwiftBuild: Encodable { + var swiftVersion: String + var resolvedVersion: String? + var swiftlySelector: String? + var sdk: SDK? + var container: Container? + + init(_ toolchain: Toolchain, sdk: SDK? = nil, container: Container? = nil) { + self.swiftVersion = toolchain.version + self.resolvedVersion = toolchain.resolved == toolchain.version ? nil : toolchain.resolved + self.swiftlySelector = toolchain.swiftly == toolchain.version ? nil : toolchain.swiftly + self.sdk = sdk + self.container = container + } + + enum CodingKeys: String, CodingKey { + case sdk, container + case swiftVersion = "swift_version" + case resolvedVersion = "toolchain" + case swiftlySelector = "swiftly" + } + } + + /// The toolchain a macOS entry runs: an Xcode that ships one, or an Xcode with a + /// swiftly-managed toolchain installed under it. + struct XcodeBuild: Encodable { + var swiftVersion: String? + var xcodeVersion: String? + var swiftlyToolchain: String? + var targets: [XcodeTarget]? + var debugOutput: Bool? + + enum CodingKeys: String, CodingKey { + case targets + case swiftVersion = "swift_version" + case xcodeVersion = "xcode_version" + case swiftlyToolchain = "swiftly_toolchain" + case debugOutput = "debug_output" + } + } + + /// The virtual machine a FreeBSD entry runs in, and the toolchain it installs there. + struct FreeBSDBuild: Encodable { + var osVersion: String + /// The executor derives SWIFT_VERSION from this: a FreeBSD entry has no `swift_build`. + var swiftVersion: String + var swiftURL: String + var buildFlags: String + var envVars: String + + enum CodingKeys: String, CodingKey { + case osVersion = "os_version" + case swiftVersion = "swift_version" + case swiftURL = "swift_url" + case buildFlags = "build_flags" + case envVars = "env_vars" + } + } +} + +extension MatrixEntry.SwiftBuild { + /// The Swift SDK a build is made against, which install-and-build-with-sdk.sh installs. + struct SDK: Encodable { + var kind: Kind + /// An NDK release is part of which SDK a build is made against, so it belongs beside the + /// triples. + var ndkVersion: String? + var triples: [String]? + + enum CodingKeys: String, CodingKey { + case triples + case kind = "type" + case ndkVersion = "ndk_version" + } + + /// The SDKs a build can be made against. + enum Kind: String, Encodable { + case staticLinux = "static-linux" + case wasm + case embeddedWasm = "embedded-wasm" + case android + } + } + + /// The container an entry runs in, as the workflow's `container:` block takes it. + struct Container: Encodable { + var image: String + var dockerfile: String? + var capabilities: [String]? + var securityOptions: [String]? + + enum CodingKeys: String, CodingKey { + case image, dockerfile, capabilities + case securityOptions = "security_options" + } + } +} + +// MARK: - Toolchains + +/// A version label and the forms upstream publishes it under. +struct Toolchain { + /// The label a caller wrote, such as `6.3` or `nightly-release`. + let version: String + /// The branch spelling upstream publishes the next release's nightly under, which + /// `nightly-release` is an alias for: 6.0 through 6.3 were "6.", 6.4 is "6.4.x". + let releaseToken: String + + /// The Docker tag infix, the Windows installer script suffix, and the argument + /// install-and-build-with-sdk.sh takes. + var resolved: String { + let alias = "nightly-\(self.releaseToken)" + return self.version == "nightly-release" || self.version == alias ? alias : self.version + } + + /// The swiftly selector. The branch token names the release snapshot's own directory under + /// dev/, and swiftly's release-snapshot grammar takes it whole, so it is passed through. + var swiftly: String { + guard self.resolved.hasPrefix("nightly-") else { return self.resolved } + let branch = String(self.resolved.dropFirst("nightly-".count)) + return branch == "main" ? "main-snapshot" : "\(branch)-snapshot" + } + + /// The image this toolchain runs in on a distribution. Upstream publishes the nightlies + /// under their own repository, so a nightly is not tagged like a release. + func image(distribution: String) -> String { + self.resolved.hasPrefix("nightly-") + ? "swiftlang/swift:\(self.resolved)-\(distribution)" + : "swift:\(self.resolved)-\(distribution)" + } +} + +// MARK: - Versions + +/// A released Swift version, ordered by its components. +struct SwiftVersion: Comparable { + private let components: [Int] + + init?(_ text: String) { + let parts = text.split(separator: ".", omittingEmptySubsequences: false) + guard (1...3).contains(parts.count) else { return nil } + let numbers = parts.compactMap { Int($0) } + guard numbers.count == parts.count else { return nil } + self.components = numbers + Array(repeating: 0, count: 3 - numbers.count) + } + + static func < (first: SwiftVersion, second: SwiftVersion) -> Bool { + first.components.lexicographicallyPrecedes(second.components) + } + + /// A version list entry, which is a number or a nightly label and nothing else. A label + /// this cannot order would otherwise be silently kept or dropped. + static func inVersionList(_ label: String) -> SwiftVersion { + guard let version = SwiftVersion(label) else { + fatal( + """ + Cannot compare '\(label)' as a version. A Swift version list takes numbers like 6.3, or a \ + nightly- label; '\(label)' is neither. + """ + ) + } + return version + } + + /// The newest release a version list holds, falling back to its last entry when the list is + /// all nightlies. + static func newestRelease(in versions: [String]) -> String { + let releases = versions.filter { !$0.hasPrefix("nightly-") } + let newest = releases.max { SwiftVersion.inVersionList($0) < SwiftVersion.inVersionList($1) } + return newest ?? versions.last ?? "" + } +} + +/// The oldest toolchain the package builds on. A version below it cannot resolve the +/// manifest, so a job on it fails for a reason the caller did not ask about. +struct MinimumVersion { + /// As the caller or the manifest wrote it, for the message. + let text: String + private let floor: SwiftVersion? + + init(_ text: String) { + self.text = text + if text.isEmpty || text == "none" { + self.floor = nil + return + } + guard let version = SwiftVersion(text) else { + fatal( + """ + Cannot compare '\(text)' as a version: minimum_swift_version must be a number like 6.3, 'none', \ + or empty. + """ + ) + } + self.floor = version + } + + /// A nightly is always kept: it is not a released version this can order. + func admits(_ label: String) -> Bool { + guard let floor = self.floor else { return true } + if label.hasPrefix("nightly-") { return true } + return SwiftVersion.inVersionList(label) >= floor + } + + /// What a caller does about a version the filter dropped. The filter is not an input of its + /// own, so a message naming + /// only what was dropped leaves them looking for a knob that isn't there. + static let remedy = + "The minimum comes from the manifest's swift-tools-version unless minimum_swift_version " + + "overrides it, so raise the versions, or lower minimum_swift_version - 'none' turns the " + + "filter off." +} + +extension MinimumVersion { + /// The oldest toolchain the package builds on: the version the caller named, or the lowest + /// its manifests declare. Reading the manifests says what they declared. + init(for configuration: Configuration) { + if !configuration.minimumSwiftVersion.isEmpty { + self.init(configuration.minimumSwiftVersion) + return + } + let detected = MinimumVersion.detected(includingSubdirectories: configuration.searchSubdirectories) + if !detected.isEmpty { log("Auto-detected minimum Swift tools version: \(detected)") } + self.init(detected) + } + + /// The lowest tools version the manifests declare, which is the oldest toolchain the package + /// claims to build on. + static func detected(includingSubdirectories: Bool) -> String { + let fileManager = FileManager.default + + func manifests(in directory: String) -> [String] { + let contents = (try? fileManager.contentsOfDirectory(atPath: directory)) ?? [] + let versioned = contents.filter { $0.hasPrefix("Package@swift-") && $0.hasSuffix(".swift") }.sorted() + return (["Package.swift"] + versioned).map { directory == "." ? $0 : "\(directory)/\($0)" } + } + + var directories = ["."] + if includingSubdirectories { + let contents = (try? fileManager.contentsOfDirectory(atPath: ".")) ?? [] + directories += contents.filter { entry in + var isDirectory: ObjCBool = false + return fileManager.fileExists(atPath: entry, isDirectory: &isDirectory) && isDirectory.boolValue + }.sorted().map { "./\($0)" } + } + + var minimum: (version: SwiftVersion, text: String)? + for path in directories.flatMap(manifests(in:)) { + guard let declared = MinimumVersion.toolsVersion(ofManifestAt: path) else { continue } + log("Found \(path) with tools-version: \(declared)") + let version = SwiftVersion.inVersionList(declared) + if let current = minimum, current.version <= version { continue } + minimum = (version, declared) + } + return minimum?.text ?? "" + } + + /// The tools version a manifest declares, or nil when there is no manifest to read or it + /// declares none. + private static func toolsVersion(ofManifestAt path: String) -> String? { + guard FileManager.default.fileExists(atPath: path) else { return nil } + let contents: String + do { + contents = try String(contentsOfFile: path, encoding: .utf8) + } catch { + fatal("Could not read \(path): \(error)") + } + var line = contents.split(separator: "\n", omittingEmptySubsequences: false).first ?? "" + guard line.hasPrefix("//") else { return nil } + line = line.dropFirst(2).drop(while: { $0 == " " }) + guard line.hasPrefix("swift-tools-version:") else { return nil } + let version = line.dropFirst("swift-tools-version:".count).drop(while: { $0 == " " }) + .prefix { $0.isNumber || $0 == "." } + return version.isEmpty ? nil : String(version) + } +} + +// MARK: - Commands + +/// What a `*_command` input names: one command, or a map of label to command. +/// +/// linux_command: swift test +/// +/// linux_command: | +/// test: swift test +/// release: +/// command: swift build -c release +/// versions: ["6.3"] +struct Commands: InputDecodable, ExpressibleByStringLiteral { + /// One command a group runs, and the label its jobs carry. + struct Variant { + var label: String + var command: String + var swiftVersions: [String]? + + init(label: String, command: String, swiftVersions: [String]?) { + self.label = label + self.command = command.trimmingTrailingNewlines + self.swiftVersions = swiftVersions + } + + /// The versions this variant runs on, in the group's own order rather than the label's. + func versions(among available: [String]) -> [String] { + guard let swiftVersions = self.swiftVersions else { return available } + return available.filter(swiftVersions.contains) + } + } + + private var variants: [Variant] + + /// The label leading an entry's job name, which is nothing when the group runs one command: + /// entry names are required status checks in adopting repositories. + func nameLabel(for variant: Variant) -> String? { + self.count > 1 ? variant.label : nil + } + + init(_ command: String) { + self.variants = [Variant(label: "", command: command, swiftVersions: nil)] + } + + init(stringLiteral command: String) { + self.init(command) + } + + /// The parse only classifies. Anything but a map of labels is the command itself, taken + /// byte for byte - including a value that is not YAML at all, such as + /// `[ -f x ] && swift build`. + /// + /// A shell command can parse as a map: `swift test --filter Foo: Bar` yields one keyed on + /// everything before the colon. Requiring every key to be a label leaves only + /// `: ` ambiguous, and that names a program whose name ends in a colon, so it + /// is not a command anyone would have run. + init(input text: String, name: String) { + self.variants = + Commands.labeled(text, name: name) ?? [Variant(label: "", command: text, swiftVersions: nil)] + } + + /// The variants a map of labels names, or nil when the value is the command itself. + private static func labeled(_ text: String, name: String) -> [Variant]? { + guard let parsed = Parsed(text) else { return nil } + if parsed.isList { + fatal( + """ + \(name) takes a command, or a map of label to command such as {test: swift test}, but got a \ + list: \(text) + """ + ) + } + let members = parsed.mapMembers + if members.isEmpty || members.contains(where: { !Commands.isLabel($0.key) }) { return nil } + // A label written twice would run one command of the two the caller named, and under the + // bare name, since one command earns no label. + guard Set(members.map(\.key)).count == members.count else { + fatal("\(name) names a label more than once: \(text)") + } + + // A label carrying anything else - a number, a misspelled key, a blank command, an empty + // version list - would leave the job running the group's default command under a name + // that says otherwise, or produce no job for that label at all. + let malformed = members.filter { settings(of: $0.value) == nil } + guard malformed.isEmpty else { + let reported = malformed.map { "\($0.key): \($0.value)" }.joined(separator: ", ") + fatal( + """ + \(name) takes each label's command as a non-blank string, or a map of command and a non-empty \ + versions list, but got: \(reported) + """ + ) + } + return members.compactMap { member in + settings(of: member.value).map { + Variant(label: member.key, command: $0.command, swiftVersions: $0.versions) + } + } + } + + /// The command and versions a label carries, or nil when it carries no usable command. + private static func settings(of value: JSONValue) -> (command: String, versions: [String]?)? { + if let command = value.asString { + return command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : (command, nil) + } + guard case .object(let settings) = value, + Set(settings.keys).subtracting(["command", "versions"]).isEmpty, + let command = settings["command"]?.asString, + !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + return nil + } + guard let listed = settings["versions"]?.nonNull else { return (command, nil) } + guard let versions = listed.asArray, versions.isEmpty == false, + versions.allSatisfy({ $0.asString != nil }) + else { + return nil + } + return (command, versions.map(\.text)) + } + + /// A label leads the job name, so it is a word. That is also what keeps a shell command + /// YAML reads as a map from being mistaken for one. + private static func isLabel(_ text: String) -> Bool { + guard let first = text.first, first.isASCII, first.isLetter || first.isNumber else { return false } + return text.allSatisfy { $0.isASCII && ($0.isLetter || $0.isNumber || "_.-".contains($0)) } + } +} + +/// One command each, and never none: a value that is not a map of labels is itself the +/// command, so there is always something to run. +extension Commands: RandomAccessCollection { + var startIndex: Int { self.variants.startIndex } + var endIndex: Int { self.variants.endIndex } + subscript(position: Int) -> Variant { self.variants[position] } +} + +extension String { + /// The text without the newlines it ends with, which is how a command reaches the runner: + /// a YAML block scalar ends with one, and `swift build\n` is the command `swift build`. + fileprivate var trimmingTrailingNewlines: String { + var text = self + while text.hasSuffix("\n") { text.removeLast() } + return text + } +} + +// MARK: - Version overrides + +/// What a `*_version_overrides` input carries: for one version, arguments to add, or a +/// command to replace. +/// +/// linux_version_overrides: | +/// 6.2: -Xswiftc -warnings-as-errors +/// nightly-main: +/// command: swift build +/// arguments: --explicit-target-dependency-import-check error +struct VersionOverrides: InputDecodable { + /// What one version's key carries. + private struct Override { + var version: String + var arguments: String? + var command: String? + + init(version: String, arguments: String?, command: String?) { + self.version = version + self.arguments = arguments + self.command = command?.trimmingTrailingNewlines + } + } + + /// The input these were read from, which a message names so the caller knows which knob to + /// turn. Empty for the default, which carries no overrides for a message to be about. + private(set) var name = "" + private var overrides: [Override] = [] + + var isEmpty: Bool { self.overrides.isEmpty } + var versions: [String] { self.overrides.map(\.version) } + + init() {} + + /// What a version's override adds to the flags, if it names any. + func arguments(for version: String) -> String? { + self.override(for: version)?.arguments + } + + /// The command a version's override replaces the group's with, if it names one. + func command(for version: String) -> String? { + self.override(for: version)?.command + } + + /// A version written twice takes the last of its overrides, which is what jq would do + /// reading these into an object: the + /// members are kept in the order they were written so that a repeated key can be seen. + private func override(for version: String) -> Override? { + self.overrides.last { $0.version == version } + } + + /// The versions this replaces the command for, of those a group runs. A key naming a version + /// the group's own list does not hold reaches none of its entries. + func versionsReplacingTheCommand(among groupVersions: [String]) -> [String] { + self.overrides.filter { $0.command != nil && groupVersions.contains($0.version) }.map(\.version) + } + + /// The override for a version is read by looking the version up, so a value of any other + /// shape is absent rather than wrong: the arguments the caller asked for go missing from a + /// job that still passes, which is how a repository loses warnings-as-errors. + init(input text: String, name: String) { + self.name = name + guard let parsed = Parsed(text) else { + fatal("\(name) is not valid JSON or YAML: \(text)") + } + // A value that carried nothing - blank, or an explicit null - is no overrides rather + // than a malformed map. + if parsed.value.isNull { return } + guard parsed.isMap else { + fatal( + """ + \(name) must be a map of version to override, such as {"6.3": "-Xswiftc -warnings-as-errors"}, \ + but got: \(text) + """ + ) + } + + let members = parsed.mapMembers + let notOverrides = members.filter { + if case .object = $0.value { return false } + return $0.value.asString == nil + } + guard notOverrides.isEmpty else { + let reported = notOverrides.map { "\($0.key): \($0.value)" }.joined(separator: ", ") + fatal( + "\(name) takes the arguments as a string, or a map with command and arguments, but got: \(reported)" + ) + } + + // A misspelled key inside the map, or a value that is not a string, carries nothing while + // looking as though it does. + let malformed = members.filter { member in + guard case .object(let settings) = member.value else { return false } + return !Set(settings.keys).subtracting(["arguments", "command"]).isEmpty + || settings.values.contains { $0.asString == nil } + } + guard malformed.isEmpty else { + let reported = malformed.map { "\($0.key): \($0.value)" }.joined(separator: ", ") + fatal("\(name) takes command and arguments, each a string, but got: \(reported)") + } + + self.overrides = members.map { member in + Override( + version: member.key, + arguments: member.value.asString ?? member.value["arguments"]?.asString, + command: member.value["command"]?.asString + ) + } + } + + /// Fails when a key names no version any enabled group runs. The arguments it carries are + /// silently lost otherwise, which is how a version rename drops warnings-as-errors. + func validateKeys(against runnableVersions: [String]) { + if self.isEmpty { return } + // No versions means no enabled group reads these, so a key names nothing because nothing + // runs. Failing there would take down the platforms that are enabled. + if runnableVersions.isEmpty { + log("WARNING: ignoring \(self.name): no enabled job group reads it") + return + } + for key in self.versions where !runnableVersions.contains(key) { + fatal( + """ + \(self.name) override key '\(key)' does not match any version in the matrix. Valid keys: \ + \(runnableVersions.joined(separator: " ")) + """ + ) + } + } +} + +// MARK: - Inputs + +/// An input. A value that carries nothing - unset, or the empty string Actions passes for an +/// input a caller left out - takes the declared default, which is already the parsed form. +@propertyWrapper +struct Input { + var wrappedValue: Value + + init(wrappedValue defaultValue: Value, _ variable: String) { + self.wrappedValue = Input.read(variable, default: defaultValue) + } + + /// What an environment variable carries, or the default when it carries nothing. + static func read(_ variable: String, default defaultValue: Value) -> Value { + let text = ProcessInfo.processInfo.environment[variable] ?? "" + return text.isEmpty ? defaultValue : Value(input: text, name: variable.lowercased()) + } +} + +/// A value an input can carry. The conformance is where that shape's rules live: an input +/// carrying the wrong shape fails the run rather than contributing nothing. +protocol InputDecodable { + init(input text: String, name: String) +} + +/// An element of a list input. +protocol InputElement { + /// - Parameters: + /// - element: the element as it parsed. + /// - text: the element as it was written, which is not the same for a number. + init(element: JSONValue, text: String) +} + +extension String: InputDecodable, InputElement { + init(input text: String, name: String) { self = text } + init(element: JSONValue, text: String) { self = text } +} + +extension Bool: InputDecodable { + /// Anything but `true` is off, which is what an unset input is. + init(input text: String, name: String) { self = text == "true" } +} + +extension Array: InputDecodable where Element: InputElement { + /// A value of another shape would contribute no entries: the platform would be absent from + /// a run that still reports success. + init(input text: String, name: String) { + guard let parsed = Parsed(text) else { + fatal("\(name) is not valid JSON or YAML: \(text)") + } + guard let items = parsed.value.asArray else { + fatal("\(name) must be a list, such as [\"a\", \"b\"], but got: \(text)") + } + self = zip(items, parsed.listElements).map(Element.init(element:text:)) + } +} + +extension JSONValue: InputDecodable { + /// An input passed through to the entry, such as an environment block. + init(input text: String, name: String) { + guard let parsed = Parsed(text) else { + fatal("\(name) is not valid JSON or YAML: \(text)") + } + guard parsed.isMap || parsed.value.isNull else { + fatal("\(name) must be a map of name to value, such as {FOO: bar}, but got: \(text)") + } + self = parsed.value.isNull ? .object([:]) : parsed.value + } +} + +/// A Swift SDK build: the SDK its entries build against, and the inputs that configure them, +/// which all share one prefix. +struct SDKBuild { + /// The prefix every one of its inputs shares. + let prefix: String + let kind: MatrixEntry.SwiftBuild.SDK.Kind + /// What its job names lead with. + let name: String + + let enabled: Bool + let versions: [String] + let commands: Commands + let setupCommand: String + + init(prefix: String, kind: MatrixEntry.SwiftBuild.SDK.Kind, name: String) { + self.prefix = prefix + self.kind = kind + self.name = name + let variable = prefix.uppercased() + self.enabled = Input.read("ENABLE_\(variable)_BUILD", default: false) + self.versions = Input.read("\(variable)_VERSIONS", default: Configuration.defaultSDKVersions) + self.commands = Input.read("\(variable)_COMMAND", default: "swift build") + self.setupCommand = Input.read("\(variable)_SETUP_COMMAND", default: "") + } + + /// The input that turns this build on. + var enableInput: String { "enable_\(self.prefix)_build" } + /// The input naming the Swift versions it builds on. + var versionsInput: String { "\(self.prefix)_versions" } + /// The input naming what it runs. + var commandInput: String { "\(self.prefix)_command" } +} + +/// An input naming one OS, or a list of them. +/// +/// Only a list is taken from the parse: a single value is used exactly as it was written, +/// because YAML reads `24.10` as the number 24.1 and drops everything after a ` #`, and no +/// image is tagged 6.3-24.1. +struct OSList: InputDecodable, ExpressibleByStringLiteral { + var names: [String] + + init(_ name: String) { + self.names = [name] + } + + init(stringLiteral name: String) { + self.init(name) + } + + init(input text: String, name: String) { + guard let parsed = Parsed(text) else { + fatal("\(name) is not valid JSON or YAML: \(text)") + } + if parsed.isList { + self.names = [String](input: text, name: name) + } else if parsed.isMap { + fatal("\(name) must be a name or a list of them, such as [\"a\", \"b\"], but got: \(text)") + } else { + self.names = [text] + } + } +} + +/// A macOS entry driven by a swiftly-managed toolchain rather than the Xcode that ships one. +struct SwiftlyToolchain: InputElement { + var xcodeVersion = "" + var swiftlyToolchain = "" + /// An entry naming its own OS runs there alone; the rest fan out over macos_os. + var osVersion: String? + var architecture: String? + + init(xcodeVersion: String, swiftlyToolchain: String) { + self.xcodeVersion = xcodeVersion + self.swiftlyToolchain = swiftlyToolchain + } + + init(element: JSONValue, text: String) { + let known = ["xcode_version", "swiftly_toolchain", "os_version", "arch"] + let unknown = Set(element.asObject?.keys ?? [:].keys).subtracting(known).sorted() + if !unknown.isEmpty { + fatal( + """ + macos_swiftly_toolchains includes unknown keys: \(unknown.joined(separator: ", ")). \ + An entry takes xcode_version, swiftly_toolchain, os_version and arch. + """ + ) + } + self.xcodeVersion = element["xcode_version"]?.text ?? "" + self.swiftlyToolchain = element["swiftly_toolchain"]?.text ?? "" + self.osVersion = element["os_version"]?.text + self.architecture = element["arch"]?.text + } +} + +// MARK: - JSON + +/// A value of any shape, which is what an input carries before its shape is known. +enum JSONValue: Codable { + case null + case bool(Bool) + case integer(Int) + case number(Double) + case string(String) + case array([JSONValue]) + case object([String: JSONValue]) + + init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + // Each `try?` asks "is it this shape," so a failure is the answer rather than an error + // to swallow. The order is the one JSON allows: an integer also decodes as a double. + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Int.self) { + self = .integer(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([JSONValue].self) { + self = .array(value) + } else { + self = .object(try container.decode([String: JSONValue].self)) + } + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .null: try container.encodeNil() + case .bool(let value): try container.encode(value) + case .integer(let value): try container.encode(value) + case .number(let value): try container.encode(value) + case .string(let value): try container.encode(value) + case .array(let items): try container.encode(items) + case .object(let members): try container.encode(members) + } + } + + subscript(key: String) -> JSONValue? { + guard case .object(let members) = self else { return nil } + return members[key] + } + + var asObject: [String: JSONValue]? { + guard case .object(let members) = self else { return nil } + return members + } + + var asString: String? { + guard case .string(let text) = self else { return nil } + return text + } + + var asBool: Bool? { + guard case .bool(let value) = self else { return nil } + return value + } + + var asArray: [JSONValue]? { + guard case .array(let items) = self else { return nil } + return items + } + + /// The value, or nil when it carried nothing: an absent setting and an explicit null are + /// the same answer. + var nonNull: JSONValue? { self.isNull ? nil : self } + + var isNull: Bool { + guard case .null = self else { return false } + return true + } + + /// The value as one line of text, the way `jq -r` writes it. A version list written + /// `[6.3]` still names the version `6.3`. + var text: String { + self.asString ?? self.description + } +} + +extension JSONValue: CustomStringConvertible { + /// The value as JSON on one line, which is how a message quotes back what a caller wrote. + var description: String { + switch self { + case .null: return "null" + case .bool(let value): return value ? "true" : "false" + case .integer(let value): return String(value) + case .number(let value): return String(value) + case .string(let text): return JSONValue.quoted(text) + case .array(let items): return "[" + items.map(\.description).joined(separator: ",") + "]" + case .object(let members): + // Sorted so a message quoting a caller's value back reads the same on every run: a + // dictionary has no order of its own. + let written = members.sorted { $0.key < $1.key } + return "{" + written.map { "\(JSONValue.quoted($0.key)):\($0.value)" }.joined(separator: ",") + "}" + } + } + + static func quoted(_ text: String) -> String { + var result = "\"" + for scalar in text.unicodeScalars { + switch scalar { + case "\"": result += "\\\"" + case "\\": result += "\\\\" + case "\n": result += "\\n" + case "\r": result += "\\r" + case "\t": result += "\\t" + case _ where scalar.value < 0x20: result += String(format: "\\u%04x", scalar.value) + default: result.unicodeScalars.append(scalar) + } + } + return result + "\"" + } +} + +// MARK: - Reading a value with yq + +/// A value as yq read it: its YAML tag, and - for a map - its members in the order they were +/// written, a key written twice included. +struct Parsed: Decodable { + struct Member: Decodable { + var key: String + var value: JSONValue + } + + private var tag: String + var value: JSONValue + /// One element holding the members when the value is a map, and empty otherwise: the filter + /// uses `select`, which yields one result or none, and yq has no conditional that returns + /// a value either way. `mapMembers` is what a caller reads. + private var members: [[Member]] + /// The same, for a list's elements as they were written. + private var elements: [[String]] + + var isList: Bool { self.tag == "!!seq" } + var isMap: Bool { self.tag == "!!map" } + var isCollection: Bool { self.isList || self.isMap } + var mapMembers: [Member] { self.members.first ?? [] } + /// A list's elements as text. YAML reads `24.10` as the number 24.1, and no image is tagged + /// 6.3-24.1, so the token the caller wrote is what a name is taken from. + var listElements: [String] { self.elements.first ?? [] } + + /// Reads a value the way yq does. Nil means the value is neither YAML nor JSON; two callers + /// distinguish that from a value of the wrong shape. + init?(_ text: String) { + let result = yq.run(["-o=json", "-I=0", Parsed.filter], input: text) + guard result.worked else { return nil } + do { + self = try JSONDecoder().decode(Parsed.self, from: result.standardOutput) + } catch { + fatal("yq produced JSON this generator could not read: \(error)") + } + } + + /// A value's shape, its members and its elements in one pass. `tostring` is applied to the + /// copies in `members` and `elements` and to nothing else, so `value` keeps the original + /// types: `versions: [6.3]` stays a number for the label's settings check to reject it. + private static let filter = """ + {"tag": tag, "value": ., \ + "members": [select(tag == "!!map") | to_entries | map({"key": (.key | tostring), "value": .value})], \ + "elements": [select(tag == "!!seq") | map(tostring)]} + """ +} + +// MARK: - Running jq and yq + +/// A program this generator shells out to. +struct Tool { + /// What one run of a tool wrote. + struct Output { + var standardOutput: Data + var standardError: String + var worked: Bool + } + + /// The file name it was looked up under, which a message names. + let name: String + private let executable: URL + + /// Looks a program up on PATH, as a shell would. + init(_ name: String) { + #if os(Windows) + let pathSeparator: Character = ";" + self.name = name + ".exe" + #else + let pathSeparator: Character = ":" + self.name = name + #endif + for variable in ["PATH", "Path"] { + let paths = ProcessInfo.processInfo.environment[variable] ?? "" + for directory in paths.split(separator: pathSeparator) { + let candidate = URL(fileURLWithPath: String(directory)).appendingPathComponent(self.name) + if FileManager.default.isExecutableFile(atPath: candidate.path) { + self.executable = candidate + return + } + } + } + fatal("\(self.name) not found on PATH") + } + + /// Runs the program over a value, and reports what it wrote. + func run(_ arguments: [String], input: String) -> Output { + let process = Process() + process.executableURL = self.executable + process.arguments = arguments + let standardInput = Pipe() + let standardOutput = Pipe() + let standardError = Pipe() + process.standardInput = standardInput + process.standardOutput = standardOutput + process.standardError = standardError + + do { + try process.run() + } catch { + fatal("Could not run \(self.executable.path): \(error)") + } + + // The value goes in on another thread: a matrix larger than the pipe's buffer would + // otherwise fill it while nothing is reading the other end yet. + DispatchQueue.global().async { + standardInput.fileHandleForWriting.write(Data(input.utf8)) + standardInput.fileHandleForWriting.closeFile() + } + // Both streams are drained at once, for the same reason: whichever went unread could fill + // and stall the tool while this waits on the other. + // + // nonisolated(unsafe) because the write happens before the group's wait returns, which the + // compiler cannot see. + nonisolated(unsafe) var diagnostic = Data() + let draining = DispatchGroup() + DispatchQueue.global().async(group: draining) { + diagnostic = standardError.fileHandleForReading.readDataToEndOfFile() + } + let output = standardOutput.fileHandleForReading.readDataToEndOfFile() + draining.wait() + process.waitUntilExit() + + return Output( + standardOutput: output, + standardError: String(decoding: diagnostic, as: UTF8.self), + worked: process.terminationStatus == 0 + ) + } + + /// The matrix as this tool rewrites it. + func format(_ matrix: Data, _ arguments: [String]) -> String { + let result = self.run(arguments, input: String(decoding: matrix, as: UTF8.self)) + guard result.worked else { + fatal("\(self.name) could not format the matrix: \(result.standardError)") + } + return String(decoding: result.standardOutput, as: UTF8.self) + } +} + +// MARK: - Diagnostics + +/// Diagnostics go to standard error; standard output carries the matrix. +func log(_ message: String) { + FileHandle.standardError.write(Data("** \(message)\n".utf8)) +} + +/// Reports a configuration that would produce a matrix without the jobs the caller asked +/// for, and stops. A green run missing those jobs is what every one of these prevents. +func fatal(_ message: String) -> Never { + FileHandle.standardError.write(Data("** ERROR: \(message)\n".utf8)) + exit(1) +} + +// MARK: - Body of the script + +let jq = Tool("jq") +let yq = Tool("yq") + +// Populates the configuration from the environment, through the `@Input` initializers. +let config = Configuration() + +guard let mode = Matrix.Mode(rawValue: config.matrixMode) else { + fatal("MATRIX_MODE must be 'jobs' or 'toolchains', got '\(config.matrixMode)'") +} +guard let format = Matrix.Format(rawValue: config.matrixFormat) else { + fatal("MATRIX_FORMAT must be 'yaml' or 'json', got '\(config.matrixFormat)'") +} +config.validatePairings(in: mode) + +let generator = Generator(config, mode: mode) +let jobMatrix = generator.generate() + +print(jobMatrix.encoded(as: format), terminator: "") diff --git a/.github/workflows/scripts/matrix/invoke-program.ps1 b/.github/workflows/scripts/matrix/invoke-program.ps1 new file mode 100644 index 00000000..324f7c24 --- /dev/null +++ b/.github/workflows/scripts/matrix/invoke-program.ps1 @@ -0,0 +1,33 @@ +##===----------------------------------------------------------------------===## +## +## This source file is part of the Swift.org open source project +## +## Copyright (c) 2026 Apple Inc. and the Swift project authors +## Licensed under Apache License v2.0 with Runtime Library Exception +## +## See https://swift.org/LICENSE.txt for license information +## See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +## +##===----------------------------------------------------------------------===## + +# Runs a program and exits the script with its exit code if it is non-zero. +# +# PowerShell does not propagate a child process's exit code on its own, and the +# obvious `exit $LASTEXITCODE` is not enough: when a command fails to launch at +# all - a broken toolchain, a missing executable - no exit code is set, so +# $LASTEXITCODE stays $null, `exit $null` exits 0, and the failure is reported as +# success. So reset it before the call and fall back to $? when nothing set it. +# +# Available to setup_command and command values in a matrix entry. +function Invoke-Program($Executable) { + $global:LASTEXITCODE = $null + & $Executable @args + $ok = $? + if ($null -eq $LASTEXITCODE) { + if (-not $ok) { + exit 1 + } + } elseif ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } +} diff --git a/.github/workflows/scripts/matrix/job-runner-linux.sh b/.github/workflows/scripts/matrix/job-runner-linux.sh new file mode 100755 index 00000000..a544350e --- /dev/null +++ b/.github/workflows/scripts/matrix/job-runner-linux.sh @@ -0,0 +1,325 @@ +#!/bin/bash +##===----------------------------------------------------------------------===## +## +## This source file is part of the Swift.org open source project +## +## Copyright (c) 2025 Apple Inc. and the Swift project authors +## Licensed under Apache License v2.0 with Runtime Library Exception +## +## See https://swift.org/LICENSE.txt for license information +## See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +## +##===----------------------------------------------------------------------===## + +set -euo pipefail + +# This script runs commands on a Linux host, either natively (via swiftly) +# or inside a Docker container when CONTAINER_JSON is set. +# +# Arguments: +# $1: Swift version label (e.g. "6.2", "nightly-release") +# $2: Setup command (can be empty) +# $3: Main command to run +# $4: JSON array or string of command arguments +# $5: JSON string of environment variables (can be empty) +# $6: needs_token (true/false, optional) +# $7: SDK JSON configuration (optional) +# +# Environment variables: +# CONTAINER_JSON - JSON with optional Docker container config (image, dockerfile, capabilities) +# SCRIPTS_ROOT - Path to the github-workflows scripts directory +# MATRIX_TOOLCHAIN - The concrete toolchain identifier for the version label +# (e.g. "nightly-6.4.x" for "nightly-release"). Defaults to +# the label, which is correct for plain release versions. +# MATRIX_SWIFTLY - The swiftly selector for the version label (e.g. +# "6.4-snapshot"). Defaults to the label. +# CROSS_PR_TESTING - "true" to check out PRs linked from this PR's description, +# after the toolchain is installed. +# CROSS_PR_REPO - The repository the pull request is against. +# CROSS_PR_NUMBER - The pull request number. + +swift_version="$1" +setup_command="${2:-}" +command="$3" +command_arguments_json="${4:-}" +env_json="${5:-}" +needs_token="${6:-false}" +sdk_json="${7:-}" + +# A hand-written matrix may supply only `swift_version`, so both resolved forms fall +# back to it rather than being re-derived here. +matrix_toolchain="${MATRIX_TOOLCHAIN:-$swift_version}" +matrix_swiftly="${MATRIX_SWIFTLY:-$swift_version}" + +log() { echo "** $*" >&2; } + +command -v jq >/dev/null || { echo "** ERROR: jq not found on PATH" >&2; exit 1; } + +container_json="${CONTAINER_JSON:-null}" +container_image="" +container_dockerfile="" +container_capabilities="[]" +container_security_options="[]" + +if [[ -n "$container_json" && "$container_json" != "null" && "$container_json" != '{}' ]]; then + container_image=$(echo "$container_json" | jq -r '.image // empty') + container_dockerfile=$(echo "$container_json" | jq -r '.dockerfile // empty') + container_capabilities=$(echo "$container_json" | jq -c '.capabilities // []') + container_security_options=$(echo "$container_json" | jq -c '.security_options // []') +fi + +parse_command_arguments() { + if [[ -n "$command_arguments_json" && "$command_arguments_json" != "null" && "$command_arguments_json" != '[]' ]]; then + if [[ "$command_arguments_json" =~ ^\[.*\]$ ]]; then + # Shell-quote each argument rather than joining on a space. The command is run + # through eval, so an argument containing whitespace would otherwise arrive as + # several - which the schema's array type promises it will not. + echo "$command_arguments_json" | jq -r 'map(@sh) | join(" ")' + else + echo "$command_arguments_json" + fi + fi +} + +command_arguments=$(parse_command_arguments) + +# --------------------------------------------------------------------------- +# Docker execution path +# --------------------------------------------------------------------------- +if [[ -n "$container_image" ]]; then + # SDK handling lives on the native path below, which this branch never reaches, + # so an entry carrying both would run the raw command and report a green SDK + # build for work that was never done. + if [[ -n "$sdk_json" && "$sdk_json" != "null" && "$sdk_json" != '{}' ]]; then + log "ERROR: an entry with a container cannot also specify an SDK" + exit 1 + fi + + log "Running in Docker container: $container_image" + + actual_image="$container_image" + + if [[ -n "$container_dockerfile" ]]; then + local_tag="local-ci-image:$(echo "$swift_version" | tr ':/' '-')" + docker buildx build \ + --build-arg SWIFT_IMAGE="$container_image" \ + -f "$container_dockerfile" \ + -t "$local_tag" \ + . + actual_image="$local_tag" + else + docker pull "$actual_image" + fi + + workspace="/$(basename "${GITHUB_WORKSPACE:-.}")" + + docker_args=( + "run" + "-v" "${GITHUB_WORKSPACE:-.}:$workspace" + "-w" "$workspace" + "-e" "CI=${CI:-}" + "-e" "GITHUB_ACTIONS=${GITHUB_ACTIONS:-}" + "-e" "SWIFT_VERSION=$swift_version" + "-e" "workspace=$workspace" + ) + + # The scripts directory lives under GITHUB_WORKSPACE, so it is already + # inside the mount - but at a different absolute path. Translate it so + # commands that reference ${SCRIPTS_ROOT} resolve inside the container. + if [[ -n "${SCRIPTS_ROOT:-}" && -n "${GITHUB_WORKSPACE:-}" ]]; then + docker_args+=("-e" "SCRIPTS_ROOT=${SCRIPTS_ROOT/#$GITHUB_WORKSPACE/$workspace}") + fi + + if [[ "$container_capabilities" != '[]' ]]; then + while IFS= read -r cap; do + docker_args+=("--cap-add=$cap") + done < <(echo "$container_capabilities" | jq -r '.[]') + fi + + if [[ "$container_security_options" != '[]' ]]; then + while IFS= read -r opt; do + docker_args+=("--security-opt=$opt") + done < <(echo "$container_security_options" | jq -r '.[]') + fi + + # Shell-quoted by jq and eval'd rather than read a line at a time, so a value + # containing a newline arrives whole and an empty one is still passed. + if [[ -n "$env_json" && "$env_json" != '{}' && "$env_json" != 'null' ]]; then + env_docker_args=$(echo "$env_json" | jq -r 'to_entries[] | "-e \((.key + "=" + (.value | tostring)) | @sh)"') + eval "docker_args+=($env_docker_args)" + fi + + if [[ "$needs_token" == "true" && -n "${GITHUB_TOKEN:-}" ]]; then + docker_args+=("-e" "GITHUB_TOKEN=$GITHUB_TOKEN") + fi + + if [[ "${CROSS_PR_TESTING:-false}" == "true" && -n "${CROSS_PR_REPO:-}" ]]; then + docker_args+=("-e" "CROSS_PR_REPO=$CROSS_PR_REPO") + docker_args+=("-e" "CROSS_PR_NUMBER=${CROSS_PR_NUMBER:-}") + fi + + docker_args+=("$actual_image") + + # Check out linked PRs inside the container, where the toolchain under test is. + # A failure must fail the job. + inner_command="" + if [[ "${CROSS_PR_TESTING:-false}" == "true" && -n "${CROSS_PR_REPO:-}" ]]; then + # Single-quoted deliberately: these must expand inside the container, from + # the values passed with -e, not on the host where the paths differ. + # shellcheck disable=SC2016 + inner_command+='cp "${SCRIPTS_ROOT}/cross-pr-checkout.swift" /tmp/cross-pr-checkout.swift'$'\n' + # shellcheck disable=SC2016 + inner_command+='swift /tmp/cross-pr-checkout.swift "$CROSS_PR_REPO" "$CROSS_PR_NUMBER"'$'\n' + fi + if [[ -n "$setup_command" ]]; then + inner_command+="$setup_command"$'\n' + fi + inner_command+="$command $command_arguments" + + docker_args+=("bash" "-ec" "$inner_command") + + log "Executing: docker ${docker_args[*]}" + docker "${docker_args[@]}" + exit $? +fi + +# --------------------------------------------------------------------------- +# Native execution path (swiftly) +# --------------------------------------------------------------------------- + +refresh_package_cache() { + if command -v apt-get &> /dev/null; then + sudo apt-get update -y -q + elif command -v dnf &> /dev/null; then + sudo dnf makecache -q + elif command -v yum &> /dev/null; then + sudo yum makecache -q + fi +} + +install_swiftly() { + if command -v swiftly &> /dev/null; then + log "swiftly is already installed" + return 0 + fi + + log "Installing swiftly..." + curl -fsSL -O "https://download.swift.org/swiftly/linux/swiftly-$(uname -m).tar.gz" + tar zxf "swiftly-$(uname -m).tar.gz" + ./swiftly init --quiet-shell-followup --skip-install --assume-yes + # shellcheck source=/dev/null + source "${SWIFTLY_HOME_DIR:-$HOME/.local/share/swiftly}/env.sh" + hash -r + rm -f "swiftly-$(uname -m).tar.gz" + rm -f swiftly + log "swiftly installed successfully" +} + +install_swift() { + local swiftly_version="$1" + + log "Installing Swift $swiftly_version using swiftly..." + local post_install_file="/tmp/swiftly-post-install.sh" + swiftly install "$swiftly_version" --use --post-install-file="$post_install_file" + if [[ -f "$post_install_file" && -s "$post_install_file" ]]; then + log "Running post-install commands..." + cat "$post_install_file" + sudo bash "$post_install_file" + rm -f "$post_install_file" + fi + log "Swift installed successfully" + swift --version +} + +skip_swift_install="${SKIP_SWIFT_INSTALL:-false}" +if [[ -n "$sdk_json" && "$sdk_json" != "null" && "$sdk_json" != '{}' ]]; then + skip_swift_install="true" +fi + +# Refresh the package cache so swiftly's post-install apt-get does not hit stale mirrors +refresh_package_cache + +if [[ "$skip_swift_install" != "true" ]]; then + install_swiftly + # shellcheck source=/dev/null + source "${SWIFTLY_HOME_DIR:-$HOME/.local/share/swiftly}/env.sh" + hash -r + install_swift "$matrix_swiftly" +else + log "Skipping Swift installation" + install_swiftly + # shellcheck source=/dev/null + source "${SWIFTLY_HOME_DIR:-$HOME/.local/share/swiftly}/env.sh" + hash -r +fi + +# Check out linked PRs after the toolchain install, so the script is compiled +# with the toolchain under test. Compiling it earlier picks up whatever Swift the +# runner image ships, which is not the one being tested. +if [[ "${CROSS_PR_TESTING:-false}" == "true" && -n "${CROSS_PR_REPO:-}" ]]; then + cross_pr_script="${SCRIPTS_ROOT:-./.github/workflows/scripts}/cross-pr-checkout.swift" + log "Checking out linked PRs" + cp "$cross_pr_script" /tmp/cross-pr-checkout.swift + swift /tmp/cross-pr-checkout.swift "$CROSS_PR_REPO" "${CROSS_PR_NUMBER:-}" +fi + +# Shell-quoted by jq and eval'd rather than read a line at a time, so a value +# containing a newline arrives whole and an empty one is still exported. +if [[ -n "$env_json" && "$env_json" != '{}' && "$env_json" != 'null' ]]; then + env_exports=$(echo "$env_json" | jq -r 'to_entries[] | "export \(.key)=\((.value | tostring) | @sh)"') + eval "$env_exports" +fi + +# The SDK script matches a toolchain, installs the SDK and builds in one +# invocation, so the toolchain and SDK come from the same snapshot. +if [[ -n "$sdk_json" && "$sdk_json" != "null" && "$sdk_json" != '{}' ]]; then + sdk_type=$(echo "$sdk_json" | jq -r '.type // empty') + + if [[ -n "$sdk_type" ]]; then + log "Will build with SDK: $sdk_type" + + sdk_script="${SCRIPTS_ROOT:-./.github/workflows/scripts}/install-and-build-with-sdk.sh" + + sdk_flags="$command_arguments" + sdk_build_cmd="$command" + + # The SDK script builds in the working directory, so the setup command + # has to run first - it is how a caller enters a package below the + # repository root. + if [[ -n "$setup_command" ]]; then + log "Running setup command" + eval "$setup_command" + fi + + case "$sdk_type" in + static-linux) + "$sdk_script" --static --flags="$sdk_flags" --build-command="$sdk_build_cmd" "$matrix_toolchain" + ;; + wasm) + "$sdk_script" --wasm --flags="$sdk_flags" --build-command="$sdk_build_cmd" "$matrix_toolchain" + ;; + embedded-wasm) + "$sdk_script" --embedded-wasm --flags="$sdk_flags" --build-command="$sdk_build_cmd" "$matrix_toolchain" + ;; + android) + ndk_version=$(echo "$sdk_json" | jq -r '.ndk_version // "r27d"') + triples=$(echo "$sdk_json" | jq -r '.triples[]?' | sed 's/^/--android-sdk-triple=/' | tr '\n' ' ') + eval "$sdk_script --android --android-ndk-version=$ndk_version $triples --flags=\"$sdk_flags\" --build-command=\"$sdk_build_cmd\" \"\$matrix_toolchain\"" + ;; + *) + log "Error: Unknown SDK type: $sdk_type" + exit 1 + ;; + esac + exit $? + fi +fi + +if [[ -n "$setup_command" ]]; then + log "Running setup command" + eval "$setup_command" +fi + +full_command="$command $command_arguments" +log "Executing command: $full_command" +eval "$full_command" diff --git a/.github/workflows/scripts/matrix/job-runner-macos.sh b/.github/workflows/scripts/matrix/job-runner-macos.sh new file mode 100755 index 00000000..f1d61013 --- /dev/null +++ b/.github/workflows/scripts/matrix/job-runner-macos.sh @@ -0,0 +1,192 @@ +#!/bin/bash +##===----------------------------------------------------------------------===## +## +## This source file is part of the Swift.org open source project +## +## Copyright (c) 2025 Apple Inc. and the Swift project authors +## Licensed under Apache License v2.0 with Runtime Library Exception +## +## See https://swift.org/LICENSE.txt for license information +## See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +## +##===----------------------------------------------------------------------===## + +set -euo pipefail + +# This script runs a command on macOS with a specific Xcode version, then +# optionally runs xcodebuild for additional platform targets (iOS, watchOS, etc.). +# +# Arguments: +# $1: Xcode version (e.g. "26.2", can be empty if swift_version is set) +# $2: Swift version (e.g. "6.2", can be empty if xcode_version is set) +# $3: Setup command (can be empty) +# $4: Main command to run +# $5: JSON array or string of command arguments +# $6: JSON string of environment variables (can be empty) +# $7: needs_token (true/false, optional) +# +# Environment variables: +# XCODE_TARGETS_JSON - JSON array of xcodebuild targets to run after the main command. +# Each entry: {"platform": "iOS", "scheme": "...", +# "build_destination": "generic/platform=ios", +# "test_destination": "name=iPhone Air", +# "build": true, "test": false} +# SWIFTLY_TOOLCHAIN - A swiftly toolchain selector (e.g. "main-snapshot"). When set, +# that toolchain is installed and selected under the chosen Xcode, +# so the command can run through `swiftly run`. +# XCODE_DEBUG_OUTPUT - "true" to drop -quiet from the xcodebuild target invocations. +# XCODE_APPLICATIONS_DIRECTORY +# - Where the Xcode apps live. Defaults to /Applications; the +# tests point it at a directory holding a symlink so the +# script can be driven without a runner's Xcode layout. + +xcode_version="${1:-}" +swift_version="${2:-}" +setup_command="${3:-}" +command="$4" +command_arguments_json="${5:-}" +env_json="${6:-}" +# GITHUB_TOKEN reaches the command through the step environment, so this argument +# only holds the position in the interface. +# shellcheck disable=SC2034 +needs_token="${7:-false}" +xcode_targets_json="${XCODE_TARGETS_JSON:-}" +swiftly_toolchain="${SWIFTLY_TOOLCHAIN:-}" +xcode_debug_output="${XCODE_DEBUG_OUTPUT:-false}" +xcode_applications_directory="${XCODE_APPLICATIONS_DIRECTORY:-/Applications}" + +log() { echo "** $*" >&2; } + +# Select Xcode. +# +# This sets DEVELOPER_DIR rather than running `xcode-select -s`, which would +# change the selection for every other job sharing the runner. +# +# "latest-beta" names whatever beta the runner currently carries, via the +# Xcode-latest.app symlink, so a workflow does not need editing each time a beta +# ships. +if [[ "$xcode_version" == "latest-beta" ]]; then + xcode_app="${xcode_applications_directory}/Xcode-latest.app" +elif [[ -n "$xcode_version" ]]; then + xcode_app="${xcode_applications_directory}/Xcode_${xcode_version}.app" +elif [[ -n "$swift_version" ]]; then + xcode_app="${xcode_applications_directory}/Xcode_swift_${swift_version}.app" +else + log "ERROR: neither xcode_version nor swift_version provided" + exit 1 +fi + +if [[ ! -d "$xcode_app" ]]; then + log "ERROR: $xcode_app not found on this runner" + exit 1 +fi + +export DEVELOPER_DIR="${xcode_app}/Contents/Developer" +log "Using DEVELOPER_DIR=$DEVELOPER_DIR" + +# Shell-quoted by jq and eval'd rather than read a line at a time, so a value +# containing a newline arrives whole and an empty one is still exported. +if [[ -n "$env_json" && "$env_json" != '{}' && "$env_json" != 'null' ]]; then + env_exports=$(echo "$env_json" | jq -r 'to_entries[] | "export \(.key)=\((.value | tostring) | @sh)"') + eval "$env_exports" +fi + +# Install a swiftly-managed toolchain on top of the selected Xcode. Used to test +# nightly snapshots on macOS, where there is no Xcode for them to come from. +if [[ -n "$swiftly_toolchain" ]]; then + swiftly_env="$HOME/.swiftly/env.sh" + if [[ ! -f "$swiftly_env" ]]; then + log "ERROR: swiftly is not installed on this runner ($swiftly_env not found)" + exit 1 + fi + # shellcheck source=/dev/null + source "$swiftly_env" + log "Installing swiftly toolchain: $swiftly_toolchain" + swiftly install "$swiftly_toolchain" --use + echo "Swiftly Swift version:" + swiftly run swift --version +else + echo "Swift version:" + xcrun swift --version + + echo "Clang version:" + xcrun clang --version +fi + +command_arguments="" +if [[ -n "$command_arguments_json" && "$command_arguments_json" != "null" && "$command_arguments_json" != '[]' ]]; then + if [[ "$command_arguments_json" =~ ^\[.*\]$ ]]; then + # Shell-quote each argument rather than joining on a space. The command is run + # through eval, so an argument containing whitespace would otherwise arrive as + # several - which the schema's array type promises it will not. + command_arguments=$(echo "$command_arguments_json" | jq -r 'map(@sh) | join(" ")') + else + command_arguments="$command_arguments_json" + fi +fi + +# The setup command and the command run in one shell, so a `cd` in setup carries +# into the command. That is how a caller reaches a package below the repository +# root, and Linux and Windows both behave this way. +full_command="$command $command_arguments" +if [[ -n "$setup_command" ]]; then + log "Running setup command" + log "Executing command: $full_command" + bash -ec "$setup_command"$'\n'"$full_command" +else + log "Executing command: $full_command" + bash -ec "$full_command" +fi + +# --------------------------------------------------------------------------- +# Xcode platform targets (build + optional test for iOS, watchOS, etc.) +# --------------------------------------------------------------------------- +if [[ -n "$xcode_targets_json" && "$xcode_targets_json" != "null" && "$xcode_targets_json" != '[]' ]]; then + target_count=$(echo "$xcode_targets_json" | jq 'length') + log "Running $target_count xcodebuild target(s)" + + # The target invocations pass -quiet, since a green run's full xcodebuild + # output is thousands of lines. XCODE_DEBUG_OUTPUT drops it, because a failing + # target is often impossible to diagnose from the summary alone. + quiet_arg=("-quiet") + if [[ "$xcode_debug_output" == "true" ]]; then + quiet_arg=() + fi + + for i in $(seq 0 $((target_count - 1))); do + target=$(echo "$xcode_targets_json" | jq -c ".[$i]") + platform=$(echo "$target" | jq -r '.platform') + scheme=$(echo "$target" | jq -r '.scheme') + build_dest=$(echo "$target" | jq -r '.build_destination // empty') + test_dest=$(echo "$target" | jq -r '.test_destination // empty') + # jq's `//` treats false as absent, so a target that asks for a test without a + # build would be built anyway. + do_build=$(echo "$target" | jq -r 'if .build == null then true else .build end') + do_test=$(echo "$target" | jq -r '.test // false') + + # A target asking for work with no destination to do it on would otherwise be + # skipped without a word and the job would pass. The generator always fills + # both, so this is reachable only from a hand-written matrix. + if [[ "$do_build" == "true" && -z "$build_dest" ]]; then + log "ERROR: $platform target has build: true but no build_destination" + exit 1 + fi + if [[ "$do_test" == "true" && -z "$test_dest" ]]; then + log "ERROR: $platform target has test: true but no test_destination" + exit 1 + fi + + if [[ "$do_build" == "true" && -n "$build_dest" ]]; then + # build-for-testing, not build, so the test code is type-checked on a platform + # where the tests are not run. It succeeds on a package with no test targets. + log "$platform build: xcodebuild -scheme $scheme -destination $build_dest build-for-testing" + /usr/bin/xcodebuild ${quiet_arg[@]+"${quiet_arg[@]}"} -scheme "$scheme" -destination "$build_dest" build-for-testing + fi + + if [[ "$do_test" == "true" && -n "$test_dest" ]]; then + log "$platform test: xcodebuild -scheme $scheme -destination $test_dest test" + /usr/bin/xcrun simctl shutdown all + /usr/bin/xcodebuild ${quiet_arg[@]+"${quiet_arg[@]}"} -scheme "$scheme" -destination "$test_dest" test + fi + done +fi diff --git a/.github/workflows/scripts/matrix/job-runner-windows.ps1 b/.github/workflows/scripts/matrix/job-runner-windows.ps1 new file mode 100644 index 00000000..16358c53 --- /dev/null +++ b/.github/workflows/scripts/matrix/job-runner-windows.ps1 @@ -0,0 +1,309 @@ +##===----------------------------------------------------------------------===## +## +## This source file is part of the Swift.org open source project +## +## Copyright (c) 2026 Apple Inc. and the Swift project authors +## Licensed under Apache License v2.0 with Runtime Library Exception +## +## See https://swift.org/LICENSE.txt for license information +## See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +## +##===----------------------------------------------------------------------===## +# Runs a matrix entry's command on Windows, either natively or inside a Docker +# container when CONTAINER_IMAGE is set. +# +# Environment variables: +# CONTAINER_IMAGE - When set, the command runs in this image instead of on the +# runner. +# SCRIPTS_ROOT - The scripts directory, translated into container paths for +# the inner command. +# MATRIX_TOOLCHAIN - The concrete toolchain identifier the installer is named +# after, which differs from the version label for a nightly. +# CROSS_PR_TESTING, CROSS_PR_REPO, CROSS_PR_NUMBER +# - When testing is enabled, the pull request whose linked PRs +# are checked out first. +# Parameters: +# -SwiftVersion: Swift version to use (e.g. "6.2", "nightly-main") +# -SetupCommand: Setup command (can be empty) +# -Command: Main command to run +# -CommandArguments: JSON array or string of command arguments +# -EnvJson: JSON string of environment variables (can be empty) +# -NeedsToken: Boolean ("true"/"false") - if "true", passes GITHUB_TOKEN to environment + +param( + [Parameter(Mandatory=$true)] + [string]$SwiftVersion, + + [Parameter(Mandatory=$false)] + [string]$SetupCommand = "", + + [Parameter(Mandatory=$true)] + [string]$Command, + + [Parameter(Mandatory=$false)] + [string]$CommandArguments = "", + + [Parameter(Mandatory=$false)] + [string]$EnvJson = "", + + [Parameter(Mandatory=$false)] + [string]$NeedsToken = "false" +) + +$ErrorActionPreference = "Stop" + +# --------------------------------------------------------------------------- +# Docker execution path +# --------------------------------------------------------------------------- +if (-not [string]::IsNullOrEmpty($env:CONTAINER_IMAGE)) { + Write-Host "Running in Docker container: $env:CONTAINER_IMAGE" + + # Wait for the Docker daemon, starting the service first - polling alone + # hangs for the full timeout when the service is not running. + $maxAttempts = 30 + $attempt = 0 + do { + $attempt++ + if ((Get-Service docker).Status -ne "Running") { + Start-Service docker + } + docker info 2>&1 | Out-Null + if ($LASTEXITCODE -eq 0) { break } + if ($attempt -ge $maxAttempts) { + Write-Error "Docker daemon did not become ready after $maxAttempts attempts" + exit 1 + } + Start-Sleep -Seconds 6 + } while ($true) + + docker pull $env:CONTAINER_IMAGE + if ($LASTEXITCODE -ne 0) { + Write-Error "Failed to pull Docker image: $env:CONTAINER_IMAGE" + exit 1 + } + + # Build command to run inside container. Joined with && rather than cmd's &, + # which sequences unconditionally and reports only the last command's status + # - a failing setup command would otherwise build at the wrong path and pass. + # + # A command written as a YAML block scalar arrives with newlines in it, and cmd + # takes a single command line, so its lines are joined the same way: they run in + # order and stop at the first failure, as the Linux container path's `bash -ec` + # does. + function Join-CommandLines([string]$Text) { + $lines = @($Text -split '\r?\n' | ForEach-Object { $_.Trim() } | Where-Object { $_.Length -gt 0 }) + return ($lines -join " && ") + } + + $innerCommand = "" + # Check out linked PRs first, inside the container: the script is compiled + # with the toolchain under test, which is the container's. + if ($env:CROSS_PR_TESTING -eq "true" -and -not [string]::IsNullOrEmpty($env:CROSS_PR_REPO)) { + $innerCommand = "swiftc %SCRIPTS_ROOT%\cross-pr-checkout.swift -o %TEMP%\cross-pr-checkout.exe && " + + "%TEMP%\cross-pr-checkout.exe %CROSS_PR_REPO% %CROSS_PR_NUMBER% && " + } + if (-not [string]::IsNullOrEmpty($SetupCommand)) { + $innerCommand += (Join-CommandLines $SetupCommand) + " && " + } + $innerCommand += "swift --version && " + (Join-CommandLines $Command) + + if (-not [string]::IsNullOrEmpty($CommandArguments) -and $CommandArguments -ne 'null' -and $CommandArguments -ne '[]') { + if ($CommandArguments.Trim().StartsWith('[')) { + # Quote each argument rather than joining on a space: the command is run + # through cmd, so one containing whitespace would otherwise arrive as + # several - which the schema's array type promises it will not. + $args_array = $CommandArguments | ConvertFrom-Json + $innerCommand += " " + (($args_array | ForEach-Object { '"' + $_ + '"' }) -join ' ') + } else { + $innerCommand += " $CommandArguments" + } + } + + $workspace = "C:\source" + $docker_args = @( + "run", + "-v", "$env:GITHUB_WORKSPACE`:$workspace", + "-w", $workspace, + "-e", "CI=$env:CI", + "-e", "GITHUB_ACTIONS=$env:GITHUB_ACTIONS", + "-e", "SWIFT_VERSION=$SwiftVersion" + ) + + if (-not [string]::IsNullOrEmpty($EnvJson) -and $EnvJson -ne '{}' -and $EnvJson -ne 'null') { + $env_obj = $EnvJson | ConvertFrom-Json + if ($null -ne $env_obj) { + $env_obj.PSObject.Properties | ForEach-Object { + $docker_args += "-e" + $docker_args += "$($_.Name)=$($_.Value)" + } + } + } + + if ($NeedsToken -eq "true" -and -not [string]::IsNullOrEmpty($env:GITHUB_TOKEN)) { + $docker_args += "-e" + $docker_args += "GITHUB_TOKEN=$env:GITHUB_TOKEN" + } + + # The scripts directory is inside the mount but at a different absolute path, + # so translate it; a command referencing %SCRIPTS_ROOT% must resolve inside + # the container. + if ($env:CROSS_PR_TESTING -eq "true" -and -not [string]::IsNullOrEmpty($env:CROSS_PR_REPO)) { + $scriptsInContainer = $env:SCRIPTS_ROOT + if (-not [string]::IsNullOrEmpty($env:GITHUB_WORKSPACE)) { + $scriptsInContainer = $env:SCRIPTS_ROOT.Replace($env:GITHUB_WORKSPACE, $workspace) + } + $docker_args += @("-e", "SCRIPTS_ROOT=$scriptsInContainer") + $docker_args += @("-e", "CROSS_PR_REPO=$env:CROSS_PR_REPO") + $docker_args += @("-e", "CROSS_PR_NUMBER=$env:CROSS_PR_NUMBER") + } + + $docker_args += @($env:CONTAINER_IMAGE, "cmd", "/s", "/c", $innerCommand) + + Write-Host "Executing: docker $($docker_args -join ' ')" + & docker @docker_args + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } + exit 0 +} + +# --------------------------------------------------------------------------- +# Native execution path +# --------------------------------------------------------------------------- + +# This script lives in scripts/matrix. The Swift and Visual Studio installers are +# shared with the legacy workflow and live in scripts/windows, so they are reached +# relative to this script rather than by probing the workspace. +$MatrixRoot = $PSScriptRoot +$WindowsRoot = Join-Path (Split-Path $PSScriptRoot -Parent) "windows" + +Write-Host "Matrix scripts: $MatrixRoot" +Write-Host "Windows scripts: $WindowsRoot" + +# Import helper functions from install-swift.ps1 +. "$WindowsRoot\swift\install-swift.ps1" + +# Python comes from the runner image; no caller installs one. Swift 6.1 and +# earlier want 3.9, later toolchains 3.10, and the hosted Windows images ship a +# version new enough for both. +Write-Host "Verifying Python installation..." +if (-not (Get-Command python -ErrorAction SilentlyContinue)) { + Write-Error "Python is not on PATH; the Windows runner image is expected to provide it." + exit 1 +} +python --version + +Write-Host "Installing Visual Studio Build Tools..." +if (-not (Test-Path "C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools")) { + . "$WindowsRoot\install-vsb.ps1" +} else { + Write-Host "Visual Studio Build Tools already installed, skipping..." +} + +# Install Swift. The install scripts are named after the concrete toolchain +# identifier rather than the version label, so "nightly-release" resolves to +# install-swift-nightly-6.4.x.ps1. +$Toolchain = if ([string]::IsNullOrEmpty($env:MATRIX_TOOLCHAIN)) { $SwiftVersion } else { $env:MATRIX_TOOLCHAIN } +Write-Host "Installing Swift $Toolchain..." +$swiftInstallScript = "$WindowsRoot\swift\install-swift-$Toolchain.ps1" +if (Test-Path $swiftInstallScript) { + . $swiftInstallScript +} else { + Write-Error "No installation script found for Swift $Toolchain at $swiftInstallScript" + exit 1 +} + +Write-Host "Verifying Swift installation..." +swift --version +if ($LASTEXITCODE -ne 0) { + Write-Error "Swift installation verification failed" + exit 1 +} + +Write-Host "Verifying Clang installation..." +clang --version +if ($LASTEXITCODE -ne 0) { + Write-Error "Clang installation verification failed" + exit 1 +} + +# Cross-PR checkout, when enabled. Any failure here fails the job: carrying on +# would test the base branch instead of the linked PRs and report success. +if ($env:CROSS_PR_TESTING -eq "true" -and -not [string]::IsNullOrEmpty($env:CROSS_PR_REPO)) { + Write-Host "Checking out linked PRs..." + $crossPrScript = "$env:SCRIPTS_ROOT\cross-pr-checkout.swift" + if (-not (Test-Path $crossPrScript)) { + Write-Error "cross-pr-checkout.swift not found at $crossPrScript" + exit 1 + } + & swiftc -sdk $env:SDKROOT $crossPrScript -o $env:TEMP\cross-pr-checkout.exe + if ($LASTEXITCODE -ne 0) { + Write-Error "Failed to compile cross-pr-checkout.swift" + exit 1 + } + & $env:TEMP\cross-pr-checkout.exe $env:CROSS_PR_REPO $env:CROSS_PR_NUMBER + if ($LASTEXITCODE -ne 0) { + Write-Error "Cross-PR checkout failed" + exit 1 + } +} + +if (-not [string]::IsNullOrEmpty($EnvJson) -and $EnvJson -ne '{}' -and $EnvJson -ne 'null') { + Write-Host "Setting custom environment variables..." + $env_obj = $EnvJson | ConvertFrom-Json + if ($null -ne $env_obj) { + $env_obj.PSObject.Properties | ForEach-Object { + if (-not [string]::IsNullOrEmpty($_.Name) -and -not [string]::IsNullOrEmpty($_.Value)) { + Write-Host " $($_.Name)=$($_.Value)" + Set-Item -Path "env:$($_.Name)" -Value $_.Value + } + } + } +} + +# command_arguments may be a JSON array, a plain string, or absent. +$command_args_string = "" +if (-not [string]::IsNullOrEmpty($CommandArguments) -and $CommandArguments -ne 'null' -and $CommandArguments -ne '[]') { + if ($CommandArguments.Trim().StartsWith('[')) { + $args_array = $CommandArguments | ConvertFrom-Json + # Single-quoted, with any single quote doubled: the command is run through + # Invoke-Expression, which parses the result as PowerShell, and in double + # quotes an argument holding $ or a backtick would be expanded rather than + # passed on. The quotes also keep an argument containing whitespace as one + # argument, which the schema's array type promises it is. + $command_args_string = ($args_array | ForEach-Object { "'" + ([string]$_).Replace("'", "''") + "'" }) -join ' ' + } else { + $command_args_string = $CommandArguments + } +} + +$fullCommand = $Command +if (-not [string]::IsNullOrEmpty($command_args_string)) { + $fullCommand = "$Command $command_args_string" +} + +# Invoke-Program propagates a child process's exit code. Dot-sourced rather than +# defined here so that it can be tested on its own, and sourced before the setup +# command runs so that both it and the main command can use it. +. "$MatrixRoot\invoke-program.ps1" + +if (-not [string]::IsNullOrEmpty($SetupCommand)) { + Write-Host "Running setup command: $SetupCommand" + Invoke-Expression $SetupCommand + if ($LASTEXITCODE -ne 0) { + # Write-Host, not Write-Error: an error record makes pwsh print a stack + # trace for the invocation that failed, which buries the command's own + # output under a frame pointing at this script. + Write-Host "::error::Setup command failed with exit code ${LASTEXITCODE}: $SetupCommand" + exit $LASTEXITCODE + } +} + +Write-Host "Running command: $fullCommand" +Invoke-Expression $fullCommand +if ($LASTEXITCODE -ne 0) { + Write-Host "::error::Command failed with exit code ${LASTEXITCODE}: $fullCommand" + exit $LASTEXITCODE +} + +Write-Host "Command completed successfully" diff --git a/.github/workflows/toolchain_matrix.yml b/.github/workflows/toolchain_matrix.yml new file mode 100644 index 00000000..eed67954 --- /dev/null +++ b/.github/workflows/toolchain_matrix.yml @@ -0,0 +1,209 @@ +name: Toolchain matrix + +permissions: + contents: read + +# Emits the supported toolchain axis - platforms, runners, Swift versions and +# container images - without a command, for a caller to pair with its own via +# execute_matrix.yml's `command` input. +# +# This is the middle ground: package_test.yml chooses the toolchains and the +# work, a hand-written matrix leaves you both to do, and this chooses the +# toolchains only. Use it for any job kind package_test.yml does not cover: +# integration tests, spec suites, custom scripts. +# +# jobs: +# toolchains: +# uses: swiftlang/github-workflows/.github/workflows/toolchain_matrix.yml@ +# with: +# linux_swift_versions: '["6.3", "nightly-release"]' +# integration-tests: +# needs: toolchains +# uses: swiftlang/github-workflows/.github/workflows/execute_matrix.yml@ +# with: +# name: "Integration tests" +# matrix_yaml_string: ${{ needs.toolchains.outputs.matrix_yaml }} +# setup_command: "sudo apt-get update -yq && sudo apt-get install -yq lsof" +# command: "./scripts/integration_tests.sh" +# +# `sudo` suits the default, where Linux runs on the runner unprivileged. A +# containerized entry runs as root and has no sudo, so a setup command that has +# to serve both should test for it. +# +# The output is a plain YAML matrix, so it can also be filtered or extended with +# `yq` before being executed - that is how non-rectangular matrices, private +# registry images and mixed native/containerized runs are expressed. + +on: + workflow_call: + inputs: + enable_linux: + type: boolean + description: "Include the Linux toolchains." + default: true + enable_macos: + type: boolean + description: "Include the macOS toolchains." + default: false + # execute_matrix.yml runs one command on every entry, and a caller pairing a + # toolchain axis with its own command usually has a POSIX shell script. Ask + # for Windows when the command runs there too. + enable_windows: + type: boolean + description: "Include the Windows toolchains." + default: false + linux_swift_versions: + type: string + description: "Linux Swift version list (JSON/YAML array)." + default: '["6.1", "6.2", "6.3", "nightly-release", "nightly-main"]' + linux_host_archs: + type: string + description: "Linux host architecture list (JSON/YAML array)." + default: '["x86_64"]' + linux_use_docker: + type: boolean + description: "Emit container images instead of native swiftly toolchains." + default: false + linux_os: + type: string + description: "Linux distribution the container image is tagged for, or a list of them (JSON/YAML array). A distribution other than the default, or more than one, implies linux_use_docker." + default: "noble" + macos_swift_versions: + type: string + description: "macOS Swift version list (JSON/YAML array). Empty, with macos_xcode_versions also empty, uses the generator's list of release versions." + default: "" + macos_xcode_versions: + type: string + description: "macOS Xcode version list (JSON/YAML array). Combined with macos_swift_versions rather than replaced by it." + default: "" + macos_os: + type: string + description: "macOS runner label, or a list of them (JSON/YAML array) to include one entry per label." + default: "tahoe" + macos_arch: + type: string + description: "macOS runner architecture label." + default: "ARM64" + macos_runner_pool: + type: string + description: "macOS self-hosted runner pool label." + default: "general" + macos_repository_owner: + type: string + description: "Owner whose self-hosted macOS runners these are. A repository under any other owner gets no macOS entries, since a fork's jobs would queue until they time out." + default: "" + windows_swift_versions: + type: string + description: "Windows Swift version list (JSON/YAML array)." + default: '["6.1", "6.2", "6.3", "nightly-release", "nightly-main"]' + windows_os: + type: string + description: "Windows runner label, or a list of them (JSON/YAML array) to include one entry per label." + default: "windows-2022" + windows_use_docker: + type: boolean + description: "Emit container images for Windows entries. A container carries a Windows SDK matched to its toolchain, which the runner image does not carry for Swift releases before 6.1." + default: false + linux_env_vars: + type: string + description: "Environment variables for Linux entries (JSON/YAML object)." + default: "{}" + macos_env_vars: + type: string + description: "Environment variables for macOS entries (JSON/YAML object)." + default: "{}" + windows_env_vars: + type: string + description: "Environment variables for Windows entries (JSON/YAML object)." + default: "{}" + minimum_swift_version: + type: string + description: "Minimum Swift version. Empty auto-detects from Package.swift, 'none' disables filtering, or name a version explicitly." + default: "" + find_subdirectory_manifests: + type: boolean + description: "Check subdirectory Package.swift files when detecting the minimum version." + default: false + workflows_repository: + type: string + description: "Repository to take the matrix scripts from. Point this at a fork to test a change to the workflows before it lands; it carries no version, so Dependabot has only the `uses:` line to bump." + default: "swiftlang/github-workflows" + workflows_ref: + type: string + description: "Ref to take the scripts from. Empty uses workflows_repository's default branch, which is correct for a released version; set it when workflows_repository is a fork whose default branch does not carry the change." + default: "" + outputs: + matrix_yaml: + description: "The toolchain matrix, as a YAML string to pass to execute_matrix.yml." + value: ${{ jobs.generate-toolchain-matrix.outputs.matrix_yaml }} + +jobs: + generate-toolchain-matrix: + name: Generate toolchain matrix + runs-on: ubuntu-latest + outputs: + matrix_yaml: ${{ steps.generate.outputs.matrix_yaml }} + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Resolve the workflows source + id: workflows_source + shell: bash + env: + WORKFLOWS_REPOSITORY: ${{ inputs.workflows_repository }} + WORKFLOWS_REF: ${{ inputs.workflows_ref }} + run: | + set -euo pipefail + + # Empty uses the default branch. + echo "ref=$WORKFLOWS_REF" >> $GITHUB_OUTPUT + + if [ "$GITHUB_REPOSITORY" = "$WORKFLOWS_REPOSITORY" ]; then + echo "needs_checkout=false" >> $GITHUB_OUTPUT + echo "root_directory=$GITHUB_WORKSPACE" >> $GITHUB_OUTPUT + else + echo "needs_checkout=true" >> $GITHUB_OUTPUT + echo "root_directory=$GITHUB_WORKSPACE/github-workflows" >> $GITHUB_OUTPUT + fi + - name: Checkout the workflows repository + if: ${{ steps.workflows_source.outputs.needs_checkout == 'true' }} + uses: actions/checkout@v7 + with: + repository: ${{ inputs.workflows_repository }} + ref: ${{ steps.workflows_source.outputs.ref }} + path: github-workflows + persist-credentials: false + - name: Generate matrix + id: generate + env: + WORKFLOWS_CHECKOUT: ${{ steps.workflows_source.outputs.root_directory }} + MATRIX_MODE: toolchains + ENABLE_LINUX: ${{ inputs.enable_linux }} + ENABLE_MACOS: ${{ inputs.enable_macos }} + ENABLE_WINDOWS: ${{ inputs.enable_windows }} + LINUX_SWIFT_VERSIONS: ${{ inputs.linux_swift_versions }} + LINUX_HOST_ARCHS: ${{ inputs.linux_host_archs }} + LINUX_USE_DOCKER: ${{ inputs.linux_use_docker }} + LINUX_OS: ${{ inputs.linux_os }} + MACOS_SWIFT_VERSIONS: ${{ inputs.macos_swift_versions }} + MACOS_XCODE_VERSIONS: ${{ inputs.macos_xcode_versions }} + MACOS_OS: ${{ inputs.macos_os }} + MACOS_ARCH: ${{ inputs.macos_arch }} + MACOS_RUNNER_POOL: ${{ inputs.macos_runner_pool }} + MACOS_REPOSITORY_OWNER: ${{ inputs.macos_repository_owner }} + GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} + WINDOWS_SWIFT_VERSIONS: ${{ inputs.windows_swift_versions }} + WINDOWS_OS: ${{ inputs.windows_os }} + WINDOWS_USE_DOCKER: ${{ inputs.windows_use_docker }} + LINUX_ENV_VARS: ${{ inputs.linux_env_vars }} + MACOS_ENV_VARS: ${{ inputs.macos_env_vars }} + WINDOWS_ENV_VARS: ${{ inputs.windows_env_vars }} + MINIMUM_SWIFT_VERSION: ${{ inputs.minimum_swift_version }} + ENABLE_SUBDIRECTORY_MANIFEST_SEARCH: ${{ inputs.find_subdirectory_manifests }} + run: | + matrix_yaml=$("${WORKFLOWS_CHECKOUT}/.github/workflows/scripts/matrix/generate-matrix.swift") + echo "matrix_yaml<> $GITHUB_OUTPUT + echo "$matrix_yaml" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT diff --git a/.gitignore b/.gitignore index e43b0f98..21a5664b 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ .DS_Store +tests/MatrixGeneratorValidator/.build/ +tests/TestPackage/.build/ diff --git a/tests/MatrixGeneratorValidator/Package.swift b/tests/MatrixGeneratorValidator/Package.swift new file mode 100644 index 00000000..fc7699c0 --- /dev/null +++ b/tests/MatrixGeneratorValidator/Package.swift @@ -0,0 +1,14 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "MatrixGeneratorValidator", + targets: [ + .target(name: "MatrixTestSupport"), + .testTarget( + name: "MatrixGeneratorTests", + dependencies: ["MatrixTestSupport"] + ), + ] +) diff --git a/tests/MatrixGeneratorValidator/Sources/MatrixTestSupport/EntryPoint.swift b/tests/MatrixGeneratorValidator/Sources/MatrixTestSupport/EntryPoint.swift new file mode 100644 index 00000000..93ecef75 --- /dev/null +++ b/tests/MatrixGeneratorValidator/Sources/MatrixTestSupport/EntryPoint.swift @@ -0,0 +1,224 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Foundation + +/// A workflow that calls the generator, and the environment it hands it. +/// +/// The environment is read out of the workflow file: every `${{ inputs.x }}` in +/// its matrix-generating step becomes that input's declared default. A test can +/// then ask what a caller who passes nothing actually gets, so a default edited +/// in the workflow and a default edited in the generator are both visible. +public struct EntryPoint: Sendable, CustomStringConvertible { + /// The workflow's file name without its extension. + public let workflow: String + + public init(workflow: String) { + self.workflow = workflow + } + + public var description: String { workflow } + + public static let packageTest = EntryPoint(workflow: "package_test") + public static let toolchainMatrix = EntryPoint(workflow: "toolchain_matrix") + public static let benchmarks = EntryPoint(workflow: "benchmarks") + + /// Every workflow that generates a matrix by calling the generator. + public static let all: [EntryPoint] = [packageTest, toolchainMatrix, benchmarks] + + /// Overridable so CI can point at a checkout elsewhere; otherwise derived from + /// the generator's location, which sits in the same checkout. + public var path: String { + // /.github/workflows/scripts/matrix/generate-matrix.swift + var url = URL(fileURLWithPath: Generator.scriptPath) + for _ in 0..<3 { + url.deleteLastPathComponent() + } + return url.appendingPathComponent("\(workflow).yml").path + } + + /// The enables that gate a whole block of the generator. A knob only one block + /// reads is dead while that block is off, so a test comparing defaults turns + /// these on first. + public static let jobKindEnables = [ + "ENABLE_LINUX", + "ENABLE_MACOS", + "ENABLE_MACOS_SWIFTLY", + "ENABLE_WINDOWS", + "ENABLE_FREEBSD", + "ENABLE_LINUX_STATIC_SDK_BUILD", + "ENABLE_WASM_SDK_BUILD", + "ENABLE_EMBEDDED_WASM_SDK_BUILD", + "ENABLE_ANDROID_SDK_BUILD", + "ENABLE_ANDROID_EMULATOR_TESTS", + "ENABLE_CXX_INTEROP", + ] + + /// What the matrix-generating step sets, with each input resolved to its + /// declared default: the environment a caller who passes nothing produces. + /// + /// A value assembled in the step's script rather than its `env` block is not + /// here, so `benchmarks.yml`'s composed commands and environment variables are + /// absent - they are the workflow's own work, not a default a caller sees. + public func environment(repositoryOwner: String = "swiftlang") throws -> [String: String] { + let defaults = try inputDefaults() + var resolved: [String: String] = [:] + for (key, value) in try stepEnvironment() { + if let input = Self.inputReference(in: value) { + guard let declared = defaults[input] else { + throw WorkflowError.unknownInput(workflow: workflow, key: key, input: input) + } + resolved[key] = declared + } else if value.contains("${{ github.repository_owner }}") { + resolved[key] = repositoryOwner + } else if value.contains("${{ steps.") { + // Where the scripts were checked out, which the test supplies itself. + continue + } else if value.contains("${{") { + throw WorkflowError.unresolvedExpression(workflow: workflow, key: key, value: value) + } else { + resolved[key] = value + } + } + return resolved + } + + /// The keys of `environment()` that carry an input's declared default, rather + /// than a value the workflow fixes for every caller. + public func inputBackedKeys() throws -> Set { + var keys: Set = [] + for (key, value) in try stepEnvironment() where Self.inputReference(in: value) != nil { + keys.insert(key) + } + return keys + } + + /// Every input's declared default, as the string Actions would pass. + public func inputDefaults() throws -> [String: String] { + let declarations = try yq( + ".on.workflow_call.inputs", + as: [String: InputDeclaration].self + ) + return declarations.mapValues { $0.default?.value ?? "" } + } + + /// The `env` block of the step that runs the generator. + private func stepEnvironment() throws -> [String: String] { + let blocks = try yq( + #"[.jobs.*.steps[] | select(.id == "generate") | .env]"#, + as: [[String: ActionsScalar]].self + ) + guard let block = blocks.first else { + throw WorkflowError.noGenerateStep(workflow: workflow) + } + return block.mapValues(\.value) + } + + /// The input a value names, when the value is exactly one `inputs.` reference. + private static func inputReference(in value: String) -> String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("${{"), trimmed.hasSuffix("}}") else { return nil } + let inner = trimmed.dropFirst(3).dropLast(2).trimmingCharacters(in: .whitespaces) + guard inner.hasPrefix("inputs.") else { return nil } + let name = inner.dropFirst("inputs.".count) + guard !name.isEmpty, name.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "_" }) else { + return nil + } + return String(name) + } + + private func yq(_ expression: String, as type: T.Type) throws -> T { + guard FileManager.default.isReadableFile(atPath: path) else { + throw WorkflowError.workflowNotFound(path) + } + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["yq", "-o=json", expression, path] + + let standardOutput = Pipe() + let standardError = Pipe() + process.standardOutput = standardOutput + process.standardError = standardError + + do { + try process.run() + } catch { + throw GeneratorError.toolNotFound("yq") + } + let outputData = standardOutput.fileHandleForReading.readDataToEndOfFile() + let errorData = standardError.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + throw WorkflowError.yqFailed( + expression: expression, + standardError: String(decoding: errorData, as: UTF8.self) + ) + } + return try JSONDecoder().decode(T.self, from: outputData) + } + + private struct InputDeclaration: Decodable { + var `default`: ActionsScalar? + } +} + +/// A YAML scalar as the string Actions would put in the environment: a boolean +/// input reaches a script as "true" or "false", and a number as its digits. +struct ActionsScalar: Decodable { + let value: String + + init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + if let string = try? container.decode(String.self) { + value = string + } else if let boolean = try? container.decode(Bool.self) { + value = boolean ? "true" : "false" + } else if let integer = try? container.decode(Int.self) { + value = String(integer) + } else if let number = try? container.decode(Double.self) { + value = String(number) + } else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Not a scalar an Actions environment variable can carry" + ) + } + } +} + +public enum WorkflowError: Error, CustomStringConvertible { + case workflowNotFound(String) + case noGenerateStep(workflow: String) + case yqFailed(expression: String, standardError: String) + case unknownInput(workflow: String, key: String, input: String) + case unresolvedExpression(workflow: String, key: String, value: String) + + public var description: String { + switch self { + case .workflowNotFound(let path): + return "Workflow not found at \(path)" + case .noGenerateStep(let workflow): + return "\(workflow).yml has no step with id 'generate'" + case .yqFailed(let expression, let standardError): + return "yq failed for \(expression): \(standardError)" + case .unknownInput(let workflow, let key, let input): + return "\(workflow).yml sets \(key) from inputs.\(input), which it does not declare" + case .unresolvedExpression(let workflow, let key, let value): + return """ + \(workflow).yml sets \(key) to an expression these tests cannot resolve: \(value). \ + Teach EntryPoint.environment() what it means. + """ + } + } +} diff --git a/tests/MatrixGeneratorValidator/Sources/MatrixTestSupport/Generator.swift b/tests/MatrixGeneratorValidator/Sources/MatrixTestSupport/Generator.swift new file mode 100644 index 00000000..4a522481 --- /dev/null +++ b/tests/MatrixGeneratorValidator/Sources/MatrixTestSupport/Generator.swift @@ -0,0 +1,188 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Foundation + +/// What one run of the generator produced. +public struct Generated: Sendable { + public var entries: [MatrixEntry] + public var standardError: String + public var exitCode: Int32 + + public var names: [String] { entries.map(\.name) } + public var platforms: [String] { entries.map(\.platform) } + /// The version label of every entry that has a Swift toolchain. + public var versions: [String] { entries.compactMap { $0.swiftBuild?.swiftVersion } } + public var count: Int { entries.count } + + public func entry(named name: String) -> MatrixEntry? { + entries.first { $0.name == name } + } +} + +public enum GeneratorError: Error, CustomStringConvertible { + case generatorNotFound(String) + case toolNotFound(String) + case decodingFailed(underlying: any Error, json: String, standardError: String) + + public var description: String { + switch self { + case .generatorNotFound(let path): + return "generate-matrix.swift not found at \(path)" + case .toolNotFound(let tool): + return "\(tool) is required to run these tests but was not found on PATH" + case .decodingFailed(let underlying, let json, let standardError): + return """ + Could not decode the generated matrix: \(underlying) + + JSON: + \(json) + + Generator stderr: + \(standardError) + """ + } + } +} + +/// Runs `generate-matrix.swift` and decodes what it emitted. +/// +/// This needs no runner, no Swift toolchain for the package under test and no +/// network. The generator does read `Package.swift` from its working directory, +/// so runs happen in a scratch directory the helper writes manifests into. +public struct Generator: Sendable { + /// Overridable so CI can point at a checkout elsewhere; otherwise derived from + /// this file's location. + public static var scriptPath: String { + if let override = ProcessInfo.processInfo.environment["GENERATE_MATRIX_PATH"] { + return override + } + // /tests/MatrixGeneratorValidator/Sources/MatrixTestSupport/Generator.swift + var url = URL(fileURLWithPath: #filePath) + for _ in 0..<5 { + url.deleteLastPathComponent() + } + return url.appendingPathComponent(".github/workflows/scripts/matrix/generate-matrix.swift").path + } + + /// Runs the generator and returns its raw output, for tests about the output + /// itself rather than the matrix it describes. + public static func runRaw( + _ environment: [String: String] = [:], + manifests: [String: String] = [:], + includePlatformDefaults: Bool = false + ) throws -> (standardOutput: String, standardError: String, exitCode: Int32) { + let script = scriptPath + guard FileManager.default.isExecutableFile(atPath: script) else { + throw GeneratorError.generatorNotFound(script) + } + + let workDirectory = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("matrix-tests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: workDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: workDirectory) } + + for (name, contents) in manifests { + try contents.write( + to: workDirectory.appendingPathComponent(name), + atomically: true, + encoding: .utf8 + ) + } + + var variables = ProcessInfo.processInfo.environment + if !includePlatformDefaults { + variables["ENABLE_LINUX"] = "false" + variables["ENABLE_WINDOWS"] = "false" + } + for (key, value) in environment { + variables[key] = value + } + + let process = Process() + process.executableURL = URL(fileURLWithPath: script) + process.environment = variables + process.currentDirectoryURL = workDirectory + + let standardOutput = Pipe() + let standardError = Pipe() + process.standardOutput = standardOutput + process.standardError = standardError + + try process.run() + // Read before waiting: a full pipe buffer would otherwise deadlock. + let outputData = standardOutput.fileHandleForReading.readDataToEndOfFile() + let errorData = standardError.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + + return ( + String(decoding: outputData, as: UTF8.self), + String(decoding: errorData, as: UTF8.self), + process.terminationStatus + ) + } + + /// Runs the generator in a scratch directory and decodes the matrix. + /// + /// - Parameters: + /// - environment: Variables for this run. Linux and Windows are disabled first + /// so a test sees only the entries it is about; pass + /// `includePlatformDefaults` to keep the generator's own defaults. + /// - manifests: Files to write into the scratch directory before running, + /// keyed by name - how minimum-version detection is given something to read. + /// - includePlatformDefaults: Leave the platform enables alone. + public static func run( + _ environment: [String: String] = [:], + manifests: [String: String] = [:], + includePlatformDefaults: Bool = false + ) throws -> Generated { + // Ask for JSON so there is nothing to convert. A caller may still override + // the format, which is how the format itself gets tested. + var variables = ["MATRIX_FORMAT": "json"] + for (key, value) in environment { + variables[key] = value + } + + let result = try runRaw( + variables, + manifests: manifests, + includePlatformDefaults: includePlatformDefaults + ) + + // A failing generator produces no matrix, which is a legitimate thing for a + // test to assert on, so report it rather than throwing. + let trimmed = result.standardOutput.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return Generated(entries: [], standardError: result.standardError, exitCode: result.exitCode) + } + + do { + let matrix = try JSONDecoder().decode(Matrix.self, from: Data(result.standardOutput.utf8)) + return Generated( + entries: matrix.config, + standardError: result.standardError, + exitCode: result.exitCode + ) + } catch { + throw GeneratorError.decodingFailed( + underlying: error, + json: result.standardOutput, + standardError: result.standardError + ) + } + } + + /// A manifest with the given tools version, for minimum-version detection. + public static func manifest(toolsVersion: String) -> String { + "// swift-tools-version:\(toolsVersion)\n" + } +} diff --git a/tests/MatrixGeneratorValidator/Sources/MatrixTestSupport/MatrixEntry.swift b/tests/MatrixGeneratorValidator/Sources/MatrixTestSupport/MatrixEntry.swift new file mode 100644 index 00000000..9b66f032 --- /dev/null +++ b/tests/MatrixGeneratorValidator/Sources/MatrixTestSupport/MatrixEntry.swift @@ -0,0 +1,174 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +/// One entry in a generated matrix: a toolchain, where to run it, and what to run. +/// +/// Decoding is itself an assertion. A required field the generator renames or +/// stops emitting fails to decode, so no test has to ask about it. The optional +/// fields are the ones the generator emits only when they carry information, so +/// `nil` is a meaningful answer rather than a missing case. +public struct MatrixEntry: Decodable, Sendable { + public var platform: String + public var name: String + public var runner: [String] + + /// Toolchain configuration for Swift on Linux and Windows. Absent on macOS and + /// FreeBSD entries. + public var swiftBuild: SwiftBuild? + + /// Toolchain configuration for macOS via Xcode. Absent everywhere else. + public var xcodeBuild: XcodeBuild? + + /// Configuration for the FreeBSD virtual machine. Absent everywhere else. + public var freebsd: FreeBSD? + + /// Omitted in toolchain-only mode, where the caller supplies them instead. + public var command: String? + public var setupCommand: String? + public var commandArguments: [String]? + + public var env: [String: String] + public var androidEmulator: Bool? + + public struct SwiftBuild: Decodable, Sendable { + /// The version label a caller wrote, such as `6.3` or `nightly-release`. + public var swiftVersion: String + /// The concrete toolchain upstream publishes under. Emitted only when it + /// differs from `swift_version`. + public var toolchain: String? + /// The swiftly selector. Emitted only when it differs from `swift_version`. + public var swiftly: String? + public var container: Container? + public var sdk: SDK? + } + + public struct Container: Decodable, Sendable { + public var image: String + public var dockerfile: String? + public var capabilities: [String]? + public var securityOptions: [String]? + } + + public struct SDK: Decodable, Sendable { + public var type: String + public var ndkVersion: String? + public var triples: [String]? + } + + public struct XcodeBuild: Decodable, Sendable { + /// Selects `Xcode_swift_.app`. + public var swiftVersion: String? + /// Selects `Xcode_.app`, or `Xcode-latest.app` for `latest-beta`. + public var xcodeVersion: String? + /// A swiftly selector installed under the selected Xcode. + public var swiftlyToolchain: String? + public var targets: [Target]? + public var debugOutput: Bool? + } + + public struct Target: Decodable, Sendable { + public var platform: String + public var scheme: String + public var buildDestination: String + public var testDestination: String + public var build: Bool + public var test: Bool + } + + public struct FreeBSD: Decodable, Sendable { + public var osVersion: String + /// The version label, which the executor uses for `SWIFT_VERSION`. FreeBSD + /// entries have no `swift_build`, so it lives here. + public var swiftVersion: String + public var swiftURL: String + public var buildFlags: String + public var envVars: String + + enum CodingKeys: String, CodingKey { + case osVersion = "os_version" + case swiftVersion = "swift_version" + case swiftURL = "swift_url" + case buildFlags = "build_flags" + case envVars = "env_vars" + } + } +} + +/// The generator emits snake_case. Every key is spelled out rather than relying on +/// a conversion strategy, so a renamed key fails to decode. +extension MatrixEntry { + enum CodingKeys: String, CodingKey { + case platform + case name + case runner + case swiftBuild = "swift_build" + case xcodeBuild = "xcode_build" + case freebsd + case command + case setupCommand = "setup_command" + case commandArguments = "command_arguments" + case env + case androidEmulator = "android_emulator" + } +} + +extension MatrixEntry.SwiftBuild { + enum CodingKeys: String, CodingKey { + case swiftVersion = "swift_version" + case toolchain + case swiftly + case container + case sdk + } +} + +extension MatrixEntry.Container { + enum CodingKeys: String, CodingKey { + case image + case dockerfile + case capabilities + case securityOptions = "security_options" + } +} + +extension MatrixEntry.SDK { + enum CodingKeys: String, CodingKey { + case type + case ndkVersion = "ndk_version" + case triples + } +} + +extension MatrixEntry.XcodeBuild { + enum CodingKeys: String, CodingKey { + case swiftVersion = "swift_version" + case xcodeVersion = "xcode_version" + case swiftlyToolchain = "swiftly_toolchain" + case targets + case debugOutput = "debug_output" + } +} + +extension MatrixEntry.Target { + enum CodingKeys: String, CodingKey { + case platform + case scheme + case buildDestination = "build_destination" + case testDestination = "test_destination" + case build + case test + } +} + +struct Matrix: Decodable { + var config: [MatrixEntry] +} diff --git a/tests/MatrixGeneratorValidator/Tests/MatrixGeneratorTests/CommandAxisTests.swift b/tests/MatrixGeneratorValidator/Tests/MatrixGeneratorTests/CommandAxisTests.swift new file mode 100644 index 00000000..7def2c7d --- /dev/null +++ b/tests/MatrixGeneratorValidator/Tests/MatrixGeneratorTests/CommandAxisTests.swift @@ -0,0 +1,598 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import MatrixTestSupport +import Testing + +/// Every job kind at once, each on one version, so a name is the bare form with +/// nothing fanned out. +private let everyKind = [ + "ENABLE_LINUX": "true", + "ENABLE_MACOS": "true", + "ENABLE_MACOS_SWIFTLY": "true", + "ENABLE_WINDOWS": "true", + "ENABLE_FREEBSD": "true", + "ENABLE_LINUX_STATIC_SDK_BUILD": "true", + "ENABLE_ANDROID_SDK_BUILD": "true", + "ENABLE_CXX_INTEROP": "true", + "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, + "MACOS_SWIFT_VERSIONS": #"["6.3"]"#, + "MACOS_XCODE_VERSIONS": #"["latest-beta"]"#, + "WINDOWS_SWIFT_VERSIONS": #"["6.3"]"#, + "LINUX_STATIC_SDK_VERSIONS": #"["6.3"]"#, + "ANDROID_SDK_VERSIONS": #"["6.3"]"#, + "ANDROID_NDK_VERSIONS": #"["r27d"]"#, +] + +/// The names those kinds produce when each runs one command. +private let bareNames = [ + "Linux Swift 6.3", + "macOS Xcode latest-beta", + "macOS Swift 6.3", + "macOS Swiftly main-snapshot (Xcode swift_6.3)", + "Windows Swift 6.3", + "Static Linux SDK Swift 6.3", + "Android SDK Swift 6.3 NDK r27d", + "Cxx interop Swift 6.3", + "FreeBSD nightly-main - 14.3 - x86_64", +] + +@Suite("The command axis") +struct CommandAxisTests { + @Test("A map of label to command runs one job per label") + func labeledCommands() throws { + let generated = try Generator.run([ + "ENABLE_LINUX": "true", + "LINUX_SWIFT_VERSIONS": #"["6.2","6.3"]"#, + "LINUX_COMMAND": """ + test: swift test + release: swift test -c release + """, + ]) + #expect(generated.exitCode == 0, "\(generated.standardError)") + #expect( + generated.names == [ + "test Linux Swift 6.2", "test Linux Swift 6.3", + "release Linux Swift 6.2", "release Linux Swift 6.3", + ] + ) + #expect(generated.entry(named: "test Linux Swift 6.3")?.command == "swift test") + #expect(generated.entry(named: "release Linux Swift 6.3")?.command == "swift test -c release") + } + + @Test("Every job kind takes a map of label to command") + func everyKindTakesLabels() throws { + // A kind reading only the scalar would drop every label but the value it read + // as the whole command, leaving a job running something the caller did not ask + // for under a name that says otherwise. + var environment = everyKind + environment["LINUX_COMMAND"] = "test: swift test\nbuild: swift build" + environment["MACOS_COMMAND"] = "test: xcrun swift test\nbuild: xcrun swift build" + environment["MACOS_SWIFTLY_COMMAND"] = + "test: swiftly run swift test\nbuild: swiftly run swift build" + environment["WINDOWS_COMMAND"] = "test: swift test\nbuild: swift build" + environment["FREEBSD_COMMAND"] = "test: swift test\nbuild: swift build" + environment["LINUX_STATIC_SDK_COMMAND"] = "test: swift build\nbuild: swift build --static-swift-stdlib" + environment["ANDROID_SDK_COMMAND"] = "test: swift build\nbuild: swift build --target A" + + let generated = try Generator.run(environment) + #expect(generated.exitCode == 0, "\(generated.standardError)") + // The kind whose command is the check itself takes no labels, so it keeps its + // single entry. + #expect(generated.count == 2 * 8 + 1, "got \(generated.names)") + for name in bareNames where !name.hasPrefix("Cxx interop") { + #expect(generated.names.contains("test \(name)"), "\(name) took no label") + #expect(generated.names.contains("build \(name)"), "\(name) took no label") + } + } + + @Test("A label's own version list narrows it to those versions") + func perLabelVersions() throws { + let generated = try Generator.run([ + "ENABLE_LINUX": "true", + "LINUX_SWIFT_VERSIONS": #"["6.1","6.2","6.3"]"#, + "LINUX_COMMAND": """ + test: swift test + release: + command: swift build -c release + versions: ["6.3"] + """, + ]) + #expect(generated.exitCode == 0, "\(generated.standardError)") + #expect( + generated.names == [ + "test Linux Swift 6.1", "test Linux Swift 6.2", "test Linux Swift 6.3", + "release Linux Swift 6.3", + ] + ) + #expect(generated.entry(named: "release Linux Swift 6.3")?.command == "swift build -c release") + } + + @Test("A label's versions are read in the version list's order, not the label's") + func perLabelVersionOrder() throws { + // The order the entries come out in is the order a run is read, and it is the + // version list that fixes it. Taking the label's order would reorder a run + // because a caller wrote a label's versions the other way round. + let generated = try Generator.run([ + "ENABLE_LINUX": "true", + "LINUX_SWIFT_VERSIONS": #"["6.1","6.2","6.3"]"#, + "LINUX_COMMAND": """ + release: + command: swift build -c release + versions: ["6.3", "6.1"] + """, + ]) + #expect(generated.exitCode == 0, "\(generated.standardError)") + #expect(generated.versions == ["6.1", "6.3"]) + } + + @Test("A macOS label selects from whichever of the two lists holds its versions") + func perLabelVersionsAcrossTheMacOSLists() throws { + // The Swift and Xcode lists combine, and a label's versions name one or the + // other. Filtering both by the same names is what keeps a label naming an + // Xcode version from producing a Swift entry for a toolchain that has no such + // version. + let generated = try Generator.run([ + "ENABLE_MACOS": "true", + "MACOS_SWIFT_VERSIONS": #"["6.2","6.3"]"#, + "MACOS_XCODE_VERSIONS": #"["latest-beta"]"#, + "MACOS_COMMAND": """ + test: xcrun swift test + beta: + command: xcrun swift build + versions: ["latest-beta"] + """, + ]) + #expect(generated.exitCode == 0, "\(generated.standardError)") + #expect( + generated.names == [ + "test macOS Xcode latest-beta", "beta macOS Xcode latest-beta", + "test macOS Swift 6.2", "test macOS Swift 6.3", + ] + ) + } +} + +@Suite("Job names under the command axis") +struct CommandNameStabilityTests { + // Entry names become required status checks in adopting repositories, and two + // swift-nio dev/ scripts parse them out of `gh pr checks`. The label is a suffix + // like the OS and the architecture: it appears only once there is more than one + // command to tell apart. + + @Test("One command leaves every job name alone, however it is written") + func oneCommandDoesNotRenameJobs() throws { + let scalar = try Generator.run(everyKind) + #expect(scalar.exitCode == 0, "\(scalar.standardError)") + #expect(scalar.names == bareNames) + + var labeled = everyKind + labeled["LINUX_COMMAND"] = "test: swift test" + labeled["MACOS_COMMAND"] = "test: xcrun swift test" + labeled["MACOS_SWIFTLY_COMMAND"] = "test: swiftly run swift test" + labeled["WINDOWS_COMMAND"] = "test: swift test" + labeled["FREEBSD_COMMAND"] = "test: swift test" + labeled["LINUX_STATIC_SDK_COMMAND"] = "test: swift build" + labeled["ANDROID_SDK_COMMAND"] = "test: swift build" + + let single = try Generator.run(labeled) + #expect(single.exitCode == 0, "\(single.standardError)") + #expect(single.names == bareNames, "a single labeled command renamed a job") + // Without this the names above would be stable because no label was read. + #expect(single.entry(named: "Linux Swift 6.3")?.command == "swift test") + #expect(single.entry(named: "Static Linux SDK Swift 6.3")?.command == "swift build") + + // A second command is what earns the suffix, so the bare names have to go. + var two = everyKind + two["LINUX_COMMAND"] = "test: swift test\nrelease: swift test -c release" + let widened = try Generator.run(two) + #expect(widened.exitCode == 0, "\(widened.standardError)") + #expect(!widened.names.contains("Linux Swift 6.3")) + #expect(widened.names.contains("test Linux Swift 6.3")) + #expect(widened.names.contains("release Linux Swift 6.3")) + } + + @Test("A job's name does not depend on another kind's command count") + func namesDoNotDependOnAnotherKind() throws { + // Each kind counts its own commands. Job names are the identity branch + // protection matches on, so adding a Windows command must not rename a Linux + // job. + let generated = try Generator.run([ + "ENABLE_LINUX": "true", + "ENABLE_WINDOWS": "true", + "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, + "WINDOWS_SWIFT_VERSIONS": #"["6.3"]"#, + "WINDOWS_COMMAND": "test: swift test\nbuild: swift build", + ]) + #expect(generated.exitCode == 0, "\(generated.standardError)") + #expect( + generated.names == [ + "Linux Swift 6.3", "test Windows Swift 6.3", "build Windows Swift 6.3", + ] + ) + } + + @Test("The label sits after the OS and architecture suffixes") + func labelIsTheLastSuffix() throws { + let generated = try Generator.run([ + "ENABLE_LINUX": "true", + "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, + "LINUX_OS": #"["jammy","noble"]"#, + "LINUX_HOST_ARCHS": #"["x86_64","aarch64"]"#, + "LINUX_COMMAND": "test: swift test\nrelease: swift build -c release", + ]) + #expect(generated.exitCode == 0, "\(generated.standardError)") + #expect(generated.count == 8) + #expect(generated.names.contains("test Linux Swift 6.3 jammy x86_64")) + #expect(generated.names.contains("release Linux Swift 6.3 noble aarch64")) + #expect(Set(generated.names).count == generated.count) + } + + @Test("The label is parenthesized, so it cannot be read as part of the version") + func labelIsParenthesized() throws { + // `nightly-release` is a version and `release-build` is a label. Appended + // bare, the two run together into a name whose words belong to different + // axes, and a reader parsing a name out of `gh pr checks` has no way to tell + // where the version ends. + let generated = try Generator.run([ + "ENABLE_LINUX": "true", + "LINUX_SWIFT_VERSIONS": #"["nightly-release"]"#, + "LINUX_COMMAND": "debug-test: swift test\nrelease-build: swift build -c release", + ]) + #expect(generated.exitCode == 0, "\(generated.standardError)") + #expect( + generated.names == [ + "debug-test Linux Swift nightly-release", + "release-build Linux Swift nightly-release", + ] + ) + } +} + +@Suite("Telling a command from a map of commands") +struct CommandClassificationTests { + @Test( + "A command YAML reads as something else is still the command, byte for byte", + arguments: [ + // A map whose key is everything before the colon. + "swift test --filter Foo: Bar", + #"echo "a: b""#, + // Not YAML at all, so there is nothing to classify it by. + "[ -f x ] && swift build", + // A block scalar spanning lines is one command; a newline cannot be the + // discriminator. + "cd tests/TestPackage\nswift build", + // A number and a comment, which a round-trip through YAML would rewrite. + "swift build --scratch-path 24.10", + "swift build # release", + ] + ) + func scalarCommandsAreUsedRaw(command: String) throws { + let generated = try Generator.run([ + "ENABLE_LINUX": "true", + "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, + "LINUX_COMMAND": command, + ]) + #expect(generated.exitCode == 0, "\(generated.standardError)") + #expect(generated.names == ["Linux Swift 6.3"], "the command was read as a map of labels") + #expect(generated.entries.first?.command == command) + } + + @Test( + "A malformed map of commands fails, naming the input and showing the value", + arguments: [ + // A list has no labels to name the jobs with. + ("[swift build, swift test]", "[swift build, swift test]"), + ("test: swift test\nrelease:", "release: null"), + ("test: swift test\nrelease: 5", "release: 5"), + // A label with nothing to run leaves a job that reports success having done + // nothing, under a name saying it ran the caller's command. + ("test: swift test\nrelease: \"\"", #"release: """#), + ("test:\n command: \" \"", #"{"command":" "}"#), + ("test: swift test\nrelease:\n commnad: swift build", #"{"commnad":"swift build"}"#), + ("test:\n command: swift build\n versions: []", #""versions":[]"#), + ("test:\n command: swift build\n versions: \"6.3\"", #""versions":"6.3""#), + ("test:\n command: swift build\n versions: [6.3]", #""versions":[6.3]"#), + ] + ) + func malformedCommandMapFails(command: String, reported: String) throws { + // Each of these reads as no command for that label, so the job would run the + // kind's default under a name saying it ran the caller's - or not appear at + // all. + let generated = try Generator.run([ + "ENABLE_LINUX": "true", + "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, + "LINUX_COMMAND": command, + ]) + #expect(generated.exitCode != 0) + #expect(generated.count == 0) + #expect(generated.standardError.contains("linux_command")) + #expect(generated.standardError.contains(reported), "\(generated.standardError)") + } + + @Test( + "Every command input is checked", + arguments: [ + ("LINUX_COMMAND", "linux_command", ["ENABLE_LINUX": "true"]), + ("MACOS_COMMAND", "macos_command", ["ENABLE_MACOS": "true"]), + ("MACOS_SWIFTLY_COMMAND", "macos_swiftly_command", ["ENABLE_MACOS_SWIFTLY": "true"]), + ("WINDOWS_COMMAND", "windows_command", ["ENABLE_WINDOWS": "true"]), + ("FREEBSD_COMMAND", "freebsd_command", ["ENABLE_FREEBSD": "true"]), + ( + "LINUX_STATIC_SDK_COMMAND", "linux_static_sdk_command", + ["ENABLE_LINUX_STATIC_SDK_BUILD": "true"] + ), + ("WASM_SDK_COMMAND", "wasm_sdk_command", ["ENABLE_WASM_SDK_BUILD": "true"]), + ( + "EMBEDDED_WASM_SDK_COMMAND", "embedded_wasm_sdk_command", + ["ENABLE_EMBEDDED_WASM_SDK_BUILD": "true"] + ), + ("ANDROID_SDK_COMMAND", "android_sdk_command", ["ENABLE_ANDROID_SDK_BUILD": "true"]), + ] + ) + func everyCommandInputIsChecked(key: String, name: String, enables: [String: String]) throws { + var environment = enables + environment[key] = "test: swift test\nrelease:" + let generated = try Generator.run(environment) + #expect(generated.exitCode != 0) + #expect(generated.count == 0) + #expect(generated.standardError.contains(name), "\(generated.standardError)") + } + + @Test("A label written twice fails rather than running one of the two commands") + func repeatedLabelFails() throws { + // The parse keeps the last of a repeated key, so this reads as one command - + // and one command earns no suffix, leaving the bare name on a job running the + // second of the two. + let generated = try Generator.run([ + "ENABLE_LINUX": "true", + "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, + "LINUX_COMMAND": "test: swift test\ntest: swift build", + ]) + #expect(generated.exitCode != 0) + #expect(generated.count == 0) + #expect(generated.standardError.contains("linux_command")) + #expect(generated.standardError.contains("more than once"), "\(generated.standardError)") + } + + @Test("A label naming a version the kind does not run fails rather than doing nothing") + func labelVersionsMustNameTheKindsVersions() throws { + // The label selects from the kind's version list, so a name the list does not + // hold produces no entry: the command is missing from a run that reports + // success, which is how a version rename loses a job. + let generated = try Generator.run([ + "ENABLE_LINUX": "true", + "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, + "LINUX_COMMAND": """ + test: swift test + release: + command: swift build -c release + versions: ["6.2"] + """, + ]) + #expect(generated.exitCode != 0) + #expect(generated.standardError.contains("linux_command")) + #expect(generated.standardError.contains("release: 6.2"), "\(generated.standardError)") + } + + @Test("The swiftly entries reject a label's versions rather than ignoring them") + func swiftlyCommandTakesNoVersions() throws { + // These fan out over macos_swiftly_toolchains, so there is no version list to + // select from and the versions would carry nothing. + let generated = try Generator.run([ + "ENABLE_MACOS_SWIFTLY": "true", + "MACOS_SWIFTLY_COMMAND": """ + test: + command: swiftly run swift test + versions: ["6.3"] + """, + ]) + #expect(generated.exitCode != 0) + #expect(generated.standardError.contains("macos_swiftly_command")) + #expect(generated.standardError.contains("macos_swiftly_toolchains")) + } + + @Test("A label's versions are not checked against a kind that is off") + func versionsForDisabledKindDoNotFailTheRun() throws { + // A kind that is off reads none of its commands, so a label naming a version + // its list does not hold loses nothing. Failing there takes down the kinds + // that are on over a setting nothing reads. + let generated = try Generator.run([ + "ENABLE_LINUX": "true", + "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, + "WASM_SDK_COMMAND": """ + build: + command: swift build --target NIOCore + versions: ["6.9"] + """, + ]) + #expect(generated.exitCode == 0, "\(generated.standardError)") + #expect(generated.names == ["Linux Swift 6.3"]) + } +} + +@Suite("A per-version command override and the command axis") +struct VersionOverrideCommandTests { + @Test( + "The override reaches every kind that runs the caller's own command", + arguments: [ + ("ENABLE_LINUX", "LINUX_SWIFT_VERSIONS", "Linux Swift 6.3"), + ("ENABLE_LINUX_STATIC_SDK_BUILD", "LINUX_STATIC_SDK_VERSIONS", "Static Linux SDK Swift 6.3"), + ("ENABLE_WASM_SDK_BUILD", "WASM_SDK_VERSIONS", "Wasm SDK Swift 6.3"), + ( + "ENABLE_EMBEDDED_WASM_SDK_BUILD", "EMBEDDED_WASM_SDK_VERSIONS", + "Embedded Wasm SDK Swift 6.3" + ), + ("ENABLE_ANDROID_SDK_BUILD", "ANDROID_SDK_VERSIONS", "Android SDK Swift 6.3 NDK r27d"), + ] + ) + func overrideReplacesTheCallersCommand( + enableKey: String, + versionsKey: String, + expectedName: String + ) throws { + // Every one of these honored `arguments:` already. A kind reading the base + // command instead ran what the caller replaced, and still passed. + var environment = [ + "ANDROID_NDK_VERSIONS": #"["r27d"]"#, + "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, + "LINUX_VERSION_OVERRIDES": #"{"6.3": {"command": "swift test --filter Foo"}}"#, + ] + environment[enableKey] = "true" + environment[versionsKey] = #"["6.3"]"# + + let generated = try Generator.run(environment) + #expect(generated.exitCode == 0, "\(generated.standardError)") + #expect(generated.entry(named: expectedName)?.command == "swift test --filter Foo") + } + + @Test("The override fails on a kind whose command is the check itself") + func overrideFailsWhereTheCommandIsTheKind() throws { + // Honoring it would leave a job named for a check it no longer runs; dropping + // it silently is how a caller believes a command reached a job it did not. + let generated = try Generator.run([ + "ENABLE_CXX_INTEROP": "true", + "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, + "LINUX_VERSION_OVERRIDES": #"{"6.3": {"command": "swift test --filter Foo"}}"#, + ]) + #expect(generated.exitCode != 0) + #expect(generated.count == 0) + #expect(generated.standardError.contains("Cxx interop Swift"), "\(generated.standardError)") + #expect(generated.standardError.contains("linux_version_overrides")) + } + + @Test("Arguments still reach a kind whose command is the check itself") + func argumentsStillReachEveryKind() throws { + // Only `command:` is refused. A string override, which is the common case, + // carries arguments and has to go on working. + let generated = try Generator.run([ + "ENABLE_CXX_INTEROP": "true", + "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, + "LINUX_VERSION_OVERRIDES": #"{"6.3": "-Xswiftc -warnings-as-errors"}"#, + ]) + #expect(generated.exitCode == 0, "\(generated.standardError)") + #expect(generated.count == 1) + for entry in generated.entries { + #expect(entry.commandArguments == ["-Xswiftc", "-warnings-as-errors"], "\(entry.name)") + } + #expect( + generated.entry(named: "Cxx interop Swift 6.3")?.command + == "${SCRIPTS_ROOT}/check-cxx-interop.sh" + ) + } + + @Test("A version that kind does not run is not its business") + func overrideOnAnotherVersionIsFine() throws { + let generated = try Generator.run([ + "ENABLE_LINUX": "true", + "ENABLE_CXX_INTEROP": "true", + "LINUX_SWIFT_VERSIONS": #"["6.2","6.3"]"#, + "CXX_INTEROP_SWIFT_VERSIONS": #"["6.3"]"#, + "LINUX_VERSION_OVERRIDES": #"{"6.2": {"command": "swift build"}}"#, + ]) + #expect(generated.exitCode == 0, "\(generated.standardError)") + #expect(generated.entry(named: "Linux Swift 6.2")?.command == "swift build") + #expect(generated.entry(named: "Linux Swift 6.3")?.command == "swift test") + #expect( + generated.entry(named: "Cxx interop Swift 6.3")?.command + == "${SCRIPTS_ROOT}/check-cxx-interop.sh" + ) + } + + @Test( + "The override fails when more than one command is configured", + arguments: [ + ( + "LINUX_COMMAND", "linux_command", + ["ENABLE_LINUX": "true", "LINUX_SWIFT_VERSIONS": #"["6.3"]"#] + ), + ( + "LINUX_STATIC_SDK_COMMAND", "linux_static_sdk_command", + [ + "ENABLE_LINUX_STATIC_SDK_BUILD": "true", "LINUX_STATIC_SDK_VERSIONS": #"["6.3"]"#, + "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, + ] + ), + ] + ) + func overrideNeedsOneCommandToReplace( + key: String, + reported: String, + enables: [String: String] + ) throws { + // An override keyed on the version alone says nothing about which label it + // replaces, so honoring it would give every label the same command and leave + // jobs differing only in name. The message names the input the caller wrote, + // which is the one to change. + var environment = enables + environment[key] = "test: swift test\nbuild: swift build" + environment["LINUX_VERSION_OVERRIDES"] = #"{"6.3": {"command": "swift build"}}"# + let generated = try Generator.run(environment) + #expect(generated.exitCode != 0) + #expect(generated.count == 0) + #expect(generated.standardError.contains(reported), "\(generated.standardError)") + } + + @Test("A version a kind does not run leaves that kind's labels alone") + func overrideOnAnotherKindsVersionIsNotAmbiguous() throws { + // The override names a version only the Linux tests run, so it reaches no SDK + // build entry and there is nothing there for it to be ambiguous about. + let generated = try Generator.run([ + "ENABLE_LINUX": "true", + "ENABLE_LINUX_STATIC_SDK_BUILD": "true", + "LINUX_SWIFT_VERSIONS": #"["6.2","6.3"]"#, + "LINUX_STATIC_SDK_VERSIONS": #"["6.3"]"#, + "LINUX_STATIC_SDK_COMMAND": "test: swift build\nrelease: swift build -c release", + "LINUX_VERSION_OVERRIDES": #"{"6.2": {"command": "swift build"}}"#, + ]) + #expect(generated.exitCode == 0, "\(generated.standardError)") + #expect(generated.entry(named: "Linux Swift 6.2")?.command == "swift build") + #expect(generated.names.contains("test Static Linux SDK Swift 6.3")) + #expect(generated.names.contains("release Static Linux SDK Swift 6.3")) + } + + @Test("A version only another kind runs leaves the labeled tests alone") + func overrideForAnotherKindDoesNotReachTheTests() throws { + // The mirror of the case above: here the override reaches only the SDK build, + // and the labeled Linux tests never run that version. + let generated = try Generator.run([ + "ENABLE_LINUX": "true", + "ENABLE_LINUX_STATIC_SDK_BUILD": "true", + "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, + "LINUX_STATIC_SDK_VERSIONS": #"["6.2"]"#, + "LINUX_COMMAND": "test: swift test\nrelease: swift build -c release", + "LINUX_VERSION_OVERRIDES": #"{"6.2": {"command": "swift build --static-swift-stdlib"}}"#, + ]) + #expect(generated.exitCode == 0, "\(generated.standardError)") + #expect( + generated.entry(named: "Static Linux SDK Swift 6.2")?.command + == "swift build --static-swift-stdlib" + ) + #expect(generated.names.contains("test Linux Swift 6.3")) + #expect(generated.names.contains("release Linux Swift 6.3")) + } + + @Test("Arguments reach every label, since they are keyed on the version alone") + func argumentsReachEveryLabel() throws { + let generated = try Generator.run([ + "ENABLE_LINUX": "true", + "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, + "LINUX_COMMAND": "test: swift test\nrelease: swift build -c release", + "LINUX_VERSION_OVERRIDES": #"{"6.3": "-Xswiftc -warnings-as-errors"}"#, + ]) + #expect(generated.exitCode == 0, "\(generated.standardError)") + try #require(generated.count == 2) + for entry in generated.entries { + #expect(entry.commandArguments == ["-Xswiftc", "-warnings-as-errors"], "\(entry.name)") + } + } +} diff --git a/tests/MatrixGeneratorValidator/Tests/MatrixGeneratorTests/CoreTests.swift b/tests/MatrixGeneratorValidator/Tests/MatrixGeneratorTests/CoreTests.swift new file mode 100644 index 00000000..af2b5ce4 --- /dev/null +++ b/tests/MatrixGeneratorValidator/Tests/MatrixGeneratorTests/CoreTests.swift @@ -0,0 +1,956 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import MatrixTestSupport +import Testing + +// There is one test per axis of customization, asserting only what that axis +// controls, so a failure names the feature that broke rather than showing a whole +// matrix and leaving the reader to work out which part matters. + +@Suite("Platform selection") +struct PlatformSelectionTests { + @Test( + "Each enable flag selects its platform", + arguments: [ + (["ENABLE_LINUX": "true", "LINUX_SWIFT_VERSIONS": #"["6.3"]"#], "Linux"), + (["ENABLE_WINDOWS": "true", "WINDOWS_SWIFT_VERSIONS": #"["6.3"]"#], "Windows"), + (["ENABLE_MACOS": "true", "MACOS_SWIFT_VERSIONS": #"["6.3"]"#], "macOS"), + ] + ) + func enableFlagSelectsPlatform(environment: [String: String], platform: String) throws { + let generated = try Generator.run(environment) + #expect(Set(generated.platforms) == [platform]) + } + + @Test("Every platform disabled generates nothing, without failing") + func allDisabled() throws { + let generated = try Generator.run() + #expect(generated.count == 0) + #expect(generated.exitCode == 0) + } + + @Test("Linux and Windows are the defaults") + func defaults() throws { + let generated = try Generator.run(includePlatformDefaults: true) + #expect(Set(generated.platforms) == ["Linux", "Windows"]) + } +} + +@Suite("Version lists") +struct VersionListTests { + @Test("The version list drives one entry each") + func versionList() throws { + let generated = try Generator.run(["ENABLE_LINUX": "true", "LINUX_SWIFT_VERSIONS": #"["6.2","6.3"]"#]) + #expect(generated.versions == ["6.2", "6.3"]) + } + + @Test("A version list may be written as YAML") + func yamlVersionList() throws { + let generated = try Generator.run([ + "ENABLE_LINUX": "true", + "LINUX_SWIFT_VERSIONS": """ + - "6.2" + - "6.3" + """, + ]) + #expect(generated.versions == ["6.2", "6.3"]) + } + + @Test( + "A list input that is not a list fails rather than dropping its job kind", + arguments: [ + (["ENABLE_LINUX": "true", "LINUX_HOST_ARCHS": "x86_64"], "linux_host_archs"), + (["ENABLE_LINUX": "true", "LINUX_SWIFT_VERSIONS": "6.3"], "linux_swift_versions"), + (["ENABLE_LINUX": "true", "LINUX_DOCKER_CAPABILITIES": "CAP_BPF"], "linux_docker_capabilities"), + (["ENABLE_WINDOWS": "true", "WINDOWS_SWIFT_VERSIONS": "6.3"], "windows_swift_versions"), + (["ENABLE_MACOS": "true", "MACOS_SWIFT_VERSIONS": "6.3"], "macos_swift_versions"), + (["ENABLE_MACOS": "true", "MACOS_XCODE_VERSIONS": "26.3"], "macos_xcode_versions"), + ( + ["ENABLE_MACOS_SWIFTLY": "true", "MACOS_SWIFTLY_TOOLCHAINS": "main-snapshot"], + "macos_swiftly_toolchains" + ), + ( + ["ENABLE_ANDROID_SDK_BUILD": "true", "ANDROID_NDK_VERSIONS": "r27d"], + "android_ndk_versions" + ), + (["ENABLE_FREEBSD": "true", "FREEBSD_OS_VERSIONS": "14.3"], "freebsd_os_versions"), + ( + ["ENABLE_LINUX": "true", "ENABLE_CXX_INTEROP": "true", "CXX_INTEROP_SWIFT_VERSIONS": "6.3"], + "cxx_interop_swift_versions" + ), + ] + ) + func malformedListInputFails(environment: [String: String], expectedName: String) throws { + // Every list input is a string carrying JSON, so a caller can write a bare + // scalar. Reading one with `jq -r '.[]'` in a process substitution fails + // invisibly: set -e does not see it, the loop body never runs, and Windows + // fills the matrix so the empty-matrix guard does not fire either. The job kind + // is then absent from a run that reports success. + var variables = environment + variables["ENABLE_WINDOWS"] = "true" + let generated = try Generator.run(variables) + #expect(generated.exitCode != 0) + #expect(generated.count == 0) + #expect(generated.standardError.contains(expectedName)) + } + + @Test("A list input that is not valid YAML fails") + func unparseableListInputFails() throws { + let generated = try Generator.run([ + "ENABLE_LINUX": "true", + "LINUX_SWIFT_VERSIONS": #"["6.3""#, + ]) + #expect(generated.exitCode != 0) + #expect(generated.standardError.contains("linux_swift_versions")) + } +} + +@Suite("OS inputs") +struct OSInputTests { + @Test("Each platform's OS takes a single value") + func singleValue() throws { + let linux = try Generator.run([ + "ENABLE_LINUX": "true", "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, "LINUX_OS": "jammy", + ]) + #expect(linux.entries.first?.swiftBuild?.container?.image == "swift:6.3-jammy") + + let macOS = try Generator.run([ + "ENABLE_MACOS": "true", "MACOS_SWIFT_VERSIONS": #"["6.3"]"#, "MACOS_OS": "sequoia", + ]) + #expect(macOS.entries.first?.runner == ["self-hosted", "macos", "sequoia", "ARM64", "general"]) + + let windows = try Generator.run([ + "ENABLE_WINDOWS": "true", "WINDOWS_SWIFT_VERSIONS": #"["6.3"]"#, "WINDOWS_OS": "windows-11-arm", + ]) + #expect(windows.entries.first?.runner == ["windows-11-arm"]) + } + + @Test("A single value is used as written, not as YAML would rewrite it") + func singleValueIsNotRoundTripped() throws { + // The input carries YAML so that it can also hold a list, but only a list is + // taken from the parse: YAML reads 24.10 as the number 24.1, and no image is + // tagged 6.3-24.1. + let linux = try Generator.run([ + "ENABLE_LINUX": "true", "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, "LINUX_OS": "24.10", + ]) + #expect(linux.entries.first?.swiftBuild?.container?.image == "swift:6.3-24.10") + + // The macOS pools are self-hosted, so a rewritten label names a pool that does + // not exist and the job queues until it times out. + let macOS = try Generator.run([ + "ENABLE_MACOS": "true", "MACOS_SWIFT_VERSIONS": #"["6.3"]"#, "MACOS_OS": "26.10", + ]) + #expect(macOS.entries.first?.runner == ["self-hosted", "macos", "26.10", "ARM64", "general"]) + } + + @Test("One OS leaves the job names alone, however it is written") + func oneOSDoesNotRenameJobs() throws { + // The OS is appended to a name only when a platform has more than one + // configured. Job names are what branch protection matches on, so one OS - + // written either way - has to produce the bare name. + func names(_ osInputs: [String: String]) throws -> [String] { + var environment = [ + "ENABLE_LINUX": "true", + "ENABLE_MACOS": "true", + "ENABLE_WINDOWS": "true", + "ENABLE_CXX_INTEROP": "true", + "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, + "MACOS_SWIFT_VERSIONS": #"["6.3"]"#, + "WINDOWS_SWIFT_VERSIONS": #"["6.3"]"#, + ] + for (key, value) in osInputs { + environment[key] = value + } + return try Generator.run(environment).names + } + + let expected = [ + "Linux Swift 6.3", "macOS Swift 6.3", "Windows Swift 6.3", "Cxx interop Swift 6.3", + ] + #expect(try names([:]) == expected) + #expect( + try names(["LINUX_OS": "jammy", "MACOS_OS": "sequoia", "WINDOWS_OS": "windows-11-arm"]) + == expected + ) + #expect( + try names([ + "LINUX_OS": #"["jammy"]"#, + "MACOS_OS": #"["sequoia"]"#, + "WINDOWS_OS": #"["windows-11-arm"]"#, + ]) == expected + ) + } + + @Test("A distribution written as a list of one is the distribution") + func listOfOneIsTheSameAsNamingIt() throws { + // How the input was written must not change what runs: a caller who brackets the + // runner's own distribution gets what naming it bare gets, and a caller who + // brackets another one gets the image for it. + let defaultAsList = try Generator.run([ + "ENABLE_LINUX": "true", "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, "LINUX_OS": #"["noble"]"#, + ]) + #expect(defaultAsList.entries.first?.swiftBuild?.container == nil) + + let defaultAsValue = try Generator.run([ + "ENABLE_LINUX": "true", "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, "LINUX_OS": "noble", + ]) + #expect(defaultAsValue.entries.first?.swiftBuild?.container == nil) + + let otherAsList = try Generator.run([ + "ENABLE_LINUX": "true", "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, "LINUX_OS": #"["jammy"]"#, + ]) + #expect(otherAsList.entries.first?.swiftBuild?.container?.image == "swift:6.3-jammy") + } + + @Test( + "An OS that is neither a single value nor a list fails", + arguments: [ + ("LINUX_OS", "linux_os"), + ("MACOS_OS", "macos_os"), + ("WINDOWS_OS", "windows_os"), + ] + ) + func malformedOSInputFails(key: String, name: String) throws { + // Unterminated YAML parses as neither, so treating it as a single value would + // name an OS spelled `["jammy"` and the job would fail somewhere far from the + // input that caused it. + let unparseable = try Generator.run([key: #"["jammy""#, "ENABLE_WINDOWS": "true"]) + #expect(unparseable.exitCode != 0) + #expect(unparseable.count == 0) + #expect(unparseable.standardError.contains(name)) + + // A map names no OS at all. + let map = try Generator.run([key: "jammy: true", "ENABLE_WINDOWS": "true"]) + #expect(map.exitCode != 0) + #expect(map.standardError.contains(name)) + } + + @Test( + "A malformed OS is reported as the input that carried it", + arguments: [ + ("LINUX_OS", "linux_os"), + ("MACOS_OS", "macos_os"), + ("WINDOWS_OS", "windows_os"), + ] + ) + func malformedOSInputNamesTheInput(key: String, name: String) throws { + // The value is parsed from stdin, so the parser's own message names `-` and a + // line within it: a caller reading it learns neither which of their inputs was + // wrong nor what it was set to. + let generated = try Generator.run([key: #"["jammy""#, "ENABLE_WINDOWS": "true"]) + #expect(generated.standardError.contains(name)) + #expect(generated.standardError.contains(#"["jammy""#)) + #expect(!generated.standardError.contains("bad file")) + } +} + +@Suite("Minimum version detection") +struct MinimumVersionTests { + @Test("The manifest's tools version filters older versions out") + func detectedFromManifest() throws { + let generated = try Generator.run( + ["ENABLE_LINUX": "true", "LINUX_SWIFT_VERSIONS": #"["6.1","6.2","6.3"]"#], + manifests: ["Package.swift": Generator.manifest(toolsVersion: "6.2")] + ) + #expect(generated.versions == ["6.2", "6.3"]) + } + + @Test("The lowest of all manifests wins") + func lowestManifestWins() throws { + let generated = try Generator.run( + ["ENABLE_LINUX": "true", "LINUX_SWIFT_VERSIONS": #"["6.0","6.1","6.2"]"#], + manifests: [ + "Package.swift": Generator.manifest(toolsVersion: "6.2"), + "Package@swift-6.1.swift": Generator.manifest(toolsVersion: "6.1"), + ] + ) + #expect(generated.versions == ["6.1", "6.2"]) + } + + @Test("An explicit minimum overrides the manifest") + func explicitMinimum() throws { + let generated = try Generator.run( + [ + "ENABLE_LINUX": "true", + "LINUX_SWIFT_VERSIONS": #"["6.1","6.2","6.3"]"#, + "MINIMUM_SWIFT_VERSION": "6.3", + ], + manifests: ["Package.swift": Generator.manifest(toolsVersion: "6.1")] + ) + #expect(generated.versions == ["6.3"]) + } + + @Test("A minimum of none disables filtering") + func noneDisablesFiltering() throws { + let generated = try Generator.run( + [ + "ENABLE_LINUX": "true", + "LINUX_SWIFT_VERSIONS": #"["6.1","6.3"]"#, + "MINIMUM_SWIFT_VERSION": "none", + ], + manifests: ["Package.swift": Generator.manifest(toolsVersion: "6.3")] + ) + #expect(generated.versions == ["6.1", "6.3"]) + } + + @Test("Nightlies are never filtered out") + func nightliesSurviveFiltering() throws { + let generated = try Generator.run( + ["ENABLE_LINUX": "true", "LINUX_SWIFT_VERSIONS": #"["6.1","nightly-main","nightly-release"]"#], + manifests: ["Package.swift": Generator.manifest(toolsVersion: "6.3")] + ) + #expect(generated.versions == ["nightly-main", "nightly-release"]) + } +} + +@Suite("A kind the minimum version empties") +struct EmptiedByTheMinimumTests { + // Dropping some of a kind's versions is what the filter is for. Dropping all of + // them leaves a kind the caller enabled contributing nothing, and the run still + // reports success: the whole-matrix guard fires only when no other kind produced + // anything, so any other enabled platform hides it. + + @Test( + "An enabled kind the filter empties fails, naming the kind", + arguments: [ + ("ENABLE_CXX_INTEROP", "CXX_INTEROP_SWIFT_VERSIONS", "enable_cxx_interop"), + ( + "ENABLE_LINUX_STATIC_SDK_BUILD", "LINUX_STATIC_SDK_VERSIONS", + "enable_linux_static_sdk_build" + ), + ("ENABLE_WASM_SDK_BUILD", "WASM_SDK_VERSIONS", "enable_wasm_sdk_build"), + ( + "ENABLE_EMBEDDED_WASM_SDK_BUILD", "EMBEDDED_WASM_SDK_VERSIONS", + "enable_embedded_wasm_sdk_build" + ), + ("ENABLE_ANDROID_SDK_BUILD", "ANDROID_SDK_VERSIONS", "enable_android_sdk_build"), + ("ENABLE_LINUX", "LINUX_SWIFT_VERSIONS", "enable_linux"), + ("ENABLE_WINDOWS", "WINDOWS_SWIFT_VERSIONS", "enable_windows"), + ("ENABLE_MACOS", "MACOS_SWIFT_VERSIONS", "enable_macos"), + ] + ) + func emptiedKindFails(enableKey: String, versionsKey: String, reported: String) throws { + // A second kind runs alongside on a version that survives, so the matrix is + // not empty and the guard at the end cannot be what fails the run. + var environment = ["MINIMUM_SWIFT_VERSION": "6.2"] + if enableKey == "ENABLE_LINUX" { + environment["ENABLE_WINDOWS"] = "true" + environment["WINDOWS_SWIFT_VERSIONS"] = #"["6.3"]"# + } else { + environment["ENABLE_LINUX"] = "true" + environment["LINUX_SWIFT_VERSIONS"] = #"["6.3"]"# + } + environment[enableKey] = "true" + environment[versionsKey] = #"["6.1"]"# + + let generated = try Generator.run(environment) + #expect(generated.exitCode != 0) + #expect(generated.standardError.contains(reported), "\(generated.standardError)") + // The versions that went and the minimum that took them are what a caller + // needs to act; a status alone says only that something went wrong. + #expect(generated.standardError.contains("6.1"), "\(generated.standardError)") + #expect(generated.standardError.contains("6.2"), "\(generated.standardError)") + #expect(generated.standardError.contains("minimum_swift_version"), "\(generated.standardError)") + // The companion kind means the whole-matrix guard has entries to see, so this + // has to be the new check rather than the old one firing on an empty matrix. + #expect(!generated.standardError.contains("No matrix entries"), "\(generated.standardError)") + + // The same run with the kind's versions raised passes, so the failure above is + // the filter and not something else the environment got wrong. + var runnable = environment + runnable[versionsKey] = #"["6.3"]"# + let raised = try Generator.run(runnable) + #expect(raised.exitCode == 0, "\(raised.standardError)") + #expect(!raised.entries.isEmpty) + } + + @Test("The default Cxx-interop version is filtered like any other") + func defaultedVersionIsChecked() throws { + // The check names no version: it defaults to the newest release in the Linux + // list, which a newer manifest puts below the minimum. This is what a caller + // bumping swift-tools-version ahead of the released toolchains hits, and the + // Linux and Windows nightlies survive to keep the matrix non-empty. + let generated = try Generator.run( + ["ENABLE_CXX_INTEROP": "true"], + manifests: ["Package.swift": Generator.manifest(toolsVersion: "6.4")], + includePlatformDefaults: true + ) + #expect(generated.exitCode != 0) + #expect(generated.standardError.contains("enable_cxx_interop"), "\(generated.standardError)") + #expect(generated.standardError.contains("6.3"), "\(generated.standardError)") + } + + @Test( + "A label the filter empties fails, naming the label", + arguments: [ + // Every site that filters a label's own versions: the Linux, macOS and + // Windows blocks, and the shared emitter behind the SDK builds. + ("LINUX_COMMAND", "linux_command", ["ENABLE_LINUX": "true", "LINUX_SWIFT_VERSIONS": #"["6.1","6.3"]"#]), + ("MACOS_COMMAND", "macos_command", ["ENABLE_MACOS": "true", "MACOS_SWIFT_VERSIONS": #"["6.1","6.3"]"#]), + ( + "WINDOWS_COMMAND", "windows_command", + ["ENABLE_WINDOWS": "true", "WINDOWS_SWIFT_VERSIONS": #"["6.1","6.3"]"#] + ), + ( + "WASM_SDK_COMMAND", "wasm_sdk_command", + ["ENABLE_WASM_SDK_BUILD": "true", "WASM_SDK_VERSIONS": #"["6.1","6.3"]"#] + ), + ] + ) + func emptiedLabelFails( + commandKey: String, + reported: String, + enables: [String: String] + ) throws { + // The label's own versions are intersected with the kind's list before the + // filter runs, so the filter can empty one label while the others still run: + // the command the caller named is absent from a matrix that is not empty. + var environment = enables + environment[commandKey] = """ + current: swift test + legacy: + command: swift test --legacy + versions: ["6.1"] + """ + environment["MINIMUM_SWIFT_VERSION"] = "6.2" + let generated = try Generator.run(environment) + #expect(generated.exitCode != 0) + #expect(generated.standardError.contains(reported), "\(generated.standardError)") + #expect(generated.standardError.contains("legacy"), "\(generated.standardError)") + #expect(generated.standardError.contains("6.2"), "\(generated.standardError)") + #expect(generated.standardError.contains("minimum_swift_version"), "\(generated.standardError)") + // The other label runs on 6.3, so the matrix this label is missing from is not + // empty: the guard at the end cannot be what failed the run. + #expect(!generated.standardError.contains("No matrix entries"), "\(generated.standardError)") + + // Widening the label's own versions is the one change that fixes it, so the + // failure above is that list and not the rest of the configuration. + var widened = environment + widened[commandKey] = """ + current: swift test + legacy: + command: swift test --legacy + versions: ["6.3"] + """ + let fixed = try Generator.run(widened) + #expect(fixed.exitCode == 0, "\(fixed.standardError)") + #expect(fixed.names.contains { $0.hasPrefix("legacy ") }, "\(fixed.names)") + + // Without the enable the kind runs nothing, so the label loses nothing. + var disabled = environment + for key in enables.keys where key.hasPrefix("ENABLE_") { + disabled[key] = "false" + } + let off = try Generator.run(disabled) + #expect(off.exitCode == 0, "\(off.standardError)") + } + + @Test("A label keeping one version is not an emptied label") + func partiallyFilteredLabelSurvives() throws { + // Losing a version the package cannot build on is the filter working. Only a + // label left with nothing is a job the caller asked for and did not get. + let generated = try Generator.run([ + "ENABLE_LINUX": "true", + "LINUX_SWIFT_VERSIONS": #"["6.1","6.2","6.3"]"#, + "MINIMUM_SWIFT_VERSION": "6.2", + "LINUX_COMMAND": """ + current: swift test + legacy: + command: swift test --legacy + versions: ["6.1", "6.2"] + """, + ]) + #expect(generated.exitCode == 0, "\(generated.standardError)") + #expect(generated.names.contains("legacy Linux Swift 6.2")) + #expect(!generated.names.contains("legacy Linux Swift 6.1")) + } + + @Test("A macOS label naming an Xcode version is not an emptied label") + func xcodeLabelIsNotEmptied() throws { + // The Xcode list names Xcodes, which the filter never sees. A label selecting + // one is fully served by the Xcode pass, so failing it would refuse a + // configuration that produces exactly the jobs the caller asked for. + let generated = try Generator.run([ + "ENABLE_MACOS": "true", + "MACOS_SWIFT_VERSIONS": #"["6.3"]"#, + "MACOS_XCODE_VERSIONS": #"["latest-beta"]"#, + "MINIMUM_SWIFT_VERSION": "6.2", + "MACOS_COMMAND": """ + test: xcrun swift test + beta: + command: xcrun swift build + versions: ["latest-beta"] + """, + ]) + #expect(generated.exitCode == 0, "\(generated.standardError)") + #expect(generated.names.contains("beta macOS Xcode latest-beta")) + } + + @Test("A kind that is off keeps a version list the filter would empty") + func disabledKindIsSilent() throws { + // A kind nobody enabled runs nothing, so a list below the minimum costs it + // nothing - and failing there would take down the kinds that are on. + let generated = try Generator.run([ + "ENABLE_LINUX": "true", + "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, + "MINIMUM_SWIFT_VERSION": "6.3", + "ENABLE_CXX_INTEROP": "false", + "CXX_INTEROP_SWIFT_VERSIONS": #"["6.1"]"#, + "ENABLE_WASM_SDK_BUILD": "false", + "WASM_SDK_VERSIONS": #"["6.1"]"#, + ]) + #expect(generated.exitCode == 0, "\(generated.standardError)") + #expect(generated.names == ["Linux Swift 6.3"]) + } + + @Test("The fork guard is a skip rather than an emptied kind") + func forkGuardIsNotAnEmptiedKind() throws { + // The guard withholds macOS because this repository cannot reach the pools, so + // the caller did not ask for macOS here. Failing would take down every fork of + // a repository whose macOS list happens to sit below its own minimum. + // + // Linux runs alongside on a version that survives, so the matrix is not empty + // either way: what the fork must not get is the emptied-kind failure. + let fork = try Generator.run([ + "ENABLE_MACOS": "true", + "ENABLE_MACOS_SWIFTLY": "true", + "MACOS_SWIFT_VERSIONS": #"["6.1"]"#, + "ENABLE_LINUX": "true", + "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, + "MINIMUM_SWIFT_VERSION": "6.3", + "MACOS_REPOSITORY_OWNER": "apple", + "GITHUB_REPOSITORY_OWNER": "a-fork", + ]) + #expect(fork.exitCode == 0, "\(fork.standardError)") + #expect(fork.names == ["Linux Swift 6.3"]) + + // The same configuration on the owning repository is an emptied kind. The + // matrix is non-empty in both runs, so this is the new check rather than the + // whole-matrix guard, and the pass above is the fork guard rather than the + // check having been dropped. + let owner = try Generator.run([ + "ENABLE_MACOS": "true", + "MACOS_SWIFT_VERSIONS": #"["6.1"]"#, + "ENABLE_LINUX": "true", + "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, + "MINIMUM_SWIFT_VERSION": "6.3", + "MACOS_REPOSITORY_OWNER": "apple", + "GITHUB_REPOSITORY_OWNER": "apple", + ]) + #expect(owner.exitCode != 0) + #expect(owner.standardError.contains("enable_macos"), "\(owner.standardError)") + #expect(!owner.standardError.contains("No matrix entries"), "\(owner.standardError)") + } + + @Test("Toolchain mode suppresses the command-only kinds rather than failing them") + func toolchainsModeIsNotAnEmptiedKind() throws { + // The mode clears those enables itself, so the caller did not ask for them + // here either - even with version lists the filter would empty. + let generated = try Generator.run([ + "MATRIX_MODE": "toolchains", + "ENABLE_LINUX": "true", + "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, + "MINIMUM_SWIFT_VERSION": "6.3", + "ENABLE_CXX_INTEROP": "true", + "CXX_INTEROP_SWIFT_VERSIONS": #"["6.1"]"#, + "ENABLE_LINUX_STATIC_SDK_BUILD": "true", + "LINUX_STATIC_SDK_VERSIONS": #"["6.1"]"#, + ]) + #expect(generated.exitCode == 0, "\(generated.standardError)") + #expect(generated.names == ["Linux Swift 6.3"]) + } + + @Test("Nothing enabled is still an empty matrix rather than a failure") + func nothingEnabledStaysLegitimate() throws { + // Every version list here sits below the minimum, and none of them belongs to + // a kind that is on. + let generated = try Generator.run([ + "ENABLE_LINUX": "false", + "ENABLE_WINDOWS": "false", + "MINIMUM_SWIFT_VERSION": "6.3", + "LINUX_SWIFT_VERSIONS": #"["6.1"]"#, + "WINDOWS_SWIFT_VERSIONS": #"["6.1"]"#, + ]) + #expect(generated.exitCode == 0, "\(generated.standardError)") + #expect(generated.count == 0) + #expect(generated.standardError.contains("nothing is enabled")) + } +} + +@Suite("Toolchain resolution") +struct ToolchainResolutionTests { + @Test("A stable version needs no resolved forms") + func stableVersion() throws { + let generated = try Generator.run(["ENABLE_LINUX": "true", "LINUX_SWIFT_VERSIONS": #"["6.3"]"#]) + let build = try #require(generated.entries.first?.swiftBuild) + #expect(build.swiftVersion == "6.3") + #expect(build.toolchain == nil) + #expect(build.swiftly == nil) + } + + @Test("nightly-release keeps the label and carries both resolved forms") + func nightlyRelease() throws { + let generated = try Generator.run(["ENABLE_LINUX": "true", "LINUX_SWIFT_VERSIONS": #"["nightly-release"]"#]) + let build = try #require(generated.entries.first?.swiftBuild) + #expect(build.swiftVersion == "nightly-release") + #expect(build.toolchain == "nightly-6.4.x") + // The branch token names the snapshot's own directory under dev/, and swiftly's + // release-snapshot grammar takes it whole, so it is passed through. + #expect(build.swiftly == "6.4.x-snapshot") + } + + @Test("The branch token is data, so it can be moved at a branch cut") + func tokenIsConfigurable() throws { + let generated = try Generator.run([ + "ENABLE_LINUX": "true", + "LINUX_SWIFT_VERSIONS": #"["nightly-release"]"#, + "NIGHTLY_RELEASE_TOKEN": "6.5", + ]) + let build = try #require(generated.entries.first?.swiftBuild) + #expect(build.toolchain == "nightly-6.5") + #expect(build.swiftly == "6.5-snapshot") + } + + @Test("nightly-main resolves only the swiftly selector") + func nightlyMain() throws { + let generated = try Generator.run(["ENABLE_LINUX": "true", "LINUX_SWIFT_VERSIONS": #"["nightly-main"]"#]) + let build = try #require(generated.entries.first?.swiftBuild) + #expect(build.toolchain == nil, "the toolchain matches the label, so it should be omitted") + #expect(build.swiftly == "main-snapshot") + } + + @Test( + "A branch named directly resolves like the alias", + arguments: [("nightly-6.4.x", "6.4.x-snapshot"), ("nightly-6.2", "6.2-snapshot")] + ) + func literalBranchNightly(version: String, expectedSelector: String) throws { + let generated = try Generator.run([ + "ENABLE_LINUX": "true", + "LINUX_SWIFT_VERSIONS": #"["\#(version)"]"#, + ]) + #expect(generated.entries.first?.swiftBuild?.swiftly == expectedSelector) + } +} + +@Suite("Linux runners and containers") +struct LinuxTests { + @Test("Architecture selects the runner") + func architectureToRunner() throws { + let generated = try Generator.run([ + "ENABLE_LINUX": "true", + "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, + "LINUX_HOST_ARCHS": #"["x86_64","aarch64"]"#, + ]) + #expect(generated.entries.map { $0.runner.first } == ["ubuntu-24.04", "ubuntu-24.04-arm"]) + } + + @Test("Linux is native until Docker is asked for") + func nativeByDefault() throws { + let native = try Generator.run(["ENABLE_LINUX": "true", "LINUX_SWIFT_VERSIONS": #"["6.3"]"#]) + #expect(native.entries.first?.swiftBuild?.container == nil) + + let containerized = try Generator.run([ + "ENABLE_LINUX": "true", + "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, + "LINUX_USE_DOCKER": "true", + ]) + #expect(containerized.entries.first?.swiftBuild?.container?.image == "swift:6.3-noble") + } + + @Test("Every job kind runs on the architecture that was configured") + func architectureAppliesToEveryKind() throws { + // The single-entry kinds do not fan out, so nothing but this makes them + // follow linux_host_archs. An aarch64-only caller must not get x86_64 jobs + // that pass without testing the architecture it ships. + let generated = try Generator.run([ + "ENABLE_LINUX": "true", + "ENABLE_CXX_INTEROP": "true", + "ENABLE_LINUX_STATIC_SDK_BUILD": "true", + "LINUX_HOST_ARCHS": #"["aarch64"]"#, + "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, + "CXX_INTEROP_SWIFT_VERSIONS": #"["6.3"]"#, + "LINUX_STATIC_SDK_VERSIONS": #"["6.3"]"#, + ]) + #expect(generated.count == 3) + for entry in generated.entries { + #expect(entry.runner == ["ubuntu-24.04-arm"], "\(entry.name) ignored linux_host_archs") + } + } + + @Test("Naming a non-default Linux OS implies a container") + func nonDefaultOSImpliesContainer() throws { + // The OS names a container image. Left native, the job would run on the + // runner's own distribution and pass, having tested nothing about the one + // asked for. + let named = try Generator.run([ + "ENABLE_LINUX": "true", "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, "LINUX_OS": "jammy", + ]) + #expect(named.entries.first?.swiftBuild?.container?.image == "swift:6.3-jammy") + + let defaulted = try Generator.run([ + "ENABLE_LINUX": "true", "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, "LINUX_OS": "noble", + ]) + #expect(defaulted.entries.first?.swiftBuild?.container == nil) + } + + @Test("A version list entry that is neither a number nor a nightly label fails") + func nonNumericVersionFails() throws { + // It would otherwise reach the arithmetic in version_gte and abort with a + // bash unbound-variable error naming neither the input nor the value. + let generated = try Generator.run([ + "ENABLE_MACOS": "true", + "MACOS_SWIFT_VERSIONS": #"["latest-beta","6.3"]"#, + "MINIMUM_SWIFT_VERSION": "6.1", + ]) + #expect(generated.exitCode != 0) + #expect(generated.standardError.contains("latest-beta")) + // The arithmetic in version_gte also aborts non-zero, with "unbound + // variable", so a message-and-status assertion alone would pass either way. + // The test has to show the run stopped before reaching it. + #expect(!generated.standardError.contains("unbound variable")) + } + + @Test("A release-branch nightly runs natively, like every other version") + func releaseBranchNightlyIsNative() throws { + // The branch token is the snapshot's published directory under dev/, so swiftly + // installs a release-branch nightly from the selector alone. Containerizing it + // would swap the toolchain under test for the image's own and cost a pull. + let generated = try Generator.run([ + "ENABLE_LINUX": "true", + "LINUX_SWIFT_VERSIONS": #"["6.3","nightly-release","nightly-main"]"#, + ]) + for name in ["Linux Swift 6.3", "Linux Swift nightly-main", "Linux Swift nightly-release"] { + #expect(generated.entry(named: name)?.swiftBuild?.container == nil, "\(name) was containerized") + } + #expect(generated.entry(named: "Linux Swift nightly-release")?.swiftBuild?.swiftly == "6.4.x-snapshot") + } + + @Test( + "Nightly images come from the nightly registry", + arguments: [ + ("nightly-release", "swiftlang/swift:nightly-6.4.x-noble"), + ("nightly-main", "swiftlang/swift:nightly-main-noble"), + ("6.3", "swift:6.3-noble"), + ] + ) + func containerImages(version: String, expectedImage: String) throws { + let generated = try Generator.run([ + "ENABLE_LINUX": "true", + "LINUX_USE_DOCKER": "true", + "LINUX_SWIFT_VERSIONS": #"["\#(version)"]"#, + ]) + #expect(generated.entries.first?.swiftBuild?.container?.image == expectedImage) + } + + @Test("An OS list forces Docker and multiplies the entries") + func osList() throws { + let generated = try Generator.run([ + "ENABLE_LINUX": "true", + "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, + "LINUX_OS": #"["jammy","noble"]"#, + ]) + #expect(generated.count == 2) + #expect( + generated.entries.compactMap { $0.swiftBuild?.container?.image } == [ + "swift:6.3-jammy", "swift:6.3-noble", + ] + ) + #expect(generated.names == ["Linux Swift 6.3 jammy", "Linux Swift 6.3 noble"]) + } + + @Test("Container capabilities and security options are carried") + func containerKnobs() throws { + let generated = try Generator.run([ + "ENABLE_LINUX": "true", + "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, + "LINUX_USE_DOCKER": "true", + "LINUX_DOCKER_CAPABILITIES": #"["CAP_BPF"]"#, + "LINUX_DOCKER_SECURITY_OPTIONS": #"["apparmor=unconfined"]"#, + ]) + let container = try #require(generated.entries.first?.swiftBuild?.container) + #expect(container.capabilities == ["CAP_BPF"]) + #expect(container.securityOptions == ["apparmor=unconfined"]) + } + + @Test("A Dockerfile implies container mode and keeps the base image") + func dockerfileImpliesContainer() throws { + let generated = try Generator.run([ + "ENABLE_LINUX": "true", + "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, + "LINUX_DOCKERFILE": "docker/ci.Dockerfile", + ]) + let container = try #require(generated.entries.first?.swiftBuild?.container) + #expect(container.dockerfile == "docker/ci.Dockerfile") + #expect(container.image == "swift:6.3-noble") + } + + @Test("Unset container knobs are omitted rather than emitted empty") + func knobsOmittedWhenUnset() throws { + let generated = try Generator.run([ + "ENABLE_LINUX": "true", + "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, + "LINUX_USE_DOCKER": "true", + ]) + let container = try #require(generated.entries.first?.swiftBuild?.container) + #expect(container.dockerfile == nil) + #expect(container.capabilities == nil) + #expect(container.securityOptions == nil) + } +} + +@Suite("Commands, arguments and overrides") +struct CommandTests { + @Test("The build and pre-build commands are carried") + func commandsCarried() throws { + let generated = try Generator.run([ + "ENABLE_LINUX": "true", + "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, + "LINUX_COMMAND": "swift test --verbose", + "LINUX_SETUP_COMMAND": "cd sub", + ]) + #expect(generated.entries.first?.command == "swift test --verbose") + #expect(generated.entries.first?.setupCommand == "cd sub") + } + + @Test("Releases take swift_flags and nightlies take swift_nightly_flags") + func flagSelection() throws { + let generated = try Generator.run([ + "ENABLE_LINUX": "true", + "LINUX_SWIFT_VERSIONS": #"["6.3","nightly-main"]"#, + "SWIFT_FLAGS": "-Xswiftc -DRELEASE", + "SWIFT_NIGHTLY_FLAGS": "-Xswiftc -DNIGHTLY", + ]) + #expect(generated.entries[0].commandArguments == ["-Xswiftc", "-DRELEASE"]) + #expect(generated.entries[1].commandArguments == ["-Xswiftc", "-DNIGHTLY"]) + } + + @Test("A string override appends arguments and leaves the command alone") + func stringOverride() throws { + let generated = try Generator.run([ + "ENABLE_LINUX": "true", + "LINUX_SWIFT_VERSIONS": #"["6.2","6.3"]"#, + "LINUX_VERSION_OVERRIDES": #"{"6.3": "-Xswiftc -warnings-as-errors"}"#, + ]) + #expect(generated.entries[0].commandArguments == [], "an untargeted version is untouched") + #expect(generated.entries[1].commandArguments == ["-Xswiftc", "-warnings-as-errors"]) + #expect(generated.entries[1].command == "swift test") + } + + @Test("An object override can replace the command") + func objectOverride() throws { + let generated = try Generator.run([ + "ENABLE_LINUX": "true", + "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, + "LINUX_VERSION_OVERRIDES": """ + 6.3: + command: swift build + arguments: --explicit-target-dependency-import-check error + """, + ]) + #expect(generated.entries.first?.command == "swift build") + #expect( + generated.entries.first?.commandArguments == [ + "--explicit-target-dependency-import-check", "error", + ] + ) + } + + @Test( + "A malformed overrides object fails, naming the input and showing the value", + arguments: [ + (#"{"6.3":"#, #"{"6.3":"#), + (#"["6.3"]"#, #"["6.3"]"#), + ("6.3", "6.3"), + (#"{"6.3": ["-Xswiftc","-warnings-as-errors"]}"#, #"["-Xswiftc","-warnings-as-errors"]"#), + (#"{"6.3": {"argument": "-Xswiftc"}}"#, #"{"argument":"-Xswiftc"}"#), + (#"{"6.3": null}"#, "6.3: null"), + (#"{"6.3": {"command": 5}}"#, #"{"command":5}"#), + ] + ) + func malformedOverridesFail(overrides: String, reported: String) throws { + // Every one of these reads as no override at all, so the job runs without the + // arguments it was asked to carry and still passes - which is how a repository + // loses warnings-as-errors. + let generated = try Generator.run([ + "ENABLE_LINUX": "true", + "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, + "LINUX_VERSION_OVERRIDES": overrides, + ]) + #expect(generated.exitCode != 0) + #expect(generated.count == 0) + #expect(generated.standardError.contains("linux_version_overrides")) + #expect(generated.standardError.contains(reported)) + // The value is parsed from stdin, so the parser's own message names `-` and a + // line within it: a caller reading it learns neither which input was wrong nor + // what it was set to. + #expect(!generated.standardError.contains("bad file")) + } + + @Test( + "Every platform's overrides input is checked", + arguments: [ + ("LINUX_VERSION_OVERRIDES", "linux_version_overrides"), + ("WINDOWS_VERSION_OVERRIDES", "windows_version_overrides"), + ("MACOS_VERSION_OVERRIDES", "macos_version_overrides"), + ] + ) + func everyOverridesInputIsChecked(key: String, name: String) throws { + let generated = try Generator.run([ + "ENABLE_LINUX": "true", + "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, + key: #"["6.3"]"#, + ]) + #expect(generated.exitCode != 0) + #expect(generated.count == 0) + #expect(generated.standardError.contains(name)) + } + + @Test("An absent override is not a malformed one") + func absenceIsNotMalformation() throws { + // A version the map does not name has nothing to add, and neither does an input + // left blank. Failing on either would take down the versions nobody overrode. + let oneVersionNamed = try Generator.run([ + "ENABLE_LINUX": "true", + "LINUX_SWIFT_VERSIONS": #"["6.2","6.3"]"#, + "LINUX_VERSION_OVERRIDES": #"{"6.3": "-Xswiftc -warnings-as-errors"}"#, + ]) + #expect(oneVersionNamed.exitCode == 0) + try #require(oneVersionNamed.count == 2) + #expect(oneVersionNamed.entries[0].commandArguments == []) + #expect(oneVersionNamed.entries[1].commandArguments == ["-Xswiftc", "-warnings-as-errors"]) + + for blank in ["", " ", "\n", "null"] { + let generated = try Generator.run([ + "ENABLE_LINUX": "true", + "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, + "LINUX_VERSION_OVERRIDES": blank, + ]) + #expect(generated.exitCode == 0, "a blank input carries no override") + try #require(generated.count == 1) + #expect(generated.entries[0].commandArguments == []) + #expect(generated.entries[0].command == "swift test") + } + } + + @Test( + "Environment variables reach the entry", + arguments: [ + (["ENABLE_LINUX": "true", "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, "LINUX_ENV_VARS": #"{"FOO":"bar"}"#], "bar"), + (["ENABLE_WINDOWS": "true", "WINDOWS_SWIFT_VERSIONS": #"["6.3"]"#, "WINDOWS_ENV_VARS": "FOO: baz"], "baz"), + ] + ) + func environmentVariables(environment: [String: String], expected: String) throws { + let generated = try Generator.run(environment) + #expect(generated.entries.first?.env["FOO"] == expected) + } +} diff --git a/tests/MatrixGeneratorValidator/Tests/MatrixGeneratorTests/EntryPointTests.swift b/tests/MatrixGeneratorValidator/Tests/MatrixGeneratorTests/EntryPointTests.swift new file mode 100644 index 00000000..cc439ecc --- /dev/null +++ b/tests/MatrixGeneratorValidator/Tests/MatrixGeneratorTests/EntryPointTests.swift @@ -0,0 +1,214 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import MatrixTestSupport +import Testing + +// Entry names become required status checks in adopting repositories, and two +// swift-nio dev/ scripts parse them out of `gh pr checks`. A default version list +// decides which of those checks exist at all, so the sets below are pinned: a +// list edited in a workflow or in the generator has to be edited here too, where +// the diff says which jobs an adopter gains or loses. + +/// A package whose minimum is the oldest version in the default lists, so nothing +/// is filtered and the pinned sets are the lists in full. +private let manifest = ["Package.swift": Generator.manifest(toolsVersion: "6.1")] + +@Suite("Entry point defaults") +struct EntryPointDefaultTests { + @Test( + "A caller who passes nothing gets exactly these jobs", + arguments: [ + ( + EntryPoint.packageTest, + [ + "Linux Swift 6.1", + "Linux Swift 6.2", + "Linux Swift 6.3", + "Linux Swift nightly-release", + "Linux Swift nightly-main", + "Windows Swift 6.1", + "Windows Swift 6.2", + "Windows Swift 6.3", + "Windows Swift nightly-release", + "Windows Swift nightly-main", + ] + ), + ( + EntryPoint.toolchainMatrix, + [ + "Linux Swift 6.1", + "Linux Swift 6.2", + "Linux Swift 6.3", + "Linux Swift nightly-release", + "Linux Swift nightly-main", + ] + ), + ( + EntryPoint.benchmarks, + [ + "Linux Swift 6.1", + "Linux Swift 6.2", + "Linux Swift 6.3", + "Linux Swift nightly-release", + "Linux Swift nightly-main", + ] + ), + ] + ) + func passNothingJobNames(entryPoint: EntryPoint, expected: [String]) throws { + let generated = try Generator.run(try entryPoint.environment(), manifests: manifest) + #expect(generated.exitCode == 0, "\(entryPoint): \(generated.standardError)") + #expect(generated.names == expected, "\(entryPoint)") + } + + @Test( + "A caller who asks for macOS and nothing else gets the same jobs from every entry point" + ) + func macOSJobNamesAgree() throws { + let expected = ["macOS Swift 6.1", "macOS Swift 6.2", "macOS Swift 6.3"] + for entryPoint in EntryPoint.all { + var environment = try entryPoint.environment() + environment["ENABLE_LINUX"] = "false" + environment["ENABLE_WINDOWS"] = "false" + environment["ENABLE_MACOS"] = "true" + let generated = try Generator.run(environment, manifests: manifest) + #expect(generated.exitCode == 0, "\(entryPoint): \(generated.standardError)") + #expect(generated.names == expected, "\(entryPoint)") + } + } + + @Test("A caller who asks for the Android SDK build gets both NDK releases") + func androidNDKJobNames() throws { + var viaWorkflow = try EntryPoint.packageTest.environment() + viaWorkflow["ENABLE_LINUX"] = "false" + viaWorkflow["ENABLE_WINDOWS"] = "false" + viaWorkflow["ENABLE_ANDROID_SDK_BUILD"] = "true" + + let expected = [ + "Android SDK Swift 6.3 NDK r27d", + "Android SDK Swift nightly-release NDK r27d", + "Android SDK Swift nightly-main NDK r27d", + "Android SDK Swift 6.3 NDK r28c", + "Android SDK Swift nightly-release NDK r28c", + "Android SDK Swift nightly-main NDK r28c", + ] + + let workflow = try Generator.run(viaWorkflow, manifests: manifest) + #expect(workflow.exitCode == 0, "\(workflow.standardError)") + #expect(workflow.names == expected) + + // An empty value is an absent one throughout the generator, so this is what a + // caller reaching the script directly gets. + var viaGenerator = viaWorkflow + viaGenerator["ANDROID_NDK_VERSIONS"] = "" + let generator = try Generator.run(viaGenerator, manifests: manifest) + #expect(generator.exitCode == 0, "\(generator.standardError)") + #expect(generator.names == expected) + } +} + +@Suite("Default agreement between the layers") +struct DefaultAgreementTests { + /// Where a workflow's declared default deliberately differs from the + /// generator's own, with why. Anything else differing is drift: the same input + /// then means different things depending on which entry point a caller used. + /// + /// `enable_windows`: the generator defaults to the Linux-plus-Windows test + /// sweep. `toolchain_matrix.yml` hands back a toolchain axis for a command + /// execute_matrix.yml runs on every entry, which is usually a POSIX shell + /// script. + static let deliberateDifferences: [String: Set] = [ + "package_test": [], + "toolchain_matrix": ["ENABLE_WINDOWS"], + "benchmarks": [], + ] + + /// The entry point's environment with every job kind it forwards turned on, so + /// no knob is dead when its default is compared. + private func allKindsEnabled(_ entryPoint: EntryPoint) throws -> [String: String] { + var environment = try entryPoint.environment() + for enable in EntryPoint.jobKindEnables where environment[enable] != nil { + environment[enable] = "true" + } + return environment + } + + @Test( + "A workflow's declared default matches the generator's own", + arguments: EntryPoint.all + ) + func declaredDefaultsMatchTheGenerator(entryPoint: EntryPoint) throws { + let baseline = try allKindsEnabled(entryPoint) + let documented = try #require(Self.deliberateDifferences[entryPoint.workflow]) + + // Emptying a variable is how the generator is asked for its own default, so + // the two runs differ only where the two layers disagree. The enables are + // held at "true" in both: emptying one would take its whole block away and + // say nothing about the defaults inside it. + var askingTheGenerator = baseline + for key in try entryPoint.inputBackedKeys() + where !EntryPoint.jobKindEnables.contains(key) && !documented.contains(key) { + askingTheGenerator[key] = "" + } + #expect(askingTheGenerator != baseline, "\(entryPoint) forwards no input to compare") + + let fromWorkflow = try Generator.run(baseline, manifests: manifest) + let fromGenerator = try Generator.run(askingTheGenerator, manifests: manifest) + #expect(fromWorkflow.exitCode == 0, "\(entryPoint): \(fromWorkflow.standardError)") + #expect(fromGenerator.exitCode == 0, "\(entryPoint): \(fromGenerator.standardError)") + #expect(!fromWorkflow.entries.isEmpty, "\(entryPoint) generated nothing to compare") + #expect(fromWorkflow.names == fromGenerator.names, "\(entryPoint)") + } + + @Test( + "No entry point carries its own copy of the macOS release list", + arguments: EntryPoint.all + ) + func macOSListHasOneSource(entryPoint: EntryPoint) throws { + // Empty means the generator's own list of release versions. A workflow + // spelling that list out instead states a default that agrees until one of + // the two copies is edited, and the macOS list has no nightly to fan out + // over, so no entry point has a reason of its own to name versions. + let declared = try #require( + try entryPoint.environment()["MACOS_SWIFT_VERSIONS"], + "\(entryPoint) no longer forwards the macOS version list" + ) + #expect(declared == "", "\(entryPoint)") + } + + @Test("Windows is off by default outside the test sweep, and one input away") + func windowsIsOffByDefault() throws { + let entryPoint = EntryPoint.toolchainMatrix + let environment = try entryPoint.environment() + #expect(environment["ENABLE_WINDOWS"] == "false", "\(entryPoint)") + + let off = try Generator.run(environment, manifests: manifest) + #expect(off.exitCode == 0, "\(entryPoint): \(off.standardError)") + #expect(!off.platforms.contains("Windows"), "\(entryPoint)") + + var on = environment + on["ENABLE_WINDOWS"] = "true" + let gained = try Generator.run(on, manifests: manifest) + #expect(gained.exitCode == 0, "\(entryPoint): \(gained.standardError)") + #expect( + Set(gained.names).subtracting(off.names) == [ + "Windows Swift 6.1", + "Windows Swift 6.2", + "Windows Swift 6.3", + "Windows Swift nightly-release", + "Windows Swift nightly-main", + ], + "\(entryPoint)" + ) + } +} diff --git a/tests/MatrixGeneratorValidator/Tests/MatrixGeneratorTests/JobKindTests.swift b/tests/MatrixGeneratorValidator/Tests/MatrixGeneratorTests/JobKindTests.swift new file mode 100644 index 00000000..6690ba27 --- /dev/null +++ b/tests/MatrixGeneratorValidator/Tests/MatrixGeneratorTests/JobKindTests.swift @@ -0,0 +1,590 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Foundation +import MatrixTestSupport +import Testing + +@Suite("SDK builds") +struct SDKTests { + @Test( + "Each SDK kind declares its type", + arguments: [ + ("ENABLE_LINUX_STATIC_SDK_BUILD", "LINUX_STATIC_SDK_VERSIONS", "static-linux"), + ("ENABLE_WASM_SDK_BUILD", "WASM_SDK_VERSIONS", "wasm"), + ("ENABLE_EMBEDDED_WASM_SDK_BUILD", "EMBEDDED_WASM_SDK_VERSIONS", "embedded-wasm"), + ] + ) + func sdkType(enableKey: String, versionsKey: String, expectedType: String) throws { + let generated = try Generator.run([enableKey: "true", versionsKey: #"["6.3"]"#]) + #expect(generated.entries.first?.swiftBuild?.sdk?.type == expectedType) + } + + @Test("An SDK entry keeps the label and carries the toolchain the SDK script needs") + func labelAndToolchain() throws { + let generated = try Generator.run([ + "ENABLE_LINUX_STATIC_SDK_BUILD": "true", + "LINUX_STATIC_SDK_VERSIONS": #"["nightly-release"]"#, + ]) + let build = try #require(generated.entries.first?.swiftBuild) + #expect(build.swiftVersion == "nightly-release") + // The SDK script derives swift.org paths from this, so the label alone would + // give it dev/release. + #expect(build.toolchain == "nightly-6.4.x") + } + + @Test("The SDK pre-build command is carried") + func preBuildCommand() throws { + let generated = try Generator.run([ + "ENABLE_LINUX_STATIC_SDK_BUILD": "true", + "LINUX_STATIC_SDK_VERSIONS": #"["6.3"]"#, + "LINUX_STATIC_SDK_SETUP_COMMAND": "cd sub", + ]) + // The SDK script builds in the working directory, so this is the only way to + // reach a package below the repository root. + #expect(generated.entries.first?.setupCommand == "cd sub") + } + + @Test("Android entries carry an NDK version each and the triples") + func androidNDKAndTriples() throws { + let generated = try Generator.run([ + "ENABLE_ANDROID_SDK_BUILD": "true", + "ANDROID_SDK_VERSIONS": #"["6.3"]"#, + "ANDROID_NDK_VERSIONS": #"["r27d","r28c"]"#, + "ANDROID_SDK_TRIPLES": #"["aarch64-unknown-linux-android28"]"#, + ]) + #expect(generated.count == 2) + #expect(generated.entries.compactMap { $0.swiftBuild?.sdk?.ndkVersion } == ["r27d", "r28c"]) + #expect(generated.entries.first?.swiftBuild?.sdk?.triples == ["aarch64-unknown-linux-android28"]) + } + + @Test("Emulator checks ask the build for test binaries") + func emulatorRequestsTestBinaries() throws { + let withoutEmulator = try Generator.run([ + "ENABLE_ANDROID_SDK_BUILD": "true", + "ANDROID_SDK_VERSIONS": #"["6.3"]"#, + "ANDROID_NDK_VERSIONS": #"["r27d"]"#, + ]) + #expect(withoutEmulator.entries.first?.androidEmulator == false) + #expect(withoutEmulator.entries.first?.commandArguments == []) + + // The emulator script stages what the build produced, so without this there is + // nothing to run. + let withEmulator = try Generator.run([ + "ENABLE_ANDROID_SDK_BUILD": "true", + "ENABLE_ANDROID_EMULATOR_TESTS": "true", + "ANDROID_SDK_VERSIONS": #"["6.3"]"#, + "ANDROID_NDK_VERSIONS": #"["r27d"]"#, + ]) + #expect(withEmulator.entries.first?.androidEmulator == true) + #expect(withEmulator.entries.first?.commandArguments == ["--build-tests"]) + } +} + +@Suite("Cxx interop") +struct SupplementaryCheckTests { + @Test("Cxx interop defaults to one version and can enter a subdirectory") + func cxxInteropScope() throws { + let generated = try Generator.run([ + "ENABLE_CXX_INTEROP": "true", + "LINUX_SWIFT_VERSIONS": #"["6.2","6.3"]"#, + "LINUX_SETUP_COMMAND": "cd sub", + ]) + #expect(generated.count == 1) + #expect(generated.entries.first?.swiftBuild?.swiftVersion == "6.3") + // check-cxx-interop.sh reads the manifest in the working directory. + #expect(generated.entries.first?.setupCommand == "cd sub") + #expect(generated.entries.first?.command == "${SCRIPTS_ROOT}/check-cxx-interop.sh") + } + + @Test("The check runs on the same distribution as the tests") + func sameDistributionAsTests() throws { + let generated = try Generator.run([ + "ENABLE_CXX_INTEROP": "true", + "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, + "LINUX_OS": #"["jammy"]"#, + ]) + let images = Set(generated.entries.compactMap { $0.swiftBuild?.container?.image }) + #expect(images == ["swift:6.3-jammy"]) + } +} + +@Suite("Job kinds") +struct JobKindTests { + @Test("Every kind generates the entries it is enabled for") + func everyKindGeneratesEntries() throws { + // A kind whose version list no caller can reach, or whose enable nothing sets, + // would be unreachable while the generator still offers it. + let enables = [ + "ENABLE_LINUX_STATIC_SDK_BUILD", + "ENABLE_WASM_SDK_BUILD", + "ENABLE_EMBEDDED_WASM_SDK_BUILD", + "ENABLE_ANDROID_SDK_BUILD", + "ENABLE_CXX_INTEROP", + ] + for enable in enables { + let generated = try Generator.run([enable: "true", "LINUX_SWIFT_VERSIONS": #"["6.3"]"#]) + #expect(generated.exitCode == 0, "\(enable): \(generated.standardError)") + #expect(!generated.entries.isEmpty, "\(enable) generated nothing") + for entry in generated.entries { + #expect(entry.platform == "Linux", "\(entry.name) is not a Linux entry") + #expect(entry.command?.isEmpty == false, "\(entry.name) has no command") + #expect(entry.swiftBuild?.swiftVersion != nil, "\(entry.name) has no toolchain") + } + } + } +} + +@Suite("FreeBSD") +struct FreeBSDTests { + @Test("A FreeBSD entry carries its virtual machine configuration") + func entryShape() throws { + let generated = try Generator.run([ + "ENABLE_FREEBSD": "true", + "FREEBSD_SWIFT_VERSIONS": #"["nightly-main"]"#, + "FREEBSD_OS_VERSIONS": #"["14.3"]"#, + "FREEBSD_COMMAND": "swift build", + "FREEBSD_SETUP_COMMAND": "cd sub", + "FREEBSD_ENV_VARS": "FOO=bar", + ]) + let entry = try #require(generated.entries.first) + #expect(entry.platform == "FreeBSD") + #expect(entry.freebsd?.osVersion == "14.3") + #expect(entry.freebsd?.envVars == "FOO=bar") + #expect(entry.command == "swift build") + #expect(entry.setupCommand == "cd sub") + // The executor derives SWIFT_VERSION from this. A FreeBSD entry has neither + // swift_build nor xcode_build, so without it anything keyed on the version - + // benchmark thresholds - would look under an empty directory name. + #expect(entry.freebsd?.swiftVersion == "nightly-main") + // The toolchain has to be the one built for the OS the job claims to run. + #expect(entry.freebsd?.swiftURL.contains("freebsd-14") == true) + } + + @Test("A version other than nightly-main fails rather than mislabeling the job") + func nonNightlyVersionFails() throws { + // One FreeBSD toolchain is published and the URL is fixed, so a job named for + // any other version would report a green check for a toolchain it never used. + let generated = try Generator.run([ + "ENABLE_FREEBSD": "true", + "FREEBSD_SWIFT_VERSIONS": #"["6.3"]"#, + ]) + #expect(generated.exitCode != 0) + #expect(generated.standardError.contains("nightly-main")) + } + + @Test("An OS version with no published toolchain fails rather than running another's") + func unpublishedOSVersionFails() throws { + // The tarballs are named by major release and only 14 has one, so a job + // labeled 15.0 would report a green check for the 14 toolchain. + let generated = try Generator.run([ + "ENABLE_FREEBSD": "true", + "FREEBSD_OS_VERSIONS": #"["15.0"]"#, + ]) + #expect(generated.exitCode != 0) + #expect(generated.standardError.contains("15.0")) + } +} + +@Suite("Output modes") +struct OutputModeTests { + @Test("Toolchain mode omits what the caller supplies instead") + func toolchainsModeOmitsCommands() throws { + let generated = try Generator.run([ + "MATRIX_MODE": "toolchains", + "ENABLE_LINUX": "true", + "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, + ]) + let entry = try #require(generated.entries.first) + #expect(entry.command == nil) + #expect(entry.setupCommand == nil) + #expect(entry.commandArguments == nil) + // The toolchain itself is still fully described, and env describes what the + // toolchain needs rather than the work. + #expect(entry.swiftBuild?.swiftVersion == "6.3") + #expect(entry.runner == ["ubuntu-24.04"]) + } + + @Test("Toolchain mode suppresses the job kinds that exist only to run a command") + func toolchainsModeSuppressesJobKinds() throws { + let generated = try Generator.run([ + "MATRIX_MODE": "toolchains", + "ENABLE_LINUX": "true", + "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, + "ENABLE_LINUX_STATIC_SDK_BUILD": "true", + "ENABLE_CXX_INTEROP": "true", + "ENABLE_FREEBSD": "true", + ]) + #expect(generated.names == ["Linux Swift 6.3"]) + } + + @Test("An empty matrix fails only when something was enabled") + func emptyMatrixFailsOnlyWhenEnabled() throws { + // Nothing enabled is legitimate - a caller who disables every platform gets an + // empty matrix. Something enabled that produced nothing is a mistake, and + // silence there is a green run that tested nothing. + let nothingEnabled = try Generator.run() + #expect(nothingEnabled.exitCode == 0) + #expect(nothingEnabled.count == 0) + + // Every listed version filtered out by the manifest's tools version. + let filteredAway = try Generator.run( + [ + "ENABLE_LINUX": "true", + "ENABLE_WINDOWS": "true", + "LINUX_SWIFT_VERSIONS": #"["6.1"]"#, + "WINDOWS_SWIFT_VERSIONS": #"["6.1"]"#, + ], + manifests: ["Package.swift": Generator.manifest(toolsVersion: "6.3")] + ) + #expect(filteredAway.exitCode != 0) + #expect(filteredAway.standardError.contains("enable_linux")) + + // A deliberate skip is not a mistake: the fork guard clears macOS, leaving + // nothing enabled rather than something enabled that produced nothing. + let forkSkipped = try Generator.run([ + "ENABLE_MACOS": "true", + "MACOS_SWIFT_VERSIONS": #"["6.3"]"#, + "MACOS_REPOSITORY_OWNER": "apple", + "GITHUB_REPOSITORY_OWNER": "somefork", + ]) + #expect(forkSkipped.exitCode == 0) + #expect(forkSkipped.count == 0) + } + + @Test("An unknown mode fails rather than guessing") + func unknownModeFails() throws { + let generated = try Generator.run(["MATRIX_MODE": "nonsense"]) + #expect(generated.exitCode != 0) + #expect(generated.standardError.contains("MATRIX_MODE")) + } + + @Test("YAML is the output format by default") + func yamlByDefault() throws { + let result = try Generator.runRaw(["ENABLE_LINUX": "true", "LINUX_SWIFT_VERSIONS": #"["6.3"]"#]) + #expect(result.standardOutput.hasPrefix("config:")) + } + + @Test("JSON output can be asked for, which is what a decoder wants") + func jsonOnRequest() throws { + let result = try Generator.runRaw([ + "MATRIX_FORMAT": "json", + "ENABLE_LINUX": "true", + "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, + ]) + #expect(result.standardOutput.hasPrefix("{")) + } + + @Test("An empty matrix is well formed in both output formats") + func emptyMatrixInBothFormats() throws { + let yaml = try Generator.runRaw() + #expect(yaml.standardOutput.trimmingCharacters(in: .whitespacesAndNewlines) == "config: []") + + // run() asks for JSON and decodes it, so this covers the JSON form through the + // same path every other test uses. + let json = try Generator.run() + #expect(json.count == 0) + #expect(json.exitCode == 0) + } + + @Test("An unknown output format fails rather than guessing") + func unknownFormatFails() throws { + let result = try Generator.runRaw(["MATRIX_FORMAT": "xml"]) + #expect(result.exitCode != 0) + #expect(result.standardError.contains("MATRIX_FORMAT")) + } +} + +@Suite("Whole-matrix invariants") +struct InvariantTests { + /// Everything enabled at once, which is the widest shape the generator produces. + private func everything() throws -> Generated { + try Generator.run( + [ + "ENABLE_MACOS": "true", + "ENABLE_FREEBSD": "true", + "ENABLE_LINUX_STATIC_SDK_BUILD": "true", + "ENABLE_WASM_SDK_BUILD": "true", + "ENABLE_ANDROID_SDK_BUILD": "true", + "ENABLE_CXX_INTEROP": "true", + "ENABLE_MACOS_SWIFTLY": "true", + "XCODE_SCHEME": "P-Package", + "XCODE_TARGETS": "[iOS, watchOS]", + // Two Linux OSes but one Windows OS, so the counts differ: a name built + // from the wrong platform's count would collide in the uniqueness check. + "LINUX_OS": #"["jammy","noble"]"#, + "WINDOWS_OS": #"["windows-2022"]"#, + ], + includePlatformDefaults: true + ) + } + + @Test("Every entry carries what the executor dispatches on") + func everyEntryIsExecutable() throws { + let generated = try everything() + #expect(generated.count >= 15, "expected a substantial matrix, got \(generated.count)") + + for entry in generated.entries { + #expect(!entry.platform.isEmpty) + #expect(!entry.name.isEmpty) + #expect(!entry.runner.isEmpty, "\(entry.name) has no runner") + #expect(entry.command?.isEmpty == false, "\(entry.name) has no command") + } + } + + @Test("Every entry has exactly one toolchain model") + func oneToolchainModelPerEntry() throws { + let generated = try everything() + for entry in generated.entries where entry.platform != "FreeBSD" { + let models = [entry.swiftBuild != nil, entry.xcodeBuild != nil].filter { $0 }.count + #expect(models == 1, "\(entry.name) has \(models) toolchain models; the dispatch assumes one") + } + } + + @Test("Job names are unique, since they are how a run is read") + func namesAreUnique() throws { + let generated = try everything() + #expect(Set(generated.names).count == generated.count) + } + + @Test("A job's name does not depend on another platform's OS count") + func nameDoesNotDependOnAnotherPlatform() throws { + // The OS suffix is appended when a platform has more than one OS configured. + // Each platform must count its own: job names are the identity branch + // protection matches on, so enabling Windows must not rename a Linux job. + func names(windowsEnabled: Bool, windowsOSVersions: String) throws -> [String] { + try Generator.run([ + "ENABLE_LINUX": "true", + "ENABLE_WINDOWS": windowsEnabled ? "true" : "false", + "ENABLE_CXX_INTEROP": "true", + "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, + "WINDOWS_SWIFT_VERSIONS": #"["6.3"]"#, + "LINUX_OS": #"["jammy","noble"]"#, + "WINDOWS_OS": windowsOSVersions, + ]).names.filter { $0.hasPrefix("Cxx interop") } + } + + let withoutWindows = try names(windowsEnabled: false, windowsOSVersions: #"["windows-2022"]"#) + let withOneWindowsOS = try names(windowsEnabled: true, windowsOSVersions: #"["windows-2022"]"#) + let withTwoWindowsOSes = try names( + windowsEnabled: true, + windowsOSVersions: #"["windows-2022","windows-2025"]"# + ) + + #expect(withoutWindows == withOneWindowsOS) + #expect(withoutWindows == withTwoWindowsOSes) + // Two Linux OSes are configured, so each name must carry its own. + #expect(Set(withoutWindows).count == withoutWindows.count) + #expect(withoutWindows.allSatisfy { $0.hasSuffix("jammy") || $0.hasSuffix("noble") }) + } + + @Test("Supplementary job kinds carry the flags for their version") + func supplementaryKindsCarryFlags() throws { + // Each of these kinds builds its own argument list, so each can drop + // swift_flags independently - and a repository that loses warnings-as-errors + // stays green. + let generated = try Generator.run([ + "ENABLE_LINUX_STATIC_SDK_BUILD": "true", + "ENABLE_WASM_SDK_BUILD": "true", + "ENABLE_EMBEDDED_WASM_SDK_BUILD": "true", + "ENABLE_CXX_INTEROP": "true", + "LINUX_STATIC_SDK_VERSIONS": #"["6.3"]"#, + "WASM_SDK_VERSIONS": #"["6.3"]"#, + "EMBEDDED_WASM_SDK_VERSIONS": #"["6.3"]"#, + "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, + "SWIFT_FLAGS": "-Xswiftc -warnings-as-errors", + ]) + #expect(generated.count == 4) + for entry in generated.entries { + #expect( + entry.commandArguments == ["-Xswiftc", "-warnings-as-errors"], + "\(entry.name) dropped swift_flags" + ) + } + } + + @Test("A nightly version gets the nightly flags, not the release ones") + func nightlyGetsNightlyFlags() throws { + let generated = try Generator.run([ + "ENABLE_LINUX_STATIC_SDK_BUILD": "true", + "LINUX_STATIC_SDK_VERSIONS": #"["nightly-main"]"#, + "SWIFT_FLAGS": "-Xswiftc -warnings-as-errors", + "SWIFT_NIGHTLY_FLAGS": "--explicit-target-dependency-import-check error", + ]) + #expect( + generated.entries.first?.commandArguments + == ["--explicit-target-dependency-import-check", "error"] + ) + } + + @Test("An argument containing a glob is passed through, not expanded") + func argumentsAreNotGlobbed() throws { + // An unquoted split would expand `--filter *Tests` against the generator's + // working directory, so the runner would receive several arguments where the + // caller wrote one. + let generated = try Generator.run( + [ + "ENABLE_LINUX": "true", + "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, + "SWIFT_FLAGS": "--filter *Tests", + ], + manifests: ["aTests": "", "bTests": ""] + ) + #expect(generated.entries.first?.commandArguments == ["--filter", "*Tests"]) + + // The macOS-swiftly entries take their flags on a path of their own, so they + // can glob independently of the Linux entries. + let swiftly = try Generator.run( + [ + "ENABLE_LINUX": "true", + "ENABLE_MACOS_SWIFTLY": "true", + "LINUX_SWIFT_VERSIONS": #"["nightly-main"]"#, + "SWIFT_NIGHTLY_FLAGS": "--filter *Tests", + ], + manifests: ["aTests": "", "bTests": ""] + ) + for entry in swiftly.entries { + #expect(entry.commandArguments == ["--filter", "*Tests"], "\(entry.name) expanded the glob") + } + } + + @Test("No Linux kind containerizes a version unless the caller asked for a container") + func containersOnlyWhenAskedFor() throws { + // A container swaps the toolchain under test for the image's own and costs a + // pull, so it is the caller's decision alone. swiftly installs every version the + // generator produces, nightly-release included, so no kind may reach for one on + // a version's behalf. + func kinds(_ extra: [String: String]) throws -> Generated { + var environment = [ + "ENABLE_LINUX": "true", + "ENABLE_CXX_INTEROP": "true", + "ENABLE_LINUX_STATIC_SDK_BUILD": "true", + "LINUX_SWIFT_VERSIONS": #"["nightly-release"]"#, + "CXX_INTEROP_SWIFT_VERSIONS": #"["nightly-release"]"#, + "LINUX_STATIC_SDK_VERSIONS": #"["nightly-release"]"#, + ] + for (key, value) in extra { + environment[key] = value + } + return try Generator.run(environment) + } + + let unasked = try kinds([:]) + #expect(unasked.count == 3) + for entry in unasked.entries { + #expect(entry.swiftBuild?.container == nil, "\(entry.name) containerized unasked") + #expect(entry.swiftBuild?.swiftly == "6.4.x-snapshot", "\(entry.name) carries the wrong selector") + } + + // Each of the three ways to ask, so a kind that reads only one of them is caught. + for (ask, image) in [ + (["LINUX_USE_DOCKER": "true"], "swiftlang/swift:nightly-6.4.x-noble"), + (["LINUX_DOCKERFILE": "docker/ci.Dockerfile"], "swiftlang/swift:nightly-6.4.x-noble"), + (["LINUX_OS": "jammy"], "swiftlang/swift:nightly-6.4.x-jammy"), + ] { + let asked = try kinds(ask) + for name in ["Linux Swift nightly-release", "Cxx interop Swift nightly-release"] { + #expect( + asked.entry(named: name)?.swiftBuild?.container?.image == image, + "\(name) ignored \(ask)" + ) + } + // An SDK entry stays native even then: job-runner-linux.sh refuses an entry + // carrying both an sdk and a container, since the SDK script fetches a + // toolchain matched to the SDK rather than using the image's. + #expect( + asked.entry(named: "Static Linux SDK Swift nightly-release")?.swiftBuild?.container == nil, + "the SDK entry containerized under \(ask)" + ) + } + } + + @Test("Asking for the Android emulator without the SDK build fails") + func emulatorWithoutSDKBuildFails() throws { + // The emulator runs what the SDK build produced, so on its own it yields no + // jobs at all - a green run that tested nothing. + let generated = try Generator.run(["ENABLE_ANDROID_EMULATOR_TESTS": "true"]) + #expect(generated.exitCode != 0) + #expect(generated.standardError.contains("enable_android_sdk_build")) + + // Toolchain mode suppresses both, so it must not fail there. + let toolchains = try Generator.run([ + "MATRIX_MODE": "toolchains", + "ENABLE_ANDROID_EMULATOR_TESTS": "true", + "ENABLE_LINUX": "true", + "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, + ]) + #expect(toolchains.exitCode == 0) + #expect(toolchains.names == ["Linux Swift 6.3"]) + } + + @Test("An override key naming no version fails rather than losing its arguments") + func unmatchedOverrideKeyFails() throws { + // These carry warnings-as-errors for NIO-family repositories. A key left + // behind by a version rename would otherwise drop them and still pass. + let generated = try Generator.run([ + "ENABLE_LINUX": "true", + "LINUX_SWIFT_VERSIONS": #"["6.3","nightly-release"]"#, + "LINUX_VERSION_OVERRIDES": #"{"nightly-next":"-Xswiftc -warnings-as-errors"}"#, + ]) + #expect(generated.exitCode != 0) + #expect(generated.standardError.contains("nightly-next")) + } + + @Test("A Linux override key may name a version from any enabled Linux list") + func overrideKeysValidatedAgainstEveryLinuxList() throws { + // The Cxx-interop and SDK lists are independent of the test sweep, and every + // one of them draws its arguments from linux_version_overrides. Checking the + // sweep alone rejects a key naming a version only the Cxx interop check runs. + let generated = try Generator.run([ + "ENABLE_LINUX": "true", + "ENABLE_CXX_INTEROP": "true", + "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, + "CXX_INTEROP_SWIFT_VERSIONS": #"["6.2","6.3"]"#, + "LINUX_VERSION_OVERRIDES": #"{"6.2":"-Xswiftc -DX"}"#, + ]) + #expect(generated.exitCode == 0) + #expect(generated.entry(named: "Cxx interop Swift 6.2")?.commandArguments == ["-Xswiftc", "-DX"]) + #expect(generated.entry(named: "Linux Swift 6.3")?.commandArguments == []) + + // A key naming no enabled list is still rejected. + let bogus = try Generator.run([ + "ENABLE_LINUX": "true", + "ENABLE_CXX_INTEROP": "true", + "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, + "CXX_INTEROP_SWIFT_VERSIONS": #"["6.3"]"#, + "LINUX_VERSION_OVERRIDES": #"{"6.2":"-Xswiftc -DX"}"#, + ]) + #expect(bogus.exitCode != 0) + } + + @Test( + "Overrides for a platform that generates nothing warn rather than failing the run", + arguments: [ + ("MACOS_VERSION_OVERRIDES", "macos_version_overrides"), + ("WINDOWS_VERSION_OVERRIDES", "windows_version_overrides"), + ] + ) + func overridesForDisabledPlatformWarn(key: String, label: String) throws { + // A disabled platform's version list still holds the generator's defaults, so a + // key naming none of them has nothing to lose. Failing there takes down every + // enabled platform's jobs over a setting nothing reads. + let generated = try Generator.run([ + "ENABLE_LINUX": "true", + "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, + key: #"{"latest-beta":"-Xswiftc -DBETA"}"#, + ]) + #expect(generated.exitCode == 0) + #expect(generated.names == ["Linux Swift 6.3"]) + #expect(generated.standardError.contains(label)) + } +} diff --git a/tests/MatrixGeneratorValidator/Tests/MatrixGeneratorTests/PlatformTests.swift b/tests/MatrixGeneratorValidator/Tests/MatrixGeneratorTests/PlatformTests.swift new file mode 100644 index 00000000..f6e65134 --- /dev/null +++ b/tests/MatrixGeneratorValidator/Tests/MatrixGeneratorTests/PlatformTests.swift @@ -0,0 +1,652 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import MatrixTestSupport +import Testing + +@Suite("macOS") +struct MacOSTests { + @Test("A macOS entry names either a Swift version or an Xcode version, not both") + func selector() throws { + let bySwift = try Generator.run(["ENABLE_MACOS": "true", "MACOS_SWIFT_VERSIONS": #"["6.3"]"#]) + let swiftBuild = try #require(bySwift.entries.first?.xcodeBuild) + #expect(swiftBuild.swiftVersion == "6.3") + #expect(swiftBuild.xcodeVersion == nil) + + let byXcode = try Generator.run(["ENABLE_MACOS": "true", "MACOS_XCODE_VERSIONS": #"["26.3"]"#]) + let xcodeBuild = try #require(byXcode.entries.first?.xcodeBuild) + #expect(xcodeBuild.xcodeVersion == "26.3") + #expect(xcodeBuild.swiftVersion == nil) + } + + @Test("The two macOS version lists combine rather than one replacing the other") + func versionListsCombine() throws { + // They are different ways of naming a toolchain, not competing spellings of + // one. NIO's macOS configuration wants a pinned Xcode beta alongside the + // release versions, which needs an entry from each list. + let generated = try Generator.run([ + "ENABLE_MACOS": "true", + "MACOS_SWIFT_VERSIONS": #"["6.2","6.3"]"#, + "MACOS_XCODE_VERSIONS": #"["latest-beta"]"#, + ]) + #expect(generated.names == ["macOS Xcode latest-beta", "macOS Swift 6.2", "macOS Swift 6.3"]) + } + + @Test("An override key is valid if it names a version in either macOS list") + func overrideKeysValidatedAgainstBothLists() throws { + // The lists combine, so validating against each on its own would reject a key + // naming a version from the other. A caller setting both lists and overriding + // only the Xcode entry hits exactly that. + let generated = try Generator.run([ + "ENABLE_MACOS": "true", + "MACOS_SWIFT_VERSIONS": #"["6.3"]"#, + "MACOS_XCODE_VERSIONS": #"["latest-beta"]"#, + "MACOS_VERSION_OVERRIDES": #"{"latest-beta": "-Xswiftc -DBETA"}"#, + ]) + #expect(generated.exitCode == 0) + #expect( + generated.entry(named: "macOS Xcode latest-beta")?.commandArguments + == ["-Xswiftc", "-DBETA"] + ) + #expect(generated.entry(named: "macOS Swift 6.3")?.commandArguments == []) + + // A key naming neither list is still rejected. + let bogus = try Generator.run([ + "ENABLE_MACOS": "true", + "MACOS_SWIFT_VERSIONS": #"["6.3"]"#, + "MACOS_XCODE_VERSIONS": #"["latest-beta"]"#, + "MACOS_VERSION_OVERRIDES": #"{"6.9": "-Xswiftc -DNOPE"}"#, + ]) + #expect(bogus.exitCode != 0) + } + + @Test("The minimum Swift version filters macOS as it does every other platform") + func minimumVersionAppliesToMacOS() throws { + // A toolchain below the manifest's tools version cannot resolve the package, + // so a macOS job on it fails for a reason the caller did not ask about. + let generated = try Generator.run([ + "ENABLE_LINUX": "true", + "ENABLE_MACOS": "true", + "MINIMUM_SWIFT_VERSION": "6.2", + "LINUX_SWIFT_VERSIONS": #"["6.0","6.1","6.2"]"#, + "MACOS_SWIFT_VERSIONS": #"["6.0","6.1","6.2"]"#, + ]) + #expect(generated.names == ["Linux Swift 6.2", "macOS Swift 6.2"]) + } + + @Test("Runner labels come from the OS, architecture and pool") + func runnerLabels() throws { + let generated = try Generator.run([ + "ENABLE_MACOS": "true", + "MACOS_SWIFT_VERSIONS": #"["6.3"]"#, + "MACOS_OS": "sequoia", + "MACOS_ARCH": "X64", + "MACOS_RUNNER_POOL": "nightly", + ]) + #expect(generated.entries.first?.runner == ["self-hosted", "macos", "sequoia", "X64", "nightly"]) + } + + @Test("A map names the platforms and what each one does") + func targetsFromMap() throws { + let generated = try Generator.run([ + "ENABLE_MACOS": "true", + "MACOS_SWIFT_VERSIONS": #"["6.3"]"#, + "XCODE_SCHEME": "P-Package", + "XCODE_TARGETS": """ + iOS: {build: true, test: true} + watchOS: {build: true} + """, + ]) + let targets = try #require(generated.entries.first?.xcodeBuild?.targets) + try #require(targets.count == 2) + #expect(targets.map(\.platform) == ["iOS", "watchOS"]) + #expect(targets[0].build == true) + #expect(targets[0].test == true) + #expect(targets[1].build == true) + #expect(targets[1].test == false, "testing needs a simulator, so it is asked for rather than assumed") + #expect(targets[0].scheme == "P-Package") + } + + @Test("macOS and Mac Catalyst are available as destinations") + func macOSAndCatalystTargets() throws { + let generated = try Generator.run([ + "ENABLE_MACOS": "true", + "MACOS_SWIFT_VERSIONS": #"["6.3"]"#, + "XCODE_SCHEME": "P-Package", + "XCODE_TARGETS": "{macOS: {}, Catalyst: {}}", + ]) + let targets = try #require(generated.entries.first?.xcodeBuild?.targets) + try #require(targets.count == 2) + #expect(targets.map(\.platform) == ["macOS", "Catalyst"]) + #expect(targets[1].buildDestination == "generic/platform=macos,variant=Mac Catalyst") + } + + @Test("A target without a scheme fails rather than building nothing") + func targetsNeedAScheme() throws { + // xcodebuild has nothing to build without a scheme, so the entry would carry + // an empty target list and the job would pass having checked nothing. This is + // the first thing a caller migrating off enable_ios_checks hits. + let generated = try Generator.run([ + "ENABLE_MACOS": "true", + "MACOS_SWIFT_VERSIONS": #"["6.3"]"#, + "XCODE_TARGETS": "[iOS]", + ]) + #expect(generated.exitCode != 0) + #expect(generated.standardError.contains("xcode_scheme")) + } + + @Test("An Apple-platform target without macOS fails rather than generating nothing") + func targetsNeedMacOS() throws { + // The targets ride on a macOS entry, so without one there is nothing for them + // to attach to and the matrix comes out empty. + let generated = try Generator.run([ + "XCODE_SCHEME": "P-Package", + "XCODE_TARGETS": "[iOS]", + ]) + #expect(generated.exitCode != 0) + #expect(generated.standardError.contains("enable_macos")) + } + + @Test("The debug-output flag reaches the entry") + func debugOutput() throws { + let generated = try Generator.run([ + "ENABLE_MACOS": "true", + "MACOS_SWIFT_VERSIONS": #"["6.3"]"#, + "XCODE_DEBUG_OUTPUT": "true", + ]) + #expect(generated.entries.first?.xcodeBuild?.debugOutput == true) + } + + @Test("A swiftly toolchain pairs a selector with an Xcode, and may override the runner") + func swiftlyToolchains() throws { + let generated = try Generator.run([ + "ENABLE_MACOS_SWIFTLY": "true", + "MACOS_SWIFTLY_TOOLCHAINS": + #"[{"xcode_version":"swift_6.3","swiftly_toolchain":"main-snapshot","os_version":"sequoia","arch":"X64"}]"#, + "MACOS_SWIFTLY_COMMAND": "swiftly run swift build", + ]) + let entry = try #require(generated.entries.first) + #expect(entry.xcodeBuild?.xcodeVersion == "swift_6.3") + #expect(entry.xcodeBuild?.swiftlyToolchain == "main-snapshot") + #expect(entry.runner == ["self-hosted", "macos", "sequoia", "X64", "general"]) + #expect(entry.command == "swiftly run swift build") + } + + @Test("A snapshot selector takes the nightly flags") + func swiftlySnapshotTakesNightlyFlags() throws { + let generated = try Generator.run([ + "ENABLE_MACOS_SWIFTLY": "true", + "SWIFT_FLAGS": "-Xswiftc -DRELEASE", + "SWIFT_NIGHTLY_FLAGS": "-Xswiftc -DNIGHTLY", + ]) + #expect(generated.entries.first?.commandArguments == ["-Xswiftc", "-DNIGHTLY"]) + } + + @Test("A swiftly entry missing its Xcode version or its selector fails") + func incompleteSwiftlyEntry() throws { + // Skipping the entry drops a job from a run that still reports success, so a + // caller who misspells one of the two keys never learns the entry did nothing. + let onlyBadEntry = try Generator.run([ + "ENABLE_MACOS_SWIFTLY": "true", + "MACOS_SWIFTLY_TOOLCHAINS": #"[{"swiftly_toolchain":"main-snapshot"}]"#, + ]) + #expect(onlyBadEntry.exitCode != 0) + #expect(onlyBadEntry.count == 0) + #expect(onlyBadEntry.standardError.contains("xcode_version")) + + // A good entry alongside is the case that has to fail: the matrix is no longer + // empty, so the empty-matrix guard cannot catch it. + let alongsideAGoodEntry = try Generator.run([ + "ENABLE_MACOS_SWIFTLY": "true", + "MACOS_SWIFTLY_TOOLCHAINS": """ + [{"swiftly_toolchain":"main-snapshot"},\ + {"xcode_version":"swift_6.3","swiftly_toolchain":"main-snapshot"}] + """, + ]) + #expect(alongsideAGoodEntry.exitCode != 0) + #expect(alongsideAGoodEntry.standardError.contains("xcode_version")) + } + + @Test("A swiftly entry's runner follows macos_os") + func swiftlyRunnerFollowsOSList() throws { + // The pools are self-hosted, so an entry naming an OS the caller did not ask + // for queues until it times out - the failure macos_repository_owner exists to + // prevent. + let generated = try Generator.run([ + "ENABLE_MACOS": "true", + "ENABLE_MACOS_SWIFTLY": "true", + "MACOS_SWIFT_VERSIONS": #"["6.3"]"#, + "MACOS_OS": #"["sequoia"]"#, + ]) + #expect(generated.count == 2) + for entry in generated.entries { + #expect(entry.runner.contains("sequoia"), "\(entry.name) ignored macos_os") + #expect(!entry.runner.contains("tahoe"), "\(entry.name) used the default OS") + } + + // An entry naming its own OS still wins over the list. + let named = try Generator.run([ + "ENABLE_MACOS_SWIFTLY": "true", + "MACOS_OS": #"["sequoia"]"#, + "MACOS_SWIFTLY_TOOLCHAINS": """ + [{"xcode_version":"swift_6.3","swiftly_toolchain":"main-snapshot","os_version":"sonoma"}] + """, + ]) + #expect(named.entries.first?.runner.contains("sonoma") == true) + } + + @Test("Swiftly entries fan out over the macOS OS list") + func swiftlyFansOutOverOSList() throws { + // Each macOS OS is a pool of its own, so a swiftly entry taking only the first + // leaves the rest of the list with no swiftly coverage at all. + let generated = try Generator.run([ + "ENABLE_MACOS_SWIFTLY": "true", + "MACOS_OS": #"["sequoia","tahoe"]"#, + "MACOS_SWIFTLY_TOOLCHAINS": + #"[{"xcode_version":"swift_6.3","swiftly_toolchain":"main-snapshot"}]"#, + ]) + try #require(generated.count == 2) + #expect( + generated.names == [ + "macOS Swiftly main-snapshot (Xcode swift_6.3) sequoia", + "macOS Swiftly main-snapshot (Xcode swift_6.3) tahoe", + ] + ) + #expect(generated.entries[0].runner.contains("sequoia")) + #expect(generated.entries[1].runner.contains("tahoe")) + + // An entry naming its own OS runs there alone. Fanning it out as well would + // give one entry per configured OS, all on the pinned one and all named alike. + let pinned = try Generator.run([ + "ENABLE_MACOS_SWIFTLY": "true", + "MACOS_OS": #"["sequoia","tahoe"]"#, + "MACOS_SWIFTLY_TOOLCHAINS": """ + [{"xcode_version":"swift_6.3","swiftly_toolchain":"main-snapshot","os_version":"sonoma"},\ + {"xcode_version":"swift_6.2","swiftly_toolchain":"6.2-snapshot"}] + """, + ]) + try #require(pinned.count == 3) + #expect( + pinned.names == [ + "macOS Swiftly main-snapshot (Xcode swift_6.3) sonoma", + "macOS Swiftly 6.2-snapshot (Xcode swift_6.2) sequoia", + "macOS Swiftly 6.2-snapshot (Xcode swift_6.2) tahoe", + ] + ) + } + + @Test("One macOS OS leaves the swiftly job name alone, however it is written") + func oneOSLeavesTheSwiftlyNameAlone() throws { + // Entry names are required status checks in adopting repositories, so a name + // gains the OS only when more than one is configured. + func names(_ macOSOS: String?) throws -> [String] { + var environment = [ + "ENABLE_MACOS_SWIFTLY": "true", + "MACOS_SWIFTLY_TOOLCHAINS": + #"[{"xcode_version":"swift_6.3","swiftly_toolchain":"main-snapshot"}]"#, + ] + if let macOSOS { + environment["MACOS_OS"] = macOSOS + } + return try Generator.run(environment).names + } + + let bare = ["macOS Swiftly main-snapshot (Xcode swift_6.3)"] + #expect(try names(nil) == bare) + #expect(try names("sequoia") == bare) + #expect(try names(#"["sequoia"]"#) == bare) + // Without this the names above would be stable because nothing fans out. + #expect(try names(#"["sequoia","tahoe"]"#).count == 2) + } + + @Test("Self-hosted entries are withheld from other owners") + func ownerGuard() throws { + let matching = try Generator.run([ + "ENABLE_MACOS": "true", + "ENABLE_MACOS_SWIFTLY": "true", + "MACOS_SWIFT_VERSIONS": #"["6.3"]"#, + "MACOS_REPOSITORY_OWNER": "apple", + "GITHUB_REPOSITORY_OWNER": "apple", + ]) + #expect(matching.count == 2) + + let fork = try Generator.run([ + "ENABLE_MACOS": "true", + "ENABLE_MACOS_SWIFTLY": "true", + "MACOS_SWIFT_VERSIONS": #"["6.3"]"#, + "MACOS_REPOSITORY_OWNER": "apple", + "GITHUB_REPOSITORY_OWNER": "a-fork", + ]) + #expect(fork.count == 0, "a fork cannot reach the pools, so it should get no jobs at all") + + let unguarded = try Generator.run([ + "ENABLE_MACOS": "true", + "MACOS_SWIFT_VERSIONS": #"["6.3"]"#, + "GITHUB_REPOSITORY_OWNER": "a-fork", + ]) + #expect(unguarded.count == 1) + } +} + +@Suite("Apple platform targets") +struct ApplePlatformTargetTests { + /// Every platform NIO builds and tests, and the destinations each uses. + @Test( + "Each platform has a build and a test destination", + arguments: [ + ("macOS", "generic/platform=macos,variant=macos", "name=My Mac,variant=macos"), + ( + "Catalyst", "generic/platform=macos,variant=Mac Catalyst", + "name=My Mac,variant=Mac Catalyst" + ), + ("iOS", "generic/platform=ios", "name=iPhone Air"), + ("watchOS", "generic/platform=watchos", "name=Apple Watch Ultra 3 (49mm)"), + ("tvOS", "generic/platform=tvos", "name=Apple TV 4K (3rd generation)"), + ("visionOS", "generic/platform=visionos", "name=Apple Vision Pro"), + ] + ) + func destinations( + platform: String, + buildDestination: String, + testDestination: String + ) throws { + let generated = try Generator.run([ + "ENABLE_MACOS": "true", + "MACOS_SWIFT_VERSIONS": #"["6.3"]"#, + "XCODE_SCHEME": "P-Package", + "XCODE_TARGETS": "\(platform): {build: true, test: true}", + ]) + let targets = try #require(generated.entries.first?.xcodeBuild?.targets) + try #require(targets.count == 1) + #expect(targets.map(\.platform) == [platform]) + #expect(targets[0].buildDestination == buildDestination) + #expect(targets[0].testDestination == testDestination) + #expect(targets[0].build == true) + #expect(targets[0].test == true) + } + + @Test("A bare list asks for each platform with every setting at its default") + func bareListTakesTheDefaults() throws { + // The common case is a build on several platforms with nothing else said, and + // writing an empty settings map for each of them is noise. + let generated = try Generator.run([ + "ENABLE_MACOS": "true", + "MACOS_SWIFT_VERSIONS": #"["6.3"]"#, + "XCODE_SCHEME": "P-Package", + "XCODE_TARGETS": "[iOS, watchOS]", + ]) + let targets = try #require(generated.entries.first?.xcodeBuild?.targets) + try #require(targets.count == 2) + #expect(targets.map(\.platform) == ["iOS", "watchOS"]) + for target in targets { + #expect(target.build == true) + #expect(target.test == false) + #expect(target.scheme == "P-Package") + #expect(!target.buildDestination.isEmpty) + #expect(!target.testDestination.isEmpty) + } + } + + @Test("Testing on a platform is asked for on top of building, and building can be dropped") + func buildAndTestAreIndependent() throws { + // The build action is build-for-testing, so a build alone still type-checks + // the tests - which is what the retired enable_ios_checks did, without a + // runner of its own. Running them is the expensive part, so it is opt-in. + let generated = try Generator.run([ + "ENABLE_MACOS": "true", + "MACOS_SWIFT_VERSIONS": #"["6.3"]"#, + "XCODE_SCHEME": "P-Package", + "XCODE_TARGETS": """ + iOS: {test: true} + tvOS: {build: false, test: true} + """, + ]) + let targets = try #require(generated.entries.first?.xcodeBuild?.targets) + try #require(targets.count == 2) + #expect(targets[0].build == true) + #expect(targets[0].test == true) + #expect(targets[1].build == false) + #expect(targets[1].test == true) + } + + @Test("A target's scheme overrides xcode_scheme, which the rest keep") + func perTargetScheme() throws { + // A package can expose more than one scheme, and a platform is often covered + // by a scheme of its own. + let generated = try Generator.run([ + "ENABLE_MACOS": "true", + "MACOS_SWIFT_VERSIONS": #"["6.3"]"#, + "XCODE_SCHEME": "P-Package", + "XCODE_TARGETS": """ + iOS: {scheme: iOS-Only} + tvOS: {} + """, + ]) + let targets = try #require(generated.entries.first?.xcodeBuild?.targets) + try #require(targets.count == 2) + #expect(targets[0].scheme == "iOS-Only") + #expect(targets[1].scheme == "P-Package") + } + + @Test("A target's destinations override the defaults") + func perTargetDestinations() throws { + // The default destinations name the newest device of each kind, which ages + // with every Xcode release. An adopter pinned to an older Xcode, or one + // testing a device the default does not name, would otherwise be stuck. + let generated = try Generator.run([ + "ENABLE_MACOS": "true", + "MACOS_SWIFT_VERSIONS": #"["6.3"]"#, + "XCODE_SCHEME": "P-Package", + "XCODE_TARGETS": """ + iOS: {test: true, test_destination: "name=iPhone 16 Pro"} + watchOS: {build_destination: "generic/platform=watchOS Simulator"} + """, + ]) + let targets = try #require(generated.entries.first?.xcodeBuild?.targets) + try #require(targets.count == 2) + #expect(targets[0].testDestination == "name=iPhone 16 Pro") + #expect(targets[0].buildDestination == "generic/platform=ios", "the build destination is untouched") + #expect(targets[1].buildDestination == "generic/platform=watchOS Simulator") + #expect( + targets[1].testDestination == "name=Apple Watch Ultra 3 (49mm)", + "the test destination is untouched" + ) + } + + @Test( + "A target the caller got wrong fails, naming the input", + arguments: [ + "[iOS", + "iOS", + "[ios]", + "iOS: true", + "iOS: {sheme: P-Package}", + "iOS: {build: false, test: false}", + ] + ) + func malformedTargetsFail(targets: String) throws { + // Each of these would otherwise leave a platform out of a run that reports + // success: a misspelled platform or setting is dropped, and a target that + // neither builds nor tests does nothing. + let generated = try Generator.run([ + "ENABLE_MACOS": "true", + "MACOS_SWIFT_VERSIONS": #"["6.3"]"#, + "XCODE_SCHEME": "P-Package", + "XCODE_TARGETS": targets, + ]) + #expect(generated.exitCode != 0) + #expect(generated.count == 0) + #expect(generated.standardError.contains("xcode_targets")) + } + + @Test("A bad target alongside a good one still fails") + func oneBadTargetFailsTheRun() throws { + // The good target fills the array, so a check on the array being empty would + // not catch this. + let generated = try Generator.run([ + "ENABLE_MACOS": "true", + "MACOS_SWIFT_VERSIONS": #"["6.3"]"#, + "XCODE_SCHEME": "P-Package", + "XCODE_TARGETS": """ + iOS: {build: true} + iPadOS: {build: true} + """, + ]) + #expect(generated.exitCode != 0) + #expect(generated.count == 0) + #expect(generated.standardError.contains("iPadOS")) + } + + @Test("Configuring targets leaves the job names alone") + func targetsDoNotRenameJobs() throws { + // Entry names become required status checks in adopting repositories, and two + // swift-nio dev/ scripts parse them out of `gh pr checks`. The targets run as + // steps inside a macOS job, so no configuration of them may move a name. + func generate(_ targets: String?) throws -> Generated { + var environment = [ + "ENABLE_LINUX": "true", + "ENABLE_MACOS": "true", + "ENABLE_WINDOWS": "true", + "LINUX_SWIFT_VERSIONS": #"["6.3"]"#, + "MACOS_SWIFT_VERSIONS": #"["6.3"]"#, + "MACOS_XCODE_VERSIONS": #"["latest-beta"]"#, + "WINDOWS_SWIFT_VERSIONS": #"["6.3"]"#, + "XCODE_SCHEME": "P-Package", + ] + if let targets { + environment["XCODE_TARGETS"] = targets + } + return try Generator.run(environment) + } + + let expected = [ + "Linux Swift 6.3", "macOS Xcode latest-beta", "macOS Swift 6.3", "Windows Swift 6.3", + ] + #expect(try generate(nil).names == expected) + + let everyPlatform = try generate("[macOS, Catalyst, iOS, watchOS, tvOS, visionOS]") + #expect(everyPlatform.names == expected) + // Without this the names above would be stable because nothing was configured. + #expect(everyPlatform.entry(named: "macOS Swift 6.3")?.xcodeBuild?.targets?.count == 6) + + let oneOverriddenTarget = try generate( + #"iOS: {test: true, scheme: Other-Package, test_destination: "name=iPhone 16"}"# + ) + #expect(oneOverriddenTarget.names == expected) + #expect(oneOverriddenTarget.entry(named: "macOS Swift 6.3")?.xcodeBuild?.targets?.count == 1) + } + + @Test("Apple platform targets ride on the macOS entries rather than their own") + func noExtraRunners() throws { + // NIO's reason for this shape: a separate runner per platform is expensive, + // because macOS runner recycling is slow. + let generated = try Generator.run([ + "ENABLE_MACOS": "true", + "MACOS_SWIFT_VERSIONS": #"["6.3"]"#, + "XCODE_SCHEME": "P-Package", + "XCODE_TARGETS": "[iOS, watchOS, tvOS, visionOS]", + ]) + #expect(generated.count == 1) + #expect(generated.entries.first?.xcodeBuild?.targets?.count == 4) + } +} + +@Suite("Windows") +struct WindowsTests { + @Test("Windows is native until a container is asked for") + func nativeUntilContainerRequested() throws { + // A Swift release before 6.1 cannot build ucrt against the runner image's + // Windows SDK, so an adopter of one needs a container and has to ask for it. + let byDefault = try Generator.run([ + "ENABLE_WINDOWS": "true", "WINDOWS_SWIFT_VERSIONS": #"["6.3"]"#, + ]) + #expect(byDefault.entries.first?.swiftBuild?.container == nil) + + let containerized = try Generator.run([ + "ENABLE_WINDOWS": "true", + "WINDOWS_SWIFT_VERSIONS": #"["6.3"]"#, + "WINDOWS_USE_DOCKER": "true", + ]) + #expect( + containerized.entries.first?.swiftBuild?.container?.image + == "swift:6.3-windowsservercore-ltsc2022" + ) + } + + @Test("The runner comes from the OS list, which also names the job") + func runnersFromOSList() throws { + let generated = try Generator.run([ + "ENABLE_WINDOWS": "true", + "WINDOWS_SWIFT_VERSIONS": #"["6.3"]"#, + "WINDOWS_OS": #"["windows-2022","windows-11-arm"]"#, + ]) + #expect(generated.entries.map { $0.runner.first } == ["windows-2022", "windows-11-arm"]) + #expect(generated.names == ["Windows Swift 6.3 windows-2022", "Windows Swift 6.3 windows-11-arm"]) + } + + @Test("Container images use the Windows tag") + func containerImage() throws { + let generated = try Generator.run([ + "ENABLE_WINDOWS": "true", + "WINDOWS_SWIFT_VERSIONS": #"["nightly-release"]"#, + "WINDOWS_USE_DOCKER": "true", + ]) + #expect( + generated.entries.first?.swiftBuild?.container?.image + == "swiftlang/swift:nightly-6.4.x-windowsservercore-ltsc2022" + ) + } + + @Test( + "A runner label with no known image fails rather than pairing one it cannot run", + arguments: ["windows-2025", "windows-11-arm", "windows-latest"] + ) + func containerTagFollowsTheRunnerLabel(label: String) throws { + // The label names the host, and a Windows container shares the host's kernel: + // an image built for another Windows release does not start there, so a job + // pinned to a tag the label does not match fails inside `docker run` with + // nothing pointing back at windows_os. + let containerized = try Generator.run([ + "ENABLE_WINDOWS": "true", + "WINDOWS_SWIFT_VERSIONS": #"["6.3"]"#, + "WINDOWS_OS": label, + "WINDOWS_USE_DOCKER": "true", + ]) + #expect(containerized.exitCode != 0) + #expect(containerized.count == 0) + #expect(containerized.standardError.contains(label)) + + // The same label runs natively, which is the mode these runners support. + let native = try Generator.run([ + "ENABLE_WINDOWS": "true", + "WINDOWS_SWIFT_VERSIONS": #"["6.3"]"#, + "WINDOWS_OS": label, + ]) + #expect(native.exitCode == 0) + #expect(native.entries.first?.runner == [label]) + } + + @Test("One label with no image fails the run, not just its own entries") + func oneUnknownLabelFailsTheRun() throws { + // windows-2022 fills the matrix, so a check on the matrix being empty would not + // catch this, and the run would come back green having skipped an OS. + let generated = try Generator.run([ + "ENABLE_WINDOWS": "true", + "WINDOWS_SWIFT_VERSIONS": #"["6.3"]"#, + "WINDOWS_OS": #"["windows-2022","windows-2025"]"#, + "WINDOWS_USE_DOCKER": "true", + ]) + #expect(generated.exitCode != 0) + #expect(generated.count == 0) + #expect(generated.standardError.contains("windows-2025")) + } +} diff --git a/tests/TestPackage/Package.swift b/tests/TestPackage/Package.swift index 079bbb33..dafaa4c1 100644 --- a/tests/TestPackage/Package.swift +++ b/tests/TestPackage/Package.swift @@ -4,6 +4,10 @@ import PackageDescription let package = Package( name: "TestPackage", + products: [ + // Named after a target: the Cxx interop check imports each library product by name. + .library(name: "Target1", targets: ["Target1"]) + ], targets: [ .target( name: "Target1" diff --git a/tests/TestPackage/Package@swift-5.10.swift b/tests/TestPackage/Package@swift-5.10.swift index d3951721..ef952bf8 100644 --- a/tests/TestPackage/Package@swift-5.10.swift +++ b/tests/TestPackage/Package@swift-5.10.swift @@ -4,6 +4,10 @@ import PackageDescription let package = Package( name: "TestPackage", + products: [ + // Named after a target: the Cxx interop check imports each library product by name. + .library(name: "Target1", targets: ["Target1"]) + ], targets: [ .target( name: "Target1" diff --git a/tests/TestPackage/Package@swift-5.9.swift b/tests/TestPackage/Package@swift-5.9.swift index 46e43c6a..75fa33a1 100644 --- a/tests/TestPackage/Package@swift-5.9.swift +++ b/tests/TestPackage/Package@swift-5.9.swift @@ -4,6 +4,10 @@ import PackageDescription let package = Package( name: "TestPackage", + products: [ + // Named after a target: the Cxx interop check imports each library product by name. + .library(name: "Target1", targets: ["Target1"]) + ], targets: [ .target( name: "Target1" diff --git a/tests/TestPackage/Package@swift-6.0.swift b/tests/TestPackage/Package@swift-6.0.swift index 3c244dc3..ab9d8467 100644 --- a/tests/TestPackage/Package@swift-6.0.swift +++ b/tests/TestPackage/Package@swift-6.0.swift @@ -4,6 +4,10 @@ import PackageDescription let package = Package( name: "TestPackage", + products: [ + // Named after a target: the Cxx interop check imports each library product by name. + .library(name: "Target1", targets: ["Target1"]) + ], targets: [ .target( name: "Target1" diff --git a/tests/check-benchmark-thresholds-tests.sh b/tests/check-benchmark-thresholds-tests.sh new file mode 100755 index 00000000..77e16034 --- /dev/null +++ b/tests/check-benchmark-thresholds-tests.sh @@ -0,0 +1,212 @@ +#!/bin/bash +##===----------------------------------------------------------------------===## +## +## This source file is part of the Swift.org open source project +## +## Copyright (c) 2026 Apple Inc. and the Swift project authors +## Licensed under Apache License v2.0 with Runtime Library Exception +## +## See https://swift.org/LICENSE.txt for license information +## See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +## +##===----------------------------------------------------------------------===## + +# Tests how check-benchmark-thresholds.sh reads its package paths and what it makes of +# the benchmark plugin's exit status. +# +# A benchmark regression and a package that does not build both leave 'thresholds check' +# non-zero, and the script tells them apart by whether 'thresholds update' then +# succeeds. Reporting a build error as a regression sends an adopter to look at +# measurements that were never taken; reporting a regression as a build error hides it. +# +# swift is stubbed, so the tests take no measurements and need no toolchain: what is +# under test is the path list, the branch on exit status, and the loop over packages. The +# benchmark plugin's own behavior is not. + +set -uo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SCRIPT="${REPO_ROOT}/.github/workflows/scripts/check-benchmark-thresholds.sh" + +failures=0 + +WORKDIR=$(mktemp -d) +trap 'rm -rf "$WORKDIR"' EXIT + +STUB_DIR="$WORKDIR/stubs" +mkdir -p "$STUB_DIR" + +# A repository of its own: the diff the script produces runs git against the working +# tree, which must not be this checkout. +REPOSITORY="$WORKDIR/repository" +mkdir -p "$REPOSITORY/one/Thresholds/6.3" "$REPOSITORY/two/Thresholds/6.3" +git -C "$REPOSITORY" init --quiet +git -C "$REPOSITORY" -c user.email=ci@example.com -c user.name=CI commit --quiet --allow-empty -m "empty" + +export STUB_INVOCATIONS="$WORKDIR/invocations.txt" + +# Stands in for the benchmark plugin. STUB_CHECK_STATUS and STUB_UPDATE_STATUS are the +# statuses 'thresholds check' and 'thresholds update' report; a fresh threshold file +# stands in for what an update writes. +cat >"$STUB_DIR/swift" <<'STUB' +#!/bin/bash +printf '%s\n' "$*" >>"$STUB_INVOCATIONS" +package_path="" +for ((index = 1; index <= $#; index++)); do + if [[ "${!index}" == "--package-path" ]]; then + next=$((index + 1)) + package_path="${!next}" + fi +done +case "$*" in + *"thresholds check"*) + exit "${STUB_CHECK_STATUS:-0}" + ;; + *"thresholds update"*) + if [[ "${STUB_UPDATE_STATUS:-0}" == "0" ]]; then + printf '{"wallClock":100}\n' >"$package_path/Thresholds/6.3/Benchmark.p90.json" + fi + exit "${STUB_UPDATE_STATUS:-0}" + ;; +esac +exit 0 +STUB +chmod +x "$STUB_DIR/swift" + +CHECK_LOG="$WORKDIR/check.log" + +# run_check [argument ...] - echoes the +# exit status; the log is left in $CHECK_LOG and the stub's invocations in +# $STUB_INVOCATIONS. +run_check() { + local check_status="$1" update_status="$2" paths_json="$3" path="$4" + shift 4 + : >"$STUB_INVOCATIONS" + git -C "$REPOSITORY" checkout --quiet -- . 2>/dev/null + git -C "$REPOSITORY" clean --quiet -fd + ( + cd "$REPOSITORY" || exit 1 + PATH="$STUB_DIR:$PATH" \ + SWIFT_VERSION="6.3" \ + STUB_CHECK_STATUS="$check_status" \ + STUB_UPDATE_STATUS="$update_status" \ + BENCHMARK_PACKAGE_PATHS="$paths_json" \ + BENCHMARK_PACKAGE_PATH="$path" \ + "$SCRIPT" "$@" >"$CHECK_LOG" 2>&1 + ) + echo "$?" +} + +assert_status() { + local what="$1" expected="$2" actual="$3" + if [[ "$expected" != "$actual" ]]; then + echo " FAIL $what: expected exit $expected, got $actual" + failures=$((failures + 1)) + else + echo " ok $what" + fi +} + +assert_contains() { + local what="$1" needle="$2" haystack="$3" + if [[ "$haystack" != *"$needle"* ]]; then + echo " FAIL $what: [$needle] not found in [$haystack]" + failures=$((failures + 1)) + else + echo " ok $what" + fi +} + +assert_lacks() { + local what="$1" needle="$2" haystack="$3" + if [[ "$haystack" == *"$needle"* ]]; then + echo " FAIL $what: [$needle] found in [$haystack]" + failures=$((failures + 1)) + else + echo " ok $what" + fi +} + +# The version labels the threshold files are keyed on, so a run without one would check +# a directory that does not exist. +missing_version_status=$( + cd "$REPOSITORY" \ + && PATH="$STUB_DIR:$PATH" BENCHMARK_PACKAGE_PATH="one" "$SCRIPT" >"$CHECK_LOG" 2>&1 + echo "$?" +) +assert_status "a run without SWIFT_VERSION is refused" "1" "$missing_version_status" +assert_contains "the refusal names SWIFT_VERSION" "SWIFT_VERSION must be specified" \ + "$(cat "$CHECK_LOG")" + +echo "== Package paths" + +assert_status "measurements within their thresholds pass" "0" "$(run_check 0 0 "" "one")" +assert_contains "the named package is checked" "--package-path one" "$(cat "$STUB_INVOCATIONS")" +assert_lacks "nothing is recalculated" "thresholds update" "$(cat "$STUB_INVOCATIONS")" + +assert_status "a JSON list of paths is accepted" "0" "$(run_check 0 0 '["one","two"]' ".")" +assert_contains "the first path is checked" "--package-path one" "$(cat "$STUB_INVOCATIONS")" +assert_contains "the second path is checked" "--package-path two" "$(cat "$STUB_INVOCATIONS")" +assert_lacks "the singular path is not also checked" "--package-path ." \ + "$(cat "$STUB_INVOCATIONS")" + +assert_status "a newline-separated list of paths is accepted" "0" \ + "$(run_check 0 0 $'one\ntwo' ".")" +assert_contains "both paths are checked" "--package-path two" "$(cat "$STUB_INVOCATIONS")" + +# The workflow passes "[]" when a caller named no paths. The container images carry no +# jq, so recognizing it must not need one: PATH holds the stubbed toolchain and nothing +# else. +: >"$STUB_INVOCATIONS" +empty_list_status=$( + cd "$REPOSITORY" \ + && PATH="$STUB_DIR" SWIFT_VERSION="6.3" \ + BENCHMARK_PACKAGE_PATHS="[]" BENCHMARK_PACKAGE_PATH="one" \ + "$SCRIPT" >"$CHECK_LOG" 2>&1 + echo "$?" +) +assert_status "an empty JSON list falls back to the singular path, without jq" "0" \ + "$empty_list_status" +assert_contains "the singular path is checked" "--package-path one" "$(cat "$STUB_INVOCATIONS")" + +assert_status "a JSON list that is not a list of strings is refused" "1" \ + "$(run_check 0 0 '[1,2]' ".")" +assert_contains "the refusal says what is wrong" "must be a JSON array of strings" \ + "$(cat "$CHECK_LOG")" + +echo "== Swift package arguments" + +assert_status "arguments are accepted" "0" "$(run_check 0 0 "" "one" --disable-sandbox)" +assert_contains "arguments reach the plugin" "--package-path one --disable-sandbox" \ + "$(cat "$STUB_INVOCATIONS")" + +echo "== Regression and build error" + +# A regression: the check fails, the update succeeds, and the diff says what moved. +assert_status "a regression fails the job" "1" "$(run_check 1 0 "" "one")" +assert_contains "the regression is recalculated" "thresholds update" "$(cat "$STUB_INVOCATIONS")" +assert_contains "the diff is printed" "=== BEGIN DIFF (one) ===" "$(cat "$CHECK_LOG")" +assert_contains "the diff holds the new threshold" "Benchmark.p90.json" "$(cat "$CHECK_LOG")" + +# A build error: neither the check nor the update can run, so there is nothing to diff. +assert_status "a build error fails the job" "2" "$(run_check 1 2 "" "one")" +assert_contains "the build error is called one" "failed to run due to build error" \ + "$(cat "$CHECK_LOG")" +assert_lacks "no diff is printed for a build error" "BEGIN DIFF" "$(cat "$CHECK_LOG")" + +echo "== Several packages" + +# One package's regression must not stop the others being measured, and the summary has +# to name the one that failed. +assert_status "a regression in one of several packages fails the job" "1" \ + "$(run_check 1 0 '["one","two"]' ".")" +assert_contains "the other package is still checked" "--package-path two" \ + "$(cat "$STUB_INVOCATIONS")" +assert_contains "the summary names both failures" "Benchmark failures in: one two" \ + "$(cat "$CHECK_LOG")" + +if [[ "$failures" -gt 0 ]]; then + printf '\n%d failed\n' "$failures" + exit 1 +fi +printf '\nall passed\n' diff --git a/tests/check-cxx-interop-tests.sh b/tests/check-cxx-interop-tests.sh new file mode 100755 index 00000000..9265ba27 --- /dev/null +++ b/tests/check-cxx-interop-tests.sh @@ -0,0 +1,155 @@ +#!/bin/bash +##===----------------------------------------------------------------------===## +## +## This source file is part of the Swift.org open source project +## +## Copyright (c) 2026 Apple Inc. and the Swift project authors +## Licensed under Apache License v2.0 with Runtime Library Exception +## +## See https://swift.org/LICENSE.txt for license information +## See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +## +##===----------------------------------------------------------------------===## + +# Tests that check-cxx-interop.sh refuses a package it cannot check and hands its +# arguments to the build. +# +# The check works by importing the package's library products from a package built in +# Cxx interoperability mode. A package with none to import compiles nothing, so a green +# check would mean the interoperability build never ran; and an argument that never +# reaches the compiler takes -Xswiftc -warnings-as-errors with it. +# +# swift is stubbed, so the tests need no toolchain. The assertions on tests/TestPackage +# read its manifests directly for that reason, and ask a real toolchain for the products +# it reports only when there is one. + +set -uo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SCRIPT="${REPO_ROOT}/.github/workflows/scripts/check-cxx-interop.sh" + +failures=0 + +WORKDIR=$(mktemp -d) +trap 'rm -rf "$WORKDIR"' EXIT + +STUB_DIR="$WORKDIR/stubs" +PACKAGE_DIR="$WORKDIR/package" +mkdir -p "$STUB_DIR" "$PACKAGE_DIR" + +export STUB_MANIFEST="$WORKDIR/manifest.json" +export STUB_BUILD_ARGUMENTS="$WORKDIR/build-arguments.txt" +export STUB_BUILD_DIRECTORY="$WORKDIR/build-directory.txt" + +# Stands in for the toolchain: reports the manifest the test asked for, lays out what +# `swift package init` would, and records the build's arguments and directory. +cat >"$STUB_DIR/swift" <<'STUB' +#!/bin/bash +case "$1 ${2:-}" in + "package dump-package") + cat "$STUB_MANIFEST" + ;; + "package init") + name=$(basename "$PWD") + mkdir -p "Sources/$name" + : >"Sources/$name/$name.swift" + printf 'let package = Package(name: "%s")\n' "$name" >Package.swift + ;; + "build "*|"build ") + shift + printf '%s\n' "$@" >"$STUB_BUILD_ARGUMENTS" + printf '%s\n' "$PWD" >"$STUB_BUILD_DIRECTORY" + ;; +esac +STUB +chmod +x "$STUB_DIR/swift" + +# run_check [argument ...] - echoes the exit status; the combined output +# is left in $CHECK_LOG. +CHECK_LOG="$WORKDIR/check.log" +run_check() { + local manifest="$1" + shift + printf '%s\n' "$manifest" >"$STUB_MANIFEST" + rm -f "$STUB_BUILD_ARGUMENTS" "$STUB_BUILD_DIRECTORY" + ( + cd "$PACKAGE_DIR" || exit 1 + PATH="$STUB_DIR:$PATH" "$SCRIPT" "$@" >"$CHECK_LOG" 2>&1 + ) + echo "$?" +} + +assert_status() { + local what="$1" expected="$2" actual="$3" + if [[ "$expected" != "$actual" ]]; then + echo " FAIL $what: expected exit $expected, got $actual" + failures=$((failures + 1)) + else + echo " ok $what" + fi +} + +assert_contains() { + local what="$1" needle="$2" haystack="$3" + if [[ "$haystack" != *"$needle"* ]]; then + echo " FAIL $what: [$needle] not found in [$haystack]" + failures=$((failures + 1)) + else + echo " ok $what" + fi +} + +# An executable product is not importable, so a package holding only one is as +# uncheckable as a package with no products at all. +no_products='{"name":"Fixture","products":[]}' +executable_only='{"name":"Fixture","products":[{"name":"tool","type":{"executable":null}}]}' +with_library='{"name":"Fixture","products":[{"name":"Lib","type":{"library":["automatic"]}},{"name":"tool","type":{"executable":null}}]}' + +assert_status "a package with no products is refused" "1" "$(run_check "$no_products")" +assert_contains "the refusal says what is wrong" "No library products" "$(cat "$CHECK_LOG")" +assert_status "a package with only an executable product is refused" "1" \ + "$(run_check "$executable_only")" + +assert_status "a package with a library product is checked" "0" \ + "$(run_check "$with_library" -Xswiftc -warnings-as-errors)" +assert_contains "the arguments reach the build" $'-Xswiftc\n-warnings-as-errors' \ + "$(cat "$STUB_BUILD_ARGUMENTS")" + +build_directory=$(cat "$STUB_BUILD_DIRECTORY") +assert_contains "the library product is depended on" \ + '.product(name: "Lib", package: "Fixture")' "$(cat "$build_directory/Package.swift")" +assert_contains "the library product is imported" "import Lib" \ + "$(cat "$build_directory/Sources/$(basename "$build_directory")/$(basename "$build_directory").swift")" +rm -rf "$build_directory" + +# The repository checks itself against tests/TestPackage, so every manifest the fixture +# offers has to declare a library product. dump-package is the authoritative answer but +# only speaks for the manifest the toolchain at hand selects. +for manifest_file in "$REPO_ROOT"/tests/TestPackage/Package*.swift; do + if grep -q '\.library(' "$manifest_file"; then + echo " ok $(basename "$manifest_file") declares a library product" + else + echo " FAIL $(basename "$manifest_file") declares no library product, so the self-test checks nothing" + failures=$((failures + 1)) + fi +done + +if ! command -v swift >/dev/null 2>&1; then + echo " skip tests/TestPackage library products (no swift on PATH)" +elif ! fixture_manifest=$(cd "$REPO_ROOT/tests/TestPackage" && swift package dump-package 2>&1); then + echo " skip tests/TestPackage library products (swift package dump-package failed)" +else + fixture_products=$(echo "$fixture_manifest" | jq -r '[.products[] | select(.type.library != null) | .name] | join(" ")') + if [[ -n "$fixture_products" ]]; then + echo " ok tests/TestPackage reports library products ($fixture_products)" + else + echo " FAIL tests/TestPackage reports no library product, so the self-test checks nothing" + failures=$((failures + 1)) + fi +fi + +if [[ "$failures" -gt 0 ]]; then + printf '\n%d failed\n' "$failures" + exit 1 +fi +printf '\nall passed\n' diff --git a/tests/execute-matrix-tests.sh b/tests/execute-matrix-tests.sh new file mode 100755 index 00000000..5d0894ba --- /dev/null +++ b/tests/execute-matrix-tests.sh @@ -0,0 +1,361 @@ +#!/bin/bash +##===----------------------------------------------------------------------===## +## +## This source file is part of the Swift.org open source project +## +## Copyright (c) 2026 Apple Inc. and the Swift project authors +## Licensed under Apache License v2.0 with Runtime Library Exception +## +## See https://swift.org/LICENSE.txt for license information +## See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +## +##===----------------------------------------------------------------------===## + +# Tests the shell execute_matrix.yml carries, and the parts of its dispatch a script +# cannot reach. +# +# The matrix string is the boundary between the generator and the executor: input that +# names no entries has to be told apart from input that is empty, misspelled or +# unparseable, all of which otherwise fan out to zero jobs and a green check. +# +# The FreeBSD dispatch runs in a VM rather than through a runner script, so it is the +# one path where an entry's command arguments, environment and ${SCRIPTS_ROOT} can go +# missing without a script to notice. Each step's script is taken from the workflow and +# run here, with swift stubbed, so the tests need no VM and no toolchain. +# +# Where cross-pr-checkout.swift clones a linked pull request is tested here too: the +# dispatch is what puts the script in a container, whose mount is the reason the location +# is not simply the checkout's parent directory. + +set -uo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORKFLOW="${REPO_ROOT}/.github/workflows/execute_matrix.yml" +LABEL_WORKFLOW="${REPO_ROOT}/.github/workflows/pull_request_label.yml" + +failures=0 + +WORKDIR=$(mktemp -d) +trap 'rm -rf "$WORKDIR"' EXIT + +STUB_DIR="$WORKDIR/stubs" +VM_SCRIPTS_DIR="$WORKDIR/github-workflows/.github/workflows/scripts" +mkdir -p "$STUB_DIR" "$VM_SCRIPTS_DIR" + +# The FreeBSD script reports the toolchain version before it builds. +printf '#!/bin/bash\nexit 0\n' >"$STUB_DIR/swift" + +# Stands in for a matrix entry's command: records its arguments, the environment the +# entry asked for, and where ${SCRIPTS_ROOT} landed. +cat >"$WORKDIR/record.sh" <<'RECORD' +#!/bin/bash +printf '%s\n' "$@" >"$RECORD_ARGUMENTS" +{ + printf 'FROM_ENTRY=%s\n' "${FROM_ENTRY-unset}" + printf 'SCRIPTS_ROOT=%s\n' "${SCRIPTS_ROOT-unset}" +} >"$RECORD_ENVIRONMENT" +RECORD + +chmod +x "$STUB_DIR/swift" "$WORKDIR/record.sh" + +step_script() { + yq "$1" "$WORKFLOW" +} + +CONVERT_SCRIPT=$(step_script '.jobs.convert-matrix.steps[] | select(.id == "convert") | .run') +FREEBSD_ENV_SCRIPT=$(step_script '.jobs.execute-matrix.steps[] | select(.id == "freebsd_env") | .run') +FREEBSD_ARGUMENTS_SCRIPT=$(step_script '.jobs.execute-matrix.steps[] | select(.id == "freebsd_arguments") | .run') +FREEBSD_RUN_SCRIPT=$(step_script '.jobs.execute-matrix.steps[] | select(.name == "Run matrix job (FreeBSD)") | .with.run') + +STEP_LOG="$WORKDIR/step.log" +STEP_OUTPUT="$WORKDIR/github_output" + +# run_convert [default_command] - echoes the exit status; the step's +# output is left in $STEP_OUTPUT and its log in $STEP_LOG. +run_convert() { + : >"$STEP_OUTPUT" + ( + cd "$WORKDIR" || exit 1 + GITHUB_OUTPUT="$STEP_OUTPUT" \ + MATRIX_YAML="$1" \ + DEFAULT_COMMAND="${2:-}" \ + DEFAULT_SETUP_COMMAND="" \ + DEFAULT_COMMAND_ARGUMENTS="" \ + DEFAULT_ENV="{}" \ + bash -c "$CONVERT_SCRIPT" >"$STEP_LOG" 2>&1 + ) + echo "$?" +} + +# run_freebsd_env - echoes the exit status. +run_freebsd_env() { + : >"$STEP_OUTPUT" + ( + cd "$WORKDIR" || exit 1 + GITHUB_OUTPUT="$STEP_OUTPUT" \ + FREEBSD_ENV_VARS="$1" \ + MATRIX_ENV="$2" \ + bash -c "$FREEBSD_ENV_SCRIPT" >"$STEP_LOG" 2>&1 + ) + echo "$?" +} + +# run_freebsd_arguments - echoes the quoted arguments the host +# hands the VM. +run_freebsd_arguments() { + : >"$STEP_OUTPUT" + ( + cd "$WORKDIR" || exit 1 + GITHUB_OUTPUT="$STEP_OUTPUT" \ + MATRIX_COMMAND_ARGUMENTS="$1" \ + bash -c "$FREEBSD_ARGUMENTS_SCRIPT" >"$STEP_LOG" 2>&1 + ) + output_block command_arguments +} + +# run_freebsd_vm - echoes the exit +# status. Run under sh, which is what the VM runs it with. +run_freebsd_vm() { + rm -f "$WORKDIR/arguments.txt" "$WORKDIR/environment.txt" + ( + cd "$WORKDIR" || exit 1 + PATH="$STUB_DIR:$PATH" \ + RECORD_ARGUMENTS="$WORKDIR/arguments.txt" \ + RECORD_ENVIRONMENT="$WORKDIR/environment.txt" \ + FREEBSD_ENV_VARS="$1" \ + MATRIX_SETUP_COMMAND="$2" \ + MATRIX_COMMAND="$WORKDIR/record.sh" \ + MATRIX_COMMAND_ARGUMENTS="$3" \ + BUILD_FLAGS="--from-build-flags" \ + SCRIPTS_ROOT_RELATIVE="github-workflows/.github/workflows/scripts" \ + CROSS_PR_TESTING="false" \ + sh -c "$FREEBSD_RUN_SCRIPT" >"$STEP_LOG" 2>&1 + ) + echo "$?" +} + +# The heredoc form a step writes a multi-line output with. +output_block() { + sed -n "/^$1< /dev/null; then + DRIVER="$WORKDIR/linked-pull-requests.swift" + { + echo "import Foundation" + sed -n '/^func linkedPullRequestsDirectory/,/^}/p' "$CROSS_PR_SCRIPT" + cat <<'DRIVER' +print( + linkedPullRequestsDirectory( + checkout: URL(fileURLWithPath: "/home/runner/work/swift-nio/swift-nio"), + parent: URL(fileURLWithPath: "/home/runner/work/swift-nio") + ).path +) +print( + linkedPullRequestsDirectory( + checkout: URL(fileURLWithPath: "/swift-nio"), + parent: URL(fileURLWithPath: "/") + ).path +) +DRIVER + } >"$DRIVER" + + if chosen=$(swift "$DRIVER" 2>"$STEP_LOG"); then + assert_text "a checkout beside its siblings clones into the parent directory" \ + "/home/runner/work/swift-nio" "$(sed -n 1p <<<"$chosen")" + # A container mounts the checkout at the root of its own filesystem, so a clone in + # the parent directory is not on the mount: the host never sees it and it does not + # outlive the container. + assert_text "a container's checkout clones below itself" \ + "/swift-nio/.linked-pull-requests" "$(sed -n 2p <<<"$chosen")" + else + echo " FAIL the clone location does not compile: $(cat "$STEP_LOG")" + failures=$((failures + 1)) + fi +else + echo " skip the clone location needs swift to compile" +fi + +echo "== Workflow declarations" + +# job_timeout is documented as the bound on every job, so every step that runs one has +# to carry a timeout: a hung VM or emulator otherwise runs to the six-hour default. +for step in \ + "Run matrix job (Linux)" \ + "Run Android emulator tests" \ + "Run matrix job (Windows)" \ + "Run matrix job (macOS)" \ + "Run matrix job (FreeBSD)" +do + timeout_expression=$(yq ".jobs.execute-matrix.steps[] | select(.name == \"$step\") | .[\"timeout-minutes\"] // \"none\"" "$WORKFLOW") + if [[ -n "$timeout_expression" && "$timeout_expression" != "none" ]]; then + echo " ok $step is bounded by a timeout" + else + echo " FAIL $step has no timeout-minutes" + failures=$((failures + 1)) + fi +done + +# The VM receives only what envs: names, so a variable the step defines and does not +# name arrives empty - and a path built from it silently loses its prefix. +freebsd_envs=$(yq '.jobs.execute-matrix.steps[] | select(.name == "Run matrix job (FreeBSD)") | .with.envs' "$WORKFLOW") +while IFS= read -r name; do + if [[ " $freebsd_envs " == *" $name "* ]]; then + echo " ok $name reaches the FreeBSD VM" + else + echo " FAIL $name is set for the FreeBSD step but not named in envs:" + failures=$((failures + 1)) + fi +done < <(yq '.jobs.execute-matrix.steps[] | select(.name == "Run matrix job (FreeBSD)") | .env | keys | .[]' "$WORKFLOW") + +# gh pr view reads pull-request metadata, which contents: read does not cover. +assert_text "the label check may read pull requests" "read" \ + "$(yq '.permissions["pull-requests"]' "$LABEL_WORKFLOW")" + +if [[ "$failures" -gt 0 ]]; then + printf '\n%d failed\n' "$failures" + exit 1 +fi +printf '\nall passed\n' diff --git a/tests/invoke-program-tests.ps1 b/tests/invoke-program-tests.ps1 new file mode 100644 index 00000000..819a8af2 --- /dev/null +++ b/tests/invoke-program-tests.ps1 @@ -0,0 +1,90 @@ +##===----------------------------------------------------------------------===## +## +## This source file is part of the Swift.org open source project +## +## Copyright (c) 2026 Apple Inc. and the Swift project authors +## Licensed under Apache License v2.0 with Runtime Library Exception +## +## See https://swift.org/LICENSE.txt for license information +## See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +## +##===----------------------------------------------------------------------===## + +# Tests for Invoke-Program's exit-code propagation. +# +# A CI step that reports success for a command that failed is the worst kind of +# bug this repository can ship: every adopter believes a green check. Each case +# runs in a child process, because the helper propagates by calling `exit`. +# +# This script leaves $ErrorActionPreference at Continue deliberately. Under Stop, +# PowerShell 7.3 and later can raise when a native command exits non-zero, which +# would abort the test run before it could read the exit code it is asserting on. + +$helper = (Resolve-Path (Join-Path $PSScriptRoot "../.github/workflows/scripts/matrix/invoke-program.ps1")).Path +$failures = 0 + +# Runs a snippet in a child pwsh with the helper dot-sourced, and returns its exit +# code. +function Get-ExitCode { + param( + [string]$Snippet, + [string]$ChildErrorActionPreference = "Stop" + ) + + $script = @" +`$ErrorActionPreference = '$ChildErrorActionPreference' +. '$helper' +$Snippet +"@ + + # pwsh -File insists on a .ps1 extension. + $file = Join-Path ([System.IO.Path]::GetTempPath()) ("invoke-program-test-" + [System.Guid]::NewGuid().ToString() + ".ps1") + Set-Content -LiteralPath $file -Value $script + + # Guard against a native non-zero exit being turned into an exception. + $previous = $null + if (Test-Path variable:PSNativeCommandUseErrorActionPreference) { + $previous = $PSNativeCommandUseErrorActionPreference + $PSNativeCommandUseErrorActionPreference = $false + } + try { + & pwsh -NoLogo -NoProfile -File $file *> $null + return $LASTEXITCODE + } finally { + if ($null -ne $previous) { + $PSNativeCommandUseErrorActionPreference = $previous + } + Remove-Item -LiteralPath $file -Force -ErrorAction SilentlyContinue + } +} + +function Assert-ExitCode([string]$What, [int]$Expected, [int]$Actual) { + if ($Expected -ne $Actual) { + Write-Host " FAIL $What : expected exit $Expected, got $Actual" + $script:failures++ + } else { + Write-Host " ok $What" + } +} + +# A command that exits non-zero must fail the script with the same code. +Assert-ExitCode "non-zero exit code propagates" 3 (Get-ExitCode -Snippet 'Invoke-Program cmd /c "exit 3"') + +# A command that succeeds must not fail the script. +Assert-ExitCode "zero exit code passes through" 0 (Get-ExitCode -Snippet 'Invoke-Program cmd /c "exit 0"') + +# A callable that fails without setting an exit code - which is what a program +# that never launches looks like - must still fail the script. `exit $null` +# exits 0, so only the $? fallback can catch it. +Assert-ExitCode "failure with no exit code fails" 1 (Get-ExitCode -Snippet 'Invoke-Program Get-Item "/definitely/does/not/exist"' -ChildErrorActionPreference "Continue") + +# A stale exit code from an earlier command must not be attributed to this one. +Assert-ExitCode "stale exit code is not reused" 0 (Get-ExitCode -Snippet '$global:LASTEXITCODE = 9; Invoke-Program cmd /c "exit 0"') + +if ($failures -gt 0) { + Write-Host "" + Write-Host "$failures failed" + exit 1 +} +Write-Host "" +Write-Host "all passed" diff --git a/tests/runner-exit-code-tests-macos.sh b/tests/runner-exit-code-tests-macos.sh new file mode 100755 index 00000000..71b41dd9 --- /dev/null +++ b/tests/runner-exit-code-tests-macos.sh @@ -0,0 +1,181 @@ +#!/bin/bash +##===----------------------------------------------------------------------===## +## +## This source file is part of the Swift.org open source project +## +## Copyright (c) 2026 Apple Inc. and the Swift project authors +## Licensed under Apache License v2.0 with Runtime Library Exception +## +## See https://swift.org/LICENSE.txt for license information +## See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +## +##===----------------------------------------------------------------------===## + +# Tests that matrix/job-runner-macos.sh propagates failure, runs the command in the +# setup command's shell, hands the entry's environment to it, and refuses an +# xcodebuild target that asks for work with nowhere to do it. +# +# A step reporting success for a command that failed is the worst kind of bug +# this repository can ship, because every adopter believes a green check. +# +# macOS only, and needs one real Xcode so `xcrun swift --version` can run. The +# script's Xcode lookup is pointed at a scratch directory holding a symlink, +# which is what XCODE_APPLICATIONS_DIRECTORY exists for. + +set -uo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +RUNNER="${REPO_ROOT}/.github/workflows/scripts/matrix/job-runner-macos.sh" + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "Skipping: job-runner-macos.sh needs macOS (this is $(uname -s))." + exit 0 +fi + +# Any real Xcode will do; the tests never build, they only need xcrun and xcodebuild +# to answer. +real_xcode="" +for candidate in /Applications/Xcode*.app; do + if [[ -d "$candidate/Contents/Developer" ]]; then + real_xcode="$candidate" + break + fi +done +if [[ -z "$real_xcode" ]]; then + echo "Skipping: no Xcode with a Contents/Developer found under /Applications." + exit 0 +fi + +WORKDIR="$(mktemp -d)" +trap 'rm -rf "$WORKDIR"' EXIT +XCODE_DIR="$WORKDIR/Applications" +mkdir -p "$XCODE_DIR" +ln -s "$real_xcode" "$XCODE_DIR/Xcode_swift_test.app" +mkdir -p "$WORKDIR/subpackage" +RUNNER_LOG="$WORKDIR/runner.log" + +# Reports the environment the runner exported, as a matrix entry's command. Newlines +# in a value are shown as "/", so one line of the report holds one variable. +cat >"$WORKDIR/report-env.sh" <<'REPORT' +#!/bin/bash +{ + printf 'MULTILINE=%s\n' "$(printf '%s' "${MULTILINE_VALUE-unset}" | tr '\n' '/')" + printf 'EMPTY=%s\n' "${EMPTY_VALUE-unset}" +} >env-report.txt +REPORT +chmod +x "$WORKDIR/report-env.sh" + +failures=0 + +# run_matrix [env_json] [xcode_targets_json] +# [xcode_debug_output] - echoes the exit status; the combined output is left in +# $RUNNER_LOG. +run_matrix() { + local setup="$1" command="$2" env_json="${3:-}" targets="${4:-}" debug_output="${5:-false}" + if [[ -z "$env_json" ]]; then + env_json='{}' + fi + ( + cd "$WORKDIR" || exit 1 + XCODE_APPLICATIONS_DIRECTORY="$XCODE_DIR" \ + XCODE_TARGETS_JSON="$targets" \ + XCODE_DEBUG_OUTPUT="$debug_output" \ + "$RUNNER" "" "test" "$setup" "$command" "[]" "$env_json" "false" >"$RUNNER_LOG" 2>&1 + echo "$?" + ) +} + +assert_status() { + local description="$1" expected="$2" actual="$3" + if [[ "$expected" == "$actual" ]]; then + echo "ok - $description" + else + echo "FAILED - $description: expected exit $expected, got $actual" + failures=$((failures + 1)) + fi +} + +assert_text() { + local description="$1" expected="$2" actual="$3" + if [[ "$expected" == "$actual" ]]; then + echo "ok - $description" + else + echo "FAILED - $description: expected [$expected], got [$actual]" + failures=$((failures + 1)) + fi +} + +assert_contains() { + local description="$1" needle="$2" haystack="$3" + if [[ "$haystack" == *"$needle"* ]]; then + echo "ok - $description" + else + echo "FAILED - $description: [$needle] not found in [$haystack]" + failures=$((failures + 1)) + fi +} + +# A command that fails must fail the job; anything else reports a green check for +# work that did not pass. +assert_status "a failing command fails the job" "1" \ + "$(run_matrix "" "exit 1")" + +assert_status "a succeeding command passes" "0" \ + "$(run_matrix "" "true")" + +# A failing setup command must stop the run, otherwise the command executes +# against whatever state the half-finished setup left behind. The exact status is +# asserted, not just non-zero, since the runner is expected to propagate it. +assert_status "a failing setup command fails the job with its own status" "3" \ + "$(run_matrix "exit 3" "true")" + +# The setup command and the command share a shell, so `cd` in setup carries over. +# Without this a caller entering a subdirectory silently tests the root package. +assert_status "the command runs in the setup command's directory" "0" \ + "$(run_matrix "cd subpackage" "test \"\$(basename \"\$PWD\")\" = subpackage")" + +# Without a cd the command must run in the working directory; otherwise the +# previous assertion would pass even if the setup command's shell were discarded. +assert_status "without a cd the command runs in the working directory" "1" \ + "$(run_matrix "" "test \"\$(basename \"\$PWD\")\" = subpackage")" + +# An entry's environment has to reach the command as written: a value from a YAML +# block scalar carries newlines, and one written empty is still a value. +env_json='{"MULTILINE_VALUE":"first\nsecond","EMPTY_VALUE":""}' +assert_status "an entry with a multi-line environment value runs" "0" \ + "$(run_matrix "" "$WORKDIR/report-env.sh" "$env_json")" +assert_text "a multi-line environment value arrives whole" "MULTILINE=first/second" \ + "$(grep '^MULTILINE=' "$WORKDIR/env-report.txt")" +assert_text "an empty environment value is still exported" "EMPTY=" \ + "$(grep '^EMPTY=' "$WORKDIR/env-report.txt")" + +# A target asking for work with no destination to do it on must fail. Skipping it +# would report a green check for a platform that was never built or tested. +assert_status "a build target with no build_destination fails" "1" \ + "$(run_matrix "" "true" "" '[{"platform":"iOS","scheme":"Widget","build":true}]')" +assert_contains "the build failure names the platform and the missing field" \ + "iOS target has build: true but no build_destination" "$(cat "$RUNNER_LOG")" + +# The same for a test with no test_destination. This target sets build: false, which +# the runner has to honor, or it fails on the missing build_destination instead. +assert_status "a test target with no test_destination fails" "1" \ + "$(run_matrix "" "true" "" '[{"platform":"watchOS","scheme":"Widget","build":false,"test":true}]')" +assert_contains "the test failure names the platform and the missing field" \ + "watchOS target has test: true but no test_destination" "$(cat "$RUNNER_LOG")" + +# With debug output the -quiet argument is dropped, leaving an empty array that bash +# 3.2 refuses to expand under `set -u` unless it is guarded. xcodebuild then fails on +# the scratch directory, which is proof enough that it was reached with an argument +# list bash was willing to build. +build_target='[{"platform":"iOS","scheme":"Widget","build":true,"build_destination":"generic/platform=iOS"}]' +run_matrix "" "true" "" "$build_target" "true" >/dev/null +assert_contains "a target with debug output reaches xcodebuild" \ + "does not contain an Xcode project" "$(cat "$RUNNER_LOG")" + +echo +if [[ "$failures" -eq 0 ]]; then + echo "All macOS runner tests passed." + exit 0 +fi +echo "$failures macOS runner test(s) failed." +exit 1 diff --git a/tests/runner-exit-code-tests.sh b/tests/runner-exit-code-tests.sh new file mode 100755 index 00000000..b094251e --- /dev/null +++ b/tests/runner-exit-code-tests.sh @@ -0,0 +1,332 @@ +#!/bin/bash +##===----------------------------------------------------------------------===## +## +## This source file is part of the Swift.org open source project +## +## Copyright (c) 2026 Apple Inc. and the Swift project authors +## Licensed under Apache License v2.0 with Runtime Library Exception +## +## See https://swift.org/LICENSE.txt for license information +## See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +## +##===----------------------------------------------------------------------===## + +# Tests that matrix/job-runner-linux.sh propagates failure, hands a matrix entry's +# environment and build command to what it runs, and refuses an entry whose fields +# contradict each other. It also tests that install-and-build-with-sdk.sh, which the +# runner invokes for an SDK entry, refuses an Android build whose triples are missing +# or empty, and survives an unset ANDROID_NDK_HOME. +# +# A step reporting success for a command that failed is the worst kind of bug this +# repository can ship, because every adopter believes a green check. +# +# The toolchain install is skipped and swiftly, docker and the SDK script are stubbed, +# so the tests need no Swift, no Docker daemon and no network, and run on any platform. + +set -uo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +RUNNER="${REPO_ROOT}/.github/workflows/scripts/matrix/job-runner-linux.sh" + +failures=0 + +WORKDIR=$(mktemp -d) +trap 'rm -rf "$WORKDIR"' EXIT +mkdir -p "$WORKDIR/subpackage" + +RUNNER_LOG="$WORKDIR/runner.log" +STUB_DIR="$WORKDIR/stubs" +SCRIPTS_DIR="$WORKDIR/scripts" +SWIFTLY_HOME="$WORKDIR/swiftly" +mkdir -p "$STUB_DIR" "$SCRIPTS_DIR" "$SWIFTLY_HOME" + +# `swiftly` on PATH is what makes the installer return instead of fetching a Linux +# tarball, and it then sources env.sh from SWIFTLY_HOME_DIR. +printf '#!/bin/bash\nexit 0\n' >"$STUB_DIR/swiftly" +: >"$SWIFTLY_HOME/env.sh" + +# docker and the SDK script record their arguments, one per line, so the tests can +# assert on what the runner asked for. An argument holding a newline spans two lines, +# which assert_contains still recognizes. +cat >"$STUB_DIR/docker" <<'STUB' +#!/bin/bash +printf '%s\n' "$@" >>docker-args.txt +STUB +cat >"$SCRIPTS_DIR/install-and-build-with-sdk.sh" <<'STUB' +#!/bin/bash +printf '%s\n' "$@" >>sdk-args.txt +STUB + +# Reports the environment the runner exported, as a matrix entry's command. Newlines +# in a value are shown as "/", so one line of the report holds one variable. +cat >"$WORKDIR/report-env.sh" <<'REPORT' +#!/bin/bash +{ + printf 'MULTILINE=%s\n' "$(printf '%s' "${MULTILINE_VALUE-unset}" | tr '\n' '/')" + printf 'EMPTY=%s\n' "${EMPTY_VALUE-unset}" +} >env-report.txt +REPORT + +chmod +x "$STUB_DIR/swiftly" "$STUB_DIR/docker" \ + "$SCRIPTS_DIR/install-and-build-with-sdk.sh" "$WORKDIR/report-env.sh" + +# drive_runner [container_json] - +# echoes the exit status; the combined output is left in $RUNNER_LOG. +drive_runner() { + ( + cd "$WORKDIR" || exit 1 + export CONTAINER_JSON="${5:-null}" + PATH="$STUB_DIR:$PATH" \ + SWIFTLY_HOME_DIR="$SWIFTLY_HOME" \ + SCRIPTS_ROOT="$SCRIPTS_DIR" \ + SKIP_SWIFT_INSTALL=true \ + "$RUNNER" "6.3" "$1" "$2" "[]" "$3" "false" "$4" >"$RUNNER_LOG" 2>&1 + ) + echo "$?" +} + +# run_matrix - echoes the exit status. +run_matrix() { + drive_runner "$1" "$2" '{}' "" +} + +assert_status() { + local what="$1" expected="$2" actual="$3" + if [[ "$expected" != "$actual" ]]; then + echo " FAIL $what: expected exit $expected, got $actual" + failures=$((failures + 1)) + else + echo " ok $what" + fi +} + +assert_text() { + local what="$1" expected="$2" actual="$3" + if [[ "$expected" != "$actual" ]]; then + echo " FAIL $what: expected [$expected], got [$actual]" + failures=$((failures + 1)) + else + echo " ok $what" + fi +} + +assert_contains() { + local what="$1" needle="$2" haystack="$3" + if [[ "$haystack" != *"$needle"* ]]; then + echo " FAIL $what: [$needle] not found in [$haystack]" + failures=$((failures + 1)) + else + echo " ok $what" + fi +} + +assert_lacks() { + local what="$1" needle="$2" haystack="$3" + if [[ "$haystack" == *"$needle"* ]]; then + echo " FAIL $what: [$needle] found in [$haystack]" + failures=$((failures + 1)) + else + echo " ok $what" + fi +} + +assert_status "non-zero exit code propagates" "3" "$(run_matrix "" "exit 3")" +assert_status "zero exit code passes through" "0" "$(run_matrix "" "true")" +assert_status "a failing setup command fails the job" "4" "$(run_matrix "exit 4" "true")" +assert_status "the command runs in the setup command's directory" "0" \ + "$(run_matrix "cd subpackage" "test \"\$(basename \"\$PWD\")\" = subpackage")" + +# Without a cd the command must run in the working directory; otherwise the previous +# assertion would pass even if the setup command's shell were discarded. +assert_status "without a cd the command runs in the working directory" "1" \ + "$(run_matrix "" "test \"\$(basename \"\$PWD\")\" = subpackage")" + +# An entry's environment has to reach the command as written: a value from a YAML +# block scalar carries newlines, and one written empty is still a value. +env_json='{"MULTILINE_VALUE":"first\nsecond","EMPTY_VALUE":""}' +assert_status "an entry with a multi-line environment value runs" "0" \ + "$(drive_runner "" "$WORKDIR/report-env.sh" "$env_json" "")" +assert_text "a multi-line environment value arrives whole" "MULTILINE=first/second" \ + "$(grep '^MULTILINE=' "$WORKDIR/env-report.txt")" +assert_text "an empty environment value is still exported" "EMPTY=" \ + "$(grep '^EMPTY=' "$WORKDIR/env-report.txt")" + +# The container path passes the same values as docker -e arguments. +rm -f "$WORKDIR/docker-args.txt" +assert_status "a container entry with a multi-line environment value runs" "0" \ + "$(drive_runner "" "true" "$env_json" "" '{"image":"swift:6.3"}')" +docker_args=$(cat "$WORKDIR/docker-args.txt") +assert_contains "a multi-line environment value reaches the container whole" \ + $'MULTILINE_VALUE=first\nsecond' "$docker_args" +assert_contains "an empty environment value reaches the container" \ + $'\nEMPTY_VALUE=\n' "$docker_args" + +# Every SDK type has to hand the caller's build command to the SDK script, which +# otherwise builds with its own default and reports success for work nobody asked +# for. The triples are only read by the Android type. +sdk_build_command="swift build --product Widget" +for sdk_type in static-linux wasm embedded-wasm android; do + rm -f "$WORKDIR/sdk-args.txt" + sdk_json='{"type":"'"$sdk_type"'","triples":["aarch64-unknown-linux-android24"]}' + assert_status "$sdk_type SDK build succeeds" "0" \ + "$(drive_runner "" "$sdk_build_command" '{}' "$sdk_json")" + assert_contains "$sdk_type passes the caller's build command" \ + "--build-command=$sdk_build_command" "$(cat "$WORKDIR/sdk-args.txt")" +done + +# An entry with both a container and an SDK has to be refused here, where the +# container path assumes there is no SDK: it returns before the SDK handling, so it +# would run the raw command and report a green SDK build. +assert_status "a container entry with an SDK is refused" "1" \ + "$(drive_runner "" "true" '{}' '{"type":"wasm"}' '{"image":"swift:6.3"}')" +assert_contains "the refusal says what is wrong" "cannot also specify an SDK" \ + "$(cat "$RUNNER_LOG")" + +# The Android build in install-and-build-with-sdk.sh loops over the triples it was +# given, so an invocation with none installs the Swift SDK and the NDK, builds nothing +# and exits 0. The runner reaches that invocation for a hand-written matrix entry with +# no "triples" field, because its jq filter yields nothing rather than failing. +# +# The refusal has to come before anything is fetched, so curl here records the attempt +# and fails: an install that got as far as the network is not a refusal. +SDK_SCRIPT="${REPO_ROOT}/.github/workflows/scripts/install-and-build-with-sdk.sh" +SDK_STUB_DIR="$WORKDIR/sdk-stubs" +CURL_CALLED="$WORKDIR/curl-called.txt" +mkdir -p "$SDK_STUB_DIR" +cat >"$SDK_STUB_DIR/curl" <>"$CURL_CALLED" +exit 1 +STUB +chmod +x "$SDK_STUB_DIR/curl" + +# refuse_android_build ... - asserts the SDK script +# refuses the arguments, says so in terms the caller can act on, and fetches nothing. +# +# The exit status alone proves little here: curl fails, so a script that did not refuse +# would also exit 1, on the download it should never have started. +refuse_android_build() { + local what="$1" needle="$2" + shift 2 + + local log="$WORKDIR/android-refusal.log" + rm -f "$CURL_CALLED" + PATH="$SDK_STUB_DIR:$PATH" "$SDK_SCRIPT" --android --android-ndk-version=r27d \ + "$@" --flags="" --build-command="swift build" "6.3" >"$log" 2>&1 + local status=$? + + assert_status "$what is refused" "1" "$status" + assert_contains "the refusal for $what names the argument" "$needle" "$(cat "$log")" + assert_text "nothing is fetched before the refusal for $what" "no" \ + "$(if [[ -e "$CURL_CALLED" ]]; then echo yes; else echo no; fi)" +} + +refuse_android_build "an Android build with no triples" \ + "--android-sdk-triple= must be specified" + +# A triple given as the empty string is a one-element list, so it passes the count +# check and reaches the compiler as a --swift-sdk with nothing after it. An adopter +# handing android_sdk_triples: "[]" to swift_package_test.yml writes exactly that, +# because join() over an empty array is the empty string. +refuse_android_build "an Android build with an empty triple" \ + "--android-sdk-triple was given a blank value" \ + "--android-sdk-triple=" +refuse_android_build "an Android build with a whitespace-only triple" \ + "--android-sdk-triple was given a blank value" \ + "--android-sdk-triple= " + +# The script runs under 'set -u', and ANDROID_NDK_HOME is set by a GitHub runner rather +# than by the script, so every read of it has to tolerate its being unset. Two log lines +# report the NDK directory: one before the NDK is installed, one before the build. +# +# These stubs take the Android path all the way through to the build without a network +# or a toolchain. 'swift sdk list' decides which of the two log lines is reached: an +# already-installed SDK returns early and reaches only the second. +ANDROID_STUB_DIR="$WORKDIR/android-stubs" +ANDROID_HOME_DIR="$WORKDIR/android-home" +mkdir -p "$ANDROID_STUB_DIR" "$ANDROID_HOME_DIR/.config/swiftpm" + +cat >"$ANDROID_STUB_DIR/curl" <<'STUB' +#!/bin/bash +# Serves the releases index and writes an empty file for the NDK archive. +output="" +url="" +while [[ $# -gt 0 ]]; do + case "$1" in + -o) output="$2"; shift 2 ;; + http*) url="$1"; shift ;; + *) shift ;; + esac +done +case "$url" in + *releases.json) + printf '%s' '[{"name":"6.3","platforms":[{"platform":"android-sdk","checksum":"0badc0de"}]}]' + ;; + *dl.google.com*) : >"$output" ;; + *) exit 1 ;; +esac +STUB + +cat >"$ANDROID_STUB_DIR/swift" <<'STUB' +#!/bin/bash +case "$1" in + --version) echo "Swift version 6.3 (swift-6.3-RELEASE)" ;; + sdk) [[ "$2" == list ]] && printf '%s\n' "$ANDROID_SDK_LIST" ;; +esac +exit 0 +STUB + +# The NDK archive is never unpacked for real: on the 6.3 path the directory is only +# logged, never read. +printf '#!/bin/bash\nexit 0\n' >"$ANDROID_STUB_DIR/unzip" +chmod +x "$ANDROID_STUB_DIR/curl" "$ANDROID_STUB_DIR/swift" "$ANDROID_STUB_DIR/unzip" + +# run_android_build - echoes the exit status. +run_android_build() { + ( + cd "$WORKDIR" || exit 1 + # A GitHub runner presets ANDROID_NDK_HOME, so the test says nothing unless it + # is removed. GITHUB_ENV goes too, so the script does not append to the + # environment file of the job running these tests. + unset ANDROID_NDK_HOME GITHUB_ENV + PATH="$ANDROID_STUB_DIR:$PATH" \ + HOME="$ANDROID_HOME_DIR" \ + ANDROID_SDK_LIST="$1" \ + "$SDK_SCRIPT" --android --android-ndk-version=r27d \ + --android-sdk-triple=aarch64-unknown-linux-android24 \ + --flags="" --build-command="swift build" "6.3" >"$2" 2>&1 + ) + echo "$?" +} + +# An installed SDK skips the install and reaches only the log line before the build. +ANDROID_BUILD_LOG="$WORKDIR/android-installed.log" +assert_status "an Android build with ANDROID_NDK_HOME unset runs" "0" \ + "$(run_android_build "swift-6.3-RELEASE_android" "$ANDROID_BUILD_LOG")" +android_build_log=$(cat "$ANDROID_BUILD_LOG") +assert_lacks "the build does not die on an unset ANDROID_NDK_HOME" \ + "unbound variable" "$android_build_log" +assert_contains "the build reports an unset NDK directory as unset" \ + "Using NDK at (unset)" "$android_build_log" +# The same run shows the validation above lets a well-formed triple through. +assert_contains "a valid triple reaches the build command" \ + "Running: swift build --swift-sdk aarch64-unknown-linux-android24" "$android_build_log" + +# A tag that does not match the installed-SDK pattern takes the install path, which +# reports the NDK directory before deciding whether to download one. +ANDROID_INSTALL_LOG="$WORKDIR/android-installing.log" +assert_status "an Android SDK install with ANDROID_NDK_HOME unset runs" "0" \ + "$(run_android_build "swift-6.3-RELEASE-android-0.1" "$ANDROID_INSTALL_LOG")" +android_install_log=$(cat "$ANDROID_INSTALL_LOG") +assert_lacks "the install does not die on an unset ANDROID_NDK_HOME" \ + "unbound variable" "$android_install_log" +assert_contains "the install reports an unset NDK directory as unset" \ + "Checking for Android NDK r27d at (unset)" "$android_install_log" +assert_contains "the install then reports the NDK it downloaded" \ + "Using NDK at ${ANDROID_HOME_DIR}/.config/swiftpm/android-ndk-r27d" "$android_install_log" + +if [[ "$failures" -gt 0 ]]; then + printf '\n%d failed\n' "$failures" + exit 1 +fi +printf '\nall passed\n'