Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .flue/agents/ci-log-analyst.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { defineAgent } from "@flue/runtime";
import { local } from "@flue/runtime/node";

export default defineAgent(() => ({
model: "anthropic/claude-sonnet-4-6",
thinkingLevel: "high",
sandbox: local(),
instructions: `
You are a CI log analyst for the Cloudflare Workers SDK repository.

Your job is to inspect downloaded GitHub Actions logs, explain why CI failed, and suggest the smallest useful next action for maintainers.

Focus on evidence in the logs. Be especially alert for:
- The "Checks" job failing because oxfmt or formatting checks failed. If the evidence points to formatting only, recommend running \`pnpm run prettify\`.
- Lint, type, changeset, generated-file, snapshot, package dependency, or test failures with concrete commands or files.
- Timed out jobs, runner cancellations, dependency download interruptions, network errors, and other signs of likely CI flakes.

Keep recommendations conservative. Do not claim a fix is certain unless the log evidence is clear. Do not ask to rerun CI unless the failure looks like infrastructure, timeout, cancellation, or another flake.
`.trim(),
}));
29 changes: 29 additions & 0 deletions .flue/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/* eslint-disable turbo/no-undeclared-env-vars -- Flue reads runtime AI Gateway secrets provided by GitHub Actions, not Turborepo cache inputs */
import { registerProvider } from "@flue/runtime";
import { flue } from "@flue/runtime/routing";

const {
CF_AI_GATEWAY_ACCOUNT_ID: accountId,
CF_AI_GATEWAY_NAME: gatewayId,
CF_AI_GATEWAY_TOKEN: gatewayToken,
} = process.env;

function assertEnv(name: string, value: string | undefined): asserts value {
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
}

assertEnv("CF_AI_GATEWAY_ACCOUNT_ID", accountId);
assertEnv("CF_AI_GATEWAY_NAME", gatewayId);
assertEnv("CF_AI_GATEWAY_TOKEN", gatewayToken);

registerProvider("anthropic", {
baseUrl: `https://gateway.ai.cloudflare.com/v1/${accountId}/${gatewayId}/anthropic`,
headers: {
Authorization: `Bearer ${gatewayToken}`,
"cf-aig-authorization": `Bearer ${gatewayToken}`,
},
});

export default flue();
10 changes: 10 additions & 0 deletions .flue/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "ES2024",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true
},
"include": ["**/*.ts"]
}
84 changes: 84 additions & 0 deletions .flue/workflows/ci-log-analysis.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { defineWorkflow } from "@flue/runtime";
import * as v from "valibot";
import ciLogAnalyst from "../agents/ci-log-analyst";

const FindingSchema = v.object({
conclusion: v.picklist(["failure", "timed_out", "cancelled", "unknown"]),
confidence: v.picklist(["low", "medium", "high"]),
evidence: v.array(v.string()),
jobName: v.string(),
likelyCause: v.string(),
suggestedFixes: v.array(v.string()),
summary: v.string(),
});

const AnalysisSchema = v.object({
classification: v.picklist([
"actionable_failure",
"likely_flake",
"infra_or_timeout",
"unknown",
]),
commentMarkdown: v.string(),
failedJobs: v.array(FindingSchema),
overallSummary: v.string(),
prettifyLikelyFix: v.boolean(),
recommendedNextSteps: v.array(v.string()),
timeoutLikelyFlake: v.boolean(),
});

const InputSchema = v.object({
conclusion: v.picklist(["failure", "timed_out"]),
logsDirectory: v.string(),
prNumber: v.number(),
repository: v.string(),
runAttempt: v.number(),
runId: v.string(),
runUrl: v.string(),
workflowName: v.string(),
});

export default defineWorkflow({
agent: ciLogAnalyst,
input: InputSchema,
output: AnalysisSchema,

async run({ harness, input }) {
const session = await harness.session();
const { data } = await session.prompt(
`
Analyze the failed GitHub Actions run for ${input.repository}#${input.prNumber}.

Run metadata:
- Workflow: ${input.workflowName}
- Run ID: ${input.runId}
- Attempt: ${input.runAttempt}
- Conclusion: ${input.conclusion}
- Run URL: ${input.runUrl}
- Downloaded logs directory: ${input.logsDirectory}

Inspect the log files under the downloaded logs directory. Start by listing files, then read the logs for failed or timed-out jobs. You may use shell commands for read-only inspection such as find, sed, rg, grep, tail, or head.

Classify the failure:
- Use "actionable_failure" when the logs point to a code, formatting, config, generated file, changeset, type, lint, or test issue that a contributor should fix.
- Use "likely_flake" when the evidence points to intermittent infrastructure, dependency fetching, runner failure, cancellation, or a timeout with no specific code failure.
- Use "infra_or_timeout" when the failure timed out or was cancelled and the root cause is not clear enough to call it a flake.
- Use "unknown" only when the logs are missing, unreadable, or too ambiguous.

For Checks job failures, look for formatting output from oxfmt, "check:format", "pnpm run check --summarize", or similar. Set prettifyLikelyFix to true only when running \`pnpm run prettify\` is likely to fix the observed failure.

For commentMarkdown:
- Write concise GitHub-flavored Markdown suitable for a PR comment.
- Start with a short summary sentence.
- Include the run link.
- Include one bullet per failed job with evidence and suggested next step.
- Mention \`pnpm run prettify\` only when prettifyLikelyFix is true.
- Keep it under 6000 characters.
- Do not include hidden HTML markers; the workflow adds those.
`.trim(),
{ result: AnalysisSchema }
);

return data;
},
});
10 changes: 10 additions & 0 deletions .github/workflows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,16 @@ Workflow changes should avoid unsuppressed `zizmor` findings. In particular:
- Posts the report and structured summary as a maintainer-facing comment on the issue and applies the suggested labels (validated against existing repo labels). The comment carries a hidden marker so that re-triage updates the existing comment (via `gh`) rather than posting a duplicate. Comments and labels are attributed to the workers-devprod bot via `GH_ACCESS_TOKEN`.
- The AI agent itself runs sandboxed (no shell or network access); all GitHub writes happen in workflow steps from the generated files.

### Analyze CI Failure (analyze-ci-failure.yml)

- Triggers
- The `CI` workflow completes with a `failure` or `timed_out` conclusion on a PR.
- Manual `workflow_dispatch` with a failed workflow run ID and PR number.
- Actions
- Downloads the failed workflow run logs with the read-only `GITHUB_TOKEN`.
- Runs the Flue `ci-log-analysis` workflow against the downloaded logs. The Flue agent uses the checked-out default branch and local CI log files for read-only analysis, with model access routed through the existing Cloudflare AI Gateway secrets.
- Posts or updates a hidden-marker PR comment with the analysis and structured JSON summary. Comments are attributed to the workers-devprod bot via `GH_ACCESS_TOKEN`.

### Generate changesets for dependabot PRs (c3-dependabot-versioning-prs.yml and miniflare-dependabot-versioning-prs.yml)

- Triggers
Expand Down
178 changes: 178 additions & 0 deletions .github/workflows/analyze-ci-failure.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
name: Analyze CI Failure

on:
workflow_run:
workflows: ["CI"]
types: [completed]
workflow_dispatch:
inputs:
run-id:
description: "Failed CI workflow run ID to analyze"
required: true
type: string
pr-number:
description: "PR number to comment on"
required: true
type: number

permissions:
actions: read
contents: read
pull-requests: read

concurrency:
group: ci-log-analysis-${{ github.event.workflow_run.id || inputs.run-id }}
cancel-in-progress: false

jobs:
analyze:
if: >-
github.event_name == 'workflow_dispatch' ||
((github.event.workflow_run.conclusion == 'failure' || github.event.workflow_run.conclusion == 'timed_out') &&
github.event.workflow_run.pull_requests[0].number)
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Resolve run context
id: context
env:
EVENT_NAME: ${{ github.event_name }}
EVENT_CONCLUSION: ${{ github.event.workflow_run.conclusion }}
EVENT_PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }}
EVENT_RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }}
EVENT_RUN_ID: ${{ github.event.workflow_run.id }}
EVENT_RUN_URL: ${{ github.event.workflow_run.html_url }}
EVENT_WORKFLOW_NAME: ${{ github.event.workflow_run.name }}
INPUT_PR_NUMBER: ${{ inputs.pr-number }}
INPUT_RUN_ID: ${{ inputs.run-id }}
run: |
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
echo "conclusion=failure" >> "$GITHUB_OUTPUT"
echo "pr-number=${INPUT_PR_NUMBER}" >> "$GITHUB_OUTPUT"
echo "run-attempt=1" >> "$GITHUB_OUTPUT"
echo "run-id=${INPUT_RUN_ID}" >> "$GITHUB_OUTPUT"
echo "run-url=https://github.com/${GITHUB_REPOSITORY}/actions/runs/${INPUT_RUN_ID}" >> "$GITHUB_OUTPUT"
echo "workflow-name=CI" >> "$GITHUB_OUTPUT"
else
echo "conclusion=${EVENT_CONCLUSION}" >> "$GITHUB_OUTPUT"
echo "pr-number=${EVENT_PR_NUMBER}" >> "$GITHUB_OUTPUT"
echo "run-attempt=${EVENT_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT"
echo "run-id=${EVENT_RUN_ID}" >> "$GITHUB_OUTPUT"
echo "run-url=${EVENT_RUN_URL}" >> "$GITHUB_OUTPUT"
echo "workflow-name=${EVENT_WORKFLOW_NAME}" >> "$GITHUB_OUTPUT"
fi

- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false

- name: Install dependencies
uses: ./.github/actions/install-dependencies
with:
turbo-api: ${{ secrets.TURBO_API }}
turbo-team: ${{ secrets.TURBO_TEAM }}
turbo-token: ${{ secrets.TURBO_TOKEN }}
turbo-signature: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}

- name: Download workflow logs
id: download
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
RUN_ID: ${{ steps.context.outputs.run-id }}
run: |
RUN_DIR="data/ci-failure/${RUN_ID}"
LOGS_DIR="${RUN_DIR}/logs"

mkdir -p "$LOGS_DIR"
gh run view "$RUN_ID" --repo "$REPO" --log > "${LOGS_DIR}/run.log"

echo "logs-dir=${LOGS_DIR}" >> "$GITHUB_OUTPUT"
find "$LOGS_DIR" -type f | sort

- name: Analyze logs with Flue
env:
CF_AI_GATEWAY_ACCOUNT_ID: ${{ secrets.CF_AI_GATEWAY_ACCOUNT_ID }}
CF_AI_GATEWAY_NAME: ${{ secrets.CF_AI_GATEWAY_NAME }}
CF_AI_GATEWAY_TOKEN: ${{ secrets.CF_AI_GATEWAY_TOKEN }}
CONCLUSION: ${{ steps.context.outputs.conclusion }}
LOGS_DIR: ${{ steps.download.outputs.logs-dir }}
PR_NUMBER: ${{ steps.context.outputs.pr-number }}
REPO: ${{ github.repository }}
RUN_ATTEMPT: ${{ steps.context.outputs.run-attempt }}
RUN_ID: ${{ steps.context.outputs.run-id }}
RUN_URL: ${{ steps.context.outputs.run-url }}
WORKFLOW_NAME: ${{ steps.context.outputs.workflow-name }}
run: |
RUN_DIR="data/ci-failure/${RUN_ID}"
INPUT_JSON=$(jq -n \
--arg conclusion "$CONCLUSION" \
--arg logsDirectory "$LOGS_DIR" \
--argjson prNumber "$PR_NUMBER" \
--arg repository "$REPO" \
--argjson runAttempt "$RUN_ATTEMPT" \
--arg runId "$RUN_ID" \
--arg runUrl "$RUN_URL" \
--arg workflowName "$WORKFLOW_NAME" \
'{
conclusion: $conclusion,
logsDirectory: $logsDirectory,
prNumber: $prNumber,
repository: $repository,
runAttempt: $runAttempt,
runId: $runId,
runUrl: $runUrl,
workflowName: $workflowName
}')

pnpm exec flue run ci-log-analysis --target node --input "$INPUT_JSON" > "${RUN_DIR}/analysis.json"
jq . "${RUN_DIR}/analysis.json"

- name: Build PR comment
env:
PR_NUMBER: ${{ steps.context.outputs.pr-number }}
RUN_ID: ${{ steps.context.outputs.run-id }}
run: |
RUN_DIR="data/ci-failure/${RUN_ID}"

{
echo "<!-- workers-sdk-ci-log-analysis -->"
echo "> [!NOTE]"
echo "> Automated CI failure analysis generated by workers-devprod. It is advisory; maintainers should confirm before applying fixes."
echo ""
jq -r ".commentMarkdown" "${RUN_DIR}/analysis.json"
echo ""
echo "<details>"
echo "<summary>Structured analysis (JSON)</summary>"
echo ""
echo '```json'
jq . "${RUN_DIR}/analysis.json"
echo '```'
echo ""
echo "</details>"
} > "${RUN_DIR}/comment.md"

cat "${RUN_DIR}/comment.md"

- name: Post PR comment
env:
GH_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }}
PR_NUMBER: ${{ steps.context.outputs.pr-number }}
REPO: ${{ github.repository }}
RUN_ID: ${{ steps.context.outputs.run-id }}
run: |
MARKER="<!-- workers-sdk-ci-log-analysis -->"
BODY_FILE="data/ci-failure/${RUN_ID}/comment.md"

COMMENT_ID=$(gh api --paginate "/repos/${REPO}/issues/${PR_NUMBER}/comments" \
--jq "map(select(.body | contains(\"${MARKER}\"))) | .[0].id // empty")

if [ -n "$COMMENT_ID" ]; then
jq -n --rawfile body "$BODY_FILE" '{body: $body}' \
| gh api --method PATCH "/repos/${REPO}/issues/comments/${COMMENT_ID}" --input - > /dev/null
echo "Updated existing CI analysis comment ${COMMENT_ID}"
else
gh issue comment "$PR_NUMBER" --repo "$REPO" --body-file "$BODY_FILE"
echo "Created new CI analysis comment"
fi
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"@changesets/changelog-github": "^0.5.0",
"@changesets/cli": "^2.29.7",
"@changesets/parse": "^0.4.1",
"@flue/runtime": "1.0.0-beta.9",
"@manypkg/cli": "^0.23.0",
"@types/node": "catalog:default",
"@vue/compiler-sfc": "^3.3.4",
Expand All @@ -53,8 +54,12 @@
"tree-kill": "catalog:default",
"turbo": "^2.7.2",
"typescript": "catalog:default",
"valibot": "^1.4.2",
"vitest": "catalog:default"
},
"devDependencies": {
"@flue/cli": "1.0.0-beta.9"
},
"engines": {
"node": ">=22.0.0",
"pnpm": "^10.33.0"
Expand Down
Loading
Loading