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
198 changes: 178 additions & 20 deletions .github/scripts/build-all-branch.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@
// theirs-biased fallback for the conflicts strategy options cannot settle
// (modify/delete, directory/file, and `-merge`-attributed paths). A PR whose
// merge cannot complete at all is skipped and reported, never fatal.
// - graphify-out/graph.json normally merges through the repo's graphify union
// driver, which CI does not install; the driver is overridden to `cp %B %A`
// (take theirs) so graph conflicts resolve the same way as everything else.
// The graph/manifest pair may go stale on `all`; nothing runs
// `graphify update` there.
// - `graphify-out/` is generated state, not union source. Every PR merge pins
// that entire subtree back to its pre-merge value, so legacy mutable caches,
// content-addressed snapshots, and root aliases cannot conflict or balloon
// the synthetic branch. The graph/manifest pair is intentionally the
// primary base's snapshot on `all`; nothing runs `graphify update` there.
// - `.github` is pinned to develop's copy: workflows never execute on `all`
// (GITHUB_TOKEN pushes trigger no workflows, and schedules only run from the
// default branch), and the pin keeps the force-push inside the default
Expand Down Expand Up @@ -82,13 +82,21 @@ const DOCTOR_SUBJECT_PREFIX = "all: build doctor";
const MANIFEST_SUBJECT_PREFIX = "all: manifest (";
const MAX_REPLAYED_DOCTOR_COMMITS = 30;

// The fast path keeps the checkout blobless. If Git asks the promisor remote
// for an object GitHub no longer serves lazily, materialize the exact live
// base and PR refs once and retry the affected merge. The refspec list is
// populated from the same live `gh pr list` snapshot used for the build.
let completeRefspecs = [];
let completeRefetchAttempted = false;

// Override the repo's graphify union merge driver (the binary is not
// installed here) with a take-theirs command matching the global bias.
// installed here). The entire derived subtree is pinned to the pre-merge
// state below, so an attributed graph.json should keep ours as well.
const MERGE_CONFIG = [
"-c",
"merge.graphify.name=all-branch take-theirs override",
"merge.graphify.name=all-branch keep-derived-base override",
"-c",
"merge.graphify.driver=cp %B %A",
"merge.graphify.driver=true",
];

const run = (command, args, options = {}) =>
Expand Down Expand Up @@ -119,6 +127,42 @@ const singleLine = (text, max = 120) => {
};
const tableCell = (text) => singleLine(text).replaceAll("|", "\\|");

export function isPromisorFetchFailure(...parts) {
const text = parts.map((part) => String(part ?? "")).join("\n");
return (
/remote error: upload-pack: not our ref [0-9a-f]{40}/i.test(text) ||
/could not fetch [0-9a-f]{40} from promisor remote/i.test(text) ||
/promisor remote[^\n]*failed/i.test(text)
);
}

function refetchCompleteInputs() {
if (completeRefetchAttempted || completeRefspecs.length === 0) return false;
completeRefetchAttempted = true;
console.warn(
`build-all-branch: lazy promisor fetch failed; refetching ${completeRefspecs.length} exact live ref(s) without a blob filter`
);
const refetch = spawnSync(
"git",
[
...gitAuthConfig(),
"fetch",
"--no-tags",
"--force",
"--refetch",
"--no-filter",
"origin",
...completeRefspecs,
],
{ encoding: "utf8", maxBuffer: 64 * 1024 * 1024, stdio: ["ignore", "inherit", "pipe"] }
);
if (refetch.status === 0) return true;
console.error(
`build-all-branch: complete-history refetch failed: ${singleLine(refetch.stderr, 300)}`
);
return false;
}

// Only commits the doctor itself created are replayed onto the next rebuild;
// pins, manifests, and human commits never are. Pure: exercised by --self-test.
export function isReplayableDoctorSubject(subject) {
Expand Down Expand Up @@ -339,32 +383,82 @@ export function renderManifest({ allBranch, baseTips, merges, skipped }) {
return `${lines.join("\n")}\n`;
}

// Graphify output is derived from each source branch independently. It has no
// useful union semantics, and legacy mutable-cache layouts can create
// directory-rename conflicts with the content-addressed layout. Restore the
// complete pre-merge subtree in the index and working tree. This handles both
// conflicted and textually-clean merges, including PR-only generated files.
function restoreDerivedGraphify(sourceRef) {
const derivedPath = "graphify-out";
const sourceHasGraphify = tryGit("cat-file", "-e", `${sourceRef}:${derivedPath}`).status === 0;
const removed = tryGit("rm", "-r", "-q", "-f", "--ignore-unmatch", "--", derivedPath);
if (removed.status !== 0) {
return {
failed: `could not clear derived Graphify subtree: ${singleLine(removed.stderr, 200)}`,
promisorFailure: isPromisorFetchFailure(removed.stdout, removed.stderr),
};
}
if (!sourceHasGraphify) return { restored: true };
const restored = tryGit("checkout", sourceRef, "--", derivedPath);
if (restored.status !== 0) {
return {
failed: `could not restore derived Graphify subtree: ${singleLine(restored.stderr, 200)}`,
promisorFailure: isPromisorFetchFailure(restored.stdout, restored.stderr),
};
}
return { restored: true };
}

// Merge one ref with the newest-wins policy. Returns { result } on success or
// { failed } after cleanly aborting, so one impossible merge never poisons the
// rest of the rebuild.
function mergeRef(ref, message) {
function mergeRefOnce(ref, message) {
const before = git("rev-parse", "HEAD");
const merge = spawnSync(
"git",
[...MERGE_CONFIG, "merge", "--no-ff", "--no-edit", "-X", "theirs", "-m", message, ref],
{ encoding: "utf8", maxBuffer: 64 * 1024 * 1024, stdio: ["ignore", "inherit", "pipe"] }
);
if (merge.status === 0) {
return git("rev-parse", "HEAD") === before
? { result: "already included" }
: { result: "clean merge" };
const unchanged = git("rev-parse", "HEAD") === before;
const pinned = restoreDerivedGraphify(before);
if (pinned.failed) {
git("reset", "--hard", before);
return pinned;
}
if (tryGit("diff", "--cached", "--quiet").status !== 0) {
const amend = tryGit("commit", "--amend", "--no-edit");
if (amend.status !== 0) {
git("reset", "--hard", before);
return { failed: `could not pin derived Graphify subtree: ${singleLine(amend.stderr, 200)}` };
}
}
return unchanged ? { result: "already included" } : { result: "clean merge" };
}
if (isPromisorFetchFailure(merge.stdout, merge.stderr)) {
tryGit("merge", "--abort");
return {
failed: `lazy promisor fetch failed: ${singleLine(merge.stderr, 200)}`,
promisorFailure: true,
};
}
if (tryGit("rev-parse", "-q", "--verify", "MERGE_HEAD").status !== 0) {
return { failed: `merge could not start: ${singleLine(merge.stderr, 200)}` };
}

const pinned = restoreDerivedGraphify(before);
if (pinned.failed) {
tryGit("merge", "--abort");
return pinned;
}

// `-X theirs` already settled every textual conflict; what remains are the
// structural cases (modify/delete, directory/file, -merge attributes).
// Resolve them with the same bias: theirs where a "theirs" version exists,
// deletion where theirs deleted.
const abort = (failed) => {
const abort = (failed, promisorFailure = false) => {
tryGit("merge", "--abort");
return { failed };
return { failed, promisorFailure };
};
const stagesByPath = new Map();
for (const entry of git("ls-files", "-u", "-z").split("\0").filter(Boolean)) {
Expand All @@ -376,11 +470,26 @@ function mergeRef(ref, message) {
}
const resolvedPaths = [];
for (const [path, stages] of stagesByPath) {
const resolved = stages.has("3")
? tryGit("checkout", "--theirs", "--", path).status === 0 &&
tryGit("add", "--", path).status === 0
: tryGit("rm", "-f", "-q", "--ignore-unmatch", "--", path).status === 0;
if (!resolved) return abort(`could not theirs-resolve ${singleLine(path, 160)}`);
const resolution = stages.has("3")
? tryGit("checkout", "--theirs", "--", path)
: tryGit("rm", "-f", "-q", "--ignore-unmatch", "--", path);
const add =
stages.has("3") && resolution.status === 0
? tryGit("add", "--", path)
: null;
const resolved = resolution.status === 0 && (!add || add.status === 0);
if (!resolved) {
const promisorFailure = isPromisorFetchFailure(
resolution.stdout,
resolution.stderr,
add?.stdout,
add?.stderr
);
return abort(
`could not theirs-resolve ${singleLine(path, 160)}`,
promisorFailure
);
}
resolvedPaths.push(path);
}
if (git("ls-files", "-u") !== "") {
Expand All @@ -392,10 +501,26 @@ function mergeRef(ref, message) {
}
const shown = singleLine(resolvedPaths.slice(0, 8).join(", "), 200);
return {
result: `auto-resolved theirs (${resolvedPaths.length} paths: ${shown}${resolvedPaths.length > 8 ? ", …" : ""})`,
result:
resolvedPaths.length === 0
? "pinned generated Graphify state"
: `auto-resolved theirs (${resolvedPaths.length} paths: ${shown}${resolvedPaths.length > 8 ? ", …" : ""}); pinned generated Graphify state`,
};
}

function mergeRef(ref, message) {
const first = mergeRefOnce(ref, message);
if (!first.promisorFailure || !refetchCompleteInputs()) return first;
console.warn(`build-all-branch: retrying ${ref} after complete-history refetch`);
const retry = mergeRefOnce(ref, message);
if (retry.failed) {
return {
failed: `${retry.failed} (complete-history retry did not recover the merge)`,
};
}
return retry;
}

// Re-establish the invariants merges can break: make .github exactly match
// sourceRef's copy (restores content, deletes extras), drop the
// `path~refs_all-build_pr-N` collision artifacts theirs-side checkouts leave
Expand Down Expand Up @@ -487,6 +612,34 @@ function selfTest() {

// Doctor replay-subject and forbidden-path rules.
{
assert.equal(
isPromisorFetchFailure(
"fatal: remote error: upload-pack: not our ref e4714e180e060da862f577de5ab5a63282de7e97"
),
true
);
assert.equal(
isPromisorFetchFailure(
"fatal: could not fetch 16686feec299bb96c2ebece335afb2b299fe41cc from promisor remote"
),
true
);
assert.equal(isPromisorFetchFailure("CONFLICT (content): Merge conflict in app.ts"), false);
const source = readFileSync(new URL(import.meta.url), "utf8");
const mergeBlock = source.slice(
source.indexOf("function mergeRefOnce("),
source.indexOf("function mergeRef(ref,"),
);
assert.equal(
[...mergeBlock.matchAll(/restoreDerivedGraphify\(before\)/g)].length,
2,
"both clean and conflicted PR merges pin the complete derived Graphify subtree",
);
assert.ok(
mergeBlock.indexOf("const pinned = restoreDerivedGraphify(before);") <
mergeBlock.indexOf("const stagesByPath = new Map();"),
"Graphify directory-rename conflicts are removed before path-level source resolution",
);
assert.equal(isReplayableDoctorSubject("all: build doctor round 1 — fix union build (client-build): a.ts"), true);
assert.equal(isReplayableDoctorSubject("all: build doctor — mechanical pnpm lockfile reset to develop"), true);
assert.equal(isReplayableDoctorSubject("all: manifest (61 PRs merged, 4 skipped)"), false);
Expand Down Expand Up @@ -596,6 +749,11 @@ function buildMode() {
});
const { ordered } = plan;
const skipped = [...plan.skipped];
completeRefetchAttempted = false;
completeRefspecs = [
...BASE_BRANCHES.map((branch) => `+refs/heads/${branch}:refs/remotes/origin/${branch}`),
...ordered.map((pr) => `+refs/pull/${pr.number}/head:refs/all-build/pr-${pr.number}`),
];
console.log(
`build-all-branch: ${pullRequests.length} open PRs — merging ${ordered.length}, skipping ${skipped.length}`
);
Expand Down
10 changes: 10 additions & 0 deletions .github/scripts/resolve-pr-conflicts-routing-contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,16 @@ function assertWorkflowSource() {
/trusted\/\.github\/scripts\/graphify-cas\.mjs/u,
"privileged Graphify publication executes only the trusted router",
);
assert.match(
graphifyBlock,
/git clean -qffdx -e trusted\//u,
"Graphify cleanup preserves the local Lopu action until runner post steps finish",
);
assert.doesNotMatch(
graphifyBlock,
/git clean -qffdx\s*\n/u,
"Graphify cleanup cannot delete the protected local-action checkout",
);
assert.match(
graphifyBlock,
/node "\$graphify_stager"/u,
Expand Down
8 changes: 7 additions & 1 deletion .github/workflows/resolve-pr-conflicts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6618,7 +6618,13 @@ jobs:
# model can enumerate and would inject nodes/edges into graph.json).
# That would smuggle model-authored content past the verify step.
git reset -q --hard HEAD
git clean -qffdx
# `trusted/` is an ignored nested checkout that supplies this job's
# local Lopu action. Nested actions can have runner-managed post
# steps, so deleting their action.yml here makes an otherwise
# successful resolution finish with a post-action lifecycle error.
# Keep the fixed control plane while removing every other ignored or
# untracked model-created path before Graphify reads the repository.
git clean -qffdx -e trusted/
# Belt: restore graphify-out from the commit outright.
rm -rf graphify-out
git checkout -q HEAD -- graphify-out/ 2>/dev/null || true
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ every entry is attributed the same way the app changelog attributes them.

## [Unreleased]

- Fixed Lopu's post-resolution cleanup so its ignored `trusted/` controller
checkout survives the Graphify preflight. This keeps the local action
definition available for GitHub's registered post-run steps while every
other ignored or untracked model-created path is still removed.
- Fixed Lopu's `all`-branch rebuild so generated `graphify-out/` state is
pinned to the primary-base snapshot after every PR merge, preventing legacy
cache/snapshot layouts from excluding real source changes or ballooning the
synthetic branch. A failed lazy promisor fetch now also triggers one
complete refetch of the exact live base and PR refs before retrying.
- Fixed Lopu's stack-rebase prompt so the live rebase checkout is explicitly
inspection-only and every bounded related edit is submitted as a verified
scratch copy, preventing model-side writes from tripping the immutable real
Expand Down
Loading
Loading