@stelstone/server 0.27.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/adapters/build-github-actions.mjs +104 -0
- package/src/adapters/index.mjs +1 -0
- package/src/core/adapter-options.mjs +62 -10
- package/src/core/config-schema.mjs +52 -1
- package/src/server.mjs +7 -2
- package/src/version.mjs +1 -1
package/package.json
CHANGED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Actions workflow runs as the deploy status source.
|
|
3
|
+
*
|
|
4
|
+
* For sites whose CI does the deploying — a Worker, a container, an rsync to
|
|
5
|
+
* a VPS — the workflow run IS the deploy, so its state is the honest thing to
|
|
6
|
+
* show. The adapter speaks the same vocabulary as the Netlify one so the
|
|
7
|
+
* admin's pill needs no per-provider knowledge.
|
|
8
|
+
*
|
|
9
|
+
* @param {Object} opts
|
|
10
|
+
* @param {string|undefined} opts.token repo-scoped token; `actions:read`
|
|
11
|
+
* @param {string|undefined} opts.owner
|
|
12
|
+
* @param {string|undefined} opts.repo
|
|
13
|
+
* @param {string|undefined} opts.workflow file name ("deploy.yml") or id;
|
|
14
|
+
* omit to watch every workflow
|
|
15
|
+
* @param {string|undefined} opts.defaultBranch
|
|
16
|
+
* @returns {import('./types.mjs').BuildAdapter}
|
|
17
|
+
*/
|
|
18
|
+
import { createGitHubApi } from "./github-api.mjs";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A finished run's conclusion, in the admin's state vocabulary.
|
|
22
|
+
*
|
|
23
|
+
* Anything that did not succeed but also did not fail outright — skipped by a
|
|
24
|
+
* path filter, superseded, stopped by hand — reads as "cancelled": the deploy
|
|
25
|
+
* did not happen, and that is not the same as a broken build.
|
|
26
|
+
*/
|
|
27
|
+
const CONCLUSION_STATE = {
|
|
28
|
+
success: "ready",
|
|
29
|
+
failure: "error",
|
|
30
|
+
timed_out: "error",
|
|
31
|
+
startup_failure: "error",
|
|
32
|
+
action_required: "error",
|
|
33
|
+
cancelled: "cancelled",
|
|
34
|
+
skipped: "cancelled",
|
|
35
|
+
stale: "cancelled",
|
|
36
|
+
neutral: "cancelled",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Queued and waiting-for-approval runs have not started; both are "queued". */
|
|
40
|
+
const PENDING_STATUSES = new Set(["queued", "pending", "waiting", "requested"]);
|
|
41
|
+
|
|
42
|
+
function runState(run) {
|
|
43
|
+
if (run.status === "completed") return CONCLUSION_STATE[run.conclusion] || "error";
|
|
44
|
+
return PENDING_STATUSES.has(run.status) ? "enqueued" : "building";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createGitHubActionsBuild({ token, owner, repo, workflow, defaultBranch }) {
|
|
48
|
+
const configured = Boolean(token && owner && repo);
|
|
49
|
+
const api = configured ? createGitHubApi({ token, owner, repo }) : null;
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
configured,
|
|
53
|
+
|
|
54
|
+
async getDeployStatus({ branch, sha } = {}) {
|
|
55
|
+
if (!configured) return { configured: false };
|
|
56
|
+
|
|
57
|
+
const query = new URLSearchParams({ per_page: "20" });
|
|
58
|
+
const targetBranch = branch || defaultBranch;
|
|
59
|
+
if (targetBranch) query.set("branch", targetBranch);
|
|
60
|
+
const path = workflow
|
|
61
|
+
? `/actions/workflows/${encodeURIComponent(workflow)}/runs?${query}`
|
|
62
|
+
: `/actions/runs?${query}`;
|
|
63
|
+
|
|
64
|
+
const body = await api.apiGet(path);
|
|
65
|
+
// apiGet answers null on 404 — for a run listing that means the repo,
|
|
66
|
+
// the workflow name or the token is wrong, not "no runs yet". Say so
|
|
67
|
+
// instead of returning an empty list the caller would poll forever.
|
|
68
|
+
if (!body) {
|
|
69
|
+
const err = new Error(`GitHub API 404 GET ${path}`);
|
|
70
|
+
err.upstreamStatus = 404;
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const runs = body.workflow_runs || [];
|
|
75
|
+
let run = null;
|
|
76
|
+
if (sha) {
|
|
77
|
+
run = runs.find((r) => r.head_sha && r.head_sha.startsWith(sha));
|
|
78
|
+
// SHA not there yet — the push has not produced a run, keep waiting.
|
|
79
|
+
if (!run) return { configured: true, deploy: null };
|
|
80
|
+
}
|
|
81
|
+
if (!run) run = runs[0] || null;
|
|
82
|
+
if (!run) return { configured: true, deploy: null };
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
configured: true,
|
|
86
|
+
deploy: {
|
|
87
|
+
id: run.id,
|
|
88
|
+
state: runState(run),
|
|
89
|
+
branch: run.head_branch,
|
|
90
|
+
commitRef: run.head_sha,
|
|
91
|
+
// Actions knows what it ran, not where the result is published, so
|
|
92
|
+
// the run page is the only link worth offering.
|
|
93
|
+
deployUrl: null,
|
|
94
|
+
adminUrl: run.html_url || null,
|
|
95
|
+
createdAt: run.created_at,
|
|
96
|
+
updatedAt: run.updated_at,
|
|
97
|
+
// A run carries no failure text; the run page has the logs.
|
|
98
|
+
errorMessage: null,
|
|
99
|
+
title: run.display_title || run.name || null,
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
package/src/adapters/index.mjs
CHANGED
|
@@ -8,5 +8,6 @@ export { createBasicAuth } from "./basic-auth.mjs";
|
|
|
8
8
|
export { createGitHubOAuth } from "./github-oauth.mjs";
|
|
9
9
|
export { createCloudflareAccess } from "./cloudflare-access.mjs";
|
|
10
10
|
export { createNetlifyBuild } from "./build-netlify.mjs";
|
|
11
|
+
export { createGitHubActionsBuild } from "./build-github-actions.mjs";
|
|
11
12
|
export { createMediaUrl } from "./media-url.mjs";
|
|
12
13
|
export { createResendMail } from "./resend-mail.mjs";
|
|
@@ -16,20 +16,29 @@
|
|
|
16
16
|
/** @typedef {(name: string) => string|undefined} SecretLookup */
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
19
|
+
* GITHUB_REPO ("owner/repo") overrides the config — set where the secrets
|
|
20
|
+
* already live, so a deploy-button user never edits a file. Lenient about
|
|
21
|
+
* pasted URLs: the last two path segments are the coordinates.
|
|
22
|
+
*
|
|
21
23
|
* @param {SecretLookup} secrets
|
|
24
|
+
* @returns {{owner?: string, repo?: string}}
|
|
22
25
|
*/
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
// already live, so a deploy-button user never edits a file. Lenient about
|
|
26
|
-
// pasted URLs: the last two path segments are the coordinates.
|
|
27
|
-
const repoEnv = String(secrets("GITHUB_REPO") ?? "")
|
|
26
|
+
function repoFromEnv(secrets) {
|
|
27
|
+
const parts = String(secrets("GITHUB_REPO") ?? "")
|
|
28
28
|
.replace(/\.git$/, "")
|
|
29
29
|
.split(/[/:]/)
|
|
30
30
|
.filter(Boolean)
|
|
31
31
|
.slice(-2);
|
|
32
|
-
|
|
32
|
+
return parts.length === 2 ? { owner: parts[0], repo: parts[1] } : {};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Options for `createGitHubContent`.
|
|
37
|
+
* @param {Object} config
|
|
38
|
+
* @param {SecretLookup} secrets
|
|
39
|
+
*/
|
|
40
|
+
export function githubContentOptions(config, secrets) {
|
|
41
|
+
const { owner: envOwner, repo: envRepo } = repoFromEnv(secrets);
|
|
33
42
|
return {
|
|
34
43
|
token: secrets(config.content.githubTokenEnv || "GITHUB_TOKEN"),
|
|
35
44
|
owner: envOwner || config.content.owner,
|
|
@@ -135,11 +144,54 @@ export function netlifyBuildOptions(config, secrets) {
|
|
|
135
144
|
return {
|
|
136
145
|
token: secrets(config.build?.netlifyTokenEnv || "NETLIFY_AUTH_TOKEN"),
|
|
137
146
|
siteId: secrets(config.build?.netlifySiteIdEnv || "NETLIFY_SITE_ID"),
|
|
138
|
-
defaultBranch:
|
|
139
|
-
secrets("STAGING_BRANCH") || config.content?.publishBranch || config.content?.branch,
|
|
147
|
+
defaultBranch: buildDefaultBranch(config, secrets),
|
|
140
148
|
};
|
|
141
149
|
}
|
|
142
150
|
|
|
151
|
+
/**
|
|
152
|
+
* The branch whose deploy the admin's status pill follows. `STAGING_BRANCH`
|
|
153
|
+
* wins for the same reason it does for content: it is what redirects a whole
|
|
154
|
+
* deployment onto another branch.
|
|
155
|
+
*/
|
|
156
|
+
function buildDefaultBranch(config, secrets) {
|
|
157
|
+
return secrets("STAGING_BRANCH") || config.content?.publishBranch || config.content?.branch;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Which build adapter to construct, and with what.
|
|
162
|
+
*
|
|
163
|
+
* Mirrors `authOptions`: the caller stays free of the naming rules and only
|
|
164
|
+
* has to map a provider name onto a factory. Netlify remains the default so
|
|
165
|
+
* existing configs keep working untouched.
|
|
166
|
+
*
|
|
167
|
+
* @param {Object} config
|
|
168
|
+
* @param {SecretLookup} secrets
|
|
169
|
+
* @returns {{provider: string, options: object}}
|
|
170
|
+
*/
|
|
171
|
+
export function buildOptions(config, secrets) {
|
|
172
|
+
const build = config.build ?? {};
|
|
173
|
+
const provider = build.provider ?? "netlify";
|
|
174
|
+
|
|
175
|
+
if (provider === "github-actions") {
|
|
176
|
+
// Defaults to the repo the content already comes from — for a site whose
|
|
177
|
+
// CI deploys it, that is nearly always the same repo, and the token that
|
|
178
|
+
// reads content can read its runs.
|
|
179
|
+
const { owner: envOwner, repo: envRepo } = repoFromEnv(secrets);
|
|
180
|
+
return {
|
|
181
|
+
provider,
|
|
182
|
+
options: {
|
|
183
|
+
token: secrets(build.githubTokenEnv || config.content?.githubTokenEnv || "GITHUB_TOKEN"),
|
|
184
|
+
owner: envOwner || build.owner || config.content?.owner,
|
|
185
|
+
repo: envRepo || build.repo || config.content?.repo,
|
|
186
|
+
workflow: build.workflow,
|
|
187
|
+
defaultBranch: buildDefaultBranch(config, secrets),
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return { provider: "netlify", options: netlifyBuildOptions(config, secrets) };
|
|
193
|
+
}
|
|
194
|
+
|
|
143
195
|
/**
|
|
144
196
|
* Options for `createCdnProxyMedia`.
|
|
145
197
|
* @param {Object} config
|
|
@@ -321,6 +321,53 @@ function checkForward(forward, at, report, getSecret) {
|
|
|
321
321
|
}
|
|
322
322
|
}
|
|
323
323
|
|
|
324
|
+
/**
|
|
325
|
+
* `build` decides where the admin's deploy pill gets its status. It is
|
|
326
|
+
* optional — a site with no CI simply shows no pill — but a misspelled
|
|
327
|
+
* provider silently falls back to Netlify, which for a site that does not use
|
|
328
|
+
* Netlify means a pill that never resolves. Name it.
|
|
329
|
+
*/
|
|
330
|
+
function checkBuild(config, report, { getSecret }) {
|
|
331
|
+
const build = config.build;
|
|
332
|
+
if (build === undefined) return;
|
|
333
|
+
if (typeof build !== "object" || build === null) {
|
|
334
|
+
report.error("build", "must be an object");
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const provider = build.provider ?? "netlify";
|
|
339
|
+
if (!["netlify", "github-actions"].includes(provider)) {
|
|
340
|
+
report.error("build.provider", `unknown provider "${provider}" — expected netlify or github-actions`);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (provider === "github-actions") {
|
|
345
|
+
const owner = build.owner || config.content?.owner;
|
|
346
|
+
const repo = build.repo || config.content?.repo;
|
|
347
|
+
// GITHUB_REPO can supply both at runtime, so this is a warning: the
|
|
348
|
+
// coordinates may well arrive from the environment.
|
|
349
|
+
if (!owner || !repo) {
|
|
350
|
+
report.warn(
|
|
351
|
+
"build",
|
|
352
|
+
"owner/repo not in the config — deploy status needs them, from build.owner/build.repo, content, or GITHUB_REPO",
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
const tokenEnv = build.githubTokenEnv || config.content?.githubTokenEnv || "GITHUB_TOKEN";
|
|
356
|
+
if (!getSecret(tokenEnv)) {
|
|
357
|
+
report.warn("build.githubTokenEnv", `${tokenEnv} is not set — deploy status is unavailable`);
|
|
358
|
+
}
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
for (const [key, fallback] of [
|
|
363
|
+
["netlifyTokenEnv", "NETLIFY_AUTH_TOKEN"],
|
|
364
|
+
["netlifySiteIdEnv", "NETLIFY_SITE_ID"],
|
|
365
|
+
]) {
|
|
366
|
+
const name = build[key] || fallback;
|
|
367
|
+
if (!getSecret(name)) report.warn(`build.${key}`, `${name} is not set — deploy status is unavailable`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
324
371
|
function checkMisc(config, report) {
|
|
325
372
|
const { locales, defaultLocale } = config;
|
|
326
373
|
if (locales !== undefined && !Array.isArray(locales)) {
|
|
@@ -372,6 +419,7 @@ export function validateConfig(config, { runtime = "node", getSecret } = {}) {
|
|
|
372
419
|
checkAuth(config, report, { getSecret: lookup });
|
|
373
420
|
checkCollections(config, report);
|
|
374
421
|
checkForms(config, report, { getSecret: lookup });
|
|
422
|
+
checkBuild(config, report, { getSecret: lookup });
|
|
375
423
|
checkMisc(config, report);
|
|
376
424
|
|
|
377
425
|
return { errors: report.errors, warnings: report.warnings };
|
|
@@ -445,7 +493,10 @@ export function describeConfig(config, { adapters, getSecret, runtime = "node" }
|
|
|
445
493
|
lines.push(`media ${config.media.cdnBase}${config.media.tenantId ? ` (tenant: ${config.media.tenantId})` : ""}`);
|
|
446
494
|
}
|
|
447
495
|
if (adapters?.build) {
|
|
448
|
-
|
|
496
|
+
const buildProvider = config.build?.provider ?? "netlify";
|
|
497
|
+
lines.push(
|
|
498
|
+
`build ${buildProvider} — ${adapters.build.configured ? "configured" : "not configured"}`,
|
|
499
|
+
);
|
|
449
500
|
}
|
|
450
501
|
|
|
451
502
|
const collections = Object.keys(config.collections || {});
|
package/src/server.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
fsContentOptions,
|
|
13
13
|
githubTemplatesOptions,
|
|
14
14
|
authOptions,
|
|
15
|
-
|
|
15
|
+
buildOptions,
|
|
16
16
|
cdnMediaOptions,
|
|
17
17
|
mailOptions,
|
|
18
18
|
} from "./core/adapter-options.mjs";
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
createGitHubOAuth,
|
|
26
26
|
createCloudflareAccess,
|
|
27
27
|
createNetlifyBuild,
|
|
28
|
+
createGitHubActionsBuild,
|
|
28
29
|
createFsTemplates,
|
|
29
30
|
createGitHubTemplates,
|
|
30
31
|
createResendMail,
|
|
@@ -79,7 +80,11 @@ export function createCmsServer({ config, rootDir, publicConfig: publicConfigFn,
|
|
|
79
80
|
// No media config → no CDN adapter; the media routes answer 404 instead
|
|
80
81
|
// of every /api request dying while the adapter bag is built.
|
|
81
82
|
const cdnMedia = config.media ? createCdnProxyMedia(cdnMediaOptions(config, auth)) : undefined;
|
|
82
|
-
const
|
|
83
|
+
const { provider: buildProvider, options: buildOpts } = buildOptions(config, secrets);
|
|
84
|
+
const build =
|
|
85
|
+
buildProvider === "github-actions"
|
|
86
|
+
? createGitHubActionsBuild(buildOpts)
|
|
87
|
+
: createNetlifyBuild(buildOpts);
|
|
83
88
|
|
|
84
89
|
const templates = useGitHub
|
|
85
90
|
? createGitHubTemplates(githubTemplatesOptions(config, secrets))
|
package/src/version.mjs
CHANGED