Skip to content
Source
Sign inSign up
Open History to see the last commit

check-nightly-release.cjs

174 lines6.6 KB

1const MINIMUM_RELEASE_GAP_MS = 6 * 60 * 60 * 1000;
2​
3function repositoryDefaultBranch(context) {
4 const branch = context.payload.repository?.default_branch;
5 if (!branch) {
6 throw new Error("GitHub did not provide the repository default branch.");
7 }
8 return branch;
9}
10​
11async function assertCommitOnDefaultBranch({ github, context, sha }) {
12 const defaultBranch = repositoryDefaultBranch(context);
13 const { data: comparison } = await github.rest.repos.compareCommitsWithBasehead({
14 ...context.repo,
15 basehead: `${sha}...${defaultBranch}`,
16 per_page: 1,
17 });
18 if (comparison.status !== "ahead" && comparison.status !== "identical") {
19 throw new Error(
20 `Release commit ${sha} is not contained in ${defaultBranch} (${comparison.status}).`,
21 );
22 }
24​
25async function assertReleaseSource({ github, context, releaseChannel }) {
26 if (releaseChannel === "preview") return;
27 if (releaseChannel !== "stable" && releaseChannel !== "nightly") {
28 throw new Error(`Unsupported release channel: ${releaseChannel}`);
29 }
30​
31 const defaultBranch = repositoryDefaultBranch(context);
32 if (context.eventName === "workflow_dispatch" && context.ref !== `refs/heads/${defaultBranch}`) {
33 throw new Error(
34 `${releaseChannel} releases must be dispatched from ${defaultBranch}; selected ${context.ref}. Use the preview channel for branch builds.`,
35 );
36 }
37​
38 await assertCommitOnDefaultBranch({ github, context, sha: context.sha });
40​
41const isNightlyTag = (tag) => /^v.*-nightly\./.test(tag) || tag.startsWith("nightly-v");
42​
43// Only the three fields the gate reads. The REST list also returns every asset,
44// which made a full scan tens of megabytes.
45const RELEASES_QUERY = `
46 query ($owner: String!, $repo: String!, $cursor: String) {
47 repository(owner: $owner, name: $repo) {
48 releases(first: 100, after: $cursor, orderBy: { field: CREATED_AT, direction: DESC }) {
49 pageInfo { hasNextPage endCursor }
50 nodes { tagName isDraft publishedAt }
51 }
52 }
53 }
54`;
55​
56// Returns the nightly with the newest publishedAt. Release order follows the
57// tagged commit date, not publication, so every page is read unless `isSettled`
58// says the newest nightly seen so far already decides the caller's answer.
59async function findLatestNightly({ github, context, isSettled = () => false }) {
60 let latest;
61 let cursor = null;
62 do {
63 const { repository } = await github.graphql(RELEASES_QUERY, { ...context.repo, cursor });
64 const { nodes, pageInfo } = repository.releases;
65 for (const release of nodes) {
66 if (release.isDraft || !release.publishedAt || !isNightlyTag(release.tagName)) continue;
67 if (!latest || Date.parse(release.publishedAt) > Date.parse(latest.publishedAt)) {
68 latest = release;
69 }
70 }
71 if (latest && isSettled(latest)) return latest;
72 cursor = pageInfo.hasNextPage ? pageInfo.endCursor : null;
73 } while (cursor);
74 return latest;
76​
77async function shouldReleaseNightly({ github, context, core, now = Date.now() }) {
78 const isInGap = (release) => now - Date.parse(release.publishedAt) < MINIMUM_RELEASE_GAP_MS;
79 // Any nightly inside the gap means the newest one is too, so stop reading.
80 const lastNightly = await findLatestNightly({ github, context, isSettled: isInGap });
81​
82 if (!lastNightly) {
83 core.info("No published nightly found. Proceeding with release.");
84 return true;
85 }
86​
87 const tag = lastNightly.tagName;
88 if (isInGap(lastNightly)) {
89 core.info(`Nightly ${tag} was published less than six hours ago. Skipping.`);
90 return false;
91 }
92​
93 const { data: comparison } = await github.rest.repos.compareCommitsWithBasehead({
94 ...context.repo,
95 basehead: `${tag}...${context.sha}`,
96 per_page: 1,
97 });
98 if (comparison.status !== "ahead") {
99 core.info(`Candidate commit is ${comparison.status} relative to ${tag}. Skipping.`);
100 return false;
101 }
102​
103 core.info(`New commits since ${tag}, and the six-hour gap has passed.`);
104 return true;
106​
107async function resolveLatestNightlyCommit({ github, context, core }) {
108 const lastNightly = await findLatestNightly({ github, context });
109 if (!lastNightly) {
110 throw new Error("No published nightly found. Stable releases build the latest nightly commit.");
111 }
112​
113 const tag = lastNightly.tagName;
114 const { data: commit } = await github.rest.repos.getCommit({ ...context.repo, ref: tag });
115 const version = /^(?:nightly-)?v(\d+\.\d+\.\d+)-nightly\./.exec(tag)?.[1];
116 if (!version) {
117 throw new Error(`Cannot derive a stable version from nightly tag ${tag}.`);
118 }
119​
120 core.info(`Latest nightly ${tag} shipped ${commit.sha} as a preview of ${version}.`);
121 return { tag, sha: commit.sha, version };
123​
124// The two places a nightly install looks for an update: the `update-feed` pointer, and
125// releases.atom, which holds the 10 newest git tags of any kind. Older installs take the first
126// nightly in the feed. Reports what each names beside the newest published nightly.
127async function readNightlyUpdateFeed({ github, context, fetch = globalThis.fetch }) {
128 const latest = await findLatestNightly({ github, context });
129 if (!latest) return {};
130​
131 const base = `https://github.com/${context.repo.owner}/${context.repo.repo}`;
132 // Only a missing or malformed pointer is stale. Rewriting deletes the old file first, so a
133 // GitHub outage must not look like one.
134 // Node waits five minutes for a stalled response, which is this job's whole time limit.
135 const signal = () => AbortSignal.timeout(30_000);
136 const pointer = await fetch(`${base}/releases/download/update-feed/nightly.json`, {
137 signal: signal(),
138 });
139 if (!pointer.ok && pointer.status !== 404) {
140 throw new Error(`The update-feed pointer answered HTTP ${pointer.status}.`);
141 }
142 let pointerTag;
143 if (pointer.ok) {
144 // A body that fails to arrive throws here; only one that is not JSON counts as malformed.
145 const body = await pointer.text();
146 try {
147 pointerTag = JSON.parse(body)?.tag_name;
148 } catch {}
149 }
150​
151 // An unreadable feed leaves `feedTags` unset, so the pointer is still repaired. An error page
152 // can answer HTTP 200, so only an Atom document counts as read.
153 let feedTags;
154 try {
155 const feed = await fetch(`${base}/releases.atom`, { signal: signal() });
156 const body = feed.ok ? await feed.text() : "";
157 if (/<feed[\s>]/.test(body)) {
158 feedTags = Array.from(body.matchAll(/href="[^"]*\/releases\/tag\/([^"]+)"/g), (match) =>
159 decodeURIComponent(match[1]),
160 );
161 }
162 } catch {}
163 const feedNightly = feedTags?.find((tag) => /^v?\d+\.\d+\.\d+-nightly\./.test(tag));
164​
165 return { tag: latest.tagName, pointerTag, feedNightly, feedTags };
167​
168module.exports = {
169 assertCommitOnDefaultBranch,
170 assertReleaseSource,
171 shouldReleaseNightly,
172 resolveLatestNightlyCommit,
173 readNightlyUpdateFeed,
174};