fix(ci): allow trusted mobile tooling to trail main (#142432)

This commit is contained in:
Vincent Koc 2026-09-09 03:08:05 +08:00 • committed by GitHub
parent 2c7ed25eed
commit 317cb7c64e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 280 additions and 19 deletions

View file

@ -284,16 +284,79 @@ function readRef(fullRef) {
);
}
function exactForwardComparison(baseSha, headSha, relationshipError) {
let comparison;
try {
comparison = JSON.parse(
gh([
"api",
`repos/${repository}/compare/${baseSha}...${headSha}`,
"--jq",
"{status, ahead_by, behind_by, base_commit: {sha: .base_commit.sha}, merge_base_commit: {sha: .merge_base_commit.sha}}",
]),
);
} catch {
fail("Trusted main ancestry lookup failed.");
}
const expectedKeys = ["ahead_by", "base_commit", "behind_by", "merge_base_commit", "status"];
if (
comparison === null ||
typeof comparison !== "object" ||
Array.isArray(comparison) ||
JSON.stringify(Object.keys(comparison).toSorted()) !== JSON.stringify(expectedKeys) ||
!Number.isSafeInteger(comparison.ahead_by) ||
comparison.ahead_by < 0 ||
!Number.isSafeInteger(comparison.behind_by) ||
comparison.behind_by < 0 ||
comparison.base_commit === null ||
typeof comparison.base_commit !== "object" ||
Array.isArray(comparison.base_commit) ||
JSON.stringify(Object.keys(comparison.base_commit)) !== JSON.stringify(["sha"]) ||
!/^[0-9a-f]{40}$/u.test(comparison.base_commit.sha) ||
comparison.merge_base_commit === null ||
typeof comparison.merge_base_commit !== "object" ||
Array.isArray(comparison.merge_base_commit) ||
JSON.stringify(Object.keys(comparison.merge_base_commit)) !== JSON.stringify(["sha"]) ||
!/^[0-9a-f]{40}$/u.test(comparison.merge_base_commit.sha)
) {
fail("Trusted main ancestry lookup failed.");
}
const identical =
baseSha === headSha &&
comparison.status === "identical" &&
comparison.ahead_by === 0 &&
comparison.behind_by === 0 &&
comparison.base_commit.sha === baseSha &&
comparison.merge_base_commit.sha === baseSha;
const advanced =
baseSha !== headSha &&
comparison.status === "ahead" &&
comparison.ahead_by > 0 &&
comparison.behind_by === 0 &&
comparison.base_commit.sha === baseSha &&
comparison.merge_base_commit.sha === baseSha;
if (!identical && !advanced) {
fail(relationshipError);
}
}
function stableReleaseRefs(expectedBase, expectedTarget, requireCurrentMain) {
const targetFullRef = `refs/heads/${targetRef}`;
const mainBefore = requireCurrentMain ? readRef("refs/heads/main") : null;
const targetBefore = readRef(targetFullRef);
if (requireCurrentMain && mainBefore !== expectedBase) {
fail("Trusted workflow SHA is no longer the exact main branch head.");
}
if (targetBefore !== expectedTarget) {
fail("Mobile release branch no longer points at the approved target SHA.");
}
if (requireCurrentMain) {
exactForwardComparison(
expectedBase,
mainBefore,
"Trusted workflow SHA is not an ancestor of the observed main branch head.",
);
if (readRef("refs/heads/main") !== mainBefore) {
fail("Main changed during trusted ancestry lookup.");
}
}
return () => {
const targetAfter = readRef(targetFullRef);
if (targetAfter !== targetBefore || targetAfter !== expectedTarget) {
@ -301,8 +364,13 @@ function stableReleaseRefs(expectedBase, expectedTarget, requireCurrentMain) {
}
if (requireCurrentMain) {
const mainAfter = readRef("refs/heads/main");
if (mainAfter !== mainBefore || mainAfter !== expectedBase) {
fail("Main changed during initial mobile release authorization.");
exactForwardComparison(
mainBefore,
mainAfter,
"Main did not advance monotonically during mobile release authority validation.",
);
if (readRef("refs/heads/main") !== mainAfter) {
fail("Main changed during trusted ancestry lookup.");
}
}
};

View file

@ -105,10 +105,13 @@ pnpm android:release:upload
`Android Beta Release` is a separate, manual workflow. Dispatch it from trusted
`main` with a canonical `release/YYYY.M.PATCH-mobile` branch and that branch's
exact full commit SHA. The workflow derives the frozen Code SHA from the release
branch and the current trusted Tooling SHA. Every later candidate commit must be
linear and may change only the five generated mobile release metadata files.
All five target files must byte-match regeneration using current trusted tooling
and the frozen Code SHA metadata. Approval of the `android-beta-release`
branch and the dispatch's trusted Tooling SHA. That Tooling SHA must be an
ancestor of the observed `main` head, and `main` may only advance forward while
authority validates the candidate. Divergence, rewind, or an unstable ancestry
lookup fails closed. Every later candidate commit must be linear and may change
only the five generated mobile release metadata files. All five target files
must byte-match regeneration using the dispatch's trusted tooling and the frozen
Code SHA metadata. Approval of the `android-beta-release`
environment gates all access to signing assets, Google Play credentials, and
immutable release-ref mutation.
@ -129,8 +132,11 @@ After the committed edit, the workflow records
the exact candidate SHA. A failed post-upload recording step may be recovered
with the workflow's `record-only` operation, the original failed run ID, and the
same release ref/SHA tuple. Recovery enters `android-beta-release`, executes
only trusted workflow-SHA tooling, and fails on missing artifacts, replay,
moved refs, mismatched digests, or a conflicting immutable ref.
only trusted workflow-SHA tooling, and admits the recovery dispatch against the
current `main` lineage. Candidate regeneration, receipts, and attestations remain
bound to the original upload run's Tooling SHA. Recovery fails on missing
artifacts, replay, moved refs, mismatched digests, divergent or rewound `main`,
or a conflicting immutable ref.
Direct Fastlane entry point:

View file

@ -147,10 +147,13 @@ same path.
`iOS Beta Release` is a separate, manual workflow. Dispatch it from trusted
`main` with a canonical `release/YYYY.M.PATCH-mobile` branch and that branch's
exact full commit SHA. The workflow derives the frozen Code SHA from the release
branch and the current trusted Tooling SHA. Every later candidate commit must be
linear and may change only the five generated mobile release metadata files.
All five target files must byte-match regeneration using current trusted tooling
and the frozen Code SHA metadata. Approval of the `ios-beta-release` environment
branch and the dispatch's trusted Tooling SHA. That Tooling SHA must be an
ancestor of the observed `main` head, and `main` may only advance forward while
authority validates the candidate. Divergence, rewind, or an unstable ancestry
lookup fails closed. Every later candidate commit must be linear and may change
only the five generated mobile release metadata files. All five target files
must byte-match regeneration using the dispatch's trusted tooling and the frozen
Code SHA metadata. Approval of the `ios-beta-release` environment
gates all access to signing assets, App Store Connect credentials, and immutable
release-ref mutation.
@ -180,9 +183,12 @@ intent and records `refs/openclaw/mobile-releases/ios/<app-store-version>-<build
at the exact candidate SHA. A failed post-upload recording step may be recovered
with the workflow's `record-only` operation, the original failed run ID, and the
same release ref/SHA tuple. Recovery enters `ios-beta-release`, executes only
trusted workflow-SHA tooling, and fails on missing artifacts, replay, moved
refs, mismatched digests, or a conflicting immutable ref. App Review and
production promotion remain manual.
trusted workflow-SHA tooling, and admits the recovery dispatch against the
current `main` lineage. Candidate regeneration, receipts, and attestations remain
bound to the original upload run's Tooling SHA. Recovery fails on missing
artifacts, replay, moved refs, mismatched digests, divergent or rewound `main`,
or a conflicting immutable ref. App Review and production promotion remain
manual.
Maintainer recovery path for a fresh clone on the same Mac:

View file

@ -233,6 +233,7 @@ function writeGhShim(binDir: string): void {
fs.writeFileSync(
shim,
`#!/usr/bin/env node
const { spawnSync } = require("node:child_process");
const fs = require("node:fs");
const path = require("node:path");
const args = process.argv.slice(2);
@ -344,6 +345,73 @@ if (endpoint.includes("/collaborators/")) {
: process.env.MOBILE_INTENT_ARTIFACT_NAME,
workflow_run: { id: Number(runId) }
}) + "\\n");
} else if (endpoint.includes("/compare/")) {
const match = endpoint.match(/\\/compare\\/([0-9a-f]{40})\\.\\.\\.([0-9a-f]{40})$/);
const jq = args[args.indexOf("--jq") + 1];
const expectedJq =
"{status, ahead_by, behind_by, base_commit: {sha: .base_commit.sha}, merge_base_commit: {sha: .merge_base_commit.sha}}";
if (!match) {
console.error("malformed compare endpoint", endpoint);
process.exit(72);
}
if (jq !== expectedJq) {
console.error("unexpected compare projection");
process.exit(72);
}
const mode = next("GH_COMPARE_MODES", "auto");
if (mode === "failure") process.exit(73);
if (mode === "malformed") {
process.stdout.write(JSON.stringify({ status: "ahead" }) + "\\n");
process.exit(0);
}
const base = match[1];
const head = match[2];
const repository = path.resolve(process.env.MOBILE_ACTION_PATH, "../../..");
const isAncestor = (from, to) =>
spawnSync("/usr/bin/git", ["-C", repository, "merge-base", "--is-ancestor", from, to]).status === 0;
const count = (range) => {
const result = spawnSync("/usr/bin/git", ["-C", repository, "rev-list", "--count", range], {
encoding: "utf8"
});
if (result.status !== 0) process.exit(result.status || 74);
return Number(result.stdout.trim());
};
let status;
let aheadBy;
let behindBy;
let mergeBase;
if (base === head) {
status = "identical";
aheadBy = 0;
behindBy = 0;
mergeBase = base;
} else if (isAncestor(base, head)) {
status = "ahead";
aheadBy = count(base + ".." + head);
behindBy = 0;
mergeBase = base;
} else if (isAncestor(head, base)) {
status = "behind";
aheadBy = 0;
behindBy = count(head + ".." + base);
mergeBase = head;
} else {
status = "diverged";
aheadBy = count(base + ".." + head);
behindBy = count(head + ".." + base);
const result = spawnSync("/usr/bin/git", ["-C", repository, "merge-base", base, head], {
encoding: "utf8"
});
if (result.status !== 0) process.exit(result.status || 75);
mergeBase = result.stdout.trim();
}
process.stdout.write(JSON.stringify({
ahead_by: aheadBy,
base_commit: { sha: base },
behind_by: behindBy,
merge_base_commit: { sha: mergeBase },
status
}) + "\\n");
} else if (endpoint.includes("/git/ref/heads/main")) {
process.stdout.write(next("GH_MAIN_REFS", process.env.MOBILE_WORKFLOW_SHA) + "\\n");
} else if (endpoint.includes("/git/ref/heads/release/")) {
@ -554,6 +622,13 @@ function advanceTrustedTooling(
return workflowSha;
}
function advanceObservedMain(fixture: Fixture, fromSha: string, branch: string): string {
git(fixture.source, "checkout", "-b", branch, fromSha);
const mainSha = emptyCommit(fixture.source, `advance ${branch}`);
git(fixture.trusted, "fetch", "origin", mainSha);
return mainSha;
}
function runAuthority(fixture: Fixture, phase: string, overrides: NodeJS.ProcessEnv = {}) {
fs.writeFileSync(fixture.outputPath, "");
return spawnSync(process.execPath, ["--experimental-strip-types", fixture.scriptPath, phase], {
@ -781,6 +856,112 @@ describe("mobile release authority", () => {
expect(receipt.workflowSha).toBe(workflowSha);
});
it("authorizes while main advances forward before and during candidate validation", () => {
const fixture = createFixture();
const mainBefore = advanceObservedMain(fixture, fixture.baseSha, "main-before-validation");
const mainAfter = advanceObservedMain(fixture, mainBefore, "main-during-validation");
const result = runAuthority(fixture, "authorize", {
GH_MAIN_REFS: `${mainBefore},${mainBefore},${mainAfter},${mainAfter}`,
});
const comparisons = readGhTrace(fixture.ghLog)
.map(({ args }) => args[1] ?? "")
.filter((endpoint) => endpoint.includes("/compare/"));
expect(result.status, result.stderr).toBe(0);
expect(comparisons).toEqual([
`repos/${REPOSITORY}/compare/${fixture.baseSha}...${mainBefore}`,
`repos/${REPOSITORY}/compare/${mainBefore}...${mainAfter}`,
]);
});
it("admits recovery from current tooling while retaining original receipt tooling", () => {
const fixture = createFixture();
const outputs = authorize(fixture);
signIntentFile(fixture, outputs);
const recovery = prepareRecoveryOverrides(fixture, outputs);
const recoveryWorkflowSha = recovery.MOBILE_WORKFLOW_SHA as string;
const mainBefore = advanceObservedMain(
fixture,
recoveryWorkflowSha,
"recovery-main-before-validation",
);
const mainAfter = advanceObservedMain(fixture, mainBefore, "recovery-main-during-validation");
resetState(fixture);
const result = runAuthority(fixture, "validate-record", {
...recovery,
GH_MAIN_REFS: `${mainBefore},${mainBefore},${mainAfter},${mainAfter}`,
});
const trace = readGhTrace(fixture.ghLog);
const comparisons = trace
.map(({ args }) => args[1] ?? "")
.filter((endpoint) => endpoint.includes("/compare/"));
expect(result.status, result.stderr).toBe(0);
expect(comparisons).toEqual([
`repos/${REPOSITORY}/compare/${recoveryWorkflowSha}...${mainBefore}`,
`repos/${REPOSITORY}/compare/${mainBefore}...${mainAfter}`,
]);
expect(fs.readFileSync(fixture.ghLog, "utf8")).toContain(
`"--source-digest","${fixture.baseSha}"`,
);
});
it("rejects divergent and rewound main transitions", () => {
const divergent = createFixture();
const left = advanceObservedMain(divergent, divergent.baseSha, "main-left");
const right = advanceObservedMain(divergent, divergent.baseSha, "main-right");
const divergence = runAuthority(divergent, "authorize", {
GH_MAIN_REFS: `${left},${left},${right},${right}`,
});
expect(divergence.status).toBe(1);
expect(divergence.stderr).toContain("did not advance monotonically");
const rewound = createFixture();
const workflowSha = advanceTrustedTooling(rewound);
const rewind = runAuthority(rewound, "authorize", {
GH_MAIN_REFS: `${rewound.baseSha},${rewound.baseSha}`,
});
expect(rewind.status).toBe(1);
expect(rewind.stderr).toContain("not an ancestor of the observed main");
expect(workflowSha).not.toBe(rewound.baseSha);
});
it.each(["failure", "malformed"])(
"rejects a %s exact-pair ancestry lookup before candidate output",
(mode) => {
const fixture = createFixture();
const result = runAuthority(fixture, "authorize", {
GH_COMPARE_MODES: mode,
});
expect(result.status).toBe(1);
expect(result.stderr).toContain("Trusted main ancestry lookup failed");
expect(fs.readFileSync(fixture.outputPath, "utf8")).toBe("");
},
);
it("rejects main movement inside either ancestry lookup window", () => {
const before = createFixture();
const beforeMain = advanceObservedMain(before, before.baseSha, "lookup-before");
const beforeMoved = advanceObservedMain(before, beforeMain, "lookup-before-moved");
const unstableBefore = runAuthority(before, "authorize", {
GH_MAIN_REFS: `${beforeMain},${beforeMoved}`,
});
expect(unstableBefore.status).toBe(1);
expect(unstableBefore.stderr).toContain("changed during trusted ancestry lookup");
const after = createFixture();
const afterMain = advanceObservedMain(after, after.baseSha, "lookup-after");
const afterMoved = advanceObservedMain(after, afterMain, "lookup-after-moved");
const unstableAfter = runAuthority(after, "authorize", {
GH_MAIN_REFS: `${after.baseSha},${after.baseSha},${afterMain},${afterMoved}`,
});
expect(unstableAfter.status).toBe(1);
expect(unstableAfter.stderr).toContain("changed during trusted ancestry lookup");
});
it("regenerates from Code SHA metadata with the newer Tooling SHA", () => {
const fixture = createFixture();
advanceTrustedTooling(fixture, {
@ -1508,7 +1689,7 @@ describe("mobile release authority", () => {
GH_MAIN_REFS: `${recoveryWorkflowSha},${OTHER_SHA}`,
});
expect(staleMain.status).toBe(1);
expect(staleMain.stderr).toContain("Main changed during initial mobile release authorization");
expect(staleMain.stderr).toContain("Main changed during trusted ancestry lookup");
resetState(fixture);
const successful = runAuthority(fixture, "validate-record", {