Skip to content

[ci] Autonomous CI flake triage - #1737

Open
Janpot wants to merge 43 commits into
masterfrom
worktree-claude-flake-fix
Open

[ci] Autonomous CI flake triage#1737
Janpot wants to merge 43 commits into
masterfrom
worktree-claude-flake-fix

Conversation

@Janpot

@Janpot Janpot commented Jul 28, 2026

Copy link
Copy Markdown
Member

Weekly CircleCI flake triage that runs Claude fully autonomously (--dangerously-skip-permissions) with nothing to leak or push.

Four jobs, each holding one credential and passing work through artifacts. fetch has the CircleCI token and downloads the failure logs. triage has only the Anthropic identity token and runs the agent unrestricted. publish has the GitHub write token: it builds the agent's patch into a commit through a throwaway git index — so nothing in the patch runs next to the token — then opens a draft PR and updates a rolling tracking issue. A small resolve-ref job finds the exact mui-public commit so fetch/publish can check out the fetcher script.

The safety is the job split, not a tool allowlist: triage holds no write token and no CircleCI token, so an unrestricted or prompt-injected agent has nothing to steal and nothing to push with. The fix branch's CI reaches no important secrets (those sit behind protected environments), and a person reviews the draft PR — CI on that PR is the real verification.

Exercised end-to-end from a test repo against material-ui's CircleCI: the cross-repo call, the WIF exchange, the agent run, and the publish path all work.

@code-infra-dashboard

code-infra-dashboard Bot commented Jul 28, 2026

Copy link
Copy Markdown

Deploy preview

https://deploy-preview-1737--mui-internal.netlify.app/
QR code for https://deploy-preview-1737--mui-internal.netlify.app/

Bundle size

Total Size Change: 0B(0.00%) - Total Gzip Change: 0B(0.00%)
Files: 69 total (0 added, 0 removed, 0 changed)

Show details for 69 more bundles

@mui/internal-docs-infra/abstractCreateDemoparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/abstractCreateDemoClientparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/abstractCreateStreamparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/abstractCreateTypesparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/ChunkProviderparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/cliparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/CodeControllerContextparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/CodeExternalsContextparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/CodeHighlighterparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/CodeHighlighter/errorsparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/CodeHighlighter/typesparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/CodeProviderparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/CoordinatedLazyparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/CoordinatedLazy/typesparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/createDemoDataparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/createDemoData/typesparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/createSitemapparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/createSitemap/typesparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/useCodeparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/useCodeWindowparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/useCoordinatedparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/useCopierparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/useCrossTabStateparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/useDemoparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/useDemoControllerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/useErrorsparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/useLocalStorageStateparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/usePreferenceparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/useScrollAnchorparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/useSearchparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/useSearch/typesparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/useStreamparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/useStream/typesparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/useTypeparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/useTypesparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/useUrlHashStateparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/internal-docs-infra/withDocsInfraparsed: 0B(0.00%) gzip: 0B(0.00%)
addLineGuttersparsed: 0B(0.00%) gzip: 0B(0.00%)
BuildEngineparsed: 0B(0.00%) gzip: 0B(0.00%)
CodeHighlighterChunkparsed: 0B(0.00%) gzip: 0B(0.00%)
CodeHighlighterClientparsed: 0B(0.00%) gzip: 0B(0.00%)
CodeInitialSourceLoaderparsed: 0B(0.00%) gzip: 0B(0.00%)
CodeSourceLoaderparsed: 0B(0.00%) gzip: 0B(0.00%)
compileCssWithPostcssparsed: 0B(0.00%) gzip: 0B(0.00%)
createFrameparsed: 0B(0.00%) gzip: 0B(0.00%)
createParseSourceWorkerClientparsed: 0B(0.00%) gzip: 0B(0.00%)
EditingEngineparsed: 0B(0.00%) gzip: 0B(0.00%)
embedTransformsparsed: 0B(0.00%) gzip: 0B(0.00%)
enhanceCodeEmphasisparsed: 0B(0.00%) gzip: 0B(0.00%)
findExpandingRangesparsed: 0B(0.00%) gzip: 0B(0.00%)
getHastTextContentparsed: 0B(0.00%) gzip: 0B(0.00%)
grammarLoadersparsed: 0B(0.00%) gzip: 0B(0.00%)
grammarsparsed: 0B(0.00%) gzip: 0B(0.00%)
isFrameSpanparsed: 0B(0.00%) gzip: 0B(0.00%)
loadIsomorphicCodeVariantparsed: 0B(0.00%) gzip: 0B(0.00%)
parseSourceparsed: 0B(0.00%) gzip: 0B(0.00%)
rolldown-runtimeparsed: 0B(0.00%) gzip: 0B(0.00%)
source.cssparsed: 0B(0.00%) gzip: 0B(0.00%)
source.jsparsed: 0B(0.00%) gzip: 0B(0.00%)
source.jsonparsed: 0B(0.00%) gzip: 0B(0.00%)
source.mdxparsed: 0B(0.00%) gzip: 0B(0.00%)
source.shellparsed: 0B(0.00%) gzip: 0B(0.00%)
source.tsparsed: 0B(0.00%) gzip: 0B(0.00%)
source.tsxparsed: 0B(0.00%) gzip: 0B(0.00%)
source.yamlparsed: 0B(0.00%) gzip: 0B(0.00%)
text.html.basicparsed: 0B(0.00%) gzip: 0B(0.00%)
text.mdparsed: 0B(0.00%) gzip: 0B(0.00%)
TransformEngineparsed: 0B(0.00%) gzip: 0B(0.00%)
transpileSourceparsed: 0B(0.00%) gzip: 0B(0.00%)

Details of bundle changes

Performance

Total duration: 17.03 ms ▼-4.44 ms(-20.7%) | Renders: 5 (+0) | Paint: 58.54 ms ▼-18.71 ms(-24.2%)

Test Duration Renders
HeavyList mount 8.02 ms ▼-3.71 ms(-31.6%) 1 (+0)

6 tests within noise — details


Check out the code infra dashboard for more information about this PR.

Janpot added 4 commits July 28, 2026 18:00
Fetch CircleCI data in a workflow step so the token never coexists with the
agent, narrow the Bash allowlist, and split publishing so a git/gh failure
can't swallow the week's report.
Skip the agent entirely on a green week, check for an open PR before paying for
a clone, and pass the CircleCI token on argv instead of staging it on disk.
Teach the skill a pre-fetched mode so the prompt stops overriding it.
A week whose workflows failed without producing a failed job was published as
'0/N failed'; report the real counts and flag it as CircleCI-side. Bound the
agent with a step timeout, since a job timeout cancels the publish steps.
The workflow was parsing summary.txt and re-rendering the skill's report header
in bash, duplicating knowledge only fetch.mjs has. It now writes report.md
itself for all four no-failed-jobs cases; the workflow just checks for it.
@Janpot

Janpot commented Jul 29, 2026

Copy link
Copy Markdown
Member Author

@claude review

@github-actions

This comment was marked as outdated.

Janpot added 15 commits July 29, 2026 10:05
summary.txt and report.md become one result.json: a status of issues/clean/
no-job-failures/no-data, the totals, and either the job list or the finished
report. Consumers read one field instead of parsing text.
The jobs array grew with the corpus (~300B per failure, ~60KB at the --max
ceiling) and nothing read it: the workflow uses status/report and the skill
greps the job files. Counts stay, the list goes.
Each job read is ~4KB per failed step and persists for the rest of the session,
with the loop running until every job matched. Stop at 12 markers (the report
can't show more) and read via a subagent past ~20 jobs.
The >20-file threshold left the orchestrator carrying every log it read on a
small window, and it carries them through the fix phase too. Delegate
unconditionally so its context is flat in the number of classes.
The description claimed repo inference that automation does not use. The two
CIRCLECI_TOKEN tests were the same condition written twice.
The hook existed because a hard stop used to lose the run's report. The publish
steps now run on !cancelled() and the report is written first, so hitting the
cap costs the week's fix rather than the week.
The step was 45 lines of bash building a --token flag, mapping exit codes and
parsing result.json. The fetcher now emits its own ::notice::/::error:: and
classify output under GITHUB_ACTIONS, so the step is one command. Output is
flat, so report.md has one path whoever wrote it.
Nothing parsed it: the report is read from report.md and the skip decision from
the classify step output, so the field was a second copy of the report that no
consumer touched.
The Actions check was at the call sites, which meant the verdict line and the
auth error were suppressed entirely outside CI rather than just unprefixed.
Only the formatting is environment-specific.
A report-only week caused by an open fix PR said nothing about which PR, so the
comment gave no reason for the missing fix. The preflight knew the number and
discarded it.
Restore the warning severity for no-data/no-job-failures, tell a workflow-filter
miss apart from an empty window, verify a subagent's marker before trusting it,
and stop the tracking issue skipping a week when the fetcher fails.
Count error/unauthorized workflows as failures, tolerate transient API errors
instead of losing the week, name the no-work reason at each call site rather
than inferring it, surface the --max cap, refuse a corpus with no log text,
and keep a fix when only the report file is missing.
Replaces every gh CLI call with Octokit: no --json/--jq parsing, core.setOutput
instead of appending to GITHUB_OUTPUT, and try/catch around label creation. The
PR step splits so git stays in bash and the API calls do not.
Truncate step logs at fetch time rather than at use, retry rate limits instead
of dropping the window, resolve three contradictory rules for marker overlap
into one counting formula, and drop a dead step output.
The skill is only ever wanted at mui-public's tip; a pinned ref was an option
nobody needs.
@Janpot

Janpot commented Jul 30, 2026

Copy link
Copy Markdown
Member Author

@claude review

@github-actions

This comment was marked as outdated.

The Bash matcher strips a bare `xargs` and matches the inner command, and stops
stripping at the first flag. So `Bash(xargs grep:*)` matched neither form the
skill runs: `xargs grep -m1` resolves to `grep` and was already covered, while
the counting step's `xargs -r grep` is matched as an `xargs` command and was not
covered at all.
@Janpot Janpot added the scope: code-infra Involves the code-infra product (https://www.notion.so/mui-org/5562c14178aa42af97bc1fa5114000cd). label Jul 30, 2026
Janpot added 3 commits July 30, 2026 12:03
`Edit` and `Write` are separate tools with separate rules: an `Edit` grant does
not authorize `Write`, and an `Edit(<path>)` deny does not restrain it. The
report, PR title and PR body are all new files, so every run would have failed
to produce any of them and published "No report this week" instead. Found by
running the skill end-to-end against these exact lists.

Deny `Write` on the same two paths as `Edit`, since the grant would otherwise
leave `.github/workflows/**` reachable, and warn in the skill that a leading
`OUT=` assignment voids approval for the rest of a compound command.
A path-scoped Edit(<path>) does authorize the Write tool for that path, which is
why claude-review.yml works with one writable file; a bare Edit does not, and
Write(<path>) grants nothing on the allow list. The previous note flattened that
into "Edit never authorizes Write", which reads as a bug in claude-review.yml
where there isn't one.
A report-only run told the agent to write report.md and, two lines later, not to
edit any file. Verified in CI: the agent completed 15 turns with no permission
denials and wrote nothing, so the run published "No report this week" — the one
output a report-only run exists to produce.
Both cost a working run and neither is visible from the failure. A repo created
after 2026-07-15 presents an immutable OIDC subject with numeric owner and repo
IDs, so the federation rule needs that form; and the token exchange happens
inside the CLI, not the action, so a rejected subject produces a step with no
error text at all.
@Janpot Janpot self-assigned this Aug 21, 2026
Janpot added 2 commits August 21, 2026 16:59
Every fix-proposing run logs permission denials. Extracting them shows two kinds: compound commands and shell variables, which the matcher refuses on shape whatever they contain, and attempts to read the CircleCI token file, which must stay refused. Neither wants a new allowlist entry, so tell the agent about the first and correct the token comment, which claimed cat was not path-confined.
Replaces the allowlist-as-security model with isolation: the agent runs
--dangerously-skip-permissions in a container holding no credentials. A sidecar
(proxy.mjs) on the same docker network mints and exchanges the Anthropic WIF
token and injects it; the CircleCI token is spent by a trusted host step before
the container starts; the GitHub write token lives only in a separate publish
job that trusts nothing from the sandbox except a patch it re-vets
(inspect-patch.mjs). A compromised agent can waste model spend and propose a bad
diff to a draft PR, nothing more.

The payload (fetch.mjs, proxy.mjs, bootstrap.sh, prompt.txt, inspect-patch.mjs)
lives tested under .github/sandbox/ and is inlined into the workflow as base64
by generate.mjs, so a caller pinning the workflow by SHA gets the exact code
that runs. The interactive circleci-why-flaky skill is removed; its methodology
folds into the agent prompt and on-demand runs use workflow_dispatch.
@Janpot Janpot changed the title [ci] Add weekly Claude CI flake triage workflow [ci] Autonomous sandboxed CI flake triage Aug 27, 2026
Drops the base64-inlined container sandbox for a plainer design. Three jobs each
hold one credential class and pass work via artifacts: fetch (CircleCI token) →
triage (id-token only, runs claude-code-action --dangerously-skip-permissions) →
publish (GitHub write token, re-vets the patch). Isolation comes from the job
split, not a tool allowlist, so the agent can run unrestricted with nothing to
steal or push. Host-run, no container — acceptable for a public repo on an
ephemeral runner.

The two scripts (fetch, patch inspection) become SHA-pinnable composite actions
under .github/actions/, keeping them in their own tested files with no base64.
Removes the hand-rolled proxy sidecar, bootstrap, and generate.mjs.
@Janpot Janpot changed the title [ci] Autonomous sandboxed CI flake triage [ci] Autonomous CI flake triage (3-job split) Aug 27, 2026
Janpot added 9 commits August 27, 2026 16:23
Callers run the workflow via workflow_call and pin it by SHA; this file in turn
pins its two internal composite actions by SHA (a reusable workflow can't
reference its own SHA, so it's a separate pin bumped on release). Everything is
SHA-pinned end to end.
Replaces the two flake-fetch/flake-publish composite actions with plain scripts
under .github/flake-fix/, fetched by the jobs that need them via a pinned
checkout of mui-public (FLAKE_SRC_REF). Only fetch and publish check out; triage
uses claude-code-action. The publish orchestration (inspect/apply/PR/issue) is
now visible in the workflow rather than hidden in an action — better for
reviewing what runs with the write token.
Everything SHA-pinned end to end: callers pin the workflow; the workflow pins the
script checkout to the commit where .github/flake-fix/ is final.
…rompt

Adds a circleci-branch input so a caller can triage another project's CircleCI while editing its own tree, and injects a report-only instruction into the agent prompt so a report-only run skips the fix step instead of doing wasted work.
Rewrite the issue body each run (Renovate Dependency Dashboard style) instead of appending a weekly comment: one always-current view of what's flaky, quieter for subscribers. History lives in the Actions log and closed fix PRs.
The triage report becomes the dashboard issue body, so steer it terse and jargon-free: one or two plain lines per problem, one example link, a one-line takeaway — no error codes, logs, or exhaustive job lists.
checkout v4.2.2 -> v7.0.1 (the version already used elsewhere in the repo) and upload-artifact v4.6.2 -> v5.0.0, pairing with the download-artifact v5 already in use. Clears the Node 20 deprecation warning.
Fixes the dashboard formatting: single-line bullets (GitHub renders stray newlines as breaks), markdown [example](url) links, no agent-written title (the issue title serves that) or job-count subtitle. The workflow now owns a one-line purpose intro and a footer that says the issue is kept current by the action and must not be edited.
@Janpot

Janpot commented Aug 27, 2026

Copy link
Copy Markdown
Member Author

@claude review

@github-actions

This comment was marked as resolved.

Janpot added 2 commits August 28, 2026 12:40
Workflow (Bugs 1-6, Docs-1):
- Bug 2: drop FLAKE_SRC_REF; a resolve-ref job reads the run's referenced_workflows
  to get the exact mui-public commit the workflow was called at (fallback github.sha),
  so the scripts always match the workflow version with nothing to hand-maintain.
- Bug 3: the fetch step's inputs go through env, not interpolated into the shell.
- Bug 5: the publish precheck now also requires pr-title.txt.
- Bug 6: the agent stages its fix and writes fix.patch itself; the git add -A capture
  is gone, so install/test byproducts can't leak into the patch.
- Bug 1 / Docs-1: header restated plainly and accurately — the real containment is
  environment-gated secrets + branch-secretless CI, not the guard; caller precondition
  documented.

Scripts:
- Bug 1: the guard also blocks .github/actions/**, and its message states policy
  (Docs-2). Parses with core.quotePath=false. Refactored to export inspectPatch /
  destinationPath so they can be unit-tested.
- Simplifications: PAD_WIDTH constant; --max/maxJobs renamed to --max-workflows/
  maxWorkflows to match what it caps.

Tests (Tests 1-3): converted to vitest and registered .github/flake-fix as a vitest
project so pnpm test runs them; added the missing guard cases (composite-action edit,
rename into .github/actions, .git write, MAX_FILES) and a direct destinationPath unit
test; tightened the oversized-patch assertion. Dropped the node:test eslint override.
inspect-patch.mjs is a CLI that prints its result to stdout; disable no-console in the file rather than carving out .github/flake-fix in the shared eslint config. fetch.mjs needs nothing (it only uses console.error).
@Janpot Janpot changed the title [ci] Autonomous CI flake triage (3-job split) [ci] Autonomous CI flake triage Aug 28, 2026
Janpot added 4 commits August 28, 2026 13:20
…regex

util.stripVTControlCharacters handles OSC sequences (e.g. terminal hyperlinks) that the old CSI-only regex missed, and drops the no-control-regex eslint-disable. Built-in, no dependency.
Per review discussion, the safety is that a fix branch's CI holds no important
secrets and a person reviews the draft PR — not path restrictions. So:

- The patch may change any file. inspect-patch.mjs drops the path/rename rules and
  only checks the patch is valid and not a runaway diff (size caps). destinationPath
  and its tests are gone.
- The publish job no longer applies the patch into the working copy it operates in
  with the write token. It builds the fix commit through a throwaway git index
  (read-tree, apply --cached, write-tree, commit-tree), so the changed files are
  never written to disk and nothing in the patch can run; the token is used only on
  the final push, and the target checkout uses persist-credentials: false.
Its validity check was redundant with the publish job's git apply --cached --check,
and with path rules gone its only unique job was a size cap — now a one-line
file-count check inline. The publish job no longer checks out any script, so it drops
the scripts checkout and its resolve-ref dependency.
@Janpot
Janpot marked this pull request as ready for review August 28, 2026 14:28
@Janpot
Janpot requested a review from a team August 28, 2026 14:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope: code-infra Involves the code-infra product (https://www.notion.so/mui-org/5562c14178aa42af97bc1fa5114000cd).

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant