mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-23 17:18:12 +00:00
Prune unused iOS surfaces and regenerate the Xcode project. Add a scoped Periphery PR gate with hardened artifact handling and stale-status cleanup. Co-authored-by: Sash Zats <sash@zats.io>
448 lines
18 KiB
YAML
448 lines
18 KiB
YAML
name: iOS Periphery Dead Code Comment
|
|
|
|
on:
|
|
workflow_run: # zizmor: ignore[dangerous-triggers] trusted PR commenter; job gates repository, source event, workflow name, live open PR, and exact current head before reading artifacts or writing comments
|
|
workflows: ["iOS Periphery Dead Code"]
|
|
types: [completed]
|
|
|
|
env:
|
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
|
|
|
permissions:
|
|
actions: read
|
|
contents: read
|
|
issues: write
|
|
pull-requests: read
|
|
|
|
jobs:
|
|
comment:
|
|
name: Comment on PR
|
|
runs-on: ubuntu-24.04
|
|
if: >
|
|
github.repository == 'openclaw/openclaw' &&
|
|
github.event.workflow_run.event == 'pull_request' &&
|
|
github.event.workflow_run.name == 'iOS Periphery Dead Code'
|
|
steps:
|
|
- name: Upsert Periphery PR comment
|
|
uses: actions/github-script@v9
|
|
with:
|
|
script: |
|
|
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const childProcess = require("node:child_process");
|
|
|
|
const marker = "<!-- openclaw-ios-periphery-dead-code -->";
|
|
const run = context.payload.workflow_run;
|
|
const pr = run.pull_requests?.[0];
|
|
if (!pr) {
|
|
core.info("No pull request attached to workflow_run.");
|
|
return;
|
|
}
|
|
|
|
const { owner, repo } = context.repo;
|
|
const repository = `${owner}/${repo}`;
|
|
if (run.repository?.full_name !== repository) {
|
|
core.info(`Skipping workflow_run from ${run.repository?.full_name ?? "unknown repository"}.`);
|
|
return;
|
|
}
|
|
if (run.event !== "pull_request") {
|
|
core.info(`Skipping workflow_run for ${run.event ?? "unknown"} event.`);
|
|
return;
|
|
}
|
|
if (run.name !== "iOS Periphery Dead Code") {
|
|
core.info(`Skipping unexpected workflow ${run.name ?? "unknown"}.`);
|
|
return;
|
|
}
|
|
|
|
const livePull = await github.rest.pulls.get({
|
|
owner,
|
|
repo,
|
|
pull_number: pr.number,
|
|
});
|
|
if (livePull.data.state !== "open") {
|
|
core.info(`Skipping closed PR #${pr.number}.`);
|
|
return;
|
|
}
|
|
if (livePull.data.base?.repo?.full_name !== repository) {
|
|
core.info(`Skipping PR #${pr.number} targeting ${livePull.data.base?.repo?.full_name ?? "unknown repository"}.`);
|
|
return;
|
|
}
|
|
if (livePull.data.head?.sha !== run.head_sha) {
|
|
core.info(`Skipping stale run ${run.id}; PR #${pr.number} is now at ${livePull.data.head?.sha}.`);
|
|
return;
|
|
}
|
|
|
|
const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, {
|
|
owner,
|
|
repo,
|
|
run_id: run.id,
|
|
filter: "latest",
|
|
per_page: 100,
|
|
});
|
|
const scopeJob = jobs.find((job) => job.name === "Detect iOS scan scope");
|
|
const scanJob = jobs.find((job) => job.name === "Scan iOS dead code");
|
|
const scanSkipped =
|
|
scopeJob?.conclusion === "success" && scanJob?.conclusion === "skipped";
|
|
if (scanSkipped) {
|
|
core.info(`Skipping intentionally omitted Periphery scan for PR #${pr.number}.`);
|
|
}
|
|
|
|
const artifacts = scanSkipped
|
|
? []
|
|
: await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
|
|
owner,
|
|
repo,
|
|
run_id: run.id,
|
|
per_page: 100,
|
|
});
|
|
|
|
const readReport = async () => {
|
|
if (scanSkipped) {
|
|
return;
|
|
}
|
|
const artifactName = `ios-periphery-dead-code-${run.id}-${run.run_attempt}`;
|
|
const artifact = artifacts.find((item) => item.name === artifactName);
|
|
if (!artifact) {
|
|
core.warning(`No ${artifactName} artifact found.`);
|
|
return;
|
|
}
|
|
if (artifact.expired) {
|
|
core.warning(`${artifactName} artifact expired.`);
|
|
return;
|
|
}
|
|
|
|
const maxArchiveBytes = 1024 * 1024;
|
|
const archiveSize = Number(artifact.size_in_bytes);
|
|
if (!Number.isSafeInteger(archiveSize) || archiveSize < 0 || archiveSize > maxArchiveBytes) {
|
|
core.warning(`Skipping ${artifactName}; compressed artifact size ${artifact.size_in_bytes ?? "unknown"} exceeds the ${maxArchiveBytes} byte limit.`);
|
|
return;
|
|
}
|
|
|
|
const archive = await github.rest.actions.downloadArtifact({
|
|
owner,
|
|
repo,
|
|
artifact_id: artifact.id,
|
|
archive_format: "zip",
|
|
});
|
|
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ios-periphery-"));
|
|
const archivePath = path.join(dir, "artifact.zip");
|
|
const archiveBuffer = Buffer.from(archive.data);
|
|
fs.writeFileSync(archivePath, archiveBuffer);
|
|
|
|
const allowedArtifactFiles = new Set([
|
|
"periphery.json",
|
|
"periphery.status",
|
|
"periphery.stderr.log",
|
|
"periphery.stdout.json",
|
|
"should-fail.txt",
|
|
]);
|
|
const maxEntries = allowedArtifactFiles.size;
|
|
const maxEntryBytes = 2 * 1024 * 1024;
|
|
const maxTotalBytes = 4 * 1024 * 1024;
|
|
|
|
const readUInt16 = (offset) => archiveBuffer.readUInt16LE(offset);
|
|
const readUInt32 = (offset) => archiveBuffer.readUInt32LE(offset);
|
|
const findEndOfCentralDirectoryOffset = () => {
|
|
const minimumOffset = Math.max(0, archiveBuffer.length - 0xffff - 22);
|
|
for (let offset = archiveBuffer.length - 22; offset >= minimumOffset; offset -= 1) {
|
|
if (readUInt32(offset) === 0x06054b50) {
|
|
return offset;
|
|
}
|
|
}
|
|
return -1;
|
|
};
|
|
|
|
const endOfCentralDirectoryOffset = findEndOfCentralDirectoryOffset();
|
|
if (endOfCentralDirectoryOffset < 0) {
|
|
core.warning(`Skipping ${artifactName}; ZIP end-of-central-directory record was not found.`);
|
|
return;
|
|
}
|
|
const entryCount = readUInt16(endOfCentralDirectoryOffset + 10);
|
|
const centralDirectorySize = readUInt32(endOfCentralDirectoryOffset + 12);
|
|
const centralDirectoryOffset = readUInt32(endOfCentralDirectoryOffset + 16);
|
|
if (entryCount < 1 || entryCount > maxEntries) {
|
|
core.warning(`Skipping ${artifactName}; artifact has ${entryCount} entries.`);
|
|
return;
|
|
}
|
|
if (
|
|
centralDirectoryOffset + centralDirectorySize > archiveBuffer.length ||
|
|
readUInt32(centralDirectoryOffset) !== 0x02014b50
|
|
) {
|
|
core.warning(`Skipping ${artifactName}; invalid ZIP central directory.`);
|
|
return;
|
|
}
|
|
|
|
const entries = new Map();
|
|
let totalUncompressedSize = 0;
|
|
let offset = centralDirectoryOffset;
|
|
for (let index = 0; index < entryCount; index += 1) {
|
|
if (offset + 46 > archiveBuffer.length || readUInt32(offset) !== 0x02014b50) {
|
|
core.warning(`Skipping ${artifactName}; invalid central directory entry.`);
|
|
return;
|
|
}
|
|
|
|
const compressionMethod = readUInt16(offset + 10);
|
|
const generalPurposeBitFlag = readUInt16(offset + 8);
|
|
const compressedSize = readUInt32(offset + 20);
|
|
const uncompressedSize = readUInt32(offset + 24);
|
|
const fileNameLength = readUInt16(offset + 28);
|
|
const extraLength = readUInt16(offset + 30);
|
|
const commentLength = readUInt16(offset + 32);
|
|
const externalAttributes = readUInt32(offset + 38);
|
|
const nameStart = offset + 46;
|
|
const nameEnd = nameStart + fileNameLength;
|
|
const nextOffset = nameEnd + extraLength + commentLength;
|
|
if (nextOffset > archiveBuffer.length) {
|
|
core.warning(`Skipping ${artifactName}; central directory entry exceeds archive bounds.`);
|
|
return;
|
|
}
|
|
|
|
const name = archiveBuffer.toString("utf8", nameStart, nameEnd);
|
|
const mode = externalAttributes >>> 16;
|
|
const fileType = mode & 0o170000;
|
|
const isRegularFile = fileType === 0 || fileType === 0o100000;
|
|
const invalidName =
|
|
!allowedArtifactFiles.has(name) ||
|
|
name.includes("/") ||
|
|
name.includes("\\") ||
|
|
name.includes("..") ||
|
|
path.isAbsolute(name);
|
|
if (invalidName) {
|
|
core.warning(`Skipping ${artifactName}; unexpected artifact entry ${name}.`);
|
|
return;
|
|
}
|
|
if (!isRegularFile || name.endsWith("/")) {
|
|
core.warning(`Skipping ${artifactName}; ${name} is not a regular file.`);
|
|
return;
|
|
}
|
|
if (entries.has(name)) {
|
|
core.warning(`Skipping ${artifactName}; duplicate artifact entry ${name}.`);
|
|
return;
|
|
}
|
|
if (![0, 8].includes(compressionMethod)) {
|
|
core.warning(`Skipping ${artifactName}; ${name} uses unsupported ZIP compression method ${compressionMethod}.`);
|
|
return;
|
|
}
|
|
if ((generalPurposeBitFlag & 0x1) !== 0) {
|
|
core.warning(`Skipping ${artifactName}; ${name} is encrypted.`);
|
|
return;
|
|
}
|
|
if (compressedSize > maxEntryBytes || uncompressedSize > maxEntryBytes) {
|
|
core.warning(`Skipping ${artifactName}; ${name} exceeds the per-file size limit.`);
|
|
return;
|
|
}
|
|
|
|
totalUncompressedSize += uncompressedSize;
|
|
if (totalUncompressedSize > maxTotalBytes) {
|
|
core.warning(`Skipping ${artifactName}; artifact exceeds the aggregate size limit.`);
|
|
return;
|
|
}
|
|
|
|
entries.set(name, { uncompressedSize });
|
|
offset = nextOffset;
|
|
}
|
|
|
|
const files = new Map();
|
|
for (const [name, entry] of entries) {
|
|
const contents = childProcess.execFileSync("unzip", ["-p", archivePath, name], {
|
|
encoding: "utf8",
|
|
maxBuffer: Math.max(1, entry.uncompressedSize + 1024),
|
|
timeout: 5000,
|
|
});
|
|
if (Buffer.byteLength(contents, "utf8") > maxEntryBytes) {
|
|
core.warning(`Skipping ${artifactName}; ${name} exceeded the per-file size limit while reading.`);
|
|
return;
|
|
}
|
|
files.set(name, contents);
|
|
}
|
|
|
|
const read = (name) => {
|
|
return files.get(name) ?? "";
|
|
};
|
|
|
|
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));
|
|
const validFindings =
|
|
Array.isArray(parsed) &&
|
|
parsed.every(
|
|
(finding) =>
|
|
finding !== null &&
|
|
typeof finding === "object" &&
|
|
!Array.isArray(finding),
|
|
);
|
|
if (validFindings) {
|
|
findings = parsed;
|
|
break;
|
|
}
|
|
} catch {}
|
|
}
|
|
return { findings, status };
|
|
};
|
|
const report = await readReport();
|
|
const status = report?.status ?? 1;
|
|
const findings = report?.findings ?? null;
|
|
|
|
const sanitizeCell = (value) => {
|
|
const normalized = String(value ?? "")
|
|
.replace(/[\u0000-\u001f\u007f-\u009f]/gu, " ")
|
|
.replace(/[\u200b-\u200f\u202a-\u202e\u2060\u2066-\u2069\ufeff]/gu, "")
|
|
.replace(/\s+/gu, " ")
|
|
.trim();
|
|
const maxEncodedLength = 180;
|
|
let escaped = "";
|
|
for (const character of normalized) {
|
|
const encoded =
|
|
character === "`"
|
|
? "'"
|
|
: character === "|"
|
|
? "\\|"
|
|
: character;
|
|
if (escaped.length + encoded.length > maxEncodedLength) {
|
|
break;
|
|
}
|
|
escaped += encoded;
|
|
}
|
|
return `\`${escaped || "-"}\``;
|
|
};
|
|
|
|
const rows = (findings ?? []).map((finding) => {
|
|
const location = String(finding.location ?? "");
|
|
const [file, line] = location.split(":");
|
|
return {
|
|
file: file ? `apps/ios/${file}` : "",
|
|
line: line || "",
|
|
kind: String(finding.kind ?? ""),
|
|
name: String(finding.name ?? ""),
|
|
};
|
|
});
|
|
|
|
let mode = "failure";
|
|
let body = `${marker}\n`;
|
|
if (scanSkipped) {
|
|
mode = "skipped";
|
|
body += [
|
|
"### iOS Periphery",
|
|
"",
|
|
"Periphery scan skipped because the pull request is a draft or no longer touches iOS scan scope.",
|
|
].join("\n");
|
|
} else if (findings === null) {
|
|
body += [
|
|
"### iOS Periphery",
|
|
"",
|
|
"Periphery did not complete or its report could not be safely read. Check the workflow run for details.",
|
|
].join("\n");
|
|
} else if (rows.length === 0 && status === 0) {
|
|
mode = "success";
|
|
body += [
|
|
"### iOS Periphery",
|
|
"",
|
|
"No dead Swift code found.",
|
|
].join("\n");
|
|
} else if (rows.length > 0) {
|
|
const shown = rows.slice(0, 50);
|
|
body += [
|
|
"### iOS Periphery",
|
|
"",
|
|
`Found ${rows.length} dead Swift code ${rows.length === 1 ? "symbol" : "symbols"}. Remove the code or add a narrow Periphery exemption with a comment explaining why it must stay.`,
|
|
"",
|
|
"| File | Line | Kind | Name |",
|
|
"| --- | ---: | --- | --- |",
|
|
...shown.map((row) => `| ${sanitizeCell(row.file)} | ${sanitizeCell(row.line)} | ${sanitizeCell(row.kind)} | ${sanitizeCell(row.name)} |`),
|
|
rows.length > shown.length ? "" : null,
|
|
rows.length > shown.length ? `Showing first ${shown.length}; full JSON is in the workflow artifact.` : null,
|
|
].filter(Boolean).join("\n");
|
|
} else {
|
|
body += [
|
|
"### iOS Periphery",
|
|
"",
|
|
"Periphery exited with a non-zero status before producing findings. Check the workflow artifact for stdout/stderr.",
|
|
].join("\n");
|
|
}
|
|
body += "\n";
|
|
const maxCommentChars = 60_000;
|
|
if (body.length > maxCommentChars) {
|
|
body = [
|
|
marker,
|
|
"### iOS Periphery",
|
|
"",
|
|
`Found ${rows.length} dead Swift code ${rows.length === 1 ? "symbol" : "symbols"}. The rendered report exceeded the safe comment limit; use the workflow artifact for details.`,
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
const comments = await github.paginate(github.rest.issues.listComments, {
|
|
owner,
|
|
repo,
|
|
issue_number: livePull.data.number,
|
|
per_page: 100,
|
|
});
|
|
const existing = comments.find(
|
|
(comment) =>
|
|
comment.user?.login === "github-actions[bot]" &&
|
|
comment.body?.includes(marker),
|
|
);
|
|
|
|
if (!existing && ["skipped", "success"].includes(mode)) {
|
|
core.info(`No existing Periphery comment and scan ${mode}; skipping comment.`);
|
|
return;
|
|
}
|
|
|
|
const currentPull = await github.rest.pulls.get({
|
|
owner,
|
|
repo,
|
|
pull_number: pr.number,
|
|
});
|
|
if (
|
|
currentPull.data.state !== "open" ||
|
|
currentPull.data.base?.repo?.full_name !== repository ||
|
|
currentPull.data.head?.sha !== run.head_sha
|
|
) {
|
|
core.info(`Skipping stale run ${run.id}; PR #${pr.number} changed before comment update.`);
|
|
return;
|
|
}
|
|
|
|
const workflowRuns = await github.paginate(github.rest.actions.listWorkflowRuns, {
|
|
owner,
|
|
repo,
|
|
workflow_id: run.workflow_id,
|
|
event: "pull_request",
|
|
head_sha: run.head_sha,
|
|
per_page: 100,
|
|
});
|
|
const supersedingRun = workflowRuns.find(
|
|
(candidate) =>
|
|
(candidate.id === run.id ||
|
|
candidate.pull_requests?.some(
|
|
(candidatePull) => candidatePull.number === pr.number,
|
|
)) &&
|
|
(candidate.run_number > run.run_number ||
|
|
(candidate.run_number === run.run_number &&
|
|
candidate.run_attempt > run.run_attempt)),
|
|
);
|
|
if (supersedingRun) {
|
|
core.info(`Skipping superseded run ${run.id} attempt ${run.run_attempt}; run ${supersedingRun.id} attempt ${supersedingRun.run_attempt} is newer.`);
|
|
return;
|
|
}
|
|
|
|
if (existing) {
|
|
await github.rest.issues.updateComment({
|
|
owner,
|
|
repo,
|
|
comment_id: existing.id,
|
|
body,
|
|
});
|
|
return;
|
|
}
|
|
|
|
await github.rest.issues.createComment({
|
|
owner,
|
|
repo,
|
|
issue_number: livePull.data.number,
|
|
body,
|
|
});
|