name: iOS Periphery Dead Code on: pull_request: types: [opened, synchronize, reopened, ready_for_review, converted_to_draft] workflow_dispatch: concurrency: group: ios-periphery-${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} cancel-in-progress: true env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" permissions: contents: read pull-requests: read jobs: scope: name: Detect iOS scan scope runs-on: ubuntu-24.04 outputs: should-scan: ${{ steps.scope.outputs.should-scan }} steps: - name: Detect changed paths id: scope uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: script: | if (context.eventName === "workflow_dispatch") { core.setOutput("should-scan", "true"); return; } if (context.payload.pull_request?.draft) { core.setOutput("should-scan", "false"); return; } const files = await github.paginate(github.rest.pulls.listFiles, { owner: context.repo.owner, repo: context.repo.repo, pull_number: context.payload.pull_request.number, per_page: 100, }); const isScanPath = (filename) => typeof filename === "string" && ( filename.startsWith("apps/ios/") || filename === ".github/workflows/ios-periphery.yml" || filename === ".github/workflows/ios-periphery-comment.yml" || filename === "config/swiftformat" || filename === "config/swiftlint.yml" ); const shouldScan = files.some( ({ filename, previous_filename: previousFilename }) => isScanPath(filename) || isScanPath(previousFilename) ); core.setOutput("should-scan", String(shouldScan)); scan: name: Scan iOS dead code needs: scope if: ${{ needs.scope.outputs.should-scan == 'true' }} runs-on: ${{ github.event_name == 'workflow_dispatch' && 'macos-26' || (github.repository == 'openclaw/openclaw' && 'blacksmith-12vcpu-macos-26' || 'macos-26') }} timeout-minutes: 45 steps: - name: Checkout uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: fetch-depth: 1 fetch-tags: false persist-credentials: false submodules: false - name: Verify Xcode run: | set -euo pipefail for xcode_app in /Applications/Xcode_26.5.app /Applications/Xcode-26.5.0.app; do if [ -d "$xcode_app/Contents/Developer" ]; then sudo xcode-select -s "$xcode_app/Contents/Developer" break fi done xcodebuild -version xcode_version="$(xcodebuild -version | awk 'NR == 1 { print $2 }')" if [[ "$xcode_version" != 26.* ]]; then echo "error: expected Xcode 26.x, got $xcode_version" >&2 exit 1 fi swift --version - name: Setup Node environment uses: ./.github/actions/setup-node-env with: install-bun: "false" - name: Install iOS Swift tooling run: brew install xcodegen swiftformat swiftlint periphery - name: Generate iOS project run: | set -euo pipefail ./scripts/ios-configure-signing.sh ./scripts/ios-write-version-xcconfig.sh cd apps/ios xcodegen generate - name: Run Periphery run: | set -euo pipefail output_dir="$RUNNER_TEMP/ios-periphery" mkdir -p "$output_dir" cd apps/ios set +e periphery scan \ --config .periphery.yml \ --strict \ --format json \ --write-results "$output_dir/periphery.json" \ >"$output_dir/periphery.stdout.json" \ 2>"$output_dir/periphery.stderr.log" periphery_status="$?" set -e printf '%s\n' "$periphery_status" >"$output_dir/periphery.status" if [ ! -s "$output_dir/periphery.json" ]; then cp "$output_dir/periphery.stdout.json" "$output_dir/periphery.json" fi - name: Build Periphery report run: | set -euo pipefail node <<'NODE' const fs = require("node:fs"); const path = require("node:path"); const outputDir = path.join(process.env.RUNNER_TEMP, "ios-periphery"); const read = (name) => { const file = path.join(outputDir, name); return fs.existsSync(file) ? fs.readFileSync(file, "utf8") : ""; }; const status = Number(read("periphery.status").trim() || "1"); let findings = null; for (const name of ["periphery.json", "periphery.stdout.json"]) { try { const parsed = JSON.parse(read(name)); if (Array.isArray(parsed)) { findings = parsed; break; } } catch {} } const escapeCommandData = (value) => String(value ?? "") .replaceAll("%", "%25") .replaceAll("\r", "%0D") .replaceAll("\n", "%0A"); const escapeCommandProperty = (value) => escapeCommandData(value) .replaceAll(":", "%3A") .replaceAll(",", "%2C"); const rows = (findings ?? []).map((finding) => { const location = String(finding.location ?? ""); const [file, line] = location.split(":"); const repoFile = file ? `apps/ios/${file}` : ""; return { file: repoFile, line: line || "", kind: String(finding.kind ?? ""), name: String(finding.name ?? ""), }; }); for (const row of rows) { if (!row.file) continue; const line = row.line ? `,line=${escapeCommandProperty(row.line)}` : ""; const title = `${row.kind || "Unused code"} ${row.name}`.trim(); console.log(`::error file=${escapeCommandProperty(row.file)}${line},title=Dead Swift code::${escapeCommandData(title)}`); } let shouldFail = "1"; let summary = ""; if (findings === null) { summary = [ "### iOS Periphery", "", "Periphery did not complete. Check the workflow artifact for stdout/stderr.", ].join("\n"); } else if (rows.length === 0 && status === 0) { shouldFail = "0"; summary = [ "### iOS Periphery", "", "No dead Swift code found.", ].join("\n"); } else if (rows.length > 0) { summary = [ "### iOS Periphery", "", `Found ${rows.length} dead Swift code ${rows.length === 1 ? "symbol" : "symbols"}. See the PR comment or workflow artifact for details.`, ].join("\n"); } else { summary = [ "### iOS Periphery", "", "Periphery exited with a non-zero status before producing findings. Check the workflow artifact for stdout/stderr.", ].join("\n"); } fs.writeFileSync(path.join(outputDir, "should-fail.txt"), `${shouldFail}\n`); fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary.trim()}\n`); NODE - name: Upload Periphery report if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: ios-periphery-dead-code-${{ github.run_id }}-${{ github.run_attempt }} path: ${{ runner.temp }}/ios-periphery if-no-files-found: warn retention-days: 14 - name: Fail on dead code run: | set -euo pipefail test "$(cat "$RUNNER_TEMP/ios-periphery/should-fail.txt")" = "0"