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 tag_name: "v1.0.1-nightly.20260905.123",
13 draft: false,
14 published_at: 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 };
47}
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 });
64
65 test(`allows a manual ${releaseChannel} release from main`, async () => {
66 const { options, calls } = releaseSourceFixture();
67 await assertReleaseSource({ ...options, releaseChannel });
68 assert.equal(calls[0].basehead, "candidate...main");
69 });
70}
71
72for (const comparisonStatus of ["behind", "diverged"]) {
73 test(`rejects a release commit that is ${comparisonStatus} from main`, async () => {
74 const { options } = releaseSourceFixture({ comparisonStatus, eventName: "push" });
75 await assert.rejects(
76 assertCommitOnDefaultBranch({ ...options, sha: "release-commit" }),
77 new RegExp(`not contained in main \\(${comparisonStatus}\\)`),
78 );
79 });
80}
81
82test("accepts a release commit already contained in main", async () => {
83 for (const comparisonStatus of ["ahead", "identical"]) {
84 const { options } = releaseSourceFixture({ comparisonStatus, eventName: "push" });
85 await assertCommitOnDefaultBranch({ ...options, sha: "release-commit" });
86 }
87});
88
89function fixture({ releases = [nightly(7)], comparisonStatus = "ahead" } = {}) {
90 const calls = [];
91 return {
92 calls,
93 options: {
94 now,
95 context: { repo: { owner: "example", repo: "app" }, sha: "new" },
96 core: { info() {} },
97 github: {
98 rest: {
99 repos: {
100 listReleases() {},
101 async compareCommitsWithBasehead(params) {
102 calls.push(params);
103 return { data: { status: comparisonStatus } };
104 },
105 },
106 },
107 async paginate() {
108 return releases;
109 },
110 },
111 },
112 };
113}
114
115test("releases the first nightly when no nightly is published", async () => {
116 const { options } = fixture({
117 releases: [nightly(0, { tag_name: "v1.0.0" }), nightly(0, { draft: true })],
118 });
119 assert.equal(await shouldReleaseNightly(options), true);
120});
121
122test("waits six hours after publication, including manual nightlies", async () => {
123 for (const age of [0, 3, 6 - 1 / 3600]) {
124 const { options, calls } = fixture({ releases: [nightly(age)] });
125 assert.equal(await shouldReleaseNightly(options), false);
126 assert.equal(calls.length, 0);
127 }
128});
129
130test("releases new commits at six hours and after an idle period", async () => {
131 for (const age of [6, 7, 24]) {
132 const { options } = fixture({ releases: [nightly(age)] });
133 assert.equal(await shouldReleaseNightly(options), true);
134 }
135});
136
137test("skips unchanged commits after the gap", async () => {
138 const { options } = fixture({ comparisonStatus: "identical" });
139 assert.equal(await shouldReleaseNightly(options), false);
140});
141
142test("uses publication time, not release order or the tagged commit date", async () => {
143 const { options } = fixture({
144 releases: [nightly(10), nightly(1), nightly(20, { tag_name: "nightly-v0.9.0" })],
145 });
146 assert.equal(await shouldReleaseNightly(options), false);
147});
148
149test("ignores stable releases and drafts when checking the gap", async () => {
150 const { options } = fixture({
151 releases: [nightly(0, { tag_name: "v1.0.0" }), nightly(0, { draft: true }), nightly(7)],
152 });
153 assert.equal(await shouldReleaseNightly(options), true);
154});
155
156test("compares against the published tag, including legacy nightly tags", async () => {
157 const tag = "nightly-v0.9.0";
158 const { options, calls } = fixture({ releases: [nightly(7, { tag_name: tag })] });
159 assert.equal(await shouldReleaseNightly(options), true);
160 assert.equal(calls[0].basehead, `${tag}...new`);
161});
162
163test("fails instead of releasing when GitHub cannot supply release state", async () => {
164 const { options } = fixture();
165 options.github.paginate = async () => {
166 throw new Error("GitHub unavailable");
167 };
168 await assert.rejects(shouldReleaseNightly(options), /GitHub unavailable/);
169});
170
171for (const status of ["behind", "diverged"]) {
172 test(`skips a candidate commit that is ${status} relative to the last nightly`, async () => {
173 const { options } = fixture({ comparisonStatus: status });
174 assert.equal(await shouldReleaseNightly(options), false);
175 });
176}
177
178const { resolveLatestNightlyCommit } = require("./check-nightly-release.cjs");
179
180function nightlyCommitFixture({ releases, commitSha = "abc123" }) {
181 const refs = [];
182 const { options } = fixture({ releases });
183 options.github.rest.repos.getCommit = async ({ ref }) => {
184 refs.push(ref);
185 return { data: { sha: commitSha } };
186 };
187 return { options, refs };
188}
189
190test("stable releases resolve the commit of the newest published nightly", async () => {
191 const { options, refs } = nightlyCommitFixture({
192 releases: [
193 nightly(10, { tag_name: "v1.0.1-nightly.20260905.100" }),
194 nightly(1, { tag_name: "v1.0.1-nightly.20260905.123" }),
195 nightly(0, { tag_name: "v1.0.0" }),
196 nightly(0, { draft: true, tag_name: "v1.0.1-nightly.20260905.999" }),
197 ],
198 commitSha: "deadbeef",
199 });
200 assert.deepEqual(await resolveLatestNightlyCommit(options), {
201 tag: "v1.0.1-nightly.20260905.123",
202 sha: "deadbeef",
203 version: "1.0.1",
204 });
205 assert.deepEqual(refs, ["v1.0.1-nightly.20260905.123"]);
206});
207
208test("stable releases derive the version from legacy nightly tags", async () => {
209 const { options } = nightlyCommitFixture({
210 releases: [nightly(1, { tag_name: "nightly-v0.9.0-nightly.20260905.5" })],
211 });
212 assert.equal((await resolveLatestNightlyCommit(options)).version, "0.9.0");
213});
214
215test("stable releases fail without a published nightly", async () => {
216 const { options } = nightlyCommitFixture({ releases: [nightly(0, { tag_name: "v1.0.0" })] });
217 await assert.rejects(resolveLatestNightlyCommit(options), /No published nightly/);
218});