@stelstone/server 0.27.0 → 0.29.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.29.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": {
@@ -1,4 +1,5 @@
1
1
  import crypto from "crypto";
2
+ import { issueMediaToken as mintMediaToken } from "./media-token.mjs";
2
3
 
3
4
  /**
4
5
  * HTTP Basic auth + HMAC-SHA256 JWT for media tokens.
@@ -24,6 +25,8 @@ export function createBasicAuth({
24
25
  users,
25
26
  jwtSecret,
26
27
  jwtTtl,
28
+ mediaTokenTtl,
29
+ mediaKeyVersion,
27
30
  realm = "Admin",
28
31
  }) {
29
32
  // Normalise: prefer `users` array, fall back to single user/pass pair.
@@ -51,23 +54,17 @@ export function createBasicAuth({
51
54
  }
52
55
 
53
56
  function issueMediaToken(tenantId) {
54
- if (!jwtSecret) throw new Error("JWT_SECRET not set");
55
- const header = Buffer.from(
56
- JSON.stringify({ alg: "HS256", typ: "JWT" }),
57
- ).toString("base64url");
58
- const payload = Buffer.from(
59
- JSON.stringify({
60
- sub: user,
61
- tenant_id: tenantId,
62
- iat: Math.floor(Date.now() / 1000),
63
- exp: Math.floor(Date.now() / 1000) + jwtTtl,
64
- }),
65
- ).toString("base64url");
66
- const sig = crypto
67
- .createHmac("sha256", jwtSecret)
68
- .update(`${header}.${payload}`)
69
- .digest("base64url");
70
- return `${header}.${payload}.${sig}`;
57
+ // Derived per tenant, and short-lived — see adapters/media-token.mjs.
58
+ // It used to be signed with the root secret and live as long as an admin
59
+ // session (8h in some configs), which is both wider and longer than an
60
+ // upload needs.
61
+ return mintMediaToken({
62
+ root: jwtSecret,
63
+ tenantId,
64
+ sub: user,
65
+ ttl: mediaTokenTtl,
66
+ keyVersion: mediaKeyVersion,
67
+ });
71
68
  }
72
69
 
73
70
  /**
@@ -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
+ }
@@ -1,4 +1,5 @@
1
1
  import crypto from "crypto";
2
+ import { issueMediaToken as mintMediaToken } from "./media-token.mjs";
2
3
 
3
4
  /**
4
5
  * GitHub OAuth adapter — drop-in replacement for createBasicAuth.
@@ -37,6 +38,8 @@ export function createGitHubOAuth({
37
38
  roles = {},
38
39
  jwtSecret,
39
40
  jwtTtl = 8 * 60 * 60,
41
+ mediaTokenTtl,
42
+ mediaKeyVersion,
40
43
  defaultRole = "editor",
41
44
  realm = "Admin",
42
45
  }) {
@@ -143,7 +146,14 @@ export function createGitHubOAuth({
143
146
  /** @param {string} capability */
144
147
  supports: (capability) => ["mediaToken", "session", "oauth"].includes(capability),
145
148
  issueMediaToken(tenantId) {
146
- return issueToken({ sub: "media", tenant_id: tenantId, type: "media" });
149
+ // Not issueToken(): that signs a session with the root secret and the
150
+ // session TTL. A media token is derived per tenant and short-lived.
151
+ return mintMediaToken({
152
+ root: jwtSecret,
153
+ tenantId,
154
+ ttl: mediaTokenTtl,
155
+ keyVersion: mediaKeyVersion,
156
+ });
147
157
  },
148
158
  verify,
149
159
  issueSessionToken,
@@ -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";
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The token the admin sends to the media CDN when it uploads.
3
+ *
4
+ * It is signed with a key DERIVED from the CDN root secret, not with the root
5
+ * itself:
6
+ *
7
+ * key = HMAC-SHA256(root, "jwt:v1:<tenant_id>:<keyVersion>")
8
+ *
9
+ * The CDN derives the same key from its own copy of the root and compares. Two
10
+ * things follow, and both are the point:
11
+ *
12
+ * - The tenant id is bound into the key. A token minted for tenant A cannot
13
+ * be replayed as tenant B: changing the claim selects a key the holder
14
+ * cannot produce.
15
+ * - Rotating one tenant is a counter bump on that tenant's row, with a grace
16
+ * window in which the previous version still verifies. Nothing else has to
17
+ * be redeployed.
18
+ *
19
+ * Signing with the raw root — which is what this used to do — worked only
20
+ * because the CDN still accepts it (LEGACY_JWT). That path treats the root as
21
+ * a master key for every tenant at once, so it is being retired.
22
+ *
23
+ * `keyVersion` has to match what the CDN holds for the tenant. It defaults to
24
+ * 1, which is where a tenant starts; after a `tenants.mjs rotate-keys`, raise
25
+ * it here inside the grace window.
26
+ */
27
+ import crypto from "node:crypto";
28
+
29
+ /** Must match KEY_PURPOSE_JWT in the CDN's lambda/auth.mjs. */
30
+ const KEY_PURPOSE_JWT = "jwt";
31
+
32
+ /** Ten minutes. An upload takes seconds; the token has no reason to outlive it. */
33
+ export const DEFAULT_MEDIA_TOKEN_TTL = 600;
34
+
35
+ /**
36
+ * @param {string} root the CDN root secret
37
+ * @param {string} tenantId
38
+ * @param {number} keyVersion
39
+ */
40
+ export function deriveMediaKey(root, tenantId, keyVersion) {
41
+ return crypto
42
+ .createHmac("sha256", root)
43
+ .update(`${KEY_PURPOSE_JWT}:v1:${tenantId}:${keyVersion}`)
44
+ .digest();
45
+ }
46
+
47
+ /**
48
+ * @param {Object} opts
49
+ * @param {string} opts.root the CDN root secret (JWT_SECRET)
50
+ * @param {string} opts.tenantId
51
+ * @param {string} [opts.sub] who is uploading, for the CDN's logs
52
+ * @param {number} [opts.ttl] seconds; kept short on purpose
53
+ * @param {number} [opts.keyVersion]
54
+ * @returns {string} a compact JWS
55
+ */
56
+ export function issueMediaToken({ root, tenantId, sub = "media", ttl, keyVersion = 1 }) {
57
+ if (!root) throw new Error("JWT_SECRET not set");
58
+ if (!tenantId) throw new Error("media.tenantId not set — a media token names one tenant");
59
+
60
+ const b64 = (o) => Buffer.from(JSON.stringify(o)).toString("base64url");
61
+ const now = Math.floor(Date.now() / 1000);
62
+
63
+ // `kid` is not what the CDN selects on — it tries the tenant's active
64
+ // versions — but it makes a rejected token readable in a log.
65
+ const header = b64({ alg: "HS256", typ: "JWT", kid: `${tenantId}.v${keyVersion}` });
66
+ const payload = b64({
67
+ sub,
68
+ tenant_id: tenantId,
69
+ type: "media",
70
+ iat: now,
71
+ exp: now + (ttl ?? DEFAULT_MEDIA_TOKEN_TTL),
72
+ });
73
+
74
+ const key = deriveMediaKey(root, tenantId, keyVersion);
75
+ const sig = crypto.createHmac("sha256", key).update(`${header}.${payload}`).digest("base64url");
76
+ return `${header}.${payload}.${sig}`;
77
+ }
@@ -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,
@@ -84,6 +93,18 @@ export function githubTemplatesOptions(config, secrets) {
84
93
  * @param {SecretLookup} secrets
85
94
  * @returns {{ provider: "basic"|"github-oauth"|"cloudflare-access", options: Object }}
86
95
  */
96
+ /**
97
+ * How the admin's media tokens are signed. These live under `media` rather
98
+ * than `auth` because they describe the CDN tenant, not the login: the key is
99
+ * derived from the tenant id and the version the CDN holds for it.
100
+ */
101
+ function mediaTokenOptions(config) {
102
+ return {
103
+ mediaTokenTtl: config.media?.tokenTtl,
104
+ mediaKeyVersion: config.media?.keyVersion ?? 1,
105
+ };
106
+ }
107
+
87
108
  export function authOptions(config, secrets) {
88
109
  const auth = config.auth ?? {};
89
110
  const provider = auth.provider ?? "basic";
@@ -99,6 +120,7 @@ export function authOptions(config, secrets) {
99
120
  defaultRole: auth.defaultRole,
100
121
  jwtSecret: secrets(auth.jwtSecretEnv),
101
122
  jwtTtl: auth.jwtTtl,
123
+ ...mediaTokenOptions(config),
102
124
  },
103
125
  };
104
126
  }
@@ -126,6 +148,7 @@ export function authOptions(config, secrets) {
126
148
  })),
127
149
  jwtSecret: secrets(auth.jwtSecretEnv),
128
150
  jwtTtl: auth.jwtTtl,
151
+ ...mediaTokenOptions(config),
129
152
  },
130
153
  };
131
154
  }
@@ -135,11 +158,54 @@ export function netlifyBuildOptions(config, secrets) {
135
158
  return {
136
159
  token: secrets(config.build?.netlifyTokenEnv || "NETLIFY_AUTH_TOKEN"),
137
160
  siteId: secrets(config.build?.netlifySiteIdEnv || "NETLIFY_SITE_ID"),
138
- defaultBranch:
139
- secrets("STAGING_BRANCH") || config.content?.publishBranch || config.content?.branch,
161
+ defaultBranch: buildDefaultBranch(config, secrets),
140
162
  };
141
163
  }
142
164
 
165
+ /**
166
+ * The branch whose deploy the admin's status pill follows. `STAGING_BRANCH`
167
+ * wins for the same reason it does for content: it is what redirects a whole
168
+ * deployment onto another branch.
169
+ */
170
+ function buildDefaultBranch(config, secrets) {
171
+ return secrets("STAGING_BRANCH") || config.content?.publishBranch || config.content?.branch;
172
+ }
173
+
174
+ /**
175
+ * Which build adapter to construct, and with what.
176
+ *
177
+ * Mirrors `authOptions`: the caller stays free of the naming rules and only
178
+ * has to map a provider name onto a factory. Netlify remains the default so
179
+ * existing configs keep working untouched.
180
+ *
181
+ * @param {Object} config
182
+ * @param {SecretLookup} secrets
183
+ * @returns {{provider: string, options: object}}
184
+ */
185
+ export function buildOptions(config, secrets) {
186
+ const build = config.build ?? {};
187
+ const provider = build.provider ?? "netlify";
188
+
189
+ if (provider === "github-actions") {
190
+ // Defaults to the repo the content already comes from — for a site whose
191
+ // CI deploys it, that is nearly always the same repo, and the token that
192
+ // reads content can read its runs.
193
+ const { owner: envOwner, repo: envRepo } = repoFromEnv(secrets);
194
+ return {
195
+ provider,
196
+ options: {
197
+ token: secrets(build.githubTokenEnv || config.content?.githubTokenEnv || "GITHUB_TOKEN"),
198
+ owner: envOwner || build.owner || config.content?.owner,
199
+ repo: envRepo || build.repo || config.content?.repo,
200
+ workflow: build.workflow,
201
+ defaultBranch: buildDefaultBranch(config, secrets),
202
+ },
203
+ };
204
+ }
205
+
206
+ return { provider: "netlify", options: netlifyBuildOptions(config, secrets) };
207
+ }
208
+
143
209
  /**
144
210
  * Options for `createCdnProxyMedia`.
145
211
  * @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)) {
@@ -332,6 +379,21 @@ function checkMisc(config, report) {
332
379
  if (config.media !== undefined) {
333
380
  if (!isPlainObject(config.media)) report.error("media", "must be an object");
334
381
  else if (!config.media.cdnBase) report.error("media.cdnBase", "is required when media is configured");
382
+ else {
383
+ const { tokenTtl, keyVersion } = config.media;
384
+ if (tokenTtl !== undefined && (!Number.isInteger(tokenTtl) || tokenTtl < 1)) {
385
+ report.error("media.tokenTtl", "must be a positive integer (seconds)");
386
+ }
387
+ // An upload takes seconds. A long-lived media token is a credential
388
+ // sitting in a browser for no reason, and the CDN may refuse it outright
389
+ // when MAX_TOKEN_TTL is set below this.
390
+ if (Number.isInteger(tokenTtl) && tokenTtl > 3600) {
391
+ report.warn("media.tokenTtl", `${tokenTtl}s is long for an upload — the CDN may cap it`);
392
+ }
393
+ if (keyVersion !== undefined && (!Number.isInteger(keyVersion) || keyVersion < 1)) {
394
+ report.error("media.keyVersion", "must be a positive integer — the CDN's key version for this tenant");
395
+ }
396
+ }
335
397
  }
336
398
 
337
399
  if (config.previewUrl !== undefined && typeof config.previewUrl !== "function") {
@@ -372,6 +434,7 @@ export function validateConfig(config, { runtime = "node", getSecret } = {}) {
372
434
  checkAuth(config, report, { getSecret: lookup });
373
435
  checkCollections(config, report);
374
436
  checkForms(config, report, { getSecret: lookup });
437
+ checkBuild(config, report, { getSecret: lookup });
375
438
  checkMisc(config, report);
376
439
 
377
440
  return { errors: report.errors, warnings: report.warnings };
@@ -445,7 +508,10 @@ export function describeConfig(config, { adapters, getSecret, runtime = "node" }
445
508
  lines.push(`media ${config.media.cdnBase}${config.media.tenantId ? ` (tenant: ${config.media.tenantId})` : ""}`);
446
509
  }
447
510
  if (adapters?.build) {
448
- lines.push(`build netlify ${adapters.build.configured ? "configured" : "not configured"}`);
511
+ const buildProvider = config.build?.provider ?? "netlify";
512
+ lines.push(
513
+ `build ${buildProvider} — ${adapters.build.configured ? "configured" : "not configured"}`,
514
+ );
449
515
  }
450
516
 
451
517
  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.29.0";