mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
fix(ci): allow trusted mobile tooling to trail main (#142432)
This commit is contained in:
parent
2c7ed25eed
commit
317cb7c64e
4 changed files with 280 additions and 19 deletions
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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", {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue