canopycms 0.7.1 → 0.8.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.
Files changed (147) hide show
  1. package/CHANGELOG.md +187 -47
  2. package/dist/api/admin-branch-health.js +47 -20
  3. package/dist/api/admin.d.ts +13 -1
  4. package/dist/api/admin.js +14 -24
  5. package/dist/api/branch-changes.d.ts +87 -0
  6. package/dist/api/branch-changes.js +269 -0
  7. package/dist/api/branch-entry-changes.d.ts +83 -0
  8. package/dist/api/branch-entry-changes.js +262 -0
  9. package/dist/api/branch-merge.js +2 -2
  10. package/dist/api/branch-review.js +35 -4
  11. package/dist/api/branch-status.js +14 -5
  12. package/dist/api/branch-withdraw.js +7 -0
  13. package/dist/api/branch.d.ts +13 -1
  14. package/dist/api/branch.js +171 -97
  15. package/dist/api/client.d.ts +15 -0
  16. package/dist/api/client.js +13 -1
  17. package/dist/api/content.d.ts +18 -18
  18. package/dist/api/content.js +1 -1
  19. package/dist/api/github-sync.js +7 -3
  20. package/dist/api/groups.d.ts +4 -4
  21. package/dist/api/pr-comment-sync.d.ts +9 -8
  22. package/dist/api/pr-comment-sync.js +23 -13
  23. package/dist/api/routes.js +4 -0
  24. package/dist/api/types.d.ts +3 -1
  25. package/dist/authorization/groups/schema.d.ts +4 -4
  26. package/dist/authorization/protected-branch.d.ts +3 -3
  27. package/dist/authorization/protected-branch.js +3 -3
  28. package/dist/branch-metadata-file.d.ts +84 -0
  29. package/dist/branch-metadata-file.js +9 -0
  30. package/dist/branch-metadata.d.ts +4 -4
  31. package/dist/branch-metadata.js +4 -5
  32. package/dist/branch-param.d.ts +24 -0
  33. package/dist/branch-param.js +87 -0
  34. package/dist/branch-provisioning.d.ts +6 -3
  35. package/dist/branch-provisioning.js +12 -10
  36. package/dist/branch-registry.d.ts +6 -0
  37. package/dist/branch-registry.js +12 -3
  38. package/dist/branch-workspace.js +22 -15
  39. package/dist/cli/cli.js +1060 -172
  40. package/dist/cli/generate-ai-content.js +732 -101
  41. package/dist/cli/init.js +126 -0
  42. package/dist/comment-store.d.ts +3 -0
  43. package/dist/comment-store.js +4 -1
  44. package/dist/config/schemas/config.d.ts +3 -0
  45. package/dist/config/schemas/config.js +2 -0
  46. package/dist/config/types.d.ts +11 -0
  47. package/dist/content-id-index.d.ts +5 -0
  48. package/dist/content-id-index.js +1 -1
  49. package/dist/content-listing.d.ts +53 -0
  50. package/dist/content-listing.js +123 -27
  51. package/dist/content-reader.js +23 -16
  52. package/dist/context.js +8 -7
  53. package/dist/editor/BranchManager.d.ts +7 -3
  54. package/dist/editor/BranchManager.js +51 -6
  55. package/dist/editor/Editor.js +166 -34
  56. package/dist/editor/EntryNavigator.d.ts +34 -10
  57. package/dist/editor/EntryNavigator.js +185 -29
  58. package/dist/editor/admin/SystemHealthPanel.js +36 -1
  59. package/dist/editor/admin/useSystemHealth.js +1 -0
  60. package/dist/editor/branch-names.d.ts +43 -0
  61. package/dist/editor/branch-names.js +75 -0
  62. package/dist/editor/components/BranchPill.d.ts +2 -2
  63. package/dist/editor/components/BranchPill.js +2 -2
  64. package/dist/editor/components/EditorHeader.d.ts +3 -1
  65. package/dist/editor/components/EditorHeader.js +2 -2
  66. package/dist/editor/components/EntryChangesDrawer.d.ts +51 -0
  67. package/dist/editor/components/EntryChangesDrawer.js +363 -0
  68. package/dist/editor/components/EntryChangesSkeleton.d.ts +9 -0
  69. package/dist/editor/components/EntryChangesSkeleton.js +7 -0
  70. package/dist/editor/components/EntryHeader.d.ts +9 -1
  71. package/dist/editor/components/EntryHeader.js +25 -3
  72. package/dist/editor/components/LazyEntryChangesDrawer.d.ts +16 -0
  73. package/dist/editor/components/LazyEntryChangesDrawer.js +45 -0
  74. package/dist/editor/components/ReviewDialogs.js +3 -3
  75. package/dist/editor/context/ApiClientContext.d.ts +9 -0
  76. package/dist/editor/context/ApiClientContext.js +26 -2
  77. package/dist/editor/context/index.d.ts +1 -1
  78. package/dist/editor/context/index.js +1 -1
  79. package/dist/editor/copy.d.ts +1 -0
  80. package/dist/editor/copy.js +1 -0
  81. package/dist/editor/editor-utils.d.ts +2 -0
  82. package/dist/editor/editor-utils.js +15 -0
  83. package/dist/editor/entry-diff.d.ts +143 -0
  84. package/dist/editor/entry-diff.js +593 -0
  85. package/dist/editor/hooks/create-branch-request.d.ts +11 -1
  86. package/dist/editor/hooks/create-branch-request.js +16 -6
  87. package/dist/editor/hooks/useBranchActions.d.ts +19 -2
  88. package/dist/editor/hooks/useBranchActions.js +29 -20
  89. package/dist/editor/hooks/useBranchChangesData.d.ts +57 -0
  90. package/dist/editor/hooks/useBranchChangesData.js +134 -0
  91. package/dist/editor/hooks/useBranchManager.d.ts +16 -0
  92. package/dist/editor/hooks/useBranchManager.js +142 -54
  93. package/dist/editor/hooks/useDraftManager.js +6 -2
  94. package/dist/editor/hooks/useEntryManager.d.ts +18 -2
  95. package/dist/editor/hooks/useEntryManager.js +36 -10
  96. package/dist/editor/utils/editor-errors.d.ts +5 -2
  97. package/dist/git-manager.d.ts +71 -0
  98. package/dist/git-manager.js +270 -1
  99. package/dist/http/handler.js +11 -9
  100. package/dist/operating-mode/branch-namespace-fixtures.d.ts +17 -0
  101. package/dist/operating-mode/branch-namespace-fixtures.js +65 -0
  102. package/dist/operating-mode/branch-namespace.d.ts +34 -0
  103. package/dist/operating-mode/branch-namespace.js +121 -0
  104. package/dist/operating-mode/client-unsafe-strategy.js +7 -0
  105. package/dist/operating-mode/types.d.ts +8 -0
  106. package/dist/paths/branch-name.d.ts +11 -19
  107. package/dist/paths/branch-name.js +17 -27
  108. package/dist/paths/index.d.ts +2 -2
  109. package/dist/paths/index.js +2 -2
  110. package/dist/paths/resolve.d.ts +0 -1
  111. package/dist/paths/resolve.js +0 -1
  112. package/dist/paths/validation.d.ts +40 -1
  113. package/dist/paths/validation.js +83 -4
  114. package/dist/pr-comments.d.ts +17 -9
  115. package/dist/pr-comments.js +53 -15
  116. package/dist/services.js +2 -1
  117. package/dist/submission-attribution.d.ts +22 -0
  118. package/dist/submission-attribution.js +62 -6
  119. package/dist/task-queue/worker-status.d.ts +6 -1
  120. package/dist/task-queue/worker-status.js +23 -2
  121. package/dist/types.d.ts +59 -0
  122. package/dist/version.d.ts +1 -1
  123. package/dist/version.js +1 -1
  124. package/dist/worker/branch-policy.d.ts +111 -16
  125. package/dist/worker/branch-policy.js +222 -17
  126. package/dist/worker/cms-worker.d.ts +24 -2
  127. package/dist/worker/cms-worker.js +35 -24
  128. package/dist/worker/git-sync.d.ts +3 -3
  129. package/dist/worker/git-sync.js +42 -1
  130. package/dist/worker/github-gateway-http.js +2 -2
  131. package/dist/worker/github-gateway-wire.d.ts +40 -25
  132. package/dist/worker/github-gateway-wire.js +14 -3
  133. package/dist/worker/github-gateway.d.ts +17 -6
  134. package/dist/worker/github-gateway.js +62 -6
  135. package/dist/worker/github-mirror.d.ts +7 -4
  136. package/dist/worker/github-mirror.js +27 -16
  137. package/dist/worker/history-rewrite.d.ts +23 -10
  138. package/dist/worker/history-rewrite.js +43 -20
  139. package/dist/worker/legacy-branch-fixtures.d.ts +21 -0
  140. package/dist/worker/legacy-branch-fixtures.js +71 -0
  141. package/dist/worker/rebase.d.ts +2 -1
  142. package/dist/worker/rebase.js +93 -36
  143. package/dist/worker/task-runner.d.ts +4 -1
  144. package/dist/worker/task-runner.js +77 -48
  145. package/dist/worker/unpublishable-branches.d.ts +34 -0
  146. package/dist/worker/unpublishable-branches.js +182 -0
  147. package/package.json +2 -1
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * The GitHub gateway's branch policy: what it never does to GitHub, whatever the worker asks. The
3
3
  * task queue, remote.git and every branch's metadata are on the shared workspace, which the CMS
4
- * Lambda can write, so nothing here takes a branch to protect from a request.
4
+ * Lambda can write, so nothing here takes a branch to protect, or one to allow, from a request.
5
5
  */
6
+ import { DEFAULT_BRANCH_NAMESPACE_TEMPLATE, branchNamespaceProblem, namespaceAncestors, resolveBranchNamespaceTemplate, } from '../operating-mode/branch-namespace.js';
6
7
  import { RESERVED_SETTINGS_BRANCH_PREFIX, sanitizeBranchName } from '../paths/branch-name.js';
7
8
  /**
8
9
  * How long GitHub's default branch, once read, is taken to be still the default.
@@ -40,6 +41,46 @@ const openingFrom = (head) => `Refusing to open a pull request from ${JSON.strin
40
41
  const PROTECTED_WHY = "it is protected (the base branch, GitHub's default branch, or a name protectedBranches " +
41
42
  'lists), which the CanopyCMS worker never changes. Nothing in CanopyCMS queues this; find out ' +
42
43
  'what wrote this task.';
44
+ const outsideNamespaceWhy = (namespace) => `it is outside this deployment's branch namespace (names starting ${namespace}), and is ` +
45
+ 'neither its settings branch nor on its legacyBranches list. CanopyCMS creates every branch ' +
46
+ 'it edits in that namespace; a branch made before it reaches GitHub only once ' +
47
+ 'legacyBranches lists it.';
48
+ const outsideNamespaceDeleteWhy = (namespace) => `it is outside this deployment's branch namespace (names starting ${namespace}), and is ` +
49
+ 'neither its settings branch nor on its legacyBranches list, so the branch stays on GitHub. ' +
50
+ 'To delete it there, add it to legacyBranches, retry this task in System health, then remove ' +
51
+ 'the entry.';
52
+ /**
53
+ * Why the policy refuses to push `branch`, for `reason`, in `namespace`.
54
+ * @internal Exported for tests.
55
+ */
56
+ export function pushRefusalMessage(branch, reason, namespace) {
57
+ const refusing = `Refusing to push ${JSON.stringify(branch)} to GitHub`;
58
+ switch (reason) {
59
+ case 'protected':
60
+ return `${refusing}: ${PROTECTED_WHY}`;
61
+ case 'settings':
62
+ return (`${refusing} under a lease: a settings branch only ever fast-forwards. Find out what ` +
63
+ 'set a history-rewrite marker on it.');
64
+ case 'other-settings':
65
+ return (`${refusing}: it is a settings branch, and not this deployment's, which is the only one ` +
66
+ 'this gateway pushes. Check that CANOPYCMS_DEPLOYMENT_NAME and CANOPYCMS_SETTINGS_BRANCH ' +
67
+ "match the CMS's own settings branch; if they do, find out what asked for this push.");
68
+ case 'outside-namespace':
69
+ return `${refusing}: ${outsideNamespaceWhy(namespace)}`;
70
+ }
71
+ }
72
+ /** Why the gateway refuses to push `branch`, in the namespace, while GitHub has `blocker`. */
73
+ export function namespaceBlockedMessage(branch, blocker) {
74
+ return (`Refusing to push ${JSON.stringify(branch)} to GitHub: GitHub has a branch named ` +
75
+ `${JSON.stringify(blocker)}, which is part of this deployment's branch namespace, and git ` +
76
+ 'cannot hold a branch inside another. Rename or delete that branch on GitHub.');
77
+ }
78
+ /** Why a push of `branch` is refused while GitHub has `inside`, a branch inside it. */
79
+ export function branchInsideMessage(branch, inside) {
80
+ return (`Refusing to push ${JSON.stringify(branch)} to GitHub: GitHub has a branch named ` +
81
+ `${JSON.stringify(inside)} inside it, and git cannot hold both. Rename or delete that ` +
82
+ 'branch on GitHub.');
83
+ }
43
84
  /**
44
85
  * Why `branch` is not a name the gateway takes, before git sees it, or null. Printable ASCII only,
45
86
  * so no two names differ invisibly; no `"`, `%`, `{` or `}`, which reach URLs and log lines; no
@@ -88,6 +129,32 @@ export function protectedPatternProblem(pattern) {
88
129
  }
89
130
  return null;
90
131
  }
132
+ /**
133
+ * Why `entry` cannot be a `legacyBranches` entry, whatever the namespace, or null. An entry is an
134
+ * exact branch name git can push: a `*` would admit names no one listed, and one alone would
135
+ * switch the namespace off. A comma separates entries in `CANOPYCMS_LEGACY_BRANCHES`. The CDK's
136
+ * `assertValidLegacyBranches` holds a copy of these rules and the constructor's, both held to
137
+ * worker/legacy-branch-fixtures.ts.
138
+ * @internal Exported for tests.
139
+ */
140
+ export function legacyBranchProblem(entry) {
141
+ if (entry.includes('*'))
142
+ return 'an entry is an exact branch name, and * is not a wildcard here';
143
+ if (entry.includes(','))
144
+ return 'it contains a comma, which separates entries';
145
+ if (entry.startsWith(RESERVED_SETTINGS_BRANCH_PREFIX)) {
146
+ return 'it is a settings branch, which the gateway pushes without being listed';
147
+ }
148
+ const problem = protectedPatternProblem(entry);
149
+ if (problem !== null)
150
+ return problem;
151
+ if (entry === 'HEAD' || entry === '@')
152
+ return `git reserves ${JSON.stringify(entry)}`;
153
+ if (entry.split('/').some((part) => part.startsWith('.') || part.endsWith('.lock'))) {
154
+ return "a part of it starts with '.' or ends with '.lock', which git refuses";
155
+ }
156
+ return null;
157
+ }
91
158
  /**
92
159
  * Whether `name` matches `pattern`, where `*` matches any run of characters. Never a regex.
93
160
  * @internal Exported for tests.
@@ -117,8 +184,16 @@ export function matchesBranchPattern(pattern, name) {
117
184
  * branch (`describe()`), so its bound is one above this.
118
185
  */
119
186
  export const MAX_PROTECTED_BRANCHES = 1024;
187
+ /** The most `legacyBranches` entries a policy takes. */
188
+ export const MAX_LEGACY_BRANCHES = 1024;
120
189
  /**
121
- * The protected set and the settings rules.
190
+ * The namespace a policy enforces when it is given none: the default template's, for the default
191
+ * deployment name.
192
+ * @internal Exported for tests.
193
+ */
194
+ export const DEFAULT_BRANCH_NAMESPACE = resolveBranchNamespaceTemplate(DEFAULT_BRANCH_NAMESPACE_TEMPLATE, 'prod', 'the default');
195
+ /**
196
+ * The protected set, the namespace and the settings rules.
122
197
  *
123
198
  * A branch is protected when it is the configured base branch, GitHub's default branch or a
124
199
  * `protectedBranches` entry, compared case-sensitively as GitHub compares refs. An exact entry
@@ -129,15 +204,47 @@ export const MAX_PROTECTED_BRANCHES = 1024;
129
204
  * Reads are not shared between concurrent callers, because a push reads inside a mirror session
130
205
  * and an operation that joined it from outside one would wait on that session.
131
206
  *
207
+ * Nothing outside the namespace is written: a push, deletion or pull request head must start with
208
+ * the namespace, be this deployment's settings branch, or be a `legacyBranches` entry, compared
209
+ * exactly. Sanitized equivalence, right for the protected set where matching more refuses more,
210
+ * would let an allow-list admit more. The protected set wins inside the namespace too. The
211
+ * namespace comes from the gateway's own configuration, never a request. The base and settings
212
+ * branches must lie outside it, by name and by workspace directory, and not be named after part of
213
+ * it, since git then holds no branch in it; a legacy entry inside it is refused as pointless, and
214
+ * one named after part of it as blocking it.
215
+ *
132
216
  * Settings branches are pushed plainly or not at all: no lease, no deletion, no pull request.
133
- * One is a name with the reserved prefix, the task runner's own test (`isSettingsBranch`): branch
134
- * creation refuses the prefix, while a configured settings name without it could be a content
135
- * branch's.
217
+ * One is a name with the reserved prefix or this deployment's settings branch. Only this
218
+ * deployment's is pushed: another deployment sharing the repository owns its own.
136
219
  */
137
220
  export class BranchPolicy {
138
221
  constructor(options = {}) {
139
222
  this.githubDefault = null;
140
223
  this.now = options.now ?? (() => Date.now());
224
+ this.namespace = options.namespace ?? DEFAULT_BRANCH_NAMESPACE;
225
+ const namespaceProblem = branchNamespaceProblem(this.namespace);
226
+ if (namespaceProblem !== null) {
227
+ throw new Error(`Invalid branch namespace ${JSON.stringify(this.namespace)}: ${namespaceProblem}`);
228
+ }
229
+ for (const [what, name] of [
230
+ ['base branch', options.baseBranch],
231
+ ['settings branch', options.settingsBranch],
232
+ ]) {
233
+ // Sanitized too: a workspace directory is the sanitized name, so `cms-prod-main` would share
234
+ // one with `cms/prod/main`.
235
+ if (name !== undefined &&
236
+ (name.startsWith(this.namespace) ||
237
+ sanitizeBranchName(name).startsWith(sanitizeBranchName(this.namespace)))) {
238
+ throw new Error(`The ${what} ${JSON.stringify(name)} is inside the branch namespace ${JSON.stringify(this.namespace)}, ` +
239
+ 'or shares a workspace directory with a branch in it, and the namespace holds only the ' +
240
+ 'branches editors create; choose a namespace it is outside');
241
+ }
242
+ // git holds no ref under a path that is itself a branch.
243
+ if (name !== undefined && this.namespace.startsWith(`${name}/`)) {
244
+ throw new Error(`The ${what} ${JSON.stringify(name)} is a branch named after part of the branch namespace ` +
245
+ `${JSON.stringify(this.namespace)}, so git can hold no branch in it; choose another namespace`);
246
+ }
247
+ }
141
248
  const entries = [...(options.protectedBranches ?? [])];
142
249
  if (entries.length > MAX_PROTECTED_BRANCHES) {
143
250
  throw new Error(`protectedBranches has ${entries.length} entries; the most it takes is ${MAX_PROTECTED_BRANCHES}`);
@@ -152,14 +259,61 @@ export class BranchPolicy {
152
259
  entries.push(options.baseBranch);
153
260
  this.exact = entries.filter((entry) => !entry.includes('*'));
154
261
  this.globs = entries.filter((entry) => entry.includes('*'));
262
+ const settingsBranch = options.settingsBranch;
263
+ if (settingsBranch !== undefined) {
264
+ const problem = branchNameProblem(settingsBranch);
265
+ if (problem !== null) {
266
+ throw new Error(`Invalid settings branch ${JSON.stringify(settingsBranch)}: ${problem}`);
267
+ }
268
+ }
269
+ this.settingsBranch = settingsBranch ?? null;
270
+ const legacy = options.legacyBranches ?? [];
271
+ if (legacy.length > MAX_LEGACY_BRANCHES) {
272
+ throw new Error(`legacyBranches has ${legacy.length} entries; the most it takes is ${MAX_LEGACY_BRANCHES}`);
273
+ }
274
+ for (const entry of legacy) {
275
+ const problem = entry.startsWith(this.namespace)
276
+ ? `it starts with ${this.namespace}, so it is already in the namespace`
277
+ : this.namespace.startsWith(`${entry}/`)
278
+ ? `it is a branch named after part of the namespace ${this.namespace}, so git can hold no branch in it`
279
+ : legacyBranchProblem(entry);
280
+ if (problem !== null) {
281
+ throw new Error(`Invalid legacyBranches entry ${JSON.stringify(entry)}: ${problem}`);
282
+ }
283
+ }
284
+ if (new Set(legacy).size !== legacy.length) {
285
+ throw new Error('legacyBranches lists a branch more than once');
286
+ }
287
+ this.legacy = new Set(legacy);
155
288
  }
156
- /** The protected set as it stands: configured names and globs, and GitHub's default as last read. */
289
+ /** The policy as it stands. */
157
290
  describe() {
158
291
  return {
159
292
  configured: [...this.exact, ...this.globs],
160
293
  githubDefault: this.githubDefault?.name ?? null,
294
+ namespace: this.namespace,
295
+ legacy: [...this.legacy],
296
+ settingsBranch: this.settingsBranch,
161
297
  };
162
298
  }
299
+ /** Whether the gateway may write `branch` at all: the namespace, the settings branch, the list. */
300
+ inNamespace(branch) {
301
+ return (branch.startsWith(this.namespace) || branch === this.settingsBranch || this.legacy.has(branch));
302
+ }
303
+ /**
304
+ * Whether the gateway opens and updates a pull request from `head`, protection aside: a branch it
305
+ * writes that is not a settings branch. `isProtected`, which may read GitHub, judges the rest.
306
+ */
307
+ takesPullRequestFrom(head) {
308
+ return !this.isSettings(head) && this.inNamespace(head);
309
+ }
310
+ /**
311
+ * The branch among `branches` named after part of the namespace, or null. While one exists git
312
+ * holds no branch in the namespace, so the gateway refuses a push into it rather than send it.
313
+ */
314
+ namespaceBlocker(branches) {
315
+ return namespaceAncestors(this.namespace).find((name) => branches.has(name)) ?? null;
316
+ }
163
317
  /** Whether `branch` is protected; GitHub's default is read only when nothing else matches. */
164
318
  async isProtected(branch, readDefault) {
165
319
  if (this.matchesConfigured(branch))
@@ -167,14 +321,32 @@ export class BranchPolicy {
167
321
  const githubDefault = await this.githubDefaultBranch(readDefault);
168
322
  return githubDefault !== null && sameBranch(githubDefault, branch);
169
323
  }
170
- /** Refuse a push to a protected branch, or a leased push to a settings branch. */
324
+ /**
325
+ * Whether `branch` is GitHub's default branch as last read, even once that read has expired:
326
+ * what protection is judged by when a fresh read fails.
327
+ */
328
+ matchesLastReadDefault(branch) {
329
+ const name = this.githubDefault?.name ?? null;
330
+ return name !== null && sameBranch(name, branch);
331
+ }
332
+ /**
333
+ * Refuse a push to a protected branch, a leased push to a settings branch, a push to another
334
+ * deployment's settings branch, and a push outside the namespace.
335
+ */
171
336
  async assertPushAllowed(branch, lease, readDefault, refuse) {
337
+ const refusal = (reason) => refuse(reason, pushRefusalMessage(branch, reason, this.namespace));
172
338
  if (await this.isProtected(branch, readDefault))
173
- throw refuse('protected');
174
- if (lease !== undefined && this.isSettings(branch))
175
- throw refuse('settings');
339
+ throw refusal('protected');
340
+ if (this.isSettings(branch)) {
341
+ if (lease !== undefined)
342
+ throw refusal('settings');
343
+ if (branch !== this.settingsBranch)
344
+ throw refusal('other-settings');
345
+ }
346
+ if (!this.inNamespace(branch))
347
+ throw refusal('outside-namespace');
176
348
  }
177
- /** Refuse to delete a protected branch or a settings branch. */
349
+ /** Refuse to delete a protected branch, a settings branch, or one outside the namespace. */
178
350
  async assertDeleteAllowed(branch, readDefault) {
179
351
  if (this.isSettings(branch)) {
180
352
  throw new GatewayRefusalError('delete', branch, 'settings', `${deleting(branch)}: it is a settings branch, which is never deleted`);
@@ -182,24 +354,37 @@ export class BranchPolicy {
182
354
  if (await this.isProtected(branch, readDefault)) {
183
355
  throw new GatewayRefusalError('delete', branch, 'protected', `${deleting(branch)}: ${PROTECTED_WHY}`);
184
356
  }
357
+ if (!this.inNamespace(branch)) {
358
+ throw new GatewayRefusalError('delete', branch, 'outside-namespace', `${deleting(branch)}: ${outsideNamespaceDeleteWhy(this.namespace)}`);
359
+ }
185
360
  }
186
361
  /**
187
- * Refuse a pull request whose head is protected or a settings branch, or whose text is over its
188
- * cap. The base is not restricted: a branch keeps the base it was created from after the
189
- * adopter moves theirs, and a pull request merges nothing by itself.
362
+ * Refuse a pull request whose head is protected, a settings branch or outside the namespace, or
363
+ * whose text is over its cap. The base is not restricted: a branch keeps the base it was created
364
+ * from after the adopter moves theirs, and a pull request merges nothing by itself.
190
365
  */
191
366
  async assertPullRequestAllowed(request, readDefault) {
192
367
  const { head } = request;
193
368
  assertPullRequestText(openingFrom(head), head, request);
369
+ await this.assertPullRequestHeadAllowed(openingFrom(head), head, readDefault);
370
+ }
371
+ /**
372
+ * Refuse a pull request head the gateway may not open one from, or act on one with. `refusing`
373
+ * opens the message and names `head`, which the rest calls "it".
374
+ */
375
+ async assertPullRequestHeadAllowed(refusing, head, readDefault) {
194
376
  if (this.isSettings(head)) {
195
- throw new GatewayRefusalError('pull-request', head, 'settings', `${openingFrom(head)}: it is a settings branch, which never gets one`);
377
+ throw new GatewayRefusalError('pull-request', head, 'settings', `${refusing}: it is a settings branch, which never gets one`);
196
378
  }
197
379
  if (await this.isProtected(head, readDefault)) {
198
- throw new GatewayRefusalError('pull-request', head, 'protected', `${openingFrom(head)}: ${PROTECTED_WHY}`);
380
+ throw new GatewayRefusalError('pull-request', head, 'protected', `${refusing}: ${PROTECTED_WHY}`);
381
+ }
382
+ if (!this.takesPullRequestFrom(head)) {
383
+ throw new GatewayRefusalError('pull-request', head, 'outside-namespace', `${refusing}: ${outsideNamespaceWhy(this.namespace)}`);
199
384
  }
200
385
  }
201
386
  isSettings(branch) {
202
- return branch.startsWith(RESERVED_SETTINGS_BRANCH_PREFIX);
387
+ return branch.startsWith(RESERVED_SETTINGS_BRANCH_PREFIX) || branch === this.settingsBranch;
203
388
  }
204
389
  matchesConfigured(branch) {
205
390
  return (this.exact.some((name) => sameBranch(name, branch)) ||
@@ -226,6 +411,26 @@ export class BranchPolicy {
226
411
  return name;
227
412
  }
228
413
  }
414
+ /**
415
+ * Whether the gateway that described its policy as `policy` would take a plain push of `branch`:
416
+ * `BranchPolicy.assertPushAllowed`'s rules, for a reader that has only the description, with
417
+ * GitHub's default as last read. A test holds the two equal.
418
+ */
419
+ export function publishesBranch(policy, branch) {
420
+ // No namespace is the description a gateway that failed to boot answers, and it pushes nothing.
421
+ if (policy.namespace === '')
422
+ return false;
423
+ const settings = branch.startsWith(RESERVED_SETTINGS_BRANCH_PREFIX) || branch === policy.settingsBranch;
424
+ const protectedMatch = policy.configured.some((entry) => entry.includes('*')
425
+ ? !settings && matchesBranchPattern(entry, branch)
426
+ : sameBranch(entry, branch)) ||
427
+ (policy.githubDefault !== null && sameBranch(policy.githubDefault, branch));
428
+ if (protectedMatch)
429
+ return false;
430
+ if (settings)
431
+ return branch === policy.settingsBranch;
432
+ return branch.startsWith(policy.namespace) || policy.legacy.includes(branch);
433
+ }
229
434
  /** Refuse pull request text over its cap; `refusing` opens the message, `branch` names its head. */
230
435
  export function assertPullRequestText(refusing, branch, text) {
231
436
  const over = (text.title?.length ?? 0) > MAX_PULL_REQUEST_TITLE_LENGTH
@@ -7,6 +7,7 @@ export { resolveWorkerGitHubAuth, normalizeGitHubAppPrivateKey, DEFAULT_GIT_TOKE
7
7
  export { createLocalGitHubGateway, type GitHubGateway, type GitHubGatewayHealth, type LocalGitHubGatewayOptions, } from './github-gateway.js';
8
8
  export { serveGitHubGateway, connectGitHubGateway, type GitHubGatewayClientOptions, type GitHubGatewayListen, type GitHubGatewayServer, type GitHubGatewayServerOptions, } from './github-gateway-http.js';
9
9
  export { GitHubGatewayUnreachableError } from './github-gateway-wire.js';
10
+ export { resolveBranchNamespace } from '../operating-mode/branch-namespace.js';
10
11
  export { GatewayInstance, type GatewayInstanceOptions } from './gateway-instance.js';
11
12
  export { gatewayLog, gatewayLogWarn, gatewayLogError, registerSecretText, registerPrivateKey, redactEgress, } from './gateway-egress.js';
12
13
  export { assertSelfChecks, gatewaySelfChecks, workerSelfChecks, type SelfCheckEnvironment, type SelfCheckName, type SelfCheckResult, } from './self-checks.js';
@@ -36,8 +37,9 @@ export interface CmsWorkerConfig extends GitHubAuthConfig {
36
37
  /**
37
38
  * A GitHub gateway to use instead of building the in-process one: a client of a gateway another
38
39
  * process serves (`connectGitHubGateway`), which holds the credential, the protected set and the
39
- * mirror. Exclusive with `githubToken`, `githubAppAuth`, `refreshGitHubToken` and
40
- * `protectedBranches`, which configure the in-process one.
40
+ * mirror. Exclusive with `githubToken`, `githubAppAuth`, `refreshGitHubToken`,
41
+ * `protectedBranches`, `legacyBranches` and `branchNamespace`, which configure the in-process
42
+ * one.
41
43
  */
42
44
  githubGateway?: GitHubGateway;
43
45
  /** GitHub owner (e.g., 'acme') */
@@ -60,6 +62,16 @@ export interface CmsWorkerConfig extends GitHubAuthConfig {
60
62
  * characters (`release/*`), which never match a settings branch. See worker/branch-policy.ts.
61
63
  */
62
64
  protectedBranches?: readonly string[];
65
+ /**
66
+ * Branches outside the namespace the in-process gateway may still write: exact names of
67
+ * branches from before the namespace. See worker/branch-policy.ts.
68
+ */
69
+ legacyBranches?: readonly string[];
70
+ /**
71
+ * The in-process gateway's branch namespace template (`CANOPYCMS_BRANCH_NAMESPACE` wins);
72
+ * resolved as operating-mode/branch-namespace.ts says.
73
+ */
74
+ branchNamespace?: string;
63
75
  /**
64
76
  * Names THIS worker's own settings branch
65
77
  * (`canopycms-settings-{deploymentName}`, default 'prod' — ProdStrategy's
@@ -104,6 +116,16 @@ export interface CmsWorkerConfig extends GitHubAuthConfig {
104
116
  */
105
117
  drainDeadlineMs?: number;
106
118
  }
119
+ /**
120
+ * The settings branch a worker owns: `settingsBranch` when set, else
121
+ * `canopycms-settings-{deploymentName}` by `resolveDeploymentName`, which reads
122
+ * `CANOPYCMS_DEPLOYMENT_NAME` first. The split gateway resolves its own the same way, from its own
123
+ * environment, so the one branch it pushes is the one its worker owns.
124
+ */
125
+ export declare function resolveWorkerSettingsBranch(config: {
126
+ settingsBranch?: string;
127
+ deploymentName?: string;
128
+ }): string;
107
129
  /**
108
130
  * The default `taskTimeoutMs`. Exported because a split worker's gateway client passes it as its
109
131
  * `fetchInactivityMs` and the gateway uses it as its `timeoutMs`, as the in-process gateway does.
@@ -10,12 +10,14 @@ import { createLocalGitHubGateway, } from './github-gateway.js';
10
10
  import { GITHUB_TRACKING_REF_PREFIX, ensureRemoteGitConfig, failOnSignalExit } from '../git-manager.js';
11
11
  import { readHeadBranch } from '../utils/git.js';
12
12
  import { isSettingsBranchName, sanitizeBranchName, RESERVED_SETTINGS_BRANCH_PREFIX, } from '../paths/branch-name.js';
13
+ import { resolveBranchNamespace } from '../operating-mode/branch-namespace.js';
13
14
  import { resolveDeploymentName } from '../operating-mode/deployment-name.js';
14
15
  import { getErrorMessage, isNodeError, redactCredentials } from '../utils/error.js';
15
16
  import { readCarriedOverStatus, readWorkerStatusStartedAt, writeWorkerStatus, } from '../task-queue/worker-status.js';
16
17
  import { CANOPYCMS_VERSION } from '../version.js';
17
18
  import { workerLog, workerLogWarn, workerLogError } from './log.js';
18
19
  import { DEFAULT_SCHEMA_HOLD_MAX_MS, readCarriedBaseHold } from './schema-gate.js';
20
+ import { readCarriedGatewayPolicy, readCarriedUnpublishableBranches, } from './unpublishable-branches.js';
19
21
  import { SharedRepoRefusalError, UntrustedRepoConfigError, assertSharedRepoConfig, sharedRepoGit, } from './shared-repo-git.js';
20
22
  import { executeTask, orphanRecoveryMaxAgeMs, processTaskQueue, pushBranchToGitHub, updateBranchMetadata, } from './task-runner.js';
21
23
  import { pollMergeState, runRebaseCycle } from './rebase.js';
@@ -40,6 +42,8 @@ resolveWorkerGitHubAuth, normalizeGitHubAppPrivateKey, DEFAULT_GIT_TOKEN_MINT_TI
40
42
  export { createLocalGitHubGateway, } from './github-gateway.js';
41
43
  export { serveGitHubGateway, connectGitHubGateway, } from './github-gateway-http.js';
42
44
  export { GitHubGatewayUnreachableError } from './github-gateway-wire.js';
45
+ // The split gateway resolves its branch namespace as the in-process one does.
46
+ export { resolveBranchNamespace } from '../operating-mode/branch-namespace.js';
43
47
  // A gateway that failed to boot still serves the instance operations.
44
48
  export { GatewayInstance } from './gateway-instance.js';
45
49
  // The gateway process's scrubbed log lines, and the exact scrubs for the secrets it holds that
@@ -92,6 +96,16 @@ async function listRemoteGitRefs(gitDir) {
92
96
  function sameRefs(a, b) {
93
97
  return a.size === b.size && [...a].every(([ref, id]) => b.get(ref) === id);
94
98
  }
99
+ /**
100
+ * The settings branch a worker owns: `settingsBranch` when set, else
101
+ * `canopycms-settings-{deploymentName}` by `resolveDeploymentName`, which reads
102
+ * `CANOPYCMS_DEPLOYMENT_NAME` first. The split gateway resolves its own the same way, from its own
103
+ * environment, so the one branch it pushes is the one its worker owns.
104
+ */
105
+ export function resolveWorkerSettingsBranch(config) {
106
+ return (config.settingsBranch ??
107
+ `${RESERVED_SETTINGS_BRANCH_PREFIX}${resolveDeploymentName({ deploymentName: config.deploymentName }, 'prod')}`);
108
+ }
95
109
  /**
96
110
  * The default `taskTimeoutMs`. Exported because a split worker's gateway client passes it as its
97
111
  * `fetchInactivityMs` and the gateway uses it as its `timeoutMs`, as the in-process gateway does.
@@ -150,18 +164,16 @@ function lockWorkerTaskDir(taskDir, lockStaleMs, options) {
150
164
  : {}),
151
165
  });
152
166
  }
153
- /**
154
- * The fields a new status snapshot carries from the previous file: `lastFatalError` and
155
- * `lastShutdown` (task-queue/worker-status.ts) and the schema gate's hold, so its bound keeps
156
- * counting from the first worker that saw each schema missing.
157
- */
158
167
  async function readCarriedFields(taskDir) {
159
168
  const { lastFatalError, lastShutdown } = await readCarriedOverStatus(taskDir);
160
169
  const baseHold = await readCarriedBaseHold(taskDir);
170
+ const unpublishableBranches = await readCarriedUnpublishableBranches(taskDir);
161
171
  return {
162
172
  ...(lastFatalError ? { lastFatalError } : {}),
163
173
  ...(lastShutdown ? { lastShutdown } : {}),
164
174
  ...(baseHold ? { baseHold } : {}),
175
+ ...(unpublishableBranches ? { unpublishableBranches } : {}),
176
+ ...(await readCarriedGatewayPolicy(taskDir)),
165
177
  };
166
178
  }
167
179
  /**
@@ -184,7 +196,7 @@ export async function recordWorkerStartupFailure(options) {
184
196
  onCompromised: (err) => workerLogError('Worker lock lost while recording a startup failure:', getErrorMessage(err)),
185
197
  });
186
198
  const now = new Date().toISOString();
187
- const { lastFatalError, lastShutdown, baseHold } = await readCarriedFields(taskDir);
199
+ const { lastFatalError, ...carried } = await readCarriedFields(taskDir);
188
200
  if (lastFatalError &&
189
201
  options.keepRecordedFatal &&
190
202
  lastFatalError.workerStartedAt === (await readWorkerStatusStartedAt(taskDir)) &&
@@ -196,8 +208,7 @@ export async function recordWorkerStartupFailure(options) {
196
208
  workerVersion: CANOPYCMS_VERSION,
197
209
  startedAt: now,
198
210
  updatedAt: now,
199
- ...(lastShutdown ? { lastShutdown } : {}),
200
- ...(baseHold ? { baseHold } : {}),
211
+ ...carried,
201
212
  lastFatalError: {
202
213
  // [REDACT] Served to the browser by the admin panel and the API's not-ready answer.
203
214
  message: redactCredentials(getErrorMessage(options.error)),
@@ -299,9 +310,7 @@ export class CmsWorker {
299
310
  */
300
311
  ensureSettingsBranch() {
301
312
  if (this.settingsBranchResolved === undefined) {
302
- this.settingsBranchResolved =
303
- this.config.settingsBranch ??
304
- `${RESERVED_SETTINGS_BRANCH_PREFIX}${resolveDeploymentName({ deploymentName: this.config.deploymentName }, 'prod')}`;
313
+ this.settingsBranchResolved = resolveWorkerSettingsBranch(this.config);
305
314
  }
306
315
  return this.settingsBranchResolved;
307
316
  }
@@ -370,17 +379,12 @@ export class CmsWorker {
370
379
  // version. Best-effort, like the startup-failure write below. The previous
371
380
  // `lastFatalError` rides along in this snapshot only, so a crash loop keeps
372
381
  // its alert between restarts while the first successful sync still clears
373
- // it. Two fields carry into the report itself: `lastShutdown`, which
374
- // describes the last worker that ran until this worker's own stop() replaces it,
375
- // and the schema gate's hold, so its bound keeps counting from the first
376
- // worker that saw each schema missing.
382
+ // it. The other carried fields (readCarriedFields) go into the report itself;
383
+ // `lastShutdown` describes the last worker that ran until this worker's own
384
+ // stop() replaces it.
377
385
  try {
378
- const { lastFatalError, lastShutdown, baseHold } = await readCarriedFields(this.taskDir);
379
- const report = this.ensureStatusReport();
380
- if (lastShutdown)
381
- report.lastShutdown = lastShutdown;
382
- if (baseHold)
383
- report.baseHold = baseHold;
386
+ const { lastFatalError, ...carried } = await readCarriedFields(this.taskDir);
387
+ const report = Object.assign(this.ensureStatusReport(), carried);
384
388
  await writeWorkerStatus(this.taskDir, {
385
389
  ...report,
386
390
  ...(lastFatalError ? { lastFatalError } : {}),
@@ -1164,9 +1168,13 @@ export class CmsWorker {
1164
1168
  conflicting.push('refreshGitHubToken');
1165
1169
  if ((this.config.protectedBranches?.length ?? 0) > 0)
1166
1170
  conflicting.push('protectedBranches');
1171
+ if ((this.config.legacyBranches?.length ?? 0) > 0)
1172
+ conflicting.push('legacyBranches');
1173
+ if (this.config.branchNamespace !== undefined)
1174
+ conflicting.push('branchNamespace');
1167
1175
  if (conflicting.length > 0) {
1168
1176
  throw new Error(`CmsWorker: githubGateway is exclusive with ${conflicting.join(', ')}, which configure the ` +
1169
- 'in-process gateway; the gateway it is given holds the credential and the protected set');
1177
+ 'in-process gateway; the gateway it is given holds the credential and the branch policy');
1170
1178
  }
1171
1179
  this.gateway = githubGateway;
1172
1180
  return this.gateway;
@@ -1191,6 +1199,9 @@ export class CmsWorker {
1191
1199
  timeoutMs: this.taskTimeoutMs,
1192
1200
  baseBranch: this.config.baseBranch,
1193
1201
  protectedBranches: this.config.protectedBranches,
1202
+ settingsBranch: this.ensureSettingsBranch(),
1203
+ legacyBranches: this.config.legacyBranches,
1204
+ branchNamespace: resolveBranchNamespace(this.config, 'prod'),
1194
1205
  };
1195
1206
  }
1196
1207
  /**
@@ -1241,8 +1252,8 @@ export class CmsWorker {
1241
1252
  // `rebaseActiveBranches` also has a consumer outside the test files:
1242
1253
  // apps/test-app/app/api/e2e-test/rebase/route.ts drives it from an e2e
1243
1254
  // fixture route. Do not delete it as unused.
1244
- async rebaseActiveBranches() {
1245
- return runRebaseCycle(this.ctx());
1255
+ async rebaseActiveBranches(gatewayPolicy) {
1256
+ return runRebaseCycle(this.ctx(), gatewayPolicy);
1246
1257
  }
1247
1258
  // --- Git-sync cluster (worker/git-sync.ts) -----------------------------
1248
1259
  //
@@ -15,9 +15,9 @@ import type { WorkerContext } from './worker-context.js';
15
15
  * (`reconcileTrackedBranches`, which holds the base branch while schema-gate.ts
16
16
  * says the serving editor lacks a schema the incoming content names), push this
17
17
  * deployment's own settings
18
- * branch, fast-forward the base branch's workspace, rebase every branch that is
19
- * behind it (rebase.ts), then sweep old tasks and expired trashed branch
20
- * directories.
18
+ * branch, fast-forward the base branch's workspace, ask the gateway for its branch policy,
19
+ * rebase every branch that is behind it (rebase.ts), then sweep old tasks and expired trashed
20
+ * branch directories.
21
21
  *
22
22
  * One ordering is load-bearing and nothing enforces it: `runRebaseCycle` MUST
23
23
  * follow `reconcileTrackedBranches`. Branch clones fetch the base tip from
@@ -25,6 +25,7 @@ import { maintainRemoteGit } from './remote-git-maintenance.js';
25
25
  import { decideBaseAdvance } from './schema-gate.js';
26
26
  import { SHARED_REPO_STATUS_ARGS, assertNoIncomingSubmodules, assertSharedRepoConfig, fetchFromRemoteGit, sharedRepoGit, } from './shared-repo-git.js';
27
27
  import { reapplySparseCones } from './sparse-cone.js';
28
+ import { findUnpublishableBranches } from './unpublishable-branches.js';
28
29
  import { readEditorWork, settleStaleMirror, } from './stale-mirror.js';
29
30
  /**
30
31
  * [C1] Retention window for `.trash-*` branch directories left behind by the
@@ -485,10 +486,50 @@ export async function syncGit(ctx) {
485
486
  const baseRefresh = await refreshBaseBranchWorkspace(ctx);
486
487
  if (stoppedForDrain(ctx, 'the rebase cycle'))
487
488
  return;
488
- const rebaseSummary = await runRebaseCycle(ctx);
489
+ // The gateway's policy, which the rebase loop needs to tell its own namespace and what the
490
+ // gateway would refuse to publish. A gateway that cannot answer, or answers with no namespace
491
+ // (a failed boot), gives none.
492
+ let health;
493
+ try {
494
+ health = await ctx.github().health(ctx.shutdownSignal());
495
+ }
496
+ catch (err) {
497
+ workerLogWarn(`Could not read the GitHub gateway's branch policy: ${getErrorMessage(err)}`);
498
+ }
499
+ const branchPolicy = health && health.branchPolicy.namespace !== '' ? health.branchPolicy : undefined;
500
+ // The namespace System health compares with the one the CMS creates branches in, and the
501
+ // names creation refuses. Written at once, as the catch below writes on a failure: branch
502
+ // creation waits for the namespace (api/branch.ts), so it must not wait on the rest of the
503
+ // cycle succeeding. Without a policy the last values stand.
504
+ if (branchPolicy) {
505
+ const report = ctx.ensureStatusReport();
506
+ report.gatewayBranchNamespace = branchPolicy.namespace;
507
+ report.gatewayProtectedBranches = branchPolicy.configured.filter((n) => !n.includes('*'));
508
+ await writeWorkerStatus(ctx.taskDir, report).catch((writeErr) => workerLogError('Failed to write worker status:', getErrorMessage(writeErr)));
509
+ }
510
+ const rebaseSummary = await runRebaseCycle(ctx, branchPolicy);
489
511
  if (stoppedForDrain(ctx, 'the cleanup sweeps'))
490
512
  return;
491
513
  await cleanupOldTasks(ctx.taskDir, undefined, ctx.log);
514
+ // For System health: the branches the gateway would refuse to publish, read after the rebase
515
+ // cycle, which archives merged branches and restores renamed ones. Without a policy, a fatal
516
+ // error, or a branches directory or branch.json that cannot be read, the last report stands.
517
+ if (health && branchPolicy) {
518
+ const report = ctx.ensureStatusReport();
519
+ if (health.fatalError === null) {
520
+ try {
521
+ const unpublishable = await findUnpublishableBranches(ctx, branchPolicy);
522
+ if (unpublishable.count > 0) {
523
+ report.unpublishableBranches = { ...unpublishable, at: new Date().toISOString() };
524
+ }
525
+ else
526
+ delete report.unpublishableBranches;
527
+ }
528
+ catch (err) {
529
+ workerLogWarn(`Could not list the branches the gateway will not publish: ${getErrorMessage(err)}`);
530
+ }
531
+ }
532
+ }
492
533
  // [C1] Sweep branch directories the admin purge action trashed more than
493
534
  // TRASH_RETENTION_MS ago. Worker-only by design: purge itself never deletes
494
535
  // anything (it stays reversible), and this is the sole place removal
@@ -220,8 +220,8 @@ class GatewayRequestHandler {
220
220
  },
221
221
  getPullRequest: async ({ number }, signal) => {
222
222
  const { merged, merged_at, state, node_id, body } = await gateway.getPullRequest(number, signal);
223
- // Octokit's type says string | null; a payload without the field crosses as null.
224
- return { merged, merged_at, state, node_id, body: body ?? null };
223
+ // A null body is the gateway withholding it (`GitHubPullRequest.body`), and crosses as one.
224
+ return { merged, merged_at, state, node_id, body };
225
225
  },
226
226
  createOrUpdatePullRequest: async (input, signal) => {
227
227
  const { number, url, created } = await gateway.createOrUpdatePullRequest(input, signal);