name: test on: push: branches: - main pull_request: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: build: runs-on: macos-26 # A green run takes about 15 minutes; a slow runner has taken 2.5x that. # Without this the default is 6 hours, so a stuck run had to be cancelled by hand. timeout-minutes: 45 permissions: contents: read env: PROWL_DERIVED_DATA_PATH: ${{ github.workspace }}/build/ci-derived-data MISE_HTTP_TIMEOUT: 120 MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true steps: - uses: actions/checkout@v6 with: submodules: recursive - uses: ./.github/actions/setup-macos - run: make lint - run: make check-localization - name: Stage debug app resources run: make embed-cli-debug embed-docs embed-skills - name: Build app and run tests in parallel shell: bash run: | set -euo pipefail mkdir -p build/ci-logs run_task() { local name="$1" shift local log="build/ci-logs/${name}.log" local started=$SECONDS # Capture the command's own exit code. Reading $? after an # `if cmd; then ...; fi` block yields the `if` status (0 when the # condition is false and there is no else), which silently masked # failures and let the step pass on a failing build. local status=0 # stdout is buffered into the log and printed as one folded group # when the task ends. stderr streams straight to the console with a # task prefix so progress (the `make test-app` heartbeat, `swift # test` results, make errors) shows while the task is still # running. The group is opened only around the buffered dump: a # `::group::` printed at task start would fold the streamed lines # away too, and GitHub does not nest groups from parallel tasks. # The stderr redirection comes first: redirections apply left to # right, and the process substitution inherits stdout as it is at # that moment, so placing it after `>"$log"` would send the # streamed lines into the log file instead of the console. "$@" 2> >(tee -a "build/ci-logs/${name}.stderr.log" | while IFS= read -r line; do printf '[%s] %s\n' "$name" "$line"; done) >"$log" || status=$? printf '| %s | %s | %s |\n' "$name" "$((SECONDS - started))" "$status" >"build/ci-logs/${name}.timing" echo "::group::${name} (exit ${status}, $((SECONDS - started))s)" cat "$log" echo "::endgroup::" return "$status" } run_task app-tests make test-app & pid_app=$! # SwiftPM serializes access to .build; keep its checks in one branch. run_task cli-tests make test-cli-smoke test-cli-unit test-cli-integration & pid_cli=$! run_task scripts make test-scripts & pid_scripts=$! failed=0 wait "$pid_app" || failed=1 wait "$pid_cli" || failed=1 wait "$pid_scripts" || failed=1 { printf '## Build and test wall time\n\n| Task | Seconds | Exit status |\n| --- | ---: | ---: |\n' cat build/ci-logs/*.timing printf '\nTasks run in parallel; their times must not be added.\n\n' python3 scripts/summarize-xcode-build.py build/test-results/*.build.json } >> "$GITHUB_STEP_SUMMARY" exit "$failed" - name: Collect mirror diagnostics if: always() shell: bash run: | mkdir -p build/ci-logs /usr/bin/log show --last 30m --style compact --info --debug \ --predicate 'category == "MirrorPairing" OR process == "mDNSResponder"' \ > build/ci-logs/mirror-system.log 2>&1 || true - name: Save build input times run: | python3 scripts/ci-source-mtimes.py save python3 scripts/ci-source-mtimes.py save --scope app --manifest "$PROWL_DERIVED_DATA_PATH/source-times.json" - name: Upload build and test logs if: always() uses: actions/upload-artifact@v4 with: name: build-test-logs-${{ github.run_id }}-${{ github.run_attempt }} path: | build/ci-logs/ build/test-results/*.log build/test-results/*.build.json if-no-files-found: ignore retention-days: 7 - name: Upload xcresult bundle (on failure) if: failure() uses: actions/upload-artifact@v4 with: name: xcresult-${{ github.run_id }}-${{ github.run_attempt }} path: build/test-results/**/*.xcresult if-no-files-found: ignore retention-days: 7