openclaw/scripts/github/mobile-store-release-notify.mjs
Josh Avant a567b8ec01
feat: schedule weekly mobile store releases with Discord results (#173725)
* fix(ios): handle App Store versions without a selected build

* feat: schedule weekly mobile store releases with Discord results

* fix: admit scheduled mobile store uploads

* fix: suppress embeds in mobile release notifications

* fix: clarify mobile release notification headlines
2026-10-15 22:45:01 -07:00

247 lines
8.3 KiB
JavaScript

import { readFile } from "node:fs/promises";
import { setTimeout as delay } from "node:timers/promises";
const SUPPRESS_EMBEDS = 1 << 2;
const repository = "openclaw/openclaw";
const releases = [
{ platform: "iOS", path: ".github/workflows/ios-store-release.yml" },
{ platform: "Android", path: ".github/workflows/android-store-release.yml" },
];
class NotificationError extends Error {}
async function request(url, options, service) {
for (let attempt = 0; ; attempt += 1) {
let response;
try {
response = await fetch(url, {
...options,
redirect: "error",
signal: AbortSignal.timeout(15_000),
});
} catch {
// An ambiguous Discord transport failure can produce a duplicate message.
// A bounded retry is preferable to silently losing the release result.
}
if (response?.ok) {
return response;
}
const retryable = !response || response.status === 429 || response.status >= 500;
if (!retryable || attempt === 2) {
throw new NotificationError(
`${service} request failed; check service availability and credentials.`,
);
}
let retryDelay = 1_000 * (attempt + 1);
if (response?.status === 429) {
let retryAfter = Number(response.headers.get("retry-after"));
if (!retryAfter) {
const body = await response.json().catch(() => null);
retryAfter = Number(body?.retry_after);
}
if (Number.isFinite(retryAfter) && retryAfter > 0) {
if (retryAfter > 30) {
throw new NotificationError(
`${service} rate limit exceeds the notification retry budget.`,
);
}
retryDelay = Math.max(retryDelay, Math.ceil(retryAfter * 1_000));
}
}
await delay(retryDelay);
}
}
async function readJobs(run, token) {
const jobs = [];
for (let page = 1; page <= 20; page += 1) {
const response = await request(
`https://api.github.com/repos/${repository}/actions/runs/${run.id}/attempts/${run.run_attempt}/jobs?per_page=100&page=${page}`,
{
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
},
"GitHub job metadata",
);
const body = await response.json();
if (
!Array.isArray(body.jobs) ||
!Number.isSafeInteger(body.total_count) ||
body.total_count < 0
) {
throw new NotificationError("GitHub job metadata is incomplete.");
}
jobs.push(...body.jobs);
if (jobs.length >= body.total_count) {
return jobs;
}
if (body.jobs.length === 0) {
break;
}
}
throw new NotificationError("GitHub job metadata is incomplete.");
}
function displayName(value) {
return String(value ?? "an unknown job")
.replace(/\p{Cc}/gu, " ")
.replace(/[\\`*_~|<>[\]]/gu, "")
.slice(0, 140);
}
function releaseResult(run, jobs, platform) {
if (jobs === null) {
return {
passed: false,
detail:
"Release job details could not be retrieved. The release outcome and store acceptance are unconfirmed.",
};
}
const releaseName = `Prepare and upload ${platform} release`;
const release = jobs.find((job) => job.name === releaseName);
const upload = release?.steps?.find((step) => step.name === releaseName);
if (
run.conclusion === "success" &&
release?.conclusion === "success" &&
upload?.conclusion === "success"
) {
return {
passed: true,
detail:
platform === "iOS"
? "Build uploaded and prepared in App Store Connect."
: "Google Play upload completed.",
};
}
const failedJob = jobs.find((job) => !["success", "skipped", "neutral"].includes(job.conclusion));
const failedStep = failedJob?.steps?.find(
(step) => !["success", "skipped", "neutral"].includes(step.conclusion),
);
const context = failedJob ? ` during ${displayName(failedStep?.name ?? failedJob.name)}` : "";
const conclusions = {
failure: `The workflow failed${context}.`,
timed_out: `The run exceeded its time limit${context}.`,
cancelled: `The run was cancelled${context}.`,
startup_failure: "GitHub could not start the workflow.",
action_required: "GitHub reported that action is required. Check the run.",
neutral: "The run finished without a confirmed successful release.",
skipped: "The workflow was skipped.",
stale: "GitHub marked the run stale.",
};
const reason =
conclusions[run.conclusion] ?? "The release outcome could not be confirmed. Check the run.";
if (upload?.conclusion === "success") {
return { passed: false, detail: `Build uploaded. ${reason}` };
}
if (release?.conclusion === "skipped") {
return {
passed: false,
detail:
run.conclusion === "success"
? "The store release job was skipped. Upload was not started."
: `${reason} The store release job was skipped; upload was not started.`,
};
}
if (upload?.conclusion === "skipped") {
return { passed: false, detail: `${reason} Upload was not started.` };
}
// The prepare/upload step also builds, captures screenshots, and stages the
// store release. Its failure alone cannot establish whether the store accepted a build.
return { passed: false, detail: `${reason} Store acceptance is unconfirmed.` };
}
async function main() {
const event = JSON.parse(await readFile(process.env.GITHUB_EVENT_PATH, "utf8"));
const run = event.workflow_run;
const release = releases.find(
({ platform, path }) =>
run?.path === path && run.display_title === `${platform} Store release (weekly)`,
);
if (
process.env.GITHUB_EVENT_NAME !== "workflow_run" ||
event.action !== "completed" ||
event.repository?.full_name !== repository ||
run?.head_repository?.full_name !== repository ||
run?.event !== "schedule" ||
run.head_branch !== "main" ||
run.status !== "completed" ||
!release
) {
console.log("No scheduled store release notification is needed.");
return;
}
if (
!Number.isSafeInteger(run.id) ||
run.id <= 0 ||
!Number.isSafeInteger(run.run_attempt) ||
run.run_attempt <= 0
) {
throw new NotificationError("The completed release run identity is invalid.");
}
const secret = process.env.DISCORD_MOBILE_RELEASE_WEBHOOK_URL;
if (!secret) {
throw new NotificationError(
"Set DISCORD_MOBILE_RELEASE_WEBHOOK_URL in the matching store-release environment.",
);
}
let webhook;
try {
webhook = new URL(secret);
if (
webhook.protocol !== "https:" ||
!["discord.com", "discordapp.com"].includes(webhook.hostname) ||
webhook.port ||
webhook.username ||
webhook.password ||
!/^\/api\/(?:v\d+\/)?webhooks\/\d+\/[\w.-]+\/?$/u.test(webhook.pathname)
) {
throw new Error();
}
} catch {
throw new NotificationError(
"DISCORD_MOBILE_RELEASE_WEBHOOK_URL must be a Discord webhook URL.",
);
}
webhook.searchParams.set("wait", "true");
let jobs = null;
try {
if (!process.env.GITHUB_TOKEN) {
throw new Error();
}
jobs = await readJobs(run, process.env.GITHUB_TOKEN);
} catch {
console.warn(
"::warning::Release job metadata is unavailable; reporting an unconfirmed outcome.",
);
}
const { passed, detail } = releaseResult(run, jobs, release.platform);
const attempt = run.run_attempt > 1 ? ` · Attempt ${run.run_attempt}` : "";
const content = [
`${passed ? "✅" : "⚠️"} **${release.platform} release ${passed ? "uploaded successfully" : "needs attention"}${attempt}**`,
detail,
`[**View run ↗**](https://github.com/${repository}/actions/runs/${run.id}/attempts/${run.run_attempt})`,
].join("\n");
await request(
webhook,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content, flags: SUPPRESS_EMBEDS, allowed_mentions: { parse: [] } }),
},
"Discord notification",
);
console.log("Scheduled store release notification delivered.");
}
try {
await main();
} catch (error) {
// Never emit request errors or response bodies: Discord URLs contain credentials.
console.error(
`::error::${error instanceof NotificationError ? error.message : "Mobile store release notification failed; check the workflow configuration."}`,
);
process.exitCode = 1;
}