Skip to content
Source
Sign inSign up

check-nightly-release.test.cjs

317 lines11.5 KB

1const assert = require("node:assert/strict");
2const test = require("node:test");
3const {
4 assertCommitOnDefaultBranch,
5 assertReleaseSource,
6 shouldReleaseNightly,
7} = require("./check-nightly-release.cjs");
8​
9const now = Date.parse("2026-09-05T12:00:00Z");
10const hour = 60 * 60 * 1000;
11const nightly = (hoursAgo, overrides = {}) => ({
12 tagName: "v1.0.1-nightly.20260905.123",
13 isDraft: false,
14 publishedAt: new Date(now - hoursAgo * hour).toISOString(),
15 ...overrides,
16});
17​
18function releaseSourceFixture({
19 comparisonStatus = "ahead",
20 eventName = "workflow_dispatch",
21 ref = "refs/heads/main",
22 sha = "candidate",
23} = {}) {
24 const calls = [];
25 return {
26 calls,
27 options: {
28 context: {
29 eventName,
30 payload: { repository: { default_branch: "main" } },
31 ref,
32 repo: { owner: "example", repo: "app" },
33 sha,
34 },
35 github: {
36 rest: {
37 repos: {
38 async compareCommitsWithBasehead(params) {
39 calls.push(params);
40 return { data: { status: comparisonStatus } };
41 },
42 },
43 },
44 },
45 },
46 };
48​
49test("allows preview releases from any branch without consulting main", async () => {
50 const { options, calls } = releaseSourceFixture({ ref: "refs/heads/feature" });
51 await assertReleaseSource({ ...options, releaseChannel: "preview" });
52 assert.equal(calls.length, 0);
53});
54​
55for (const releaseChannel of ["stable", "nightly"]) {
56 test(`rejects a manual ${releaseChannel} release from a feature branch`, async () => {
57 const { options, calls } = releaseSourceFixture({ ref: "refs/heads/feature" });
58 await assert.rejects(
59 assertReleaseSource({ ...options, releaseChannel }),
60 new RegExp(`${releaseChannel} releases must be dispatched from main`),
61 );
62 assert.equal(calls.length, 0);
63 });
65​
66for (const comparisonStatus of ["behind", "diverged"]) {
67 test(`rejects a release commit that is ${comparisonStatus} from main`, async () => {
68 const { options } = releaseSourceFixture({ comparisonStatus, eventName: "push" });
69 await assert.rejects(
70 assertCommitOnDefaultBranch({ ...options, sha: "release-commit" }),
71 new RegExp(`not contained in main \\(${comparisonStatus}\\)`),
72 );
73 });
75​
76// `pages` holds the GraphQL release pages; `releases` is shorthand for one page.
77function fixture({ releases = [nightly(7)], pages = [releases], comparisonStatus = "ahead" } = {}) {
78 const calls = [];
79 const cursors = [];
80 return {
81 calls,
82 cursors,
83 options: {
84 now,
85 context: { repo: { owner: "example", repo: "app" }, sha: "new" },
86 core: { info() {} },
87 github: {
88 rest: {
89 repos: {
90 async compareCommitsWithBasehead(params) {
91 calls.push(params);
92 return { data: { status: comparisonStatus } };
93 },
94 },
95 },
96 async graphql(_query, { owner, repo, cursor }) {
97 assert.deepEqual({ owner, repo }, { owner: "example", repo: "app" });
98 cursors.push(cursor);
99 const index = cursor === null ? 0 : Number(cursor);
100 const hasNextPage = index + 1 < pages.length;
101 return {
102 repository: {
103 releases: {
104 nodes: pages[index],
105 pageInfo: { hasNextPage, endCursor: hasNextPage ? String(index + 1) : null },
106 },
107 },
108 };
109 },
110 },
111 },
112 };
114​
115test("releases the first nightly when no nightly is published", async () => {
116 const { options } = fixture({
117 releases: [nightly(0, { tagName: "v1.0.0" }), nightly(0, { isDraft: true })],
118 });
119 assert.equal(await shouldReleaseNightly(options), true);
120});
121​
122test("uses publication time, not release order or the tagged commit date", async () => {
123 const { options } = fixture({
124 releases: [nightly(10), nightly(1), nightly(20, { tagName: "nightly-v0.9.0" })],
125 });
126 assert.equal(await shouldReleaseNightly(options), false);
127});
128​
129test("the six-hour gap ends exactly six hours after publication", async () => {
130 const atBoundary = fixture({ releases: [nightly(6)] });
131 assert.equal(await shouldReleaseNightly(atBoundary.options), true);
132 const justInside = fixture({ releases: [nightly(6 - 1 / 3_600_000)] });
133 assert.equal(await shouldReleaseNightly(justInside.options), false);
134 assert.equal(justInside.calls.length, 0);
135});
136​
137test("stops reading releases once a nightly inside the gap is seen", async () => {
138 const { options, cursors } = fixture({ pages: [[nightly(10), nightly(2)], [nightly(30)]] });
139 assert.equal(await shouldReleaseNightly(options), false);
140 assert.deepEqual(cursors, [null]);
141});
142​
143test("reads every page when no nightly seen so far settles the gap", async () => {
144 const { options, cursors, calls } = fixture({
145 pages: [
146 [nightly(10), nightly(0, { tagName: "v1.0.0" })],
147 [nightly(1, { tagName: "nightly-v0.9.0" })],
148 ],
149 });
150 assert.equal(await shouldReleaseNightly(options), false);
151 assert.deepEqual(cursors, [null, "1"]);
152 assert.equal(calls.length, 0);
153});
154​
155test("compares against the newest nightly across pages once the gap has passed", async () => {
156 const { options, calls } = fixture({
157 pages: [[nightly(10, { tagName: "v1.0.1-nightly.20260905.100" })], [nightly(8)]],
158 });
159 assert.equal(await shouldReleaseNightly(options), true);
160 assert.deepEqual(
161 calls.map((call) => call.basehead),
162 ["v1.0.1-nightly.20260905.123...new"],
163 );
164});
165​
166test("ignores stable releases and drafts when checking the gap", async () => {
167 const { options } = fixture({
168 releases: [nightly(0, { tagName: "v1.0.0" }), nightly(0, { isDraft: true }), nightly(7)],
169 });
170 assert.equal(await shouldReleaseNightly(options), true);
171});
172​
173test("fails instead of releasing when GitHub cannot supply release state", async () => {
174 const { options } = fixture();
175 options.github.graphql = async () => {
176 throw new Error("GitHub unavailable");
177 };
178 await assert.rejects(shouldReleaseNightly(options), /GitHub unavailable/);
179});
180​
181for (const status of ["behind", "diverged"]) {
182 test(`skips a candidate commit that is ${status} relative to the last nightly`, async () => {
183 const { options } = fixture({ comparisonStatus: status });
184 assert.equal(await shouldReleaseNightly(options), false);
185 });
187​
188const { resolveLatestNightlyCommit } = require("./check-nightly-release.cjs");
189​
190function nightlyCommitFixture({ releases, pages, commitSha = "abc123" }) {
191 const refs = [];
192 const { options } = fixture({ releases, pages });
193 options.github.rest.repos.getCommit = async ({ ref }) => {
194 refs.push(ref);
195 return { data: { sha: commitSha } };
196 };
197 return { options, refs };
199​
200test("stable releases resolve the commit of the newest published nightly", async () => {
201 const { options, refs } = nightlyCommitFixture({
202 releases: [
203 nightly(10, { tagName: "v1.0.1-nightly.20260905.100" }),
204 nightly(1, { tagName: "v1.0.1-nightly.20260905.123" }),
205 nightly(0, { tagName: "v1.0.0" }),
206 nightly(0, { isDraft: true, tagName: "v1.0.1-nightly.20260905.999" }),
207 ],
208 commitSha: "deadbeef",
209 });
210 assert.deepEqual(await resolveLatestNightlyCommit(options), {
211 tag: "v1.0.1-nightly.20260905.123",
212 sha: "deadbeef",
213 version: "1.0.1",
214 });
215 assert.deepEqual(refs, ["v1.0.1-nightly.20260905.123"]);
216});
217​
218test("stable releases derive the version from legacy nightly tags", async () => {
219 const { options } = nightlyCommitFixture({
220 releases: [nightly(1, { tagName: "nightly-v0.9.0-nightly.20260905.5" })],
221 });
222 assert.equal((await resolveLatestNightlyCommit(options)).version, "0.9.0");
223});
224​
225test("stable releases read every page, even after a recent nightly", async () => {
226 const { options, refs } = nightlyCommitFixture({
227 pages: [
228 [nightly(2, { tagName: "v1.0.1-nightly.20260905.100" })],
229 [nightly(1, { tagName: "v1.0.1-nightly.20260905.123" })],
230 ],
231 });
232 assert.equal((await resolveLatestNightlyCommit(options)).tag, "v1.0.1-nightly.20260905.123");
233 assert.deepEqual(refs, ["v1.0.1-nightly.20260905.123"]);
234});
235​
236test("stable releases fail when no nightly is published", async () => {
237 const { options } = nightlyCommitFixture({ releases: [nightly(1, { tagName: "v1.0.0" })] });
238 await assert.rejects(resolveLatestNightlyCommit(options), /No published nightly found/);
239});
240​
241const { readNightlyUpdateFeed } = require("./check-nightly-release.cjs");
242​
243// GitHub percent-encodes tag names in the feed, and any tag in the repository takes a slot.
244const atomEntry = (tag) =>
245 `<entry><link rel="alternate" type="text/html" href="https://github.com/example/app/releases/tag/${encodeURIComponent(tag)}"/></entry>`;
246const atomFeed = (tags) => new Response(`<feed>${tags.map(atomEntry).join("")}</feed>`);
247const NEWEST = "v1.0.1-nightly.20260905.123";
248const OLDER = "v1.0.1-nightly.20260905.100";
249​
250function updateFeedFixture({ feed, pointer }) {
251 const { options } = fixture({
252 releases: [nightly(1), nightly(0, { tagName: "v1.0.2-preview.1" })],
253 });
254 options.fetch = async (url) =>
255 url === "https://github.com/example/app/releases.atom"
256 ? feed
257 : url === "https://github.com/example/app/releases/download/update-feed/nightly.json"
258 ? pointer
259 : assert.fail(`unexpected fetch ${url}`);
260 return options;
262​
263const missing = () => new Response("Not Found", { status: 404 });
264// HTTP 200, then the connection drops before the body arrives.
265const brokenBody = () =>
266 new Response(new ReadableStream({ start: (controller) => controller.error(new Error("reset")) }));
267for (const [name, { feed, pointer }, expected] of [
268 [
269 "a nightly crowded out of the feed and a missing pointer",
270 { feed: atomFeed(["v1.0.2-preview.1", "mobile-android-build/2.3.0/abc"]), pointer: missing() },
271 { pointerTag: undefined, feedNightly: undefined },
272 ],
273 [
274 "the first nightly in the feed, which is the one older installs take",
275 { feed: atomFeed(["update-feed", OLDER, NEWEST]), pointer: Response.json({ tag_name: OLDER }) },
276 { pointerTag: OLDER, feedNightly: OLDER },
277 ],
278 [
279 "a malformed pointer as stale and an unreadable feed as naming nothing",
280 { feed: new Response("", { status: 503 }), pointer: Response.json(null) },
281 { pointerTag: undefined, feedNightly: undefined, unreadable: true },
282 ],
283 [
284 "an HTML page with release links as an unreadable feed",
285 {
286 feed: new Response(
287 `<html><feedback-form></feedback-form><a href="/example/app/releases/tag/${OLDER}">x</a></html>`,
288 ),
289 pointer: Response.json({ tag_name: NEWEST }),
290 },
291 { pointerTag: NEWEST, feedNightly: undefined, unreadable: true },
292 ],
293 [
294 "the pointer when the feed's body fails to arrive",
295 { feed: brokenBody(), pointer: Response.json({ tag_name: NEWEST }) },
296 { pointerTag: NEWEST, feedNightly: undefined, unreadable: true },
297 ],
298]) {
299 test(`update feed check reports ${name}`, async () => {
300 const { tag, pointerTag, feedNightly, feedTags } = await readNightlyUpdateFeed(
301 updateFeedFixture({ feed, pointer }),
302 );
303 const { unreadable = false, ...named } = expected;
304 assert.deepEqual({ tag, pointerTag, feedNightly }, { tag: NEWEST, ...named });
305 assert.equal(feedTags === undefined, unreadable);
306 });
308​
309for (const [name, pointer, error] of [
310 ["answers with an error", () => new Response("", { status: 503 }), /pointer answered HTTP 503/],
311 ["drops the connection", brokenBody, /reset/],
312]) {
313 test(`update feed check does not call a pointer stale when GitHub ${name}`, async () => {
314 const options = updateFeedFixture({ feed: atomFeed([NEWEST]), pointer: pointer() });
315 await assert.rejects(readNightlyUpdateFeed(options), error);
316 });