Skip to content
Merged
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
7 changes: 4 additions & 3 deletions .github/MAINTAINERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,9 +141,10 @@ lists, and approve
([runbook](../apps/cli/docs/release-process.md#claude-resolved-develop-into-next)).
Otherwise merge the target into the sync branch, resolve, and push; then
**approve** the PR. Approval fast-forwards the
target and deletes the branch. Never use the merge button. If the target or the
source moved, the bot merges the latest target and source into the approved head and lands
it directly if the merge is clean, as for a clean sync (the merge commit is
target and deletes the branch. Never use the merge button. A moved source does
not block landing; the next sync run brings its newer commits in. If the target
moved, the bot merges the latest target into the approved head and lands it
directly if the merge is clean, as for a clean sync (the merge commit is
untested); only a new conflict, or a target that keeps moving, sends the PR
back for a new approval. Only two sync pairs
exist (`sync/main-into-develop` into `develop`, `sync/develop-into-next` into
Expand Down
11 changes: 11 additions & 0 deletions .github/ai-models.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"$comment": "Model IDs by family and tier. A new model can also need the Claude Code or Codex CLI version pinned in the workflows that read this file bumped.",
"claude": {
"opus": "claude-opus-5-5",
"sonnet": "claude-sonnet-5-5",
"haiku": "claude-haiku-5-5"
},
"codex": {
"sol": "gpt-6.1-sol"
}
}
4 changes: 2 additions & 2 deletions .github/scripts/ai-review/post-review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -603,8 +603,8 @@ export function renderInlineComment(finding: MergedFinding): string {
export interface ReviewFooterInfo {
trigger: Trigger;
runUrl: string;
/** e.g. `` `claude-fable-5` + `gpt-5.6-sol` ``. Sourced from the workflow's
* model env vars so there's one source of truth for the model names. */
/** e.g. `` `claude-fable-5` + `gpt-5.6-sol` ``. Sourced from
* `.github/ai-models.json` through the workflow's model env vars. */
modelsFooter: string;
}

Expand Down
49 changes: 9 additions & 40 deletions .github/scripts/fast-forward.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -733,8 +733,11 @@ describe("runFastForward sync", () => {
expect(() => repo.remoteTip("sync/main-into-develop")).toThrow();
});

test("lands the approved head merged with a non-conflicting target move", async () => {
test("lands the approved head merged with a target move without pulling in the moved source", async () => {
const { repo, head, io, comments } = resolvedSyncScenario();
git(repo.seed, "switch", "main");
const movedMain = repo.commit(repo.seed, "late-main.txt", "late\n", "fix(cli): late main");
git(repo.seed, "push", "origin", "main");
git(repo.seed, "switch", "develop");
const movedDevelop = repo.commit(repo.seed, "late.txt", "late\n", "feat(cli): late");
git(repo.seed, "push", "origin", "develop");
Expand All @@ -745,6 +748,7 @@ describe("runFastForward sync", () => {
expect(outcome).toEqual({ status: "merged-after-resync", sha: landed });
expect(repo.isAncestor(head, landed)).toBe(true);
expect(repo.isAncestor(movedDevelop, landed)).toBe(true);
expect(repo.isAncestor(movedMain, landed)).toBe(false);
expect(() => repo.remoteTip("sync/main-into-develop")).toThrow();
expect(comments).toHaveLength(1);
expect(comments[0]).toContain("moved since approval");
Expand Down Expand Up @@ -846,52 +850,17 @@ describe("runFastForward sync", () => {
expect(comments[0]).toContain("- `shared.txt`");
});

test("lands the approved head merged with a non-conflicting source move", async () => {
const { repo, head, io, comments } = resolvedSyncScenario();
git(repo.seed, "switch", "main");
const movedMain = repo.commit(repo.seed, "late-main.txt", "late\n", "fix(cli): late main");
git(repo.seed, "push", "origin", "main");

const outcome = await runFastForward(io, { reviewCommitId: head });

const landed = repo.remoteTip("develop");
expect(outcome).toEqual({ status: "merged-after-resync", sha: landed });
expect(landed).not.toBe(head);
expect(repo.isAncestor(head, landed)).toBe(true);
expect(repo.isAncestor(movedMain, landed)).toBe(true);
expect(() => repo.remoteTip("sync/main-into-develop")).toThrow();
expect(comments).toHaveLength(1);
expect(comments[0]).toContain("`main` moved since approval");
});

test("refuses and leaves the branch alone when a moved source conflicts", async () => {
test("fast-forwards the approved head even when the source moved with a conflicting change", async () => {
const { repo, head, io, comments } = resolvedSyncScenario();
const developBefore = repo.remoteTip("develop");
git(repo.seed, "switch", "main");
repo.commit(repo.seed, "shared.txt", "conflicting main\n", "fix(cli): conflicting main");
git(repo.seed, "push", "origin", "main");

const outcome = await runFastForward(io, { reviewCommitId: head });

expect(outcome.status).toBe("refused");
expect(repo.remoteTip("sync/main-into-develop")).toBe(head);
expect(repo.remoteTip("develop")).toBe(developBefore);
expect(comments[0]).toContain("`main` moved since approval");
expect(comments[0]).toContain("- `shared.txt`");
});

test("dry run pushes nothing when the source moved cleanly", async () => {
const { repo, head, io, comments } = resolvedSyncScenario();
const developBefore = repo.remoteTip("develop");
git(repo.seed, "switch", "main");
repo.commit(repo.seed, "late-main.txt", "late\n", "fix(cli): late main");
git(repo.seed, "push", "origin", "main");

const outcome = await runFastForward(io, { reviewCommitId: head, dryRun: true });

expect(outcome.status).toBe("dry-run");
expect(repo.remoteTip("develop")).toBe(developBefore);
expect(repo.remoteTip("sync/main-into-develop")).toBe(head);
expect(outcome).toEqual({ status: "fast-forwarded", sha: head });
expect(repo.remoteTip("develop")).toBe(head);
expect(() => repo.remoteTip("sync/main-into-develop")).toThrow();
expect(comments).toEqual([]);
});
});
71 changes: 25 additions & 46 deletions .github/scripts/fast-forward.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,40 +371,31 @@ async function resyncSyncBranch(
promotion: Promotion,
headSha: string,
syncBranch: string,
moved: string,
): Promise<FastForwardOutcome> {
const { git } = io;
const { target, source } = promotion;
const { target } = promotion;
const targetRef = `refs/remotes/origin/${target}`;
let resyncedSha = headSha;

for (let attempt = 1; attempt <= MAX_PUSH_ATTEMPTS; attempt += 1) {
gitOrThrow(git, [
"fetch",
"--no-tags",
"origin",
`+refs/heads/${target}:${targetRef}`,
`+refs/heads/${source}:refs/remotes/origin/${source}`,
]);
gitOrThrow(git, ["fetch", "--no-tags", "origin", `+refs/heads/${target}:${targetRef}`]);
const targetSha = gitOrThrow(git, ["rev-parse", targetRef]);
gitOrThrow(git, ["checkout", "--detach", headSha]);
for (const ref of [target, source]) {
const merge = git(["merge", "--no-edit", `refs/remotes/origin/${ref}`]);
if (merge.status !== 0) {
const files = git(["diff", "--name-only", "--diff-filter=U"]).stdout;
git(["merge", "--abort"]);
const list = files
.split("\n")
.filter(Boolean)
.map((file) => `- \`${file}\``)
.join("\n");
return refuse(
io,
input,
`Merging ${ref} into ${syncBranch} conflicts`,
`\`${moved}\` moved since approval and merging \`${ref}\` into \`${syncBranch}\` conflicts:\n\n${list}\n\nResolve on the branch and re-approve.`,
);
}
const merge = git(["merge", "--no-edit", targetRef]);
if (merge.status !== 0) {
const files = git(["diff", "--name-only", "--diff-filter=U"]).stdout;
git(["merge", "--abort"]);
const list = files
.split("\n")
.filter(Boolean)
.map((file) => `- \`${file}\``)
.join("\n");
return refuse(
io,
input,
`Merging ${target} into ${syncBranch} conflicts`,
`\`${target}\` moved since approval and merging it into \`${syncBranch}\` conflicts:\n\n${list}\n\nResolve on the branch and re-approve.`,
);
}
resyncedSha = gitOrThrow(git, ["rev-parse", "HEAD"]);

Expand All @@ -416,15 +407,15 @@ async function resyncSyncBranch(
if (pushMergedTarget(git, target, targetSha) === "pushed") {
git(["push", "origin", "--delete", syncBranch]);
await io.comment(
`\`${moved}\` moved since approval, so I merged the latest \`${target}\` and \`${source}\` into the approved head and landed it as ${short(resyncedSha)}.`,
`\`${target}\` moved since approval, so I merged the latest \`${target}\` into the approved head and landed it as ${short(resyncedSha)}.`,
);
return { status: "merged-after-resync", sha: resyncedSha };
}
}

gitOrThrow(git, ["push", "origin", `${resyncedSha}:refs/heads/${syncBranch}`]);
await io.comment(
`\`${target}\` kept moving, so I merged the latest \`${target}\` and \`${source}\` into \`${syncBranch}\` (${short(resyncedSha)}). Re-approve once checks pass.`,
`\`${target}\` kept moving, so I merged the latest \`${target}\` into \`${syncBranch}\` (${short(resyncedSha)}). Re-approve once checks pass.`,
);
return { status: "resynced", sha: resyncedSha };
}
Expand Down Expand Up @@ -482,9 +473,6 @@ export async function runFastForward(
const fetchRefs = [
`+refs/heads/${promotion.target}:refs/remotes/origin/${promotion.target}`,
`+refs/pull/${pullRequest.number}/head:refs/ff/pr-head`,
...(promotion.kind === "sync"
? [`+refs/heads/${promotion.source}:refs/remotes/origin/${promotion.source}`]
: []),
];
gitOrThrow(git, ["fetch", "--no-tags", "origin", ...fetchRefs]);

Expand Down Expand Up @@ -515,18 +503,9 @@ export async function runFastForward(
const canFastForward = (): boolean =>
git(["merge-base", "--is-ancestor", targetRef, headSha]).status === 0;

const sourceCurrent = (): boolean =>
promotion.kind !== "sync" ||
git(["merge-base", "--is-ancestor", `refs/remotes/origin/${promotion.source}`, headSha])
.status === 0;

let movedBranch: string | null = null;
if (!canFastForward()) {
movedBranch = promotion.target;
} else if (!sourceCurrent()) {
movedBranch = promotion.source;
}
if (movedBranch === null) {
// Source commits newer than the approved head reach the target through the next sync run.
let targetMoved = !canFastForward();
if (!targetMoved) {
const push = pushSha(io, input, headSha, promotion.target);
if (!push.ok) {
gitOrThrow(git, [
Expand All @@ -538,13 +517,13 @@ export async function runFastForward(
if (canFastForward()) {
throw new Error(`git push to ${promotion.target} failed: ${push.stderr}`);
}
movedBranch = promotion.target;
targetMoved = true;
}
}

if (movedBranch !== null) {
if (targetMoved) {
if (promotion.kind === "sync") {
return resyncSyncBranch(io, input, promotion, headSha, pullRequest.head.ref, movedBranch);
return resyncSyncBranch(io, input, promotion, headSha, pullRequest.head.ref);
}
const recovery =
promotion.kind === "deploy"
Expand Down
22 changes: 22 additions & 0 deletions .github/scripts/sync-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { type CheckRun, REQUIRED_CHECKS } from "./fast-forward.ts";
import { createTestRepo, git, type TestRepo } from "./promotion-test-repo.ts";
import {
buildSuggestions,
failureExcerpt,
leftoverConflictMarkers,
planRepair,
type ReviewThread,
Expand Down Expand Up @@ -340,3 +341,24 @@ describe("leftoverConflictMarkers", () => {
}
});
});

test("failure excerpt keeps an early test failure that the log tail would drop", () => {
const log = [
"2026-10-13T10:48:27.3385710Z supabase:test:integration: \x1b[41m FAIL \x1b[49m src/signing-key.integration.test.ts > flushes telemetry",
"2026-10-13T10:48:27.3386440Z AssertionError: expected 'supabase/.env' to be '.env'",
...Array.from(
{ length: 1000 },
(_, index) => `2026-10-13T10:49:30.0000000Z stack output ${index}`,
),
"2026-10-13T10:49:31.0000000Z ##[error]Process completed with exit code 1.",
].join("\n");

const excerpt = failureExcerpt(log);

expect(excerpt).toContain("AssertionError: expected 'supabase/.env' to be '.env'");
expect(excerpt).toContain("##[error]Process completed with exit code 1.");
expect(excerpt).not.toContain("stack output 100\n");
expect(excerpt).toContain("[...]");
expect(excerpt).not.toContain("\x1b");
expect(excerpt).not.toMatch(/^\S+Z /m);
});
49 changes: 46 additions & 3 deletions .github/scripts/sync-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -584,7 +584,11 @@ async function fetchOpenFindings(
});
}

async function logTail(token: string, repository: string, jobId: number): Promise<string> {
async function jobFailureExcerpt(
token: string,
repository: string,
jobId: number,
): Promise<string> {
const response = await fetch(
`https://api.github.com/repos/${repository}/actions/jobs/${jobId}/logs`,
{
Expand All @@ -594,7 +598,46 @@ async function logTail(token: string, repository: string, jobId: number): Promis
if (!response.ok) {
return `(log unavailable: ${response.status})`;
}
return (await response.text()).split("\n").slice(-LOG_TAIL_LINES).join("\n");
return failureExcerpt(await response.text());
}

const ERROR_CONTEXT_LINES = 40;
const MAX_ERROR_WINDOWS = 10;

/**
* Keeps the lines after each error annotation plus the log tail, because Turbo interleaves packages and the failing
* one's output often ends long before the job does.
*/
export function failureExcerpt(log: string): string {
const lines = log
// oxlint-disable-next-line no-control-regex -- strips ANSI colour codes from job logs
.replace(/\x1b\[[0-9;]*m/g, "")
.split("\n")
.map((line) => line.replace(/^\S+Z /, ""));
const kept = new Set<number>();
let windows = 0;
lines.forEach((line, index) => {
// Turbo prefixes each line with its task name when it streams several packages.
if (windows < MAX_ERROR_WINDOWS && /^(\S+:\s)?\s*(##\[error\]|FAIL\s)/.test(line)) {
windows += 1;
for (let at = index; at < Math.min(index + ERROR_CONTEXT_LINES, lines.length); at += 1) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ Severity: LOW

A failing check can print attacker-controlled text after FAIL or ##[error]; this copies 40 following log lines into the repair agent's context. An injected directive can make the model mark an unresolved AI finding fixed, and Publish uses that result to resolve the GitHub thread, suppressing a security-review signal.
Helpful? Add 👍 / 👎

💡 Fix Suggestion

Suggestion: Treat the CI failure excerpt as untrusted data, not as instructions. Where ci-failures.md is built or interpolated into the repair agent's prompt, wrap the excerpt in a clearly delimited block, such as a fenced block with a random nonce. Tell the model to ignore any directives inside it. Strip or neutralize control sequences and any text that imitates prompt structure. Do not let model output alone decide whether a finding is resolved. Before Publish resolves a GitHub review thread, check independently that the originating check now passes or that the code changed as expected. Keep the log excerpt limited to what the model needs to diagnose the failure. Most of these changes belong where the prompt is assembled and where Publish resolves threads, not at line 623.

kept.add(at);
}
}
});
for (let at = Math.max(0, lines.length - LOG_TAIL_LINES); at < lines.length; at += 1) {
kept.add(at);
}
let previous = -1;
const excerpt: string[] = [];
for (const index of [...kept].sort((a, b) => a - b)) {
if (index !== previous + 1) {
excerpt.push("[...]");
}
excerpt.push(lines[index] ?? "");
previous = index;
}
return excerpt.join("\n");
}

/** Whether an `AI Review` run for this pull request is queued or running; its run name carries the PR number. */
Expand Down Expand Up @@ -688,7 +731,7 @@ async function collect(outputDir: string): Promise<void> {
const failures = await Promise.all(
plan.failures.map(
async (run) =>
`## ${run.name} (${run.conclusion})\n\n\`\`\`\`text\n${await logTail(token, repository, run.id)}\n\`\`\`\`\n`,
`## ${run.name} (${run.conclusion})\n\n\`\`\`\`text\n${await jobFailureExcerpt(token, repository, run.id)}\n\`\`\`\`\n`,
),
);
writeFileSync(join(contextDir, "ci-failures.md"), failures.join("\n") || "No CI job failed.\n");
Expand Down
2 changes: 1 addition & 1 deletion .github/sync-branches/repair-prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ major; `next` collects breaking changes for the following major. The working dir
sync branch: `develop` with `next` merged in and its conflicts already resolved, but some checks
fail. Repository conventions live in `CLAUDE.md`, `AGENTS.md`, and `docs/adr/`. Read these first:

- `ci-failures.md` in the added context directory: the failed checks and the end of their output.
- `ci-failures.md` in the added context directory: the failed checks, the output after each error, and the end of their logs.
- `review-findings.md` in the same directory: the AI review's open findings, numbered.

## Your task
Expand Down
Loading
Loading