fix(release): preserve draft attempt history
This commit is contained in:
1 parent
4793004e20
commit
8831594919
5 files changed
+387
-19
No files matched your search
@@ -41,6 +41,10 @@ on:
|
||||
description: "Google Play delivery Run ID when Android was rebuilt"
|
||||
required: false
|
||||
type: string
|
||||
attempt_run_ids:
|
||||
description: "Comma-separated Draft workflow Run IDs across every release attempt"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -168,6 +172,7 @@ jobs:
|
||||
DOCKER_RUN_ID: ${{ inputs.docker_run_id }}
|
||||
MOBILE_RUN_ID: ${{ inputs.mobile_run_id }}
|
||||
STORE_RUN_ID: ${{ inputs.store_run_id }}
|
||||
ATTEMPT_RUN_IDS: ${{ inputs.attempt_run_ids }}
|
||||
TCR_RUN_ID: ${{ steps.release.outputs.tcr_run_id }}
|
||||
run: |
|
||||
collect_run() {
|
||||
@@ -190,6 +195,26 @@ jobs:
|
||||
collect_run cloudflare "$DEMO_RUN_ID"
|
||||
collect_run tcr-source "$TCR_RUN_ID"
|
||||
|
||||
mkdir -p "$RUNNER_TEMP/attempts"
|
||||
attempt_files=()
|
||||
IFS=',' read -ra attempt_run_ids <<<"$ATTEMPT_RUN_IDS"
|
||||
for run_id in "${attempt_run_ids[@]}"; do
|
||||
[[ -z "$run_id" ]] && continue
|
||||
[[ "$run_id" =~ ^[0-9]+$ ]]
|
||||
gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}" > "$RUNNER_TEMP/attempts/${run_id}-run.json"
|
||||
gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/jobs?filter=latest&per_page=100" > "$RUNNER_TEMP/attempts/${run_id}-jobs.json"
|
||||
jq -s '{run: .[0], jobs: .[1].jobs}' \
|
||||
"$RUNNER_TEMP/attempts/${run_id}-run.json" \
|
||||
"$RUNNER_TEMP/attempts/${run_id}-jobs.json" \
|
||||
> "$RUNNER_TEMP/attempts/${run_id}.json"
|
||||
attempt_files+=("$RUNNER_TEMP/attempts/${run_id}.json")
|
||||
done
|
||||
if (( ${#attempt_files[@]} > 0 )); then
|
||||
jq -s 'unique_by(.run.id)' "${attempt_files[@]}" > "$RUNNER_TEMP/attempts.json"
|
||||
else
|
||||
printf '[]\n' > "$RUNNER_TEMP/attempts.json"
|
||||
fi
|
||||
|
||||
- name: Generate unified Release timing report
|
||||
env:
|
||||
RELEASE_URL: ${{ steps.release.outputs.release_url }}
|
||||
@@ -211,6 +236,7 @@ jobs:
|
||||
--mobile-mode "${{ inputs.mobile_mode }}" \
|
||||
--docker "$RUNNER_TEMP/docker.json" \
|
||||
"${store_args[@]}" \
|
||||
--attempts "$RUNNER_TEMP/attempts.json" \
|
||||
--cloudflare "$RUNNER_TEMP/cloudflare.json" \
|
||||
--tcr-source "$RUNNER_TEMP/tcr-source.json" \
|
||||
--tcr-ready-at "$TCR_READY_AT" \
|
||||
|
||||
+189
-15
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user