fix(release): preserve draft attempt history

This commit is contained in:
tianma-if committed 2026-08-31 18:27:36 +08:00
1 parent 4793004e20
commit 8831594919
5 files changed
+387 -19

No files matched your search

+189 -15
View File
@@ -632,6 +632,81 @@ const listWorkflowRuns = ({ repository, workflow, event }) => ghJson([
const releaseCheckpointMarker = (tag) =>
`<!-- edgeever-release-checkpoint:${tag}\n`;
const CHECKPOINT_RUN_FIELDS = [
"desktopRunId",
"mobileRunId",
"dockerRunId",
"storeRunId",
"storeRecoveryRunId",
"androidPlaySignatureRunId",
"windowsUpdateAuditRunId",
];
const CANCELLABLE_CHECKPOINT_RUN_FIELDS = new Set([
"desktopRunId",
"mobileRunId",
"dockerRunId",
"androidPlaySignatureRunId",
"windowsUpdateAuditRunId",
]);
export const recordCheckpointRun = (checkpoint, field, runId) => {
if (!CHECKPOINT_RUN_FIELDS.includes(field)) {
throw new Error(`Unsupported checkpoint Run field: ${field}`);
}
checkpoint[field] = Number(runId);
checkpoint.runHistory ??= [];
if (!checkpoint.runHistory.some((entry) => Number(entry.runId) === Number(runId))) {
checkpoint.runHistory.push({
field,
runId: Number(runId),
releaseSha: checkpoint.releaseSha,
});
}
return Number(runId);
};
export const checkpointRunIds = (checkpoint) => [...new Set([
...(checkpoint.runHistory ?? []).map((entry) => Number(entry.runId)),
...CHECKPOINT_RUN_FIELDS.map((field) => Number(checkpoint[field])),
].filter((runId) => Number.isInteger(runId) && runId > 0))];
export const prepareReleaseCheckpoint = ({ storedState, releaseSha }) => {
if (storedState.releaseSha === releaseSha) return storedState;
const legacyHistory = CHECKPOINT_RUN_FIELDS.flatMap((field) => {
const runId = Number(storedState[field]);
return Number.isInteger(runId) && runId > 0
? [{ field, runId, releaseSha: storedState.releaseSha ?? null }]
: [];
});
const runHistory = [...(storedState.runHistory ?? []), ...legacyHistory]
.filter((entry, index, entries) =>
entries.findIndex((candidate) => Number(candidate.runId) === Number(entry.runId)) === index
);
return {
releaseSha,
...(runHistory.length > 0 ? { runHistory } : {}),
...(storedState.playDelivery ? { playDelivery: storedState.playDelivery } : {}),
};
};
export const playDeliveryResumeAction = ({
playDelivery,
tag,
headSha,
mobileInputsChanged = false,
}) => {
if (!playDelivery || playDelivery.tag !== tag) return "upload";
if (playDelivery.releaseSha === headSha) return "verify";
return mobileInputsChanged ? "block" : "verify";
};
export const shouldCancelSupersededRun = ({ runView, headSha }) =>
Boolean(
runView &&
runView.headSha !== headSha &&
runView.status !== "completed",
);
export const parseReleaseCheckpoint = (body, tag) => {
const marker = releaseCheckpointMarker(tag);
if (!body?.startsWith(marker)) return null;
@@ -698,6 +773,35 @@ const viewWorkflowRun = ({ repository, runId }) => ghJson([
"status,conclusion,url,headSha,jobs",
]);
const cancelSupersededCheckpointRuns = ({ repository, checkpoint, headSha }) => {
const candidates = CHECKPOINT_RUN_FIELDS.flatMap((field) => checkpoint[field]
? [{ field, runId: checkpoint[field] }]
: []).filter(({ field }, index, entries) =>
CANCELLABLE_CHECKPOINT_RUN_FIELDS.has(field) &&
entries.findIndex((candidate) => Number(candidate.runId) === Number(entries[index].runId)) === index
);
for (const { field, runId } of candidates) {
let runView;
try {
runView = viewWorkflowRun({ repository, runId });
} catch {
console.warn(`[release] could not inspect superseded ${field} Run ${runId}; continuing`);
continue;
}
if (!shouldCancelSupersededRun({ runView, headSha })) continue;
const cancellation = run(
"gh",
["run", "cancel", String(runId), "--repo", repository],
{ allowFailure: true },
);
if (cancellation.status === 0) {
console.log(`[release] cancelled superseded ${field}: ${runView.url}`);
} else {
console.warn(`[release] superseded ${field} was no longer cancellable: ${runView.url}`);
}
}
};
export const signedWindowsUpdateAuditPassed = (runView) =>
runView.jobs?.some(
(job) => job.name === "Audit signed Windows update" && job.conclusion === "success",
@@ -839,7 +943,7 @@ const dispatchAndWaitForPlayRecovery = async ({
headSha,
recoverPlayApk: true,
});
checkpoint.storeRecoveryRunId = recoveryRunId;
recordCheckpointRun(checkpoint, "storeRecoveryRunId", recoveryRunId);
persistCheckpoint();
await waitForRun({
repository,
@@ -855,7 +959,25 @@ const ensurePlayDelivery = async ({
headSha,
checkpoint,
persistCheckpoint,
resumeAction = "upload",
}) => {
const recordCompletedDelivery = ({ storeRunId = null, recoveryRunId = null }) => {
checkpoint.playDelivery = {
tag,
releaseSha: headSha,
...(storeRunId ? { storeRunId: Number(storeRunId) } : {}),
...(recoveryRunId ? { recoveryRunId: Number(recoveryRunId) } : {}),
};
persistCheckpoint();
};
if (resumeAction === "verify") {
console.log(
"[release] mobile inputs are unchanged since Play delivery; skipping upload and verifying the existing Play-signed APK",
);
return checkpoint.playDelivery?.recoveryRunId ?? checkpoint.playDelivery?.storeRunId ?? null;
}
let storeRunId = checkpoint.storeRunId;
if (!storeRunId) {
storeRunId = await dispatchStoreDeliveryWorkflow({
@@ -864,7 +986,7 @@ const ensurePlayDelivery = async ({
headSha,
recoverPlayApk: false,
});
checkpoint.storeRunId = storeRunId;
recordCheckpointRun(checkpoint, "storeRunId", storeRunId);
persistCheckpoint();
} else {
const existing = viewWorkflowRun({ repository, runId: storeRunId });
@@ -880,6 +1002,7 @@ const ensurePlayDelivery = async ({
runId: storeRunId,
label: "Google Play delivery",
});
recordCompletedDelivery({ storeRunId });
return storeRunId;
} catch (error) {
const failedRun = viewWorkflowRun({ repository, runId: storeRunId });
@@ -887,13 +1010,14 @@ const ensurePlayDelivery = async ({
console.log(
"[release] Play upload may have completed; recovering its signed APK without re-uploading",
);
await dispatchAndWaitForPlayRecovery({
const recoveryRunId = await dispatchAndWaitForPlayRecovery({
repository,
tag,
headSha,
checkpoint,
persistCheckpoint,
});
recordCompletedDelivery({ storeRunId, recoveryRunId });
return storeRunId;
}
@@ -904,13 +1028,14 @@ const ensurePlayDelivery = async ({
headSha,
recoverPlayApk: false,
});
checkpoint.storeRunId = storeRunId;
recordCheckpointRun(checkpoint, "storeRunId", storeRunId);
persistCheckpoint();
await waitForRun({
repository,
runId: storeRunId,
label: "Google Play delivery retry",
});
recordCompletedDelivery({ storeRunId });
return storeRunId;
}
};
@@ -930,7 +1055,7 @@ const requirePlaySignedDraftApk = async ({
tag,
headSha,
});
checkpoint.androidPlaySignatureRunId = runId;
recordCheckpointRun(checkpoint, "androidPlaySignatureRunId", runId);
persistCheckpoint();
await waitForRun({
repository,
@@ -1355,10 +1480,17 @@ const releaseMain = async (options) => {
tag,
});
let checkpointCommentId = storedCheckpoint.commentId;
const checkpoint =
storedCheckpoint.state.releaseSha === releaseSha
? storedCheckpoint.state
: { releaseSha };
if (storedCheckpoint.state.releaseSha && storedCheckpoint.state.releaseSha !== releaseSha) {
cancelSupersededCheckpointRuns({
repository: options.repository,
checkpoint: storedCheckpoint.state,
headSha: releaseSha,
});
}
const checkpoint = prepareReleaseCheckpoint({
storedState: storedCheckpoint.state,
releaseSha,
});
const persistCheckpoint = () => {
checkpointCommentId = saveReleaseCheckpoint({
repository: options.repository,
@@ -1370,6 +1502,34 @@ const releaseMain = async (options) => {
};
persistCheckpoint();
let playDeliveryAction = "upload";
if (mobilePlan.rebuild && checkpoint.playDelivery?.tag === tag) {
const deliveredSha = checkpoint.playDelivery.releaseSha;
const deliveredTargetIsAncestor = run(
"git",
["merge-base", "--is-ancestor", deliveredSha, releaseSha],
{ allowFailure: true },
).status === 0;
if (!deliveredTargetIsAncestor) {
throw new Error("Stored Play delivery is not an ancestor of the current Draft target.");
}
const mobileInputsChanged = planNativeRelease(
"mobile",
changedFilesBetween(deliveredSha, releaseSha),
).rebuild;
playDeliveryAction = playDeliveryResumeAction({
playDelivery: checkpoint.playDelivery,
tag,
headSha: releaseSha,
mobileInputsChanged,
});
if (playDeliveryAction === "block") {
throw new Error(
"Mobile release inputs changed after this versionCode was delivered to Google Play. Withdraw this Draft and prepare a new version/versionCode instead of re-uploading the immutable Play version.",
);
}
}
const resolveDraftRun = async (field, workflow, label) => {
const reusableRunId = resumedDraft
? await resumeDraftWorkflowRun({
@@ -1379,12 +1539,15 @@ const releaseMain = async (options) => {
label,
})
: null;
return reusableRunId ?? dispatchReleaseWorkflow({
const runId = reusableRunId ?? await dispatchReleaseWorkflow({
repository: options.repository,
workflow,
tag,
headSha: releaseSha,
});
recordCheckpointRun(checkpoint, field, runId);
persistCheckpoint();
return runId;
};
const [desktopRunId, mobileRunId, dockerRunId] = await Promise.all([
@@ -1392,8 +1555,6 @@ const releaseMain = async (options) => {
resolveDraftRun("mobileRunId", RELEASE_WORKFLOWS.mobile, "Draft Android assets"),
resolveDraftRun("dockerRunId", RELEASE_WORKFLOWS.docker, "Draft Docker image"),
]);
Object.assign(checkpoint, { desktopRunId, mobileRunId, dockerRunId });
persistCheckpoint();
const androidReleaseReady = (async () => {
await waitForRun({
@@ -1408,6 +1569,7 @@ const releaseMain = async (options) => {
headSha: releaseSha,
checkpoint,
persistCheckpoint,
resumeAction: playDeliveryAction,
});
}
await requirePlaySignedDraftApk({
@@ -1445,7 +1607,7 @@ const releaseMain = async (options) => {
tag,
headSha: releaseSha,
});
checkpoint.windowsUpdateAuditRunId = windowsUpdateAuditRunId;
recordCheckpointRun(checkpoint, "windowsUpdateAuditRunId", windowsUpdateAuditRunId);
persistCheckpoint();
await waitForRun({
repository: options.repository,
@@ -1577,6 +1739,12 @@ const releaseMain = async (options) => {
"--body",
`Released in [${tag}](${releaseUrl}).\n\nRequired local validations, Draft asset and image preparation, and post-publication audits passed.`,
]);
const timingStoreRunId =
checkpoint.storeRecoveryRunId ??
checkpoint.storeRunId ??
checkpoint.playDelivery?.recoveryRunId ??
checkpoint.playDelivery?.storeRunId;
const timingAttemptRunIds = checkpointRunIds(checkpoint).join(",");
const timingDispatch = run("gh", [
"workflow",
"run",
@@ -1601,8 +1769,14 @@ const releaseMain = async (options) => {
`mobile_mode=${mobilePlan.rebuild ? "rebuild" : "reuse"}`,
"-f",
`docker_run_id=${dockerRunId}`,
...(checkpoint.storeRunId
? ["-f", `store_run_id=${checkpoint.storeRunId}`]
...(timingStoreRunId
? [
"-f",
`store_run_id=${timingStoreRunId}`,
]
: []),
...(timingAttemptRunIds
? ["-f", `attempt_run_ids=${timingAttemptRunIds}`]
: []),
], { allowFailure: true });
if (timingDispatch.status === 0) {
+70 -1
View File
@@ -6,17 +6,22 @@ import {
buildReleaseNotes,
buildReleaseSummary,
buildReleaseTitle,
checkpointRunIds,
draftRunResumeAction,
nextVersion,
parseReleaseCheckpoint,
parseReleaseArgs,
playDeliveryResumeAction,
playDeliveryFailureStrategy,
prepareReleaseCheckpoint,
recordCheckpointRun,
RELEASE_WORKFLOWS,
RELEASE_VALIDATIONS,
resolveReleaseVersion,
reusedAssetMatches,
selectPublishedDmg,
signedWindowsUpdateAuditPassed,
shouldCancelSupersededRun,
waitForRun,
} from "./release.mjs";
@@ -31,7 +36,8 @@ describe("release automation", () => {
expect(releaseSource).toContain("desktop_run_id=\${desktopRunId}");
expect(releaseSource).toContain("mobile_run_id=\${mobileRunId}");
expect(releaseSource).toContain("docker_run_id=\${dockerRunId}");
expect(releaseSource).toContain("store_run_id=\${checkpoint.storeRunId}");
expect(releaseSource).toContain("store_run_id=\${timingStoreRunId}");
expect(releaseSource).toContain("attempt_run_ids=\${timingAttemptRunIds}");
expect(releaseSource).toContain("endpoint timing report continues in background");
});
@@ -97,6 +103,69 @@ describe("release automation", () => {
expect(parseReleaseCheckpoint("malformed", "v1.42.0")).toBeNull();
});
test("preserves Play delivery and Run history when a Draft target advances", () => {
const storedState = {
releaseSha: "old",
desktopRunId: 11,
storeRunId: 12,
playDelivery: { tag: "v1.42.0", releaseSha: "old", storeRunId: 12 },
};
const checkpoint = prepareReleaseCheckpoint({ storedState, releaseSha: "new" });
expect(checkpoint).toMatchObject({
releaseSha: "new",
playDelivery: storedState.playDelivery,
runHistory: [
{ field: "desktopRunId", runId: 11, releaseSha: "old" },
{ field: "storeRunId", runId: 12, releaseSha: "old" },
],
});
expect(checkpoint.desktopRunId).toBeUndefined();
recordCheckpointRun(checkpoint, "mobileRunId", 13);
recordCheckpointRun(checkpoint, "mobileRunId", 13);
expect(checkpointRunIds(checkpoint)).toEqual([11, 12, 13]);
});
test("verifies an immutable Play delivery without uploading across mobile-compatible fixes", () => {
const playDelivery = { tag: "v1.42.0", releaseSha: "old" };
expect(playDeliveryResumeAction({
playDelivery,
tag: "v1.42.0",
headSha: "old",
})).toBe("verify");
expect(playDeliveryResumeAction({
playDelivery,
tag: "v1.42.0",
headSha: "new",
mobileInputsChanged: false,
})).toBe("verify");
expect(playDeliveryResumeAction({
playDelivery,
tag: "v1.42.0",
headSha: "new",
mobileInputsChanged: true,
})).toBe("block");
expect(playDeliveryResumeAction({
playDelivery,
tag: "v1.43.0",
headSha: "new",
})).toBe("upload");
});
test("cancels only active Runs from superseded release targets", () => {
expect(shouldCancelSupersededRun({
runView: { headSha: "old", status: "in_progress" },
headSha: "new",
})).toBe(true);
expect(shouldCancelSupersededRun({
runView: { headSha: "old", status: "completed" },
headSha: "new",
})).toBe(false);
expect(shouldCancelSupersededRun({
runView: { headSha: "new", status: "in_progress" },
headSha: "new",
})).toBe(false);
});
test("reuses successful Draft runs and reruns only failed ones", () => {
const headSha = "abc";
expect(draftRunResumeAction({
+55 -3
View File
@@ -30,6 +30,36 @@ const escapeCell = (value) => String(value ?? "—").replaceAll("|", "\\|");
const runLink = (url) => (url ? `[Run](${url})` : "—");
export function summarizeReleaseAttempts(attempts = []) {
const unique = attempts.filter((payload, index, entries) =>
entries.findIndex((candidate) => candidate.run.id === payload.run.id) === index
);
const starts = unique
.map(({ run }) => timestamp(run.created_at ?? run.run_started_at))
.filter((value) => value !== null);
const ends = unique
.map(({ run }) => timestamp(run.updated_at))
.filter((value) => value !== null);
const releaseTargets = new Set(
unique.map(({ run }) => run.head_sha).filter(Boolean),
);
return {
workflowRunCount: unique.length,
releaseTargetCount: releaseTargets.size,
failedRunCount: unique.filter(({ run }) =>
["failure", "cancelled", "timed_out"].includes(run.conclusion)
).length,
endToEndDurationMs:
starts.length > 0 && ends.length > 0
? Math.max(...ends) - Math.min(...starts)
: null,
cumulativeWorkflowDurationMs: unique.reduce(
(total, { run }) => total + (runDurationMs(run) ?? 0),
0,
),
};
}
function componentRow({ target, mode, candidate, duration, detail, url }) {
return {
target,
@@ -144,6 +174,8 @@ export function createReleaseTimingReport({
mobileMode,
docker,
store = null,
storeMode = "",
attempts = [],
cloudflare,
tcrSource,
tcrReadyAt,
@@ -159,6 +191,15 @@ export function createReleaseTimingReport({
"Build and verify signed Play bundle",
);
const storeUploadStep = findStep(storeJob, "Upload bundle to Google Play");
const storeDownloadStep = findStep(storeJob, "Download Play-signed universal APK");
const resolvedStoreMode = storeMode || (
storeJob &&
(!storeBuildStep || resultOf(storeBuildStep) === "skipped") &&
storeDownloadStep &&
resultOf(storeDownloadStep) === "success"
? "recover"
: "build + deliver"
);
const tcrSourceJob = findJob(
tcrSource,
"Trigger asynchronous Tencent-side image build",
@@ -197,10 +238,12 @@ export function createReleaseTimingReport({
? [
componentRow({
target: "Google Play signed APK",
mode: "build + deliver",
mode: resolvedStoreMode,
candidate: storeJob,
duration: jobDurationMs(storeJob),
detail: `AAB build ${formatDuration(stepDurationMs(storeBuildStep))}; Play upload ${formatDuration(stepDurationMs(storeUploadStep))}`,
detail: resolvedStoreMode === "recover"
? `recovered existing Play-signed APK ${formatDuration(stepDurationMs(storeDownloadStep))}`
: `AAB build ${formatDuration(stepDurationMs(storeBuildStep))}; Play upload ${formatDuration(stepDurationMs(storeUploadStep))}`,
url: storeJob.html_url ?? store.run.html_url,
}),
]
@@ -220,9 +263,10 @@ export function createReleaseTimingReport({
: null;
return {
schemaVersion: 1,
schemaVersion: 2,
release,
generatedAt: new Date().toISOString(),
preparation: summarizeReleaseAttempts(attempts),
allEndpointsReady,
allEndpointsReadyDurationMs:
publishedAt !== null &&
@@ -243,6 +287,12 @@ export function renderReleaseTimingMarkdown(report) {
"",
`${readinessSummary} Client and GHCR assets are prepared in the Draft phase and therefore report their actual preparation jobs rather than time after publication.`,
"",
...(report.preparation.workflowRunCount > 0
? [
`Draft preparation spanned **${formatDuration(report.preparation.endToEndDurationMs)}** across **${report.preparation.workflowRunCount} workflow runs** and **${report.preparation.releaseTargetCount} release target(s)**; ${report.preparation.failedRunCount} run(s) failed or were cancelled. Cumulative workflow duration was ${formatDuration(report.preparation.cumulativeWorkflowDurationMs)}.`,
"",
]
: []),
"| Target | Mode | Result | Duration | Detail | Workflow |",
"| --- | --- | --- | ---: | --- | --- |",
];
@@ -306,6 +356,8 @@ if (import.meta.main) {
mobileMode: options["mobile-mode"],
docker: readJson(options.docker),
store: options.store ? readJson(options.store) : null,
storeMode: options["store-mode"] ?? "",
attempts: options.attempts ? readJson(options.attempts) : [],
cloudflare: readJson(options.cloudflare),
tcrSource: readJson(options["tcr-source"]),
tcrReadyAt: options["tcr-ready-at"] || null,
+47
View File
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
import {
createReleaseTimingReport,
renderReleaseTimingMarkdown,
summarizeReleaseAttempts,
} from "./report-release-timings.mjs";
const step = (name, start, seconds) => ({
@@ -179,4 +180,50 @@ describe("release timing report", () => {
"At least one post-publication endpoint failed or was not observed",
);
});
test("reports every Draft attempt and distinguishes Play APK recovery", () => {
const start = "2026-08-25T00:00:00Z";
const attempts = [
payload("Desktop failed", start, 420, []),
payload("Desktop retry", "2026-08-25T00:07:30Z", 360, []),
];
Object.assign(attempts[0].run, { id: 1, head_sha: "old", conclusion: "failure" });
Object.assign(attempts[1].run, { id: 2, head_sha: "new", conclusion: "success" });
expect(summarizeReleaseAttempts(attempts)).toEqual({
workflowRunCount: 2,
releaseTargetCount: 2,
failedRunCount: 1,
endToEndDurationMs: 810_000,
cumulativeWorkflowDurationMs: 780_000,
});
const common = {
release: { tag: "v1.41.3", sha: "new", publishedAt: start },
desktop: payload("Desktop", start, 40, [job("Plan desktop release asset", start, 40)]),
desktopMode: "rebuild",
mobile: payload("Mobile", start, 30, [job("Plan Android release asset", start, 30)]),
mobileMode: "rebuild",
docker: payload("Docker", start, 60, [job("Publish official multi-platform image", start, 60)]),
store: payload("Store recovery", start, 120, [
job("Deliver Google Play", start, 120, [
step("Download Play-signed universal APK", start, 45),
]),
]),
storeMode: "recover",
attempts,
cloudflare: payload("Cloudflare", start, 60, [job("Build and deploy Demo Worker", start, 60)]),
tcrSource: payload("TCR", start, 15, [job("Trigger asynchronous Tencent-side image build", start, 15)]),
tcrReadyAt: "2026-08-25T00:02:00Z",
tcrStatus: "success",
};
const report = createReleaseTimingReport(common);
expect(report.rows.at(-1)).toMatchObject({
target: "Google Play signed APK",
mode: "recover",
detail: "recovered existing Play-signed APK 45s",
});
const markdown = renderReleaseTimingMarkdown(report);
expect(markdown).toContain("across **2 workflow runs** and **2 release target(s)**");
expect(markdown).toContain("1 run(s) failed or were cancelled");
});
});