Skip to content

Commit caeacaa

Browse files
committed
ci: automatically sync next with main
1 parent 34b5a69 commit caeacaa

2 files changed

Lines changed: 470 additions & 0 deletions

File tree

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Wait for GitHub Actions check suites to complete for a given commit.
4+
*
5+
* Environment variables:
6+
* - COMMIT_SHA (required)
7+
* - GITHUB_OWNER (required)
8+
* - GITHUB_REPO (required)
9+
* - GITHUB_TOKEN (required) - token with `repo` scope (BOT token preferred)
10+
* - TIMEOUT_MINUTES (optional, default: 60)
11+
* - POLL_INTERVAL_SECONDS (optional, default: 60)
12+
*
13+
* Output: JSON printed to stdout, for example:
14+
* {
15+
* "status": "success" | "failure" | "timeout" | "not-found",
16+
* "durationSeconds": number,
17+
* "failures": [
18+
* { "name": "Workflow Name", "conclusion": "failure", "url": "..." }
19+
* ]
20+
* }
21+
*/
22+
23+
const https = require("node:https");
24+
25+
const failureConclusions = new Set([
26+
"failure",
27+
"timed_out",
28+
"cancelled",
29+
"action_required",
30+
]);
31+
32+
function getEnv(name, fallback) {
33+
const value = process.env[name];
34+
if (value === undefined || value === "") {
35+
if (fallback === undefined) {
36+
throw new Error(`Missing required environment variable: ${name}`);
37+
}
38+
return fallback;
39+
}
40+
return value;
41+
}
42+
43+
async function sleep(ms) {
44+
return new Promise((resolve) => setTimeout(resolve, ms));
45+
}
46+
47+
async function fetchJson(url, token) {
48+
return new Promise((resolve, reject) => {
49+
const request = https.request(
50+
url,
51+
{
52+
method: "GET",
53+
headers: {
54+
Authorization: `Bearer ${token}`,
55+
"X-GitHub-Api-Version": "2022-11-28",
56+
Accept: "application/vnd.github+json",
57+
"User-Agent": "prisma-sync-next-script",
58+
},
59+
},
60+
(response) => {
61+
let data = "";
62+
response.on("data", (chunk) => {
63+
data += chunk;
64+
});
65+
response.on("end", () => {
66+
if (
67+
response.statusCode &&
68+
response.statusCode >= 200 &&
69+
response.statusCode < 300
70+
) {
71+
try {
72+
resolve(JSON.parse(data || "null"));
73+
} catch (error) {
74+
reject(
75+
new Error(
76+
`Failed to parse JSON response from ${url}: ${error.message}`,
77+
),
78+
);
79+
}
80+
} else {
81+
reject(
82+
new Error(
83+
`GitHub API request failed (${response.statusCode} ${response.statusMessage}): ${data}`,
84+
),
85+
);
86+
}
87+
});
88+
},
89+
);
90+
91+
request.on("error", (error) => {
92+
reject(error);
93+
});
94+
95+
request.end();
96+
});
97+
}
98+
99+
async function collectFailuresForSuite(owner, repo, suite, token) {
100+
const failures = [];
101+
102+
if (!suite.conclusion || !failureConclusions.has(suite.conclusion)) {
103+
return failures;
104+
}
105+
106+
// Fetch individual check runs for more detailed information
107+
const runsUrl = `https://api.github.com/repos/${owner}/${repo}/check-suites/${suite.id}/check-runs?per_page=100`;
108+
const runsData = await fetchJson(runsUrl, token);
109+
const runs = runsData.check_runs || [];
110+
111+
const failingRuns = runs.filter((run) =>
112+
failureConclusions.has(run.conclusion),
113+
);
114+
115+
if (failingRuns.length === 0) {
116+
failures.push({
117+
name: suite.name,
118+
conclusion: suite.conclusion,
119+
url: suite.html_url,
120+
});
121+
return failures;
122+
}
123+
124+
for (const run of failingRuns) {
125+
failures.push({
126+
name: run.name,
127+
conclusion: run.conclusion,
128+
url: run.html_url,
129+
});
130+
}
131+
132+
return failures;
133+
}
134+
135+
async function main() {
136+
const commit = getEnv("COMMIT_SHA");
137+
const owner = getEnv("GITHUB_OWNER");
138+
const repo = getEnv("GITHUB_REPO");
139+
const token = getEnv("GITHUB_TOKEN");
140+
const timeoutMinutes = Number.parseInt(getEnv("TIMEOUT_MINUTES", "60"), 10);
141+
const pollIntervalSeconds = Number.parseInt(
142+
getEnv("POLL_INTERVAL_SECONDS", "60"),
143+
10,
144+
);
145+
146+
const startedAt = Date.now();
147+
const deadline = startedAt + timeoutMinutes * 60 * 1000;
148+
149+
const result = {
150+
status: "not-found",
151+
durationSeconds: 0,
152+
failures: [],
153+
};
154+
155+
while (Date.now() < deadline) {
156+
const suitesUrl = `https://api.github.com/repos/${owner}/${repo}/commits/${commit}/check-suites?per_page=100`;
157+
158+
let suitesData;
159+
try {
160+
suitesData = await fetchJson(suitesUrl, token);
161+
} catch (error) {
162+
// Retry on transient failures
163+
await sleep(pollIntervalSeconds * 1000);
164+
continue;
165+
}
166+
167+
const suites =
168+
suitesData.check_suites?.filter(
169+
(suite) => suite.app?.slug === "github-actions",
170+
) ?? [];
171+
172+
if (suites.length === 0) {
173+
await sleep(pollIntervalSeconds * 1000);
174+
continue;
175+
}
176+
177+
const incomplete = suites.some((suite) => suite.status !== "completed");
178+
if (incomplete) {
179+
await sleep(pollIntervalSeconds * 1000);
180+
continue;
181+
}
182+
183+
const failures = [];
184+
for (const suite of suites) {
185+
const suiteFailures = await collectFailuresForSuite(
186+
owner,
187+
repo,
188+
suite,
189+
token,
190+
);
191+
failures.push(...suiteFailures);
192+
}
193+
194+
result.durationSeconds = Math.round((Date.now() - startedAt) / 1000);
195+
196+
if (failures.length > 0) {
197+
result.status = "failure";
198+
result.failures = failures;
199+
} else {
200+
result.status = "success";
201+
}
202+
203+
process.stdout.write(JSON.stringify(result));
204+
return;
205+
}
206+
207+
result.status = "timeout";
208+
result.durationSeconds = Math.round((Date.now() - startedAt) / 1000);
209+
process.stdout.write(JSON.stringify(result));
210+
}
211+
212+
main().catch((error) => {
213+
console.error(error.message);
214+
process.stdout.write(
215+
JSON.stringify({
216+
status: "error",
217+
durationSeconds: 0,
218+
failures: [
219+
{
220+
name: "exception",
221+
conclusion: "error",
222+
url: "",
223+
message: error.message,
224+
},
225+
],
226+
}),
227+
);
228+
process.exitCode = 1;
229+
});

0 commit comments

Comments
 (0)