From 054495d16dd6c161a3afc1429d299b060dc1676d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Jul 2026 02:25:15 -0400 Subject: [PATCH] fix(ci): verify stale bugs before closure (#114365) --- .github/workflows/clawsweeper-dispatch.yml | 9 +- .github/workflows/stale.yml | 293 ++++++++++++++++++++- docs/ci.md | 2 + scripts/test-projects.test-support.mjs | 1 + test/scripts/ci-workflow-guards.test.ts | 70 +++++ test/scripts/test-projects.test.ts | 1 + 6 files changed, 371 insertions(+), 5 deletions(-) diff --git a/.github/workflows/clawsweeper-dispatch.yml b/.github/workflows/clawsweeper-dispatch.yml index bf398467c751..786a1e3342a7 100644 --- a/.github/workflows/clawsweeper-dispatch.yml +++ b/.github/workflows/clawsweeper-dispatch.yml @@ -30,7 +30,14 @@ jobs: (github.actor != 'clawsweeper[bot]' && github.actor != 'openclaw-clawsweeper[bot]')) && !( endsWith(github.actor, '[bot]') && - (github.event.action == 'labeled' || github.event.action == 'unlabeled') + (github.event.action == 'labeled' || github.event.action == 'unlabeled') && + !( + github.event_name == 'issues' && + github.event.action == 'labeled' && + github.event.label.name == 'stale' && + contains(github.event.issue.labels.*.name, 'bug') && + (github.actor_id == '257215752' || github.actor_id == '264559031') + ) ) }} env: diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index b35fe04dad23..fc640a5a1e4e 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -74,7 +74,7 @@ jobs: days-before-pr-close: 7 stale-issue-label: stale stale-pr-label: stale - exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle,clawsweeper:queueable-fix,clawsweeper:source-repro,clawsweeper:fix-shape-clear + exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle,bug,clawsweeper:queueable-fix,clawsweeper:source-repro,clawsweeper:fix-shape-clear exempt-pr-labels: maintainer,no-stale,bad-barnacle operations-per-run: 2000 ascending: true @@ -106,7 +106,7 @@ jobs: days-before-pr-stale: -1 days-before-pr-close: -1 stale-issue-label: stale - exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle,clawsweeper:queueable-fix,clawsweeper:source-repro,clawsweeper:fix-shape-clear + exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle,bug,clawsweeper:queueable-fix,clawsweeper:source-repro,clawsweeper:fix-shape-clear operations-per-run: 2000 ascending: true include-only-assigned: true @@ -178,7 +178,7 @@ jobs: days-before-pr-close: 7 stale-issue-label: stale stale-pr-label: stale - exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle,clawsweeper:queueable-fix,clawsweeper:source-repro,clawsweeper:fix-shape-clear + exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle,bug,clawsweeper:queueable-fix,clawsweeper:source-repro,clawsweeper:fix-shape-clear exempt-pr-labels: maintainer,no-stale,bad-barnacle operations-per-run: 2000 ascending: true @@ -209,7 +209,7 @@ jobs: days-before-pr-stale: -1 days-before-pr-close: -1 stale-issue-label: stale - exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle,clawsweeper:queueable-fix,clawsweeper:source-repro,clawsweeper:fix-shape-clear + exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle,bug,clawsweeper:queueable-fix,clawsweeper:source-repro,clawsweeper:fix-shape-clear operations-per-run: 2000 ascending: true include-only-assigned: true @@ -246,6 +246,190 @@ jobs: If you believe this PR should be revived, post in #clawtributors on Discord to talk to a maintainer. That channel is the escape hatch for high-quality PRs that get auto-closed. + stale-bug-verification: + if: ${{ github.event_name != 'workflow_dispatch' || inputs.backfill_stale_closures != true }} + permissions: + issues: write + runs-on: ubuntu-24.04 + steps: + - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + id: app-token + continue-on-error: true + with: + app-id: "2729701" + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + permission-issues: write + permission-metadata: read + - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + id: app-token-fallback + continue-on-error: true + with: + app-id: "2971289" + private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }} + permission-issues: write + permission-metadata: read + - name: Mark inactive bugs for ClawSweeper verification + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }} + retries: 3 + script: | + const dayMs = 24 * 60 * 60 * 1000; + const nowMs = Date.now(); + const maxMarks = 25; + const barnacleActorIds = new Set([257215752, 264559031]); + const exemptLabels = new Set([ + "enhancement", + "maintainer", + "pinned", + "security", + "no-stale", + "bad-barnacle", + "clawsweeper:queueable-fix", + "clawsweeper:source-repro", + "clawsweeper:fix-shape-clear", + ]); + const { owner, repo } = context.repo; + const marked = []; + const unmarked = []; + + const labelNames = item => + new Set( + (item.labels || []).map(label => + (typeof label === "string" ? label : label.name).toLowerCase(), + ), + ); + const eventTimestamp = event => + Date.parse(event.created_at || event.submitted_at || ""); + const isBarnacleStaleComment = event => + event.event === "commented" && + barnacleActorIds.has(event.actor?.id || event.user?.id || 0) && + String(event.body || "").includes("marked as stale"); + const hasSubstantiveUpdateSinceStale = (item, timeline) => { + const orderedTimeline = timeline + .map((event, index) => ({ event, index, timestamp: eventTimestamp(event) })) + .filter(entry => Number.isFinite(entry.timestamp)) + .toSorted( + (left, right) => left.timestamp - right.timestamp || left.index - right.index, + ); + const staleEventIndex = orderedTimeline.findLastIndex( + entry => + entry.event.event === "labeled" && + String(entry.event.label?.name || "").toLowerCase() === "stale", + ); + if (staleEventIndex < 0) return false; + const staleAtMs = orderedTimeline[staleEventIndex].timestamp; + const updatedAtMs = Date.parse(item.updated_at); + if (!Number.isFinite(updatedAtMs)) return false; + const laterEvents = orderedTimeline + .slice(staleEventIndex + 1) + .map(entry => entry.event); + if (laterEvents.some(event => !isBarnacleStaleComment(event))) return true; + const lastAutomationAtMs = Math.max(staleAtMs, ...laterEvents.map(eventTimestamp)); + return updatedAtMs > lastAutomationAtMs; + }; + const removeStale = async item => { + try { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: item.number, + name: "stale", + }); + unmarked.push(item); + } catch (error) { + if (error.status !== 404) throw error; + } + }; + + for await (const response of github.paginate.iterator(github.rest.issues.listForRepo, { + owner, + repo, + state: "open", + labels: "bug", + sort: "updated", + direction: "asc", + per_page: 100, + })) { + for (const listedItem of response.data) { + if (listedItem.pull_request) continue; + const listedLabels = labelNames(listedItem); + const isExempt = [...listedLabels].some(label => exemptLabels.has(label)); + + if (listedLabels.has("stale")) { + if (isExempt) { + await removeStale(listedItem); + continue; + } + const timeline = await github.paginate( + github.rest.issues.listEventsForTimeline, + { owner, repo, issue_number: listedItem.number, per_page: 100 }, + ); + if (hasSubstantiveUpdateSinceStale(listedItem, timeline)) { + await removeStale(listedItem); + } + // Existing stale bugs are intentionally not re-labeled or + // dispatched; #114238 requires separate backfill approval. + continue; + } + + if (isExempt || marked.length >= maxMarks) continue; + const assigned = (listedItem.assignees || []).length > 0; + const staleAfterDays = assigned ? 30 : 14; + if (Date.parse(listedItem.updated_at) >= nowMs - staleAfterDays * dayMs) continue; + + // Re-read immediately before mutation so a recent comment, + // assignment, or exemption cannot be raced by the scan. + const { data: item } = await github.rest.issues.get({ + owner, + repo, + issue_number: listedItem.number, + }); + const currentLabels = labelNames(item); + const currentlyExempt = [...currentLabels].some(label => exemptLabels.has(label)); + const currentlyAssigned = (item.assignees || []).length > 0; + const currentStaleAfterDays = currentlyAssigned ? 30 : 14; + if ( + item.state !== "open" || + currentLabels.has("stale") || + !currentLabels.has("bug") || + currentlyExempt || + Date.parse(item.updated_at) >= nowMs - currentStaleAfterDays * dayMs + ) { + continue; + } + + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: item.number, + labels: ["stale"], + }); + const body = [ + currentlyAssigned + ? "This assigned bug report has been automatically marked as stale after 30 days of inactivity." + : "This bug report has been automatically marked as stale due to inactivity.", + "ClawSweeper is checking whether current `main` already fixes the reported behavior.", + "Inactivity alone will not close a bug report.", + ].join("\n"); + await github.rest.issues.createComment({ + owner, + repo, + issue_number: item.number, + body, + }); + marked.push(item); + } + } + + core.info(`Marked ${marked.length} bug issue(s) for ClawSweeper verification.`); + core.info(`Removed stale from ${unmarked.length} updated or exempt bug issue(s).`); + await core.summary + .addHeading("Bug stale verification") + .addRaw(`Marked for ClawSweeper review: ${marked.length}\n\n`) + .addRaw(`Returned to active/exempt state: ${unmarked.length}\n\n`) + .write(); + backfill-stale-closures: if: ${{ github.event_name == 'workflow_dispatch' && inputs.backfill_stale_closures == true }} permissions: @@ -286,6 +470,7 @@ jobs: "security", "no-stale", "bad-barnacle", + "bug", "clawsweeper:queueable-fix", "clawsweeper:source-repro", "clawsweeper:fix-shape-clear", @@ -497,6 +682,106 @@ jobs: } } + audit-bug-closure-reasons: + needs: stale + if: ${{ github.event_name != 'workflow_dispatch' || inputs.backfill_stale_closures != true }} + permissions: + issues: read + runs-on: ubuntu-24.04 + steps: + - name: Reject Barnacle NOT_PLANNED bug closures + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + github-token: ${{ github.token }} + script: | + const auditWindowMs = 48 * 60 * 60 * 1000; + const cutoffMs = Date.now() - auditWindowMs; + // Immutable GitHub bot account ids for openclaw-barnacle[bot] and + // barnacle-openclaw[bot]. Logins are display-only and spoofable. + const barnacleActorIds = new Set([257215752, 264559031]); + const { owner, repo } = context.repo; + const violations = []; + const escapeSummaryCell = value => + String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); + + pages: for await (const response of github.paginate.iterator( + github.rest.issues.listForRepo, + { + owner, + repo, + state: "closed", + sort: "updated", + direction: "desc", + per_page: 100, + }, + )) { + const items = response.data; + + for (const item of items) { + const updatedAtMs = Date.parse(item.updated_at); + if (Number.isFinite(updatedAtMs) && updatedAtMs < cutoffMs) break pages; + if (item.pull_request || item.state_reason !== "not_planned") continue; + const closedAtMs = Date.parse(item.closed_at || ""); + if (!Number.isFinite(closedAtMs) || closedAtMs < cutoffMs) continue; + const labels = (item.labels || []).map(label => + typeof label === "string" ? label : label.name, + ); + if (!labels.includes("bug")) continue; + + const timeline = await github.paginate(github.rest.issues.listEventsForTimeline, { + owner, + repo, + issue_number: item.number, + per_page: 100, + }); + const closeEvent = timeline + .filter(event => event.event === "closed") + .toSorted((left, right) => + Date.parse(left.created_at || "") - Date.parse(right.created_at || ""), + ) + .at(-1); + if (!closeEvent || !barnacleActorIds.has(closeEvent.actor?.id || 0)) continue; + violations.push({ + number: item.number, + title: item.title, + actor: closeEvent.actor.login, + url: item.html_url, + }); + } + } + + if (violations.length === 0) { + core.info("No Barnacle NOT_PLANNED bug closures found in the last 48 hours."); + return; + } + + await core.summary + .addHeading("Barnacle bug closure invariant failed") + .addRaw( + "Bug-labeled issues must be verified by ClawSweeper instead of closed by Barnacle as not planned.\n\n", + ) + .addTable([ + [ + { data: "Issue", header: true }, + { data: "Title", header: true }, + { data: "Actor", header: true }, + ], + ...violations.map(violation => [ + `[#${violation.number}](${violation.url})`, + escapeSummaryCell(violation.title), + violation.actor, + ]), + ]) + .write(); + core.setFailed( + `Found ${violations.length} Barnacle NOT_PLANNED bug closure(s) in the last 48 hours.`, + ); + lock-closed-issues: needs: stale if: ${{ github.event_name != 'workflow_dispatch' || inputs.backfill_stale_closures != true }} diff --git a/docs/ci.md b/docs/ci.md index 74168153a202..ea4df11538e0 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -152,6 +152,8 @@ General activity is observation, not delivery-by-default. The ClawSweeper agent Treat GitHub titles, comments, bodies, review text, branch names, and commit messages as untrusted data throughout this path. They are input for summarization and triage, not instructions for the workflow or agent runtime. +Barnacle treats bug-labeled issues as verification candidates rather than inactivity-close candidates. It may add the `stale` label, which dispatches one exact ClawSweeper review, but it cannot close that issue. ClawSweeper may then apply an evidence-backed resolution; a proven fix on current `main` closes as completed, while current or inconclusive bugs stay open. The stale workflow also audits recent close events and fails when a Barnacle identity closes a bug as `not_planned`. + ## Manual dispatches Manual CI dispatches run the same job graph as normal CI but force every non-Android scoped lane on: Linux Node shards, bundled-plugin shards, plugin and channel contract shards, Node 22 compatibility, `check-*`, `check-additional-*`, built-artifact smoke checks, docs checks, Python skills, Windows, macOS, iOS build, and Control UI/native app i18n. Automatic source PRs verify native extraction inventory and Android/Apple localization safety without requiring translated or platform-generated output in the same PR. The serialized Native App Locale Refresh workflow rebuilds those artifacts in one isolated PR and enables exact-head auto-merge after required checks pass. Full native parity remains blocking for generated-artifact PRs, manual CI, Full Release Validation, and release prep. Control UI locale parity remains advisory on automatic PR and `main` runs and blocking on manual/release CI. Standalone manual CI dispatches run Android only with `include_android=true` (the `release_gate` input also forces Android); the full release umbrella enables Android by passing `include_android=true`. Plugin prerelease static checks, the release-only `agentic-plugins` shard, the full extension batch sweep, and plugin prerelease Docker lanes are excluded from CI. The Docker prerelease suite runs only when `Full Release Validation` dispatches the separate `Plugin Prerelease` workflow with the release-validation gate enabled. diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index e3194842ccff..39452b948974 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -749,6 +749,7 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([ [".github/workflows/clawsweeper-dispatch.yml", ["test/scripts/ci-workflow-guards.test.ts"]], [".github/workflows/labeler.yml", ["test/scripts/ci-workflow-guards.test.ts"]], [".github/workflows/real-behavior-proof.yml", ["test/scripts/ci-workflow-guards.test.ts"]], + [".github/workflows/stale.yml", ["test/scripts/ci-workflow-guards.test.ts"]], [ ".github/workflows/security-sensitive-guard.yml", ["test/scripts/security-sensitive-guard-workflow.test.ts"], diff --git a/test/scripts/ci-workflow-guards.test.ts b/test/scripts/ci-workflow-guards.test.ts index b9481e47e142..678b5b6f1e9c 100644 --- a/test/scripts/ci-workflow-guards.test.ts +++ b/test/scripts/ci-workflow-guards.test.ts @@ -48,6 +48,7 @@ const MATURITY_GENERATED_PR_PATHS = [ type WorkflowStep = { env?: Record; + id?: string; if?: string; name?: string; run?: string; @@ -790,6 +791,75 @@ describe("ci workflow guards", () => { }); }); + it("routes stale bug issues through ClawSweeper instead of Barnacle closure", () => { + const staleWorkflow = readWorkflow(".github/workflows/stale.yml"); + const staleSteps = staleWorkflow.jobs.stale.steps as WorkflowStep[]; + const stepNamed = (name: string) => + expectDefined( + staleSteps.find((step) => step.name === name), + name, + ); + + for (const name of [ + "Mark stale unassigned issues and pull requests (primary)", + "Mark stale assigned issues (primary)", + "Mark stale unassigned issues and pull requests (fallback)", + "Mark stale assigned issues (fallback)", + ]) { + const exemptLabels = String(stepNamed(name).with?.["exempt-issue-labels"]) + .split(",") + .map((label) => label.trim()); + expect(exemptLabels, name).toContain("bug"); + } + + const bugJob = staleWorkflow.jobs["stale-bug-verification"]; + expect(bugJob.permissions).toEqual({ issues: "write" }); + expect(bugJob["runs-on"]).toBe("ubuntu-24.04"); + const bugScript = String( + (bugJob.steps as WorkflowStep[]).find( + (step) => step.name === "Mark inactive bugs for ClawSweeper verification", + )?.with?.script, + ); + expect(bugScript).toContain("const maxMarks = 25;"); + expect(bugScript).toContain('labels: "bug"'); + expect(bugScript).toContain("github.rest.issues.addLabels"); + expect(bugScript).toContain("github.rest.issues.removeLabel"); + expect(bugScript).toContain("Inactivity alone will not close a bug report."); + expect(bugScript).toContain("requires separate backfill approval"); + expect(bugScript).toContain("slice(staleEventIndex + 1)"); + expect(bugScript).toContain("updatedAtMs > lastAutomationAtMs"); + expect(bugScript).toContain('item.state !== "open"'); + expect(bugScript).not.toContain("15_000"); + expect(bugScript).not.toContain("github.rest.issues.update"); + + const backfillScript = String( + (staleWorkflow.jobs["backfill-stale-closures"].steps as WorkflowStep[]).find( + (step) => step.name === "Backfill stale closures", + )?.with?.script, + ); + expect(backfillScript).toMatch(/issueExemptLabels[\s\S]*"bug"/); + + const dispatchWorkflow = readWorkflow(".github/workflows/clawsweeper-dispatch.yml"); + const dispatchCondition = String(dispatchWorkflow.jobs.dispatch.if); + expect(dispatchCondition).toContain("github.event.label.name == 'stale'"); + expect(dispatchCondition).toContain("contains(github.event.issue.labels.*.name, 'bug')"); + expect(dispatchCondition).toContain("github.actor_id == '257215752'"); + expect(dispatchCondition).toContain("github.actor_id == '264559031'"); + + const auditJob = staleWorkflow.jobs["audit-bug-closure-reasons"]; + expect(auditJob.permissions).toEqual({ issues: "read" }); + const auditScript = String((auditJob.steps as WorkflowStep[])[0]?.with?.script); + expect(auditScript).toContain('item.state_reason !== "not_planned"'); + expect(auditScript).toContain("github.rest.issues.listEventsForTimeline"); + expect(auditScript).toContain("github.paginate.iterator("); + expect(auditScript).toContain("new Set([257215752, 264559031])"); + expect(auditScript).toContain("escapeSummaryCell(violation.title)"); + expect(auditScript).toContain('.replaceAll("<", "<")'); + expect(auditScript).toContain("core.setFailed("); + expect(auditScript).not.toContain("github.rest.issues.update"); + expect(auditScript).not.toContain("github.rest.issues.createComment"); + }); + it("makes the hosted release-gate fallback explicit and exact-SHA only", () => { const workflow = readCiWorkflow(); const releaseGate = workflow.on.workflow_dispatch.inputs.release_gate; diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index 2b948aecebb3..2cfc1b3cf72b 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -1323,6 +1323,7 @@ describe("scripts/test-projects changed-target routing", () => { ".github/workflows/clawsweeper-dispatch.yml", ".github/workflows/labeler.yml", ".github/workflows/real-behavior-proof.yml", + ".github/workflows/stale.yml", ]) { expect(resolveChangedTestTargetPlan([workflowPath])).toEqual({ mode: "targets",