@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stelstone/server",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "Runtime-agnostic CMS server built on the Web Fetch API, with pluggable adapters for content, media, auth, and build.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
+ }
@@ -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
- * Options for `createGitHubContent`.
20
- * @param {Object} config
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
- export function githubContentOptions(config, secrets) {
24
- // GITHUB_REPO ("owner/repo") overrides the config — set where the secrets
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
- const [envOwner, envRepo] = repoEnv.length === 2 ? repoEnv : [];
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
- lines.push(`build netlify ${adapters.build.configured ? "configured" : "not configured"}`);
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
- netlifyBuildOptions,
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 build = createNetlifyBuild(netlifyBuildOptions(config, secrets));
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
@@ -5,4 +5,4 @@
5
5
  * require() and no import.meta.url, so reading the manifest at runtime yields
6
6
  * "unknown" there.
7
7
  */
8
- export const SERVER_VERSION = "0.27.0";
8
+ export const SERVER_VERSION = "0.28.0";