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
|
@@ -2,8 +2,10 @@ import path from 'node:path';
|
|
|
2
2
|
import { BranchMetadataFileManager, getBranchMetadataFileManager } from '../branch-metadata.js';
|
|
3
3
|
import { sanitizeBranchName } from '../paths/branch-name.js';
|
|
4
4
|
import { getErrorMessage, redactCredentials } from '../utils/error.js';
|
|
5
|
+
import { escapeForLog } from '../utils/escape-text.js';
|
|
5
6
|
import { isStaleLeaseRejection } from '../utils/git.js';
|
|
6
7
|
import { enqueueTask, listTasks } from '../task-queue/cms-task-queue.js';
|
|
8
|
+
import { publishesBranch } from './branch-policy.js';
|
|
7
9
|
import { pinnedReceivePack, sharedRepoGit } from './shared-repo-git.js';
|
|
8
10
|
import { workerLog, workerLogWarn } from './log.js';
|
|
9
11
|
/**
|
|
@@ -31,24 +33,34 @@ export async function readPublishedSha(ctx, branchRef) {
|
|
|
31
33
|
}
|
|
32
34
|
}
|
|
33
35
|
/**
|
|
34
|
-
* Record that this worker rewrote `expectedSha` out of
|
|
35
|
-
* published history (see BranchMetadata.historyRewrittenFrom).
|
|
36
|
+
* Record that this worker rewrote `expectedSha` out of `branchRef`'s already
|
|
37
|
+
* published history (see BranchMetadata.historyRewrittenFrom), on a branch.json
|
|
38
|
+
* whose `name` is `branchRef` only: it never creates one, nor marks another
|
|
39
|
+
* branch's that shares the key.
|
|
36
40
|
*
|
|
37
41
|
* Set-once: an existing marker is LEFT ALONE. Across two rebases before any
|
|
38
42
|
* GitHub push lands, GitHub still holds the commit the FIRST rebase replaced,
|
|
39
43
|
* so advancing the marker would aim the lease at a commit GitHub never had and
|
|
40
44
|
* permanently wedge the branch.
|
|
41
45
|
*
|
|
42
|
-
*
|
|
43
|
-
* runs concurrently with syncGit() and may
|
|
44
|
-
* branch was rebasing.
|
|
46
|
+
* Decided against the metadata on disk inside the write's lock, not the
|
|
47
|
+
* caller's snapshot: the task loop runs concurrently with syncGit() and may
|
|
48
|
+
* have cleared the marker while this branch was rebasing.
|
|
49
|
+
*
|
|
50
|
+
* Resolves whether `branchRef`'s branch.json now holds a marker, set here or kept by set-once.
|
|
51
|
+
* When it does not, the caller publishes nothing: a rewrite pushed without its marker leaves
|
|
52
|
+
* remote.git rewritten and the queued GitHub push failing with a false divergence diagnosis.
|
|
45
53
|
*/
|
|
46
|
-
export async function markHistoryRewritten(ctx, branchPath,
|
|
47
|
-
const current = await BranchMetadataFileManager.loadOnly(branchPath);
|
|
48
|
-
if (typeof current?.branch.historyRewrittenFrom === 'string')
|
|
49
|
-
return;
|
|
54
|
+
export async function markHistoryRewritten(ctx, branchPath, branchRef, expectedSha) {
|
|
50
55
|
const meta = getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath);
|
|
51
|
-
|
|
56
|
+
let marked = false;
|
|
57
|
+
await meta.update((current) => {
|
|
58
|
+
marked = current?.branch.name === branchRef;
|
|
59
|
+
if (!marked || typeof current?.branch.historyRewrittenFrom === 'string')
|
|
60
|
+
return null;
|
|
61
|
+
return { branch: { historyRewrittenFrom: expectedSha } };
|
|
62
|
+
});
|
|
63
|
+
return marked;
|
|
52
64
|
}
|
|
53
65
|
/**
|
|
54
66
|
* Clear the marker once GitHub is confirmed to hold the rewritten history.
|
|
@@ -64,15 +76,17 @@ export async function markHistoryRewritten(ctx, branchPath, branchDir, expectedS
|
|
|
64
76
|
* Tracked, with the concurrent-clear race that can drop a marker mid-arming,
|
|
65
77
|
* in safeinsights/canopycms#902.
|
|
66
78
|
*/
|
|
67
|
-
export async function clearHistoryRewrittenMarker(ctx, branchPath,
|
|
79
|
+
export async function clearHistoryRewrittenMarker(ctx, branchPath, branchRef) {
|
|
68
80
|
try {
|
|
69
81
|
const meta = getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath);
|
|
70
82
|
// Explicit undefined clears the key -- save()'s merge only overwrites
|
|
71
|
-
// keys present in the update (same pattern as rebaseFailure).
|
|
72
|
-
|
|
83
|
+
// keys present in the update (same pattern as rebaseFailure). Only a
|
|
84
|
+
// marker on `branchRef`'s own branch.json is cleared, so none is ever created here.
|
|
85
|
+
await meta.saveIf({ branch: { historyRewrittenFrom: undefined } }, (current) => current?.branch.name === branchRef &&
|
|
86
|
+
typeof current.branch.historyRewrittenFrom === 'string');
|
|
73
87
|
}
|
|
74
88
|
catch (err) {
|
|
75
|
-
workerLogWarn(` Failed to clear history-rewrite marker for ${
|
|
89
|
+
workerLogWarn(` Failed to clear history-rewrite marker for ${branchRef}: ${getErrorMessage(err)}`);
|
|
76
90
|
}
|
|
77
91
|
}
|
|
78
92
|
/**
|
|
@@ -124,10 +138,19 @@ export async function forcePublishToLocalRemote(ctx, branchPath, branchRef, expe
|
|
|
124
138
|
*
|
|
125
139
|
* Deliberately NOT skipped when a marker is already set: inferring "a task must
|
|
126
140
|
* already be queued" from the marker starves this hop whenever a task was lost,
|
|
127
|
-
* failed permanently, or was never written.
|
|
128
|
-
*
|
|
141
|
+
* failed permanently, or was never written. Skipped instead when `gatewayPolicy`,
|
|
142
|
+
* the gateway's own description, says it would refuse the branch: the marker
|
|
143
|
+
* clears only on a push that lands, so such a task would fail permanently on
|
|
144
|
+
* every cycle, and System health's unpublishable alert already names the branch.
|
|
145
|
+
* Without a description every hop is queued. So while a marker stands, at most
|
|
146
|
+
* one push task is in flight and one is added per sync cycle, only for a failure
|
|
147
|
+
* the policy does not predict; the tasks are idempotent.
|
|
129
148
|
*/
|
|
130
|
-
export async function enqueueGitHubPush(ctx, branchRef) {
|
|
149
|
+
export async function enqueueGitHubPush(ctx, branchRef, gatewayPolicy) {
|
|
150
|
+
if (gatewayPolicy && !publishesBranch(gatewayPolicy, branchRef)) {
|
|
151
|
+
workerLog(` Not queueing a GitHub push for ${escapeForLog(branchRef)}: the GitHub gateway would refuse it`);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
131
154
|
try {
|
|
132
155
|
// Dedupe against tasks actually in flight rather than against the
|
|
133
156
|
// marker: a branch whose GitHub push keeps failing would otherwise
|
|
@@ -157,7 +180,7 @@ export async function enqueueGitHubPush(ctx, branchRef) {
|
|
|
157
180
|
* is the one commit we know our own rebase replaced.
|
|
158
181
|
*/
|
|
159
182
|
export async function reconcilePendingRewrite(ctx, options) {
|
|
160
|
-
const { branchPath, branchDir, branchRef, headSha, marker } = options;
|
|
183
|
+
const { branchPath, branchDir, branchRef, headSha, marker, gatewayPolicy } = options;
|
|
161
184
|
const publishedSha = await readPublishedSha(ctx, branchRef);
|
|
162
185
|
if (publishedSha === null) {
|
|
163
186
|
workerLogWarn(` ${branchDir}: a rewritten history is recorded but ${branchRef} is gone from remote.git -- nothing to publish`);
|
|
@@ -166,14 +189,14 @@ export async function reconcilePendingRewrite(ctx, options) {
|
|
|
166
189
|
if (publishedSha === headSha) {
|
|
167
190
|
// remote.git already carries the rewrite; only the GitHub hop is left
|
|
168
191
|
// (a crash between the push and the queue, or a task that was lost).
|
|
169
|
-
await enqueueGitHubPush(ctx, branchRef);
|
|
192
|
+
await enqueueGitHubPush(ctx, branchRef, gatewayPolicy);
|
|
170
193
|
return;
|
|
171
194
|
}
|
|
172
195
|
if (publishedSha === marker) {
|
|
173
196
|
// The publish into remote.git never landed -- a crash right after the
|
|
174
197
|
// marker was written, or a lease refused by a concurrent submit.
|
|
175
198
|
if (await forcePublishToLocalRemote(ctx, branchPath, branchRef, marker)) {
|
|
176
|
-
await enqueueGitHubPush(ctx, branchRef);
|
|
199
|
+
await enqueueGitHubPush(ctx, branchRef, gatewayPolicy);
|
|
177
200
|
}
|
|
178
201
|
return;
|
|
179
202
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared `legacyBranches` test fixture.
|
|
3
|
+
*
|
|
4
|
+
* The rules deciding whether a `legacyBranches` list is usable exist TWICE by necessity: at
|
|
5
|
+
* runtime (`branch-policy.ts`'s `legacyBranchProblem` and the `BranchPolicy` constructor) and at
|
|
6
|
+
* synth time (`canopycms-cdk`'s `constructs/cms-service.ts`, which does not import core). A rule
|
|
7
|
+
* tightened at runtime but not at synth synths clean and then crash-loops the gateway at boot, so
|
|
8
|
+
* the CDK suite (`cms-deploy.test.ts`) requires both copies' verdict on every list here. Add a
|
|
9
|
+
* case when you change either copy of the rule.
|
|
10
|
+
*
|
|
11
|
+
* Dependency-free (plain arrays, no vitest import) so the CDK suite can import it across the
|
|
12
|
+
* package boundary. Each case is a resolved namespace and the list checked against it.
|
|
13
|
+
*/
|
|
14
|
+
/** Lists both copies must ACCEPT: `[label, namespace, entries]`. */
|
|
15
|
+
export declare const VALID_LEGACY_BRANCH_LISTS: readonly (readonly [string, string, readonly string[]])[];
|
|
16
|
+
/** Lists both copies must REJECT: `[label, namespace, entries]`. */
|
|
17
|
+
export declare const INVALID_LEGACY_BRANCH_LISTS: readonly (readonly [
|
|
18
|
+
string,
|
|
19
|
+
string,
|
|
20
|
+
readonly string[]
|
|
21
|
+
])[];
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared `legacyBranches` test fixture.
|
|
3
|
+
*
|
|
4
|
+
* The rules deciding whether a `legacyBranches` list is usable exist TWICE by necessity: at
|
|
5
|
+
* runtime (`branch-policy.ts`'s `legacyBranchProblem` and the `BranchPolicy` constructor) and at
|
|
6
|
+
* synth time (`canopycms-cdk`'s `constructs/cms-service.ts`, which does not import core). A rule
|
|
7
|
+
* tightened at runtime but not at synth synths clean and then crash-loops the gateway at boot, so
|
|
8
|
+
* the CDK suite (`cms-deploy.test.ts`) requires both copies' verdict on every list here. Add a
|
|
9
|
+
* case when you change either copy of the rule.
|
|
10
|
+
*
|
|
11
|
+
* Dependency-free (plain arrays, no vitest import) so the CDK suite can import it across the
|
|
12
|
+
* package boundary. Each case is a resolved namespace and the list checked against it.
|
|
13
|
+
*/
|
|
14
|
+
const DEFAULT_NAMESPACE = 'canopycms/prod/';
|
|
15
|
+
/** Lists both copies must ACCEPT: `[label, namespace, entries]`. */
|
|
16
|
+
export const VALID_LEGACY_BRANCH_LISTS = [
|
|
17
|
+
['no entries', DEFAULT_NAMESPACE, []],
|
|
18
|
+
['plain names', DEFAULT_NAMESPACE, ['spring', 'feature/x', 'v1.0', 'Release-2']],
|
|
19
|
+
['a name of 255 characters, the most a branch name has', DEFAULT_NAMESPACE, ['a'.repeat(255)]],
|
|
20
|
+
['a dash where the namespace has a slash', DEFAULT_NAMESPACE, ['canopycms-spring']],
|
|
21
|
+
['a sibling of the namespace', DEFAULT_NAMESPACE, ['canopycms/prodigy', 'canopycms/staging/x']],
|
|
22
|
+
['a name that only sanitizes into the namespace', DEFAULT_NAMESPACE, ['canopycms-prod-x']],
|
|
23
|
+
['another namespace', 'cms/', ['canopycms/prod/x', 'cmsx']],
|
|
24
|
+
['a sibling inside the parent of a custom namespace', 'cms/a/', ['cms/b']],
|
|
25
|
+
['1024 entries', DEFAULT_NAMESPACE, Array.from({ length: 1024 }, (_, i) => `r${i}`)],
|
|
26
|
+
];
|
|
27
|
+
/** Lists both copies must REJECT: `[label, namespace, entries]`. */
|
|
28
|
+
export const INVALID_LEGACY_BRANCH_LISTS = [
|
|
29
|
+
['an entry in the namespace', DEFAULT_NAMESPACE, ['canopycms/prod/fix']],
|
|
30
|
+
['an entry in a custom namespace', 'cms/', ['cms/fix']],
|
|
31
|
+
['an entry named after the namespace', DEFAULT_NAMESPACE, ['canopycms/prod']],
|
|
32
|
+
['an entry named after part of the namespace', DEFAULT_NAMESPACE, ['canopycms']],
|
|
33
|
+
['an entry named after part of a custom namespace', 'edits/prod/drafts/', ['edits/prod']],
|
|
34
|
+
['a wildcard', DEFAULT_NAMESPACE, ['release/*']],
|
|
35
|
+
['a lone wildcard, which would switch the namespace off', DEFAULT_NAMESPACE, ['*']],
|
|
36
|
+
['a comma', DEFAULT_NAMESPACE, ['a,b']],
|
|
37
|
+
['a settings branch', DEFAULT_NAMESPACE, ['canopycms-settings-prod']],
|
|
38
|
+
['an empty entry', DEFAULT_NAMESPACE, ['']],
|
|
39
|
+
['a space', DEFAULT_NAMESPACE, ['a b']],
|
|
40
|
+
['a control character', DEFAULT_NAMESPACE, ['a\tb']],
|
|
41
|
+
['non-ASCII', DEFAULT_NAMESPACE, ['café']],
|
|
42
|
+
['a percent sign', DEFAULT_NAMESPACE, ['a%2fb']],
|
|
43
|
+
['a double quote', DEFAULT_NAMESPACE, ['a"b']],
|
|
44
|
+
['a brace', DEFAULT_NAMESPACE, ['a{b']],
|
|
45
|
+
['a closing brace', DEFAULT_NAMESPACE, ['a}b']],
|
|
46
|
+
['a caret', DEFAULT_NAMESPACE, ['a^b']],
|
|
47
|
+
['a colon', DEFAULT_NAMESPACE, ['a:b']],
|
|
48
|
+
['an opening bracket', DEFAULT_NAMESPACE, ['a[b']],
|
|
49
|
+
['DEL', DEFAULT_NAMESPACE, ['a\x7fb']],
|
|
50
|
+
['a leading dash', DEFAULT_NAMESPACE, ['-x']],
|
|
51
|
+
['a leading refs/', DEFAULT_NAMESPACE, ['refs/heads/main']],
|
|
52
|
+
['a leading tags/ in another case', DEFAULT_NAMESPACE, ['Tags/x']],
|
|
53
|
+
['a leading heads/', DEFAULT_NAMESPACE, ['heads/x']],
|
|
54
|
+
['a leading remotes/', DEFAULT_NAMESPACE, ['remotes/origin/x']],
|
|
55
|
+
['a git-forbidden character', DEFAULT_NAMESPACE, ['release-?']],
|
|
56
|
+
['a tilde', DEFAULT_NAMESPACE, ['a~1']],
|
|
57
|
+
['a backslash', DEFAULT_NAMESPACE, ['a\\b']],
|
|
58
|
+
['dot-dot', DEFAULT_NAMESPACE, ['a..b']],
|
|
59
|
+
['a reflog selector', DEFAULT_NAMESPACE, ['a@{1}']],
|
|
60
|
+
['an empty path component', DEFAULT_NAMESPACE, ['a//b']],
|
|
61
|
+
['a leading slash', DEFAULT_NAMESPACE, ['/a']],
|
|
62
|
+
['a trailing slash', DEFAULT_NAMESPACE, ['a/']],
|
|
63
|
+
['a trailing dot', DEFAULT_NAMESPACE, ['a.']],
|
|
64
|
+
['a dot-led component', DEFAULT_NAMESPACE, ['a/.b']],
|
|
65
|
+
['a .lock component', DEFAULT_NAMESPACE, ['a.lock/b']],
|
|
66
|
+
['HEAD', DEFAULT_NAMESPACE, ['HEAD']],
|
|
67
|
+
['a lone @', DEFAULT_NAMESPACE, ['@']],
|
|
68
|
+
['a name of 256 characters, one over the most', DEFAULT_NAMESPACE, ['a'.repeat(256)]],
|
|
69
|
+
['an entry listed twice', DEFAULT_NAMESPACE, ['spring', 'summer', 'spring']],
|
|
70
|
+
['1025 entries', DEFAULT_NAMESPACE, Array.from({ length: 1025 }, (_, i) => `r${i}`)],
|
|
71
|
+
];
|
package/dist/worker/rebase.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type BranchMetadataFile } from '../branch-metadata.js';
|
|
2
|
+
import type { BranchPolicyDescription } from './branch-policy.js';
|
|
2
3
|
import type { WorkerContext } from './worker-context.js';
|
|
3
4
|
/**
|
|
4
5
|
* The rebase loop: the deepest leaf of the git-sync cluster, and the most
|
|
@@ -78,4 +79,4 @@ export declare function pollMergeState(ctx: RebaseContext, branchDir: string, br
|
|
|
78
79
|
* that branch's cross-host content-write lock and runs a series of git
|
|
79
80
|
* subprocesses against a shared filesystem.
|
|
80
81
|
*/
|
|
81
|
-
export declare function runRebaseCycle(ctx: RebaseContext): Promise<RebaseSummary>;
|
|
82
|
+
export declare function runRebaseCycle(ctx: RebaseContext, gatewayPolicy?: BranchPolicyDescription): Promise<RebaseSummary>;
|
package/dist/worker/rebase.js
CHANGED
|
@@ -3,12 +3,13 @@ import path from 'node:path';
|
|
|
3
3
|
import { BranchMetadataFileManager, buildMergedBranchUpdate, getBranchMetadataFileManager, } from '../branch-metadata.js';
|
|
4
4
|
import { extractIdFromFilename } from '../content-id-index.js';
|
|
5
5
|
import { invalidateBranchContentCaches } from '../content-index-generation.js';
|
|
6
|
-
import { isSettingsBranchName } from '../paths/branch-name.js';
|
|
6
|
+
import { isSettingsBranchName, sanitizeBranchName } from '../paths/branch-name.js';
|
|
7
7
|
import { normalizeFilesystemPath } from '../paths/normalize.js';
|
|
8
8
|
import { ROOT_COLLECTION_ID } from '../paths/types.js';
|
|
9
9
|
import { tryAcquireContentWriteLock } from '../utils/content-write-lock.js';
|
|
10
10
|
import { holdProvisionedWorkspace, releaseProvisionedWorkspace, } from './provisioned-workspace.js';
|
|
11
11
|
import { getErrorMessage, isNodeError, redactCredentials } from '../utils/error.js';
|
|
12
|
+
import { escapeForLog } from '../utils/escape-text.js';
|
|
12
13
|
import { isCanopyInternalPath, isRebaseInProgress } from '../utils/git.js';
|
|
13
14
|
import { TRACKED_CANOPY_STATE_FIX, restoreRetiredSchemaCache, splitByUpstreamTracking, trackedCanopyStateChanges, } from './canopy-state.js';
|
|
14
15
|
import { enqueueGitHubPush, forcePublishToLocalRemote, markHistoryRewritten, readPublishedSha, reconcilePendingRewrite, } from './history-rewrite.js';
|
|
@@ -29,6 +30,9 @@ export async function pollMergeState(ctx, branchDir, branchPath, metaFile) {
|
|
|
29
30
|
const prNumber = metaFile?.branch.pullRequestNumber;
|
|
30
31
|
if (!prNumber)
|
|
31
32
|
return;
|
|
33
|
+
// Recorded only on a branch.json that still names the branch this poll read: never a missing
|
|
34
|
+
// one, nor another branch's that shares the key.
|
|
35
|
+
const sameBranch = (current) => current?.branch.name === metaFile?.branch.name;
|
|
32
36
|
const controller = new AbortController();
|
|
33
37
|
const timer = setTimeout(() => controller.abort(), ctx.taskTimeoutMs);
|
|
34
38
|
try {
|
|
@@ -37,10 +41,11 @@ export async function pollMergeState(ctx, branchDir, branchPath, metaFile) {
|
|
|
37
41
|
const meta = getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath);
|
|
38
42
|
// GitHub's actual merge time when available; buildMergedBranchUpdate
|
|
39
43
|
// falls back to "now" when merged_at is absent.
|
|
40
|
-
await meta.
|
|
41
|
-
branch: buildMergedBranchUpdate(
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
+
const saved = await meta.saveIf({
|
|
45
|
+
branch: buildMergedBranchUpdate(data.merged_at ? new Date(data.merged_at) : undefined),
|
|
46
|
+
}, sameBranch);
|
|
47
|
+
if (saved)
|
|
48
|
+
workerLog(` PR #${prNumber} for ${branchDir} is merged -> archived`);
|
|
44
49
|
return;
|
|
45
50
|
}
|
|
46
51
|
const newState = data.state === 'closed' ? 'closed' : 'open';
|
|
@@ -50,8 +55,9 @@ export async function pollMergeState(ctx, branchDir, branchPath, metaFile) {
|
|
|
50
55
|
if (currentMeta?.branch.pullRequestState === newState)
|
|
51
56
|
return;
|
|
52
57
|
const meta = getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath);
|
|
53
|
-
await meta.
|
|
54
|
-
|
|
58
|
+
const saved = await meta.saveIf({ branch: { pullRequestState: newState } }, sameBranch);
|
|
59
|
+
if (saved)
|
|
60
|
+
workerLog(` PR #${prNumber} for ${branchDir}: pullRequestState -> ${newState}`);
|
|
55
61
|
}
|
|
56
62
|
catch (err) {
|
|
57
63
|
// Non-fatal: transient GitHub/network errors are retried next cycle.
|
|
@@ -72,9 +78,10 @@ export async function pollMergeState(ctx, branchDir, branchPath, metaFile) {
|
|
|
72
78
|
* paths: a corrupt branch.json, lock contention or any other save failure must
|
|
73
79
|
* never abort the per-branch iteration. One of the two call sites is inside the
|
|
74
80
|
* outer per-branch catch with no further catch around it, so the WHOLE method
|
|
75
|
-
* is wrapped, not just the load.
|
|
81
|
+
* is wrapped, not just the load. It updates an existing branch.json only, never creating one,
|
|
82
|
+
* and, given `branchRef`, only the one whose `name` is that ref.
|
|
76
83
|
*/
|
|
77
|
-
async function recordRebaseFailure(ctx, branchPath, branchDir, message) {
|
|
84
|
+
async function recordRebaseFailure(ctx, branchPath, branchDir, message, branchRef) {
|
|
78
85
|
const RECORD_REFRESH_MS = 60 * 60 * 1000; // 1 hour
|
|
79
86
|
// [REDACT] Defense in depth: rebaseFailure.message is persisted to
|
|
80
87
|
// branch.json and served to the browser by the branch-health admin endpoint.
|
|
@@ -96,12 +103,7 @@ async function recordRebaseFailure(ctx, branchPath, branchDir, message) {
|
|
|
96
103
|
const nowIso = now.toISOString();
|
|
97
104
|
const firstAt = sameMessage ? prior.firstAt : nowIso;
|
|
98
105
|
const meta = getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath);
|
|
99
|
-
await meta.
|
|
100
|
-
branch: {
|
|
101
|
-
name: branchDir,
|
|
102
|
-
rebaseFailure: { message: redactedMessage, firstAt, lastAt: nowIso },
|
|
103
|
-
},
|
|
104
|
-
});
|
|
106
|
+
await meta.saveIf({ branch: { rebaseFailure: { message: redactedMessage, firstAt, lastAt: nowIso } } }, (current) => current !== null && (branchRef === undefined || current.branch.name === branchRef));
|
|
105
107
|
}
|
|
106
108
|
catch (err) {
|
|
107
109
|
// Includes BranchMetadataCorruptError from the load above (a save() against
|
|
@@ -328,7 +330,7 @@ function recordConflicts(recorded, found) {
|
|
|
328
330
|
* clone's pre-rebase tip is exactly what the rebase destroys.
|
|
329
331
|
*/
|
|
330
332
|
async function carryForwardRewrittenHistory(ctx, args) {
|
|
331
|
-
const { branchPath, branchDir, branchRef, publishedSha, preRebaseHead } = args;
|
|
333
|
+
const { branchPath, branchDir, branchRef, publishedSha, preRebaseHead, gatewayPolicy } = args;
|
|
332
334
|
if (publishedSha !== null) {
|
|
333
335
|
if (publishedSha === preRebaseHead) {
|
|
334
336
|
// ARMING GUARD: remote.git holds EXACTLY what this clone just rebased
|
|
@@ -345,14 +347,19 @@ async function carryForwardRewrittenHistory(ctx, args) {
|
|
|
345
347
|
// delete that fixup from remote.git and then GitHub. Keying the lease to
|
|
346
348
|
// the pre-rebase tip turns that case into the visible divergence it is.
|
|
347
349
|
//
|
|
348
|
-
// Order matters: mark, then push, then queue
|
|
350
|
+
// Order matters: mark, then push, then queue, and a mark that declines
|
|
351
|
+
// (no branch.json names this ref) publishes nothing. A crash after any step
|
|
349
352
|
// leaves the marker set with the work unfinished, which
|
|
350
353
|
// reconcilePendingRewrite() completes on a later cycle. Pushing first
|
|
351
354
|
// would leave remote.git rewritten, GitHub stale and nothing recorded --
|
|
352
355
|
// unrecoverable, and diagnosed as a false "another deployment" collision.
|
|
353
|
-
await markHistoryRewritten(ctx, branchPath,
|
|
356
|
+
if (!(await markHistoryRewritten(ctx, branchPath, branchRef, publishedSha))) {
|
|
357
|
+
workerLogWarn(` ${branchDir}: rebased, but its branch.json is gone or names another branch than ` +
|
|
358
|
+
`${branchRef}, so the rewrite is not published`);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
354
361
|
if (await forcePublishToLocalRemote(ctx, branchPath, branchRef, publishedSha)) {
|
|
355
|
-
await enqueueGitHubPush(ctx, branchRef);
|
|
362
|
+
await enqueueGitHubPush(ctx, branchRef, gatewayPolicy);
|
|
356
363
|
}
|
|
357
364
|
}
|
|
358
365
|
else {
|
|
@@ -360,7 +367,7 @@ async function carryForwardRewrittenHistory(ctx, args) {
|
|
|
360
367
|
`clone never had (a direct push to the branch?). Left untouched -- reconcile it ` +
|
|
361
368
|
`before submitting again.`;
|
|
362
369
|
workerLogWarn(` ${branchDir}: ${divergence}`);
|
|
363
|
-
await recordRebaseFailure(ctx, branchPath, branchDir, divergence);
|
|
370
|
+
await recordRebaseFailure(ctx, branchPath, branchDir, divergence, branchRef);
|
|
364
371
|
}
|
|
365
372
|
}
|
|
366
373
|
}
|
|
@@ -376,7 +383,7 @@ async function carryForwardRewrittenHistory(ctx, args) {
|
|
|
376
383
|
* that branch's cross-host content-write lock and runs a series of git
|
|
377
384
|
* subprocesses against a shared filesystem.
|
|
378
385
|
*/
|
|
379
|
-
export async function runRebaseCycle(ctx) {
|
|
386
|
+
export async function runRebaseCycle(ctx, gatewayPolicy) {
|
|
380
387
|
const rebased = [];
|
|
381
388
|
const skippedDirty = [];
|
|
382
389
|
const skippedLocked = [];
|
|
@@ -433,7 +440,7 @@ export async function runRebaseCycle(ctx) {
|
|
|
433
440
|
workerLogWarn(` Skipping ${branchDir}: settings branch, never a content workspace`);
|
|
434
441
|
continue;
|
|
435
442
|
}
|
|
436
|
-
const outcome = await holdAndRebaseOneBranch(ctx, branchDir, branchPath);
|
|
443
|
+
const outcome = await holdAndRebaseOneBranch(ctx, branchDir, branchPath, gatewayPolicy);
|
|
437
444
|
if (outcome.kind === 'rebased')
|
|
438
445
|
rebased.push(branchDir);
|
|
439
446
|
else if (outcome.kind === 'skippedDirty')
|
|
@@ -450,6 +457,50 @@ export async function runRebaseCycle(ctx) {
|
|
|
450
457
|
}
|
|
451
458
|
return { rebased, skippedDirty, skippedLocked, failed };
|
|
452
459
|
}
|
|
460
|
+
/**
|
|
461
|
+
* The ref a branch clone's HEAD names, or null for a detached or unreadable one. Read from the
|
|
462
|
+
* file rather than through git, so it runs before `assertSharedRepoConfig` vets the clone.
|
|
463
|
+
*/
|
|
464
|
+
async function readCloneRef(branchPath) {
|
|
465
|
+
try {
|
|
466
|
+
const head = (await fs.readFile(path.join(branchPath, '.git', 'HEAD'), 'utf-8')).trim();
|
|
467
|
+
return head.startsWith('ref: refs/heads/') ? head.slice('ref: refs/heads/'.length) : null;
|
|
468
|
+
}
|
|
469
|
+
catch {
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Restore `name` to the clone's own ref when a worker that predates the namespace renamed a
|
|
475
|
+
* namespace branch to its directory (`canopycms-prod-x` for `canopycms/prod/x`), which no request
|
|
476
|
+
* resolves. Only when the ref is in `namespace` and the directory is that ref sanitized, so the
|
|
477
|
+
* ref is the name the branch was created with. Runs for every status, so a branch in review heals
|
|
478
|
+
* too. Resolves the metadata the rest of the cycle reads; a branch left alone, or a write that
|
|
479
|
+
* fails, keeps `metaFile`.
|
|
480
|
+
*/
|
|
481
|
+
async function restoreRenamedBranch(ctx, branchDir, branchPath, metaFile, namespace) {
|
|
482
|
+
// A name already in the namespace was never renamed; one that differs from the ref shares its
|
|
483
|
+
// key with another branch, which this does not decide.
|
|
484
|
+
if (metaFile === null || namespace === undefined || metaFile.branch.name.startsWith(namespace)) {
|
|
485
|
+
return metaFile;
|
|
486
|
+
}
|
|
487
|
+
const staleName = metaFile.branch.name;
|
|
488
|
+
const ref = await readCloneRef(branchPath);
|
|
489
|
+
if (ref === null || !ref.startsWith(namespace) || sanitizeBranchName(ref) !== branchDir) {
|
|
490
|
+
return metaFile;
|
|
491
|
+
}
|
|
492
|
+
try {
|
|
493
|
+
const restored = await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).update((existing) => (existing?.branch.name === staleName ? { branch: { name: ref } } : null), { keepUpdatedAt: true });
|
|
494
|
+
if (restored === null)
|
|
495
|
+
return BranchMetadataFileManager.loadOnly(branchPath);
|
|
496
|
+
workerLog(` ${branchDir}: restored its name ${escapeForLog(ref)} from ${escapeForLog(staleName)}`);
|
|
497
|
+
return restored;
|
|
498
|
+
}
|
|
499
|
+
catch (err) {
|
|
500
|
+
workerLogWarn(` ${branchDir}: could not restore its name ${escapeForLog(ref)}: ${getErrorMessage(err)}`);
|
|
501
|
+
return metaFile;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
453
504
|
/**
|
|
454
505
|
* {@link rebaseOneBranch} under the branch's provisioning lock, so no git step races a clone of
|
|
455
506
|
* this directory. A branch whose lock is held elsewhere is `skippedLocked` and retried next cycle;
|
|
@@ -457,7 +508,7 @@ export async function runRebaseCycle(ctx) {
|
|
|
457
508
|
* content-write lock inside it; see `provisioned-workspace.ts` for why that order cannot deadlock.
|
|
458
509
|
* Never throws, like rebaseOneBranch.
|
|
459
510
|
*/
|
|
460
|
-
async function holdAndRebaseOneBranch(ctx, branchDir, branchPath) {
|
|
511
|
+
async function holdAndRebaseOneBranch(ctx, branchDir, branchPath, gatewayPolicy) {
|
|
461
512
|
let hold;
|
|
462
513
|
try {
|
|
463
514
|
hold = await holdProvisionedWorkspace(ctx.contentBranchesPath, branchDir);
|
|
@@ -477,7 +528,7 @@ async function holdAndRebaseOneBranch(ctx, branchDir, branchPath) {
|
|
|
477
528
|
if (hold.kind === 'not-provisioned')
|
|
478
529
|
return { kind: 'none' };
|
|
479
530
|
try {
|
|
480
|
-
return await rebaseOneBranch(ctx, branchDir, branchPath, hold.isCompromised);
|
|
531
|
+
return await rebaseOneBranch(ctx, branchDir, branchPath, hold.isCompromised, gatewayPolicy);
|
|
481
532
|
}
|
|
482
533
|
finally {
|
|
483
534
|
await releaseProvisionedWorkspace(hold.release, branchDir);
|
|
@@ -495,12 +546,13 @@ async function holdAndRebaseOneBranch(ctx, branchDir, branchPath) {
|
|
|
495
546
|
* returned as `{ kind: 'failed' }`, so one unreadable branch cannot halt the
|
|
496
547
|
* rest of the cycle ([SYNC-M2]).
|
|
497
548
|
*/
|
|
498
|
-
async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCompromised) {
|
|
549
|
+
async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCompromised, gatewayPolicy) {
|
|
499
550
|
// Read only by the catch below. See the `rebased` rider on
|
|
500
551
|
// BranchRebaseOutcome.
|
|
501
552
|
let didRebase = false;
|
|
502
553
|
try {
|
|
503
|
-
|
|
554
|
+
// Restored first: everything below this cycle compares `name`.
|
|
555
|
+
const metaFile = await restoreRenamedBranch(ctx, branchDir, branchPath, await BranchMetadataFileManager.loadOnly(branchPath), gatewayPolicy?.namespace);
|
|
504
556
|
const branchStatus = metaFile?.branch.status;
|
|
505
557
|
// Skip branches that must not be mutated:
|
|
506
558
|
// - submitted/approved: in review, so never rewrite history under an active
|
|
@@ -683,6 +735,7 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
683
735
|
branchRef,
|
|
684
736
|
headSha: (await branchGit.revparse(['HEAD'])).trim(),
|
|
685
737
|
marker: metaFile.branch.historyRewrittenFrom,
|
|
738
|
+
gatewayPolicy,
|
|
686
739
|
});
|
|
687
740
|
}
|
|
688
741
|
await fetchFromRemoteGit(branchGit, ctx.remoteGitPath, ctx.baseBranch);
|
|
@@ -714,7 +767,8 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
714
767
|
if (currentMeta?.branch.rebaseFailure === undefined) {
|
|
715
768
|
return { kind: 'none' };
|
|
716
769
|
}
|
|
717
|
-
|
|
770
|
+
const storedName = currentMeta.branch.name;
|
|
771
|
+
await meta.saveIf({ branch: { rebaseFailure: undefined } }, (existing) => existing?.branch.name === storedName);
|
|
718
772
|
return { kind: 'none' };
|
|
719
773
|
}
|
|
720
774
|
workerLog(`Rebasing ${branchDir} (${behindCount} commits behind)...`);
|
|
@@ -790,15 +844,17 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
790
844
|
workerLog(hadConflicts
|
|
791
845
|
? ` Rebased ${branchDir} (kept branch version for ${conflictIdsDeduped.length} conflicting file(s))`
|
|
792
846
|
: ` Rebased ${branchDir} successfully`);
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
847
|
+
// Recorded only on a branch.json that still names the branch this cycle read.
|
|
848
|
+
await meta.update((existing) => existing !== null && existing.branch.name === metaFile?.branch.name
|
|
849
|
+
? {
|
|
850
|
+
branch: {
|
|
851
|
+
...recordConflicts(existing.branch.conflictFiles, conflictIdsDeduped),
|
|
852
|
+
// The cycle completed, so clear any prior failure record regardless
|
|
853
|
+
// of the conflict outcome.
|
|
854
|
+
rebaseFailure: undefined,
|
|
855
|
+
},
|
|
856
|
+
}
|
|
857
|
+
: null);
|
|
802
858
|
didRebase = true;
|
|
803
859
|
await carryForwardRewrittenHistory(ctx, {
|
|
804
860
|
branchPath,
|
|
@@ -806,6 +862,7 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
|
|
|
806
862
|
branchRef,
|
|
807
863
|
publishedSha,
|
|
808
864
|
preRebaseHead,
|
|
865
|
+
gatewayPolicy,
|
|
809
866
|
});
|
|
810
867
|
return { kind: 'rebased' };
|
|
811
868
|
}
|
|
@@ -62,7 +62,10 @@ export declare function orphanRecoveryMaxAgeMs(ctx: Pick<TaskRunnerContext, 'tas
|
|
|
62
62
|
export declare function processTaskQueue(ctx: TaskRunnerContext): Promise<void>;
|
|
63
63
|
export declare function executeTask(ctx: TaskRunnerContext, task: Task, signal: AbortSignal): Promise<Record<string, unknown>>;
|
|
64
64
|
/**
|
|
65
|
-
* Update branch metadata after successful task completion.
|
|
65
|
+
* Update branch metadata after successful task completion. Like every outcome write here, it
|
|
66
|
+
* updates only a branch.json whose `name` is the task's ref: a workspace without one is not a
|
|
67
|
+
* branch whose name could be recorded, and one naming another ref is that branch's
|
|
68
|
+
* (`noMetadataToRecord`).
|
|
66
69
|
*/
|
|
67
70
|
export declare function updateBranchMetadata(ctx: TaskRunnerContext, task: Task, result: Record<string, unknown>): Promise<void>;
|
|
68
71
|
export declare function pushBranchToGitHub(ctx: TaskRunnerContext, branch: string, signal?: AbortSignal): Promise<void>;
|