canopycms 0.7.1 → 0.8.1
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/CHANGELOG.md +197 -47
- package/dist/api/admin-branch-health.js +47 -20
- package/dist/api/admin.d.ts +13 -1
- package/dist/api/admin.js +14 -24
- package/dist/api/branch-changes.d.ts +87 -0
- package/dist/api/branch-changes.js +269 -0
- package/dist/api/branch-entry-changes.d.ts +83 -0
- package/dist/api/branch-entry-changes.js +262 -0
- package/dist/api/branch-merge.js +2 -2
- package/dist/api/branch-review.js +35 -4
- package/dist/api/branch-status.js +14 -5
- package/dist/api/branch-withdraw.js +7 -0
- package/dist/api/branch.d.ts +13 -1
- package/dist/api/branch.js +171 -97
- package/dist/api/client.d.ts +15 -0
- package/dist/api/client.js +13 -1
- package/dist/api/content.d.ts +18 -18
- package/dist/api/content.js +1 -1
- package/dist/api/github-sync.js +7 -3
- package/dist/api/groups.d.ts +4 -4
- package/dist/api/pr-comment-sync.d.ts +9 -8
- package/dist/api/pr-comment-sync.js +23 -13
- package/dist/api/routes.js +4 -0
- package/dist/api/types.d.ts +3 -1
- package/dist/authorization/groups/schema.d.ts +4 -4
- package/dist/authorization/protected-branch.d.ts +3 -3
- package/dist/authorization/protected-branch.js +3 -3
- package/dist/branch-metadata-file.d.ts +84 -0
- package/dist/branch-metadata-file.js +9 -0
- package/dist/branch-metadata.d.ts +4 -4
- package/dist/branch-metadata.js +4 -5
- package/dist/branch-param.d.ts +24 -0
- package/dist/branch-param.js +87 -0
- package/dist/branch-provisioning.d.ts +6 -3
- package/dist/branch-provisioning.js +12 -10
- package/dist/branch-registry.d.ts +6 -0
- package/dist/branch-registry.js +12 -3
- package/dist/branch-schema-cache.d.ts +22 -2
- package/dist/branch-schema-cache.js +88 -12
- package/dist/branch-workspace.js +22 -15
- package/dist/cli/cli.js +1122 -181
- package/dist/cli/generate-ai-content.js +791 -109
- package/dist/cli/init.js +126 -0
- package/dist/comment-store.d.ts +3 -0
- package/dist/comment-store.js +4 -1
- package/dist/config/schemas/config.d.ts +3 -0
- package/dist/config/schemas/config.js +2 -0
- package/dist/config/types.d.ts +11 -0
- package/dist/content-id-index.d.ts +5 -0
- package/dist/content-id-index.js +1 -1
- package/dist/content-listing.d.ts +53 -0
- package/dist/content-listing.js +123 -27
- package/dist/content-reader.d.ts +24 -10
- package/dist/content-reader.js +66 -22
- package/dist/context.js +51 -48
- package/dist/editor/BranchManager.d.ts +7 -3
- package/dist/editor/BranchManager.js +51 -6
- package/dist/editor/Editor.js +166 -34
- package/dist/editor/EntryNavigator.d.ts +34 -10
- package/dist/editor/EntryNavigator.js +185 -29
- package/dist/editor/admin/SystemHealthPanel.js +36 -1
- package/dist/editor/admin/useSystemHealth.js +1 -0
- package/dist/editor/branch-names.d.ts +43 -0
- package/dist/editor/branch-names.js +75 -0
- package/dist/editor/components/BranchPill.d.ts +2 -2
- package/dist/editor/components/BranchPill.js +2 -2
- package/dist/editor/components/EditorHeader.d.ts +3 -1
- package/dist/editor/components/EditorHeader.js +2 -2
- package/dist/editor/components/EntryChangesDrawer.d.ts +51 -0
- package/dist/editor/components/EntryChangesDrawer.js +363 -0
- package/dist/editor/components/EntryChangesSkeleton.d.ts +9 -0
- package/dist/editor/components/EntryChangesSkeleton.js +7 -0
- package/dist/editor/components/EntryHeader.d.ts +9 -1
- package/dist/editor/components/EntryHeader.js +25 -3
- package/dist/editor/components/LazyEntryChangesDrawer.d.ts +16 -0
- package/dist/editor/components/LazyEntryChangesDrawer.js +45 -0
- package/dist/editor/components/ReviewDialogs.js +3 -3
- package/dist/editor/context/ApiClientContext.d.ts +9 -0
- package/dist/editor/context/ApiClientContext.js +26 -2
- package/dist/editor/context/index.d.ts +1 -1
- package/dist/editor/context/index.js +1 -1
- package/dist/editor/copy.d.ts +1 -0
- package/dist/editor/copy.js +1 -0
- package/dist/editor/editor-utils.d.ts +2 -0
- package/dist/editor/editor-utils.js +15 -0
- package/dist/editor/entry-diff.d.ts +143 -0
- package/dist/editor/entry-diff.js +593 -0
- package/dist/editor/hooks/create-branch-request.d.ts +11 -1
- package/dist/editor/hooks/create-branch-request.js +16 -6
- package/dist/editor/hooks/useBranchActions.d.ts +19 -2
- package/dist/editor/hooks/useBranchActions.js +29 -20
- package/dist/editor/hooks/useBranchChangesData.d.ts +57 -0
- package/dist/editor/hooks/useBranchChangesData.js +134 -0
- package/dist/editor/hooks/useBranchManager.d.ts +16 -0
- package/dist/editor/hooks/useBranchManager.js +142 -54
- package/dist/editor/hooks/useDraftManager.js +6 -2
- package/dist/editor/hooks/useEntryManager.d.ts +18 -2
- package/dist/editor/hooks/useEntryManager.js +36 -10
- package/dist/editor/utils/editor-errors.d.ts +5 -2
- package/dist/git-manager.d.ts +71 -0
- package/dist/git-manager.js +270 -1
- package/dist/http/handler.js +11 -9
- package/dist/operating-mode/branch-namespace-fixtures.d.ts +17 -0
- package/dist/operating-mode/branch-namespace-fixtures.js +65 -0
- package/dist/operating-mode/branch-namespace.d.ts +34 -0
- package/dist/operating-mode/branch-namespace.js +121 -0
- package/dist/operating-mode/client-unsafe-strategy.js +7 -0
- package/dist/operating-mode/types.d.ts +8 -0
- package/dist/paths/branch-name.d.ts +11 -19
- package/dist/paths/branch-name.js +17 -27
- package/dist/paths/index.d.ts +2 -2
- package/dist/paths/index.js +2 -2
- package/dist/paths/resolve.d.ts +0 -1
- package/dist/paths/resolve.js +0 -1
- package/dist/paths/validation.d.ts +40 -1
- package/dist/paths/validation.js +83 -4
- package/dist/pr-comments.d.ts +17 -9
- package/dist/pr-comments.js +53 -15
- package/dist/server.d.ts +6 -1
- package/dist/server.js +6 -1
- package/dist/services.js +2 -1
- package/dist/submission-attribution.d.ts +22 -0
- package/dist/submission-attribution.js +62 -6
- package/dist/task-queue/worker-status.d.ts +6 -1
- package/dist/task-queue/worker-status.js +23 -2
- package/dist/types.d.ts +59 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/branch-policy.d.ts +111 -16
- package/dist/worker/branch-policy.js +222 -17
- package/dist/worker/cms-worker.d.ts +24 -2
- package/dist/worker/cms-worker.js +35 -24
- package/dist/worker/git-sync.d.ts +3 -3
- package/dist/worker/git-sync.js +42 -1
- package/dist/worker/github-gateway-http.js +2 -2
- package/dist/worker/github-gateway-wire.d.ts +42 -27
- package/dist/worker/github-gateway-wire.js +14 -3
- package/dist/worker/github-gateway.d.ts +17 -6
- package/dist/worker/github-gateway.js +62 -6
- package/dist/worker/github-mirror.d.ts +7 -4
- package/dist/worker/github-mirror.js +27 -16
- package/dist/worker/history-rewrite.d.ts +23 -10
- package/dist/worker/history-rewrite.js +43 -20
- package/dist/worker/legacy-branch-fixtures.d.ts +21 -0
- package/dist/worker/legacy-branch-fixtures.js +71 -0
- package/dist/worker/rebase.d.ts +2 -1
- package/dist/worker/rebase.js +93 -36
- package/dist/worker/task-runner.d.ts +4 -1
- package/dist/worker/task-runner.js +77 -48
- package/dist/worker/unpublishable-branches.d.ts +34 -0
- package/dist/worker/unpublishable-branches.js +182 -0
- 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
|
|
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
|
|
134
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
174
|
-
if (
|
|
175
|
-
|
|
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
|
|
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
|
|
188
|
-
* cap. The base is not restricted: a branch keeps the base it was created
|
|
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', `${
|
|
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', `${
|
|
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
|
|
40
|
-
* `protectedBranches`, which configure the in-process
|
|
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,
|
|
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
|
-
...
|
|
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.
|
|
374
|
-
// describes the last worker that ran until this worker's own
|
|
375
|
-
//
|
|
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,
|
|
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
|
|
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,
|
|
19
|
-
* behind it (rebase.ts), then sweep old tasks and expired trashed
|
|
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
|
package/dist/worker/git-sync.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
224
|
-
return { merged, merged_at, state, node_id, body
|
|
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);
|