canopycms 0.0.68-int.111 → 0.0.68-int.112

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.
@@ -45,15 +45,17 @@ async function readMetaMtime(branchRoot) {
45
45
  }
46
46
  /**
47
47
  * Scan a healthy branch's content tree for duplicate embedded IDs, giving up
48
- * at `deadline` (epoch ms). Never throws: one branch's unreadable content tree
48
+ * when `budget` runs out. Never throws: one branch's unreadable content tree
49
49
  * must not take down the whole health scan. Costs a full recursive readdir, so
50
50
  * it runs only on request and under a budget. A readdir cannot be cancelled: a
51
51
  * scan that misses the deadline keeps running unobserved until it settles.
52
52
  */
53
- async function scanDuplicateContentIds(branchRoot, contentRootName, deadline) {
54
- const remainingMs = deadline - Date.now();
55
- if (remainingMs <= 0)
53
+ async function scanDuplicateContentIds(branchRoot, contentRootName, budget) {
54
+ const remainingMs = budget.deadline - Date.now();
55
+ if (budget.spent || remainingMs <= 0) {
56
+ budget.spent = true;
56
57
  return { state: 'unknown', reason: 'out-of-time' };
58
+ }
57
59
  const scan = (async () => {
58
60
  try {
59
61
  const idIndex = new ContentIdIndex(branchRoot);
@@ -67,7 +69,10 @@ async function scanDuplicateContentIds(branchRoot, contentRootName, deadline) {
67
69
  })();
68
70
  let timer;
69
71
  const outOfTime = new Promise((resolve) => {
70
- timer = setTimeout(() => resolve({ state: 'unknown', reason: 'out-of-time' }), remainingMs);
72
+ timer = setTimeout(() => {
73
+ budget.spent = true;
74
+ resolve({ state: 'unknown', reason: 'out-of-time' });
75
+ }, remainingMs);
71
76
  });
72
77
  try {
73
78
  return await Promise.race([scan, outOfTime]);
@@ -88,8 +93,8 @@ async function scanDuplicateContentIds(branchRoot, contentRootName, deadline) {
88
93
  * reached after the budget is spent reports `unknown`/`out-of-time`.
89
94
  */
90
95
  export async function scanBranchHealth(baseRoot, opts) {
91
- const duplicateScanDeadline = opts.duplicateIdScan
92
- ? Date.now() + opts.duplicateIdScan.budgetMs
96
+ const duplicateScanBudget = opts.duplicateIdScan
97
+ ? { deadline: Date.now() + opts.duplicateIdScan.budgetMs, spent: false }
93
98
  : undefined;
94
99
  const resolvedRoot = path.resolve(baseRoot);
95
100
  const sanitizedBaseBranchName = sanitizeBranchName(opts.baseBranchName);
@@ -154,9 +159,9 @@ export async function scanBranchHealth(baseRoot, opts) {
154
159
  }
155
160
  if (meta) {
156
161
  const [duplicateIdScan, rebaseInProgress] = await Promise.all([
157
- duplicateScanDeadline === undefined
162
+ duplicateScanBudget === undefined
158
163
  ? undefined
159
- : scanDuplicateContentIds(branchRoot, contentRootName, duplicateScanDeadline),
164
+ : scanDuplicateContentIds(branchRoot, contentRootName, duplicateScanBudget),
160
165
  isRebaseInProgress(branchRoot),
161
166
  ]);
162
167
  entries.push({
@@ -49,6 +49,16 @@ export type BareRemoteRepackResult = {
49
49
  * it packed and their emptied fan-out directories. `git` as for `ensureRemoteGitConfig`.
50
50
  */
51
51
  export declare function repackBareRemoteIfNeeded(gitDir: string, git?: SimpleGit): Promise<BareRemoteRepackResult>;
52
+ /**
53
+ * simple-git reads a git that exited by signal (exit code null, often with no stderr) as success,
54
+ * so a clone or checkout the OOM killer stopped would pass for complete. Given as `errors` to a
55
+ * GitManager's own instance, to `cloneRepo`'s, and to the worker's ref probes of an existing
56
+ * remote.git (worker/cms-worker.ts); its static helpers and the bare-remote functions do not pass
57
+ * it yet (.claude/future-tasks/simple-git-signal-exit-reads-as-success.md).
58
+ */
59
+ export declare function failOnSignalExit(error: Buffer | Error | undefined, result: {
60
+ exitCode: number;
61
+ }): Buffer | Error | undefined;
52
62
  /**
53
63
  * Config every workspace clone writes into its own `.git/config`: the managed
54
64
  * marker that ensureRemote's guard checks, and a fallback author for internal
@@ -179,10 +179,11 @@ export async function repackBareRemoteIfNeeded(gitDir, git = simpleGit().env(git
179
179
  /**
180
180
  * simple-git reads a git that exited by signal (exit code null, often with no stderr) as success,
181
181
  * so a clone or checkout the OOM killer stopped would pass for complete. Given as `errors` to a
182
- * GitManager's own instance and to `cloneRepo`'s; its static helpers and the bare-remote functions
183
- * do not pass it yet (.claude/future-tasks/simple-git-signal-exit-reads-as-success.md).
182
+ * GitManager's own instance, to `cloneRepo`'s, and to the worker's ref probes of an existing
183
+ * remote.git (worker/cms-worker.ts); its static helpers and the bare-remote functions do not pass
184
+ * it yet (.claude/future-tasks/simple-git-signal-exit-reads-as-success.md).
184
185
  */
185
- function failOnSignalExit(error, result) {
186
+ export function failOnSignalExit(error, result) {
186
187
  if (error)
187
188
  return error;
188
189
  if (result.exitCode === null) {
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const CANOPYCMS_VERSION = "0.0.68-int.111";
1
+ export declare const CANOPYCMS_VERSION = "0.0.68-int.112";
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Generated by scripts/bump-version.mjs, its only writer. The package builds with plain
2
2
  // tsc (rootDir: src), so src/ cannot import ../package.json; version.test.ts keeps the two equal.
3
- export const CANOPYCMS_VERSION = '0.0.68-int.111';
3
+ export const CANOPYCMS_VERSION = '0.0.68-int.112';
@@ -278,9 +278,19 @@ export declare class CmsWorker {
278
278
  /**
279
279
  * An unconfigured base branch is the one remote.git's HEAD names, which is what the Lambda
280
280
  * reads too (GitManager.detectBaseBranch); before remote.git exists, it is GitHub's default
281
- * branch, which the clone then records as that HEAD. Never assumes 'main'.
281
+ * branch, which the clone then records as that HEAD. So it is too when remote.git has no branch
282
+ * outside the settings namespace: no base branch is there for HEAD to name, and ensureRemoteGit
283
+ * replaces it from GitHub unless something in it is at stake. Never assumes 'main'.
282
284
  */
283
285
  private resolveBaseBranch;
286
+ /**
287
+ * Check remote.git's config at boot. A refusal is never a case for replacement: it reports that
288
+ * something wrote there, and listing the refs a replacement would lose means running git under
289
+ * that config. The message says so, so System health does not read it as a routine failure.
290
+ */
291
+ private assertRemoteGitConfig;
292
+ /** Whether the bare repo at `gitDir` has a branch that is not a settings branch. */
293
+ private hasContentBranch;
284
294
  /**
285
295
  * Point remote.git's HEAD at the base branch this worker uses, so a Lambda left to detect it
286
296
  * (GitManager.detectBaseBranch) reads the same name. Only the worker writes it, at boot under
@@ -337,9 +347,27 @@ export declare class CmsWorker {
337
347
  * pushed) succeeds and leaves a refs-less repo that `fs.stat` cannot tell from a healthy one, so
338
348
  * left unchecked it silently poisons remote.git and the stat short-circuit means it never heals.
339
349
  * The base branch is therefore verified right after seeding AND on the already-exists fast
340
- * path, since a previous run can have left a poisoned remote.git behind.
350
+ * path, where a poisoned remote.git is replaced (`replacePoisonedRemoteGit`).
341
351
  */
342
352
  private ensureRemoteGit;
353
+ /**
354
+ * Replace an existing remote.git that has no base branch with a fresh seed from GitHub, but only
355
+ * when every ref in it names a commit a GitHub branch contains, so replacing it loses no commit;
356
+ * a branch name GitHub lacks, such as a merged branch deleted there, goes. Otherwise refuse,
357
+ * naming the refs at stake: unpushed work such as a settings branch.
358
+ *
359
+ * The refs are compared in the same mirror session that fetches GitHub and seeds the
360
+ * replacement, and listed again just before the swap, since the Lambda can push into remote.git
361
+ * while the seed runs. The swap is two renames, so the name remote.git resolves to the old repo,
362
+ * the new one, or for an instant nothing, never a half-built or half-deleted one.
363
+ */
364
+ private replacePoisonedRemoteGit;
365
+ /**
366
+ * Seed a new bare repo from GitHub at `<remote.git>.cloning`, verified and configured, and
367
+ * return its path for the caller to rename into place. `check` runs in the mirror session right
368
+ * after the GitHub fetch, and its throw stops the seed. Throws with the staging directory gone.
369
+ */
370
+ private seedRemoteGitStaging;
343
371
  /**
344
372
  * Refuse a state directory on the shared filesystem: the mirror there would be as writable by
345
373
  * the Lambda as remote.git is.
@@ -6,9 +6,9 @@ import lockfile from 'proper-lockfile';
6
6
  import { recoverOrphanedTasks, cmsTaskQueueLogger } from '../task-queue/cms-task-queue.js';
7
7
  import { createCanopyOctokit } from '../github-service.js';
8
8
  import { isTransientAuthFailure, resolveWorkerGitHubAuth, } from './github-auth.js';
9
- import { ensureRemoteGitConfig } from '../git-manager.js';
9
+ import { GITHUB_TRACKING_REF_PREFIX, ensureRemoteGitConfig, failOnSignalExit } from '../git-manager.js';
10
10
  import { readHeadBranch } from '../utils/git.js';
11
- import { sanitizeBranchName, RESERVED_SETTINGS_BRANCH_PREFIX } from '../paths/branch-name.js';
11
+ import { isSettingsBranchName, sanitizeBranchName, RESERVED_SETTINGS_BRANCH_PREFIX, } from '../paths/branch-name.js';
12
12
  import { resolveDeploymentName } from '../operating-mode/deployment-name.js';
13
13
  import { getErrorMessage, isNodeError, redactCredentials } from '../utils/error.js';
14
14
  import { readCarriedOverStatus, readWorkerStatusStartedAt, writeWorkerStatus, } from '../task-queue/worker-status.js';
@@ -55,6 +55,44 @@ function defaultStateDirectory(workspacePath) {
55
55
  const key = createHash('sha256').update(path.resolve(workspacePath)).digest('hex').slice(0, 16);
56
56
  return path.join(os.tmpdir(), `canopycms-worker-${key}`);
57
57
  }
58
+ /** How many refs a refusal to replace remote.git names before summarising the rest. */
59
+ const MAX_REFS_LISTED = 20;
60
+ /** A refusal to replace a poisoned remote.git because it holds work GitHub does not have. */
61
+ class RemoteGitKeptError extends Error {
62
+ }
63
+ /**
64
+ * Every ref in the bare repo at `gitDir` outside the GitHub tracking namespace, which only ever
65
+ * holds GitHub's own branches, mapped to the object it names. A ref missing from this list is one
66
+ * a replacement would discard unchecked, so it throws when git reports anything on stderr
67
+ * (`for-each-ref` skips a ref it cannot read with only a warning and exit 0) and when git is killed
68
+ * by a signal, which simple-git otherwise resolves as empty output. A dangling symbolic ref is
69
+ * skipped without a warning, and names nothing to lose.
70
+ */
71
+ async function listRemoteGitRefs(gitDir) {
72
+ let stderr = '';
73
+ const output = await sharedRepoGit(gitDir, 'bare', { errors: failOnSignalExit })
74
+ .outputHandler((_command, _stdout, err) => {
75
+ err.on('data', (chunk) => {
76
+ stderr += String(chunk);
77
+ });
78
+ })
79
+ .raw(['for-each-ref', '--format=%(objectname) %(refname)']);
80
+ if (stderr.trim() !== '')
81
+ throw new Error(stderr.trim());
82
+ const refs = new Map();
83
+ for (const line of output.split('\n')) {
84
+ const space = line.indexOf(' ');
85
+ if (space === -1)
86
+ continue;
87
+ const ref = line.slice(space + 1);
88
+ if (!ref.startsWith(GITHUB_TRACKING_REF_PREFIX))
89
+ refs.set(ref, line.slice(0, space));
90
+ }
91
+ return refs;
92
+ }
93
+ function sameRefs(a, b) {
94
+ return a.size === b.size && [...a].every(([ref, id]) => b.get(ref) === id);
95
+ }
58
96
  const DEFAULT_TASK_TIMEOUT = 60_000;
59
97
  const DEFAULT_MAX_RETRIES = 3;
60
98
  const DEFAULT_LOCK_STALE_MS = 60_000;
@@ -675,7 +713,9 @@ export class CmsWorker {
675
713
  /**
676
714
  * An unconfigured base branch is the one remote.git's HEAD names, which is what the Lambda
677
715
  * reads too (GitManager.detectBaseBranch); before remote.git exists, it is GitHub's default
678
- * branch, which the clone then records as that HEAD. Never assumes 'main'.
716
+ * branch, which the clone then records as that HEAD. So it is too when remote.git has no branch
717
+ * outside the settings namespace: no base branch is there for HEAD to name, and ensureRemoteGit
718
+ * replaces it from GitHub unless something in it is at stake. Never assumes 'main'.
679
719
  */
680
720
  async resolveBaseBranch() {
681
721
  if (this.resolvedBaseBranch)
@@ -698,10 +738,11 @@ export class CmsWorker {
698
738
  // Before the first git to read it, and on its own: a refusal is recorded as itself, not as a
699
739
  // base branch to configure or a remote.git to delete.
700
740
  if (remoteGitExists)
701
- await assertSharedRepoConfig(this.remoteGitPath, 'bare');
741
+ await this.assertRemoteGitConfig();
702
742
  let name;
703
743
  try {
704
- name = remoteGitExists
744
+ const fromRemoteGit = remoteGitExists && (await this.hasContentBranch(this.remoteGitPath));
745
+ name = fromRemoteGit
705
746
  ? await readHeadBranch(this.remoteGitPath, sharedRepoGit(this.remoteGitPath, 'bare'))
706
747
  : (await this.octokitClient().repos.get({
707
748
  owner: this.config.githubOwner,
@@ -714,6 +755,33 @@ export class CmsWorker {
714
755
  this.setBaseBranch(name);
715
756
  workerLog(`Base branch: '${name}' (detected; CANOPYCMS_BASE_BRANCH is not set)`);
716
757
  }
758
+ /**
759
+ * Check remote.git's config at boot. A refusal is never a case for replacement: it reports that
760
+ * something wrote there, and listing the refs a replacement would lose means running git under
761
+ * that config. The message says so, so System health does not read it as a routine failure.
762
+ */
763
+ async assertRemoteGitConfig() {
764
+ try {
765
+ await assertSharedRepoConfig(this.remoteGitPath, 'bare');
766
+ }
767
+ catch (err) {
768
+ if (!(err instanceof SharedRepoRefusalError))
769
+ throw err;
770
+ throw new SharedRepoRefusalError(`${err.message.replace(/\.$/, '')}. This needs an operator: the worker never repairs or ` +
771
+ `replaces a remote.git whose config was tampered with or cannot be read.`);
772
+ }
773
+ }
774
+ /** Whether the bare repo at `gitDir` has a branch that is not a settings branch. */
775
+ async hasContentBranch(gitDir) {
776
+ const branches = await sharedRepoGit(gitDir, 'bare', { errors: failOnSignalExit }).raw([
777
+ 'for-each-ref',
778
+ '--format=%(refname:strip=2)',
779
+ 'refs/heads/',
780
+ ]);
781
+ return branches
782
+ .split('\n')
783
+ .some((branch) => branch !== '' && !isSettingsBranchName(branch, this.ensureSettingsBranch()));
784
+ }
717
785
  /**
718
786
  * Point remote.git's HEAD at the base branch this worker uses, so a Lambda left to detect it
719
787
  * (GitManager.detectBaseBranch) reads the same name. Only the worker writes it, at boot under
@@ -843,7 +911,7 @@ export class CmsWorker {
843
911
  * pushed) succeeds and leaves a refs-less repo that `fs.stat` cannot tell from a healthy one, so
844
912
  * left unchecked it silently poisons remote.git and the stat short-circuit means it never heals.
845
913
  * The base branch is therefore verified right after seeding AND on the already-exists fast
846
- * path, since a previous run can have left a poisoned remote.git behind.
914
+ * path, where a poisoned remote.git is replaced (`replacePoisonedRemoteGit`).
847
915
  */
848
916
  async ensureRemoteGit() {
849
917
  let exists;
@@ -857,7 +925,7 @@ export class CmsWorker {
857
925
  if (exists) {
858
926
  // At boot, so a refusal lands in worker-status.json as a startup failure, and first: the
859
927
  // scrub is git reading this config too.
860
- await assertSharedRepoConfig(this.remoteGitPath, 'bare');
928
+ await this.assertRemoteGitConfig();
861
929
  // SELF-HEAL, before anything else changes this repo: a remote.git cloned from GitHub by an
862
930
  // older worker recorded the token-bearing clone URL in its config.
863
931
  await this.scrubPersistedRemote(this.remoteGitPath);
@@ -866,15 +934,121 @@ export class CmsWorker {
866
934
  }
867
935
  catch (err) {
868
936
  workerLogError(`remote.git base branch verification failed: ${getErrorMessage(err)}`);
869
- // Do NOT auto-delete: an existing remote.git can hold unpushed
870
- // canopycms-settings-* branches or other state worth preserving, so
871
- // deletion is the operator's call.
872
- throw new Error(`remote.git at ${this.remoteGitPath} has no branch '${this.baseBranch}' (likely cloned while the GitHub repo was empty). Delete ${this.remoteGitPath} and restart the worker to re-clone.`);
937
+ await this.replacePoisonedRemoteGit();
938
+ return;
873
939
  }
874
940
  await this.applyRemoteGitConfig(this.remoteGitPath);
875
941
  return; // Already exists and has the base branch
876
942
  }
877
943
  workerLog('Initializing remote.git from GitHub...');
944
+ const stagingPath = await this.seedRemoteGitStaging();
945
+ await fs.rename(stagingPath, this.remoteGitPath);
946
+ workerLog('remote.git initialized');
947
+ }
948
+ /**
949
+ * Replace an existing remote.git that has no base branch with a fresh seed from GitHub, but only
950
+ * when every ref in it names a commit a GitHub branch contains, so replacing it loses no commit;
951
+ * a branch name GitHub lacks, such as a merged branch deleted there, goes. Otherwise refuse,
952
+ * naming the refs at stake: unpushed work such as a settings branch.
953
+ *
954
+ * The refs are compared in the same mirror session that fetches GitHub and seeds the
955
+ * replacement, and listed again just before the swap, since the Lambda can push into remote.git
956
+ * while the seed runs. The swap is two renames, so the name remote.git resolves to the old repo,
957
+ * the new one, or for an instant nothing, never a half-built or half-deleted one.
958
+ */
959
+ async replacePoisonedRemoteGit() {
960
+ const poisoned = `remote.git at ${this.remoteGitPath} has no branch '${this.baseBranch}' (cloned while the ` +
961
+ `GitHub repository was empty, or the base branch is set to one it never had)`;
962
+ const notReplaced = (reason) => new Error(`${poisoned}, and it was not replaced: ${reason}`);
963
+ let refs;
964
+ try {
965
+ refs = await listRemoteGitRefs(this.remoteGitPath);
966
+ }
967
+ catch (err) {
968
+ throw notReplaced(`its refs could not all be read (${redactCredentials(getErrorMessage(err)).trim()}), so ` +
969
+ `the worker cannot tell whether replacing it would lose work. Deleting ` +
970
+ `${this.remoteGitPath} and restarting the worker re-clones it and discards every ref in it.`);
971
+ }
972
+ // Only a base branch the listing confirms absent: a check that failed for any other reason
973
+ // is no evidence the repo is poisoned.
974
+ if (refs.has(`refs/heads/${this.baseBranch}`)) {
975
+ throw new Error(`remote.git at ${this.remoteGitPath} has branch '${this.baseBranch}', but git could not ` +
976
+ `verify it, so the worker will not replace remote.git. Restarting the worker checks again.`);
977
+ }
978
+ workerLog(`remote.git has no branch '${this.baseBranch}': replacing it if GitHub has every ref in it`);
979
+ const stagingPath = await this.seedRemoteGitStaging(async (mirror) => {
980
+ const unpushed = [];
981
+ for (const [ref, id] of refs) {
982
+ if (!(await mirror.isOnGitHub(id)))
983
+ unpushed.push(ref);
984
+ }
985
+ if (unpushed.length === 0)
986
+ return;
987
+ const shown = unpushed.slice(0, MAX_REFS_LISTED);
988
+ const more = unpushed.length - shown.length;
989
+ throw new RemoteGitKeptError(`${poisoned}, and it holds ${unpushed.length === 1 ? 'a ref' : `${unpushed.length} refs`} ` +
990
+ `GitHub does not have, so the worker will not replace it: ${shown.join(', ')}` +
991
+ `${more > 0 ? ` and ${more} more` : ''}. Deleting ${this.remoteGitPath} and restarting ` +
992
+ `the worker re-clones it and discards ${unpushed.length === 1 ? 'that ref' : 'those refs'}.`);
993
+ }).catch((err) => {
994
+ if (err instanceof RemoteGitKeptError)
995
+ throw err;
996
+ throw notReplaced(redactCredentials(getErrorMessage(err)));
997
+ });
998
+ const discardStaging = () => fs.rm(stagingPath, { recursive: true, force: true });
999
+ let current;
1000
+ try {
1001
+ current = await listRemoteGitRefs(this.remoteGitPath);
1002
+ }
1003
+ catch (err) {
1004
+ await discardStaging();
1005
+ throw notReplaced(`its refs could not all be read again (${getErrorMessage(err).trim()}). Restarting the ` +
1006
+ `worker checks again.`);
1007
+ }
1008
+ if (!sameRefs(refs, current)) {
1009
+ await discardStaging();
1010
+ throw notReplaced('its refs changed while the worker seeded its replacement. Restarting the worker checks again.');
1011
+ }
1012
+ const replaced = `${this.remoteGitPath}.replaced-${Date.now()}`;
1013
+ try {
1014
+ await fs.rename(this.remoteGitPath, replaced);
1015
+ }
1016
+ catch (err) {
1017
+ await discardStaging();
1018
+ throw err;
1019
+ }
1020
+ try {
1021
+ await fs.rename(stagingPath, this.remoteGitPath);
1022
+ }
1023
+ catch (err) {
1024
+ await fs.rename(replaced, this.remoteGitPath);
1025
+ await discardStaging();
1026
+ throw err;
1027
+ }
1028
+ // A push that resolved remote.git before the rename can still land in the old repo. One that
1029
+ // lands before this listing keeps it; a later one is lost from remote.git, though the clone
1030
+ // that pushed still has its commit.
1031
+ const after = await listRemoteGitRefs(replaced).catch(() => null);
1032
+ if (after !== null && sameRefs(refs, after)) {
1033
+ // remote.git is already replaced, so a push still writing here costs only this directory.
1034
+ await fs
1035
+ .rm(replaced, { recursive: true, force: true, maxRetries: 3 })
1036
+ .catch((err) => {
1037
+ workerLogWarn(`Could not remove the replaced remote.git at ${replaced}: ${getErrorMessage(err)}`);
1038
+ });
1039
+ }
1040
+ else {
1041
+ workerLogError(`Kept the replaced remote.git at ${replaced}: its refs changed as it was swapped out, so ` +
1042
+ `it may hold work GitHub does not have.`);
1043
+ }
1044
+ workerLog('remote.git replaced');
1045
+ }
1046
+ /**
1047
+ * Seed a new bare repo from GitHub at `<remote.git>.cloning`, verified and configured, and
1048
+ * return its path for the caller to rename into place. `check` runs in the mirror session right
1049
+ * after the GitHub fetch, and its throw stops the seed. Throws with the staging directory gone.
1050
+ */
1051
+ async seedRemoteGitStaging(check) {
878
1052
  // Seeded under a TEMP name and renamed into place only once verified, so a crash mid-seed
879
1053
  // leaves only this staging directory, which the next boot deletes, rather than a poisoned
880
1054
  // `remote.git` that fs.stat cannot distinguish from a healthy one.
@@ -884,6 +1058,7 @@ export class CmsWorker {
884
1058
  const githubUrl = await this.buildGitHubUrl();
885
1059
  await this.githubMirror.exclusive(async (mirror) => {
886
1060
  await mirror.fetchFromGitHub(githubUrl);
1061
+ await check?.(mirror);
887
1062
  if ((await mirror.branchTip(this.baseBranch)) === null) {
888
1063
  throw new Error(`GitHub has no branch '${this.baseBranch}'`);
889
1064
  }
@@ -899,11 +1074,15 @@ export class CmsWorker {
899
1074
  await this.applyRemoteGitConfig(stagingPath);
900
1075
  }
901
1076
  catch (err) {
902
- workerLogError(`remote.git seeding failed: ${redactCredentials(getErrorMessage(err))}`);
1077
+ if (!(err instanceof RemoteGitKeptError)) {
1078
+ workerLogError(`remote.git seeding failed: ${redactCredentials(getErrorMessage(err))}`);
1079
+ }
903
1080
  // Deleting before throwing is what makes this recoverable: the next
904
1081
  // start() sees no remote.git and re-clones, instead of sticking forever
905
1082
  // behind a poisoned bare repo fs.stat alone cannot detect.
906
1083
  await fs.rm(stagingPath, { recursive: true, force: true });
1084
+ if (err instanceof RemoteGitKeptError)
1085
+ throw err;
907
1086
  if (err instanceof SharedRepoRefusalError) {
908
1087
  // Not the refusal's own advice: the directory it names is gone.
909
1088
  throw new SharedRepoRefusalError(`Refusing to run git in ${stagingPath}: something wrote to it while the worker seeded ` +
@@ -915,8 +1094,7 @@ export class CmsWorker {
915
1094
  }
916
1095
  throw new Error(`remote.git clone of ${this.config.githubOwner}/${this.config.githubRepo} failed or has no branch '${this.baseBranch}' - the GitHub repository may be empty, or the base branch may not exist. Push an initial commit to '${this.baseBranch}' and restart the worker (systemd will retry automatically).`);
917
1096
  }
918
- await fs.rename(stagingPath, this.remoteGitPath);
919
- workerLog('remote.git initialized');
1097
+ return stagingPath;
920
1098
  }
921
1099
  /**
922
1100
  * Refuse a state directory on the shared filesystem: the mirror there would be as writable by
@@ -87,9 +87,10 @@ export async function pushSettingsBranches(ctx, git, trackedNames) {
87
87
  // worker's private mirror, which alone ever sees the credential.
88
88
  const sha = (await git.revparse(['--verify', `refs/heads/${settingsBranch}`])).trim();
89
89
  const githubUrl = await ctx.buildGitHubUrl();
90
- await ctx
91
- .githubMirror()
92
- .exclusive((mirror) => mirror.pushToGitHub(githubUrl, settingsBranch, sha, { signal: ctx.shutdownSignal() }));
90
+ await ctx.githubMirror().exclusive((mirror) => mirror.pushToGitHub(githubUrl, settingsBranch, sha, {
91
+ signal: ctx.shutdownSignal(),
92
+ protectedBranches: [ctx.baseBranch],
93
+ }));
93
94
  workerLog(`Pushed settings branch ${settingsBranch} to GitHub`);
94
95
  }
95
96
  catch (err) {
@@ -43,6 +43,22 @@ export declare class GitHubMirror {
43
43
  /** Repack when it needs it, and say once if it has grown past {@link MIRROR_SIZE_WARN_KIB}. */
44
44
  maintain(): Promise<BareRemoteRepackResult>;
45
45
  }
46
+ /**
47
+ * A push the worker never makes: to a name that is not a plain branch name, to the base branch, or
48
+ * to GitHub's default branch. No CanopyCMS flow asks for any of these, so a task that does was not
49
+ * written by CanopyCMS.
50
+ */
51
+ export declare class RefusedPushError extends Error {
52
+ readonly branch: string;
53
+ constructor(branch: string, reason: 'invalid' | 'protected');
54
+ }
55
+ /**
56
+ * Throw {@link RefusedPushError} unless `branch` is a plain branch name: one
57
+ * `git check-ref-format --branch` accepts unchanged. That rules out a `:` (git would read what
58
+ * follows it in a refspec as another destination), `..`, `^`, `~`, `@{`, spaces and a leading
59
+ * `-`, so `refs/heads/<branch>` is exactly the ref a push names.
60
+ */
61
+ export declare function assertPlainBranchName(branch: string): Promise<void>;
46
62
  /** What one {@link GitHubMirror.exclusive} call may do. */
47
63
  export declare class MirrorSession {
48
64
  private readonly gitDir;
@@ -66,8 +82,19 @@ export declare class MirrorSession {
66
82
  * objects the Lambda can write.
67
83
  */
68
84
  private fetchFromRemoteGit;
85
+ /**
86
+ * The branch GitHub's HEAD names, or null for a repository with none (empty, or HEAD unborn). A
87
+ * failure to reach GitHub throws, so a push that cannot learn it does not happen.
88
+ */
89
+ private githubDefaultBranch;
69
90
  /** The mirror's tip for `branch`, or null when GitHub had no such branch at the last fetch. */
70
91
  branchTip(branch: string): Promise<string | null>;
92
+ /**
93
+ * Whether a branch GitHub had at the last fetch contains the commit `id`, or the commit an
94
+ * annotated tag `id` points at. False for an object the mirror does not have, or one git cannot
95
+ * read as a commit.
96
+ */
97
+ isOnGitHub(id: string): Promise<boolean>;
71
98
  /**
72
99
  * Copy GitHub's branches into `remote.git`'s tracking namespace, pruning the ones GitHub no
73
100
  * longer has. `reconcileTrackedBranches` (worker/git-sync.ts) moves `refs/heads/*` from there.
@@ -83,8 +110,9 @@ export declare class MirrorSession {
83
110
  * commit from `remote.git` first is the step that brings the Lambda's objects across, through
84
111
  * the pinned `upload-pack`.
85
112
  */
86
- pushToGitHub(githubUrl: string, branch: string, sha: string, options?: {
113
+ pushToGitHub(githubUrl: string, branch: string, sha: string, options: {
87
114
  lease?: string;
88
115
  signal?: AbortSignal;
116
+ protectedBranches: readonly string[];
89
117
  }): Promise<void>;
90
118
  }
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { simpleGit } from 'simple-git';
4
4
  import { GITHUB_TRACKING_REF_PREFIX, gitNetworkChildEnv, repackBareRemoteIfNeeded, } from '../git-manager.js';
5
+ import { sanitizeBranchName } from '../paths/branch-name.js';
5
6
  import { getErrorMessage, redactCredentials } from '../utils/error.js';
6
7
  import { workerLogWarn } from './log.js';
7
8
  import { mirrorGitOptions, pinnedReceivePack, pinnedUploadPack } from './shared-repo-git.js';
@@ -119,6 +120,44 @@ async function assertOwnDirectory(dir) {
119
120
  `credential is used in the repository there`);
120
121
  }
121
122
  }
123
+ /**
124
+ * A push the worker never makes: to a name that is not a plain branch name, to the base branch, or
125
+ * to GitHub's default branch. No CanopyCMS flow asks for any of these, so a task that does was not
126
+ * written by CanopyCMS.
127
+ */
128
+ export class RefusedPushError extends Error {
129
+ constructor(branch, reason) {
130
+ super(reason === 'invalid'
131
+ ? `Refusing to push ${JSON.stringify(branch)} to GitHub: it is not a valid branch name.`
132
+ : `Refusing to push "${branch}" to GitHub: it is the base branch or GitHub's default ` +
133
+ `branch, which the CanopyCMS worker never pushes. Nothing in CanopyCMS queues such a ` +
134
+ `push; find out what wrote this task.`);
135
+ this.branch = branch;
136
+ this.name = 'RefusedPushError';
137
+ }
138
+ }
139
+ /**
140
+ * Throw {@link RefusedPushError} unless `branch` is a plain branch name: one
141
+ * `git check-ref-format --branch` accepts unchanged. That rules out a `:` (git would read what
142
+ * follows it in a refspec as another destination), `..`, `^`, `~`, `@{`, spaces and a leading
143
+ * `-`, so `refs/heads/<branch>` is exactly the ref a push names.
144
+ */
145
+ export async function assertPlainBranchName(branch) {
146
+ let normalized;
147
+ try {
148
+ normalized = (await simpleGit().raw(['check-ref-format', '--branch', branch])).trim();
149
+ }
150
+ catch (err) {
151
+ // Only git's own verdict is a refusal; a failure to run git at all is transient.
152
+ if (/is not a valid branch name/.test(getErrorMessage(err))) {
153
+ throw new RefusedPushError(branch, 'invalid');
154
+ }
155
+ throw err;
156
+ }
157
+ // `@{-1}` and the like resolve to some other name.
158
+ if (normalized !== branch)
159
+ throw new RefusedPushError(branch, 'invalid');
160
+ }
122
161
  /** What one {@link GitHubMirror.exclusive} call may do. */
123
162
  export class MirrorSession {
124
163
  constructor(gitDir, remoteGitPath, timeoutMs) {
@@ -186,6 +225,21 @@ export class MirrorSession {
186
225
  ...refspecs,
187
226
  ]);
188
227
  }
228
+ /**
229
+ * The branch GitHub's HEAD names, or null for a repository with none (empty, or HEAD unborn). A
230
+ * failure to reach GitHub throws, so a push that cannot learn it does not happen.
231
+ */
232
+ async githubDefaultBranch(githubUrl, signal) {
233
+ const out = await this.git(signal).raw([
234
+ 'ls-remote',
235
+ '--symref',
236
+ '--end-of-options',
237
+ githubUrl,
238
+ 'HEAD',
239
+ ]);
240
+ const match = /^ref: refs\/heads\/(.+)\tHEAD$/m.exec(out);
241
+ return match ? match[1] : null;
242
+ }
189
243
  /** The mirror's tip for `branch`, or null when GitHub had no such branch at the last fetch. */
190
244
  async branchTip(branch) {
191
245
  try {
@@ -196,6 +250,28 @@ export class MirrorSession {
196
250
  return null;
197
251
  }
198
252
  }
253
+ /**
254
+ * Whether a branch GitHub had at the last fetch contains the commit `id`, or the commit an
255
+ * annotated tag `id` points at. False for an object the mirror does not have, or one git cannot
256
+ * read as a commit.
257
+ */
258
+ async isOnGitHub(id) {
259
+ if (!isObjectId(id))
260
+ return false;
261
+ try {
262
+ const found = await this.git().raw([
263
+ 'for-each-ref',
264
+ '--count=1',
265
+ '--format=%(refname)',
266
+ `--contains=${id}`,
267
+ 'refs/heads/',
268
+ ]);
269
+ return found.trim() !== '';
270
+ }
271
+ catch {
272
+ return false;
273
+ }
274
+ }
199
275
  /**
200
276
  * Copy GitHub's branches into `remote.git`'s tracking namespace, pruning the ones GitHub no
201
277
  * longer has. `reconcileTrackedBranches` (worker/git-sync.ts) moves `refs/heads/*` from there.
@@ -225,11 +301,25 @@ export class MirrorSession {
225
301
  * commit from `remote.git` first is the step that brings the Lambda's objects across, through
226
302
  * the pinned `upload-pack`.
227
303
  */
228
- async pushToGitHub(githubUrl, branch, sha, options = {}) {
304
+ async pushToGitHub(githubUrl, branch, sha, options) {
229
305
  for (const id of [sha, ...(options.lease === undefined ? [] : [options.lease])]) {
230
306
  if (!isObjectId(id))
231
307
  throw new Error(`Not a commit ID: ${JSON.stringify(id)}`);
232
308
  }
309
+ // Here, at the one place every GitHub push (plain or under a lease) goes through, so no caller
310
+ // can skip it, and before any git runs with `branch` in a refspec. The task queue, remote.git
311
+ // and the lease marker are all Lambda-writable, and the worker's base branch comes from
312
+ // Lambda-writable state when it is not configured (`CmsWorker.resolveBaseBranch`); GitHub's default branch is read from GitHub
313
+ // itself, per push.
314
+ await assertPlainBranchName(branch);
315
+ const protectedNames = [...options.protectedBranches];
316
+ const githubDefault = await this.githubDefaultBranch(githubUrl, options.signal);
317
+ if (githubDefault !== null)
318
+ protectedNames.push(githubDefault);
319
+ const target = sanitizeBranchName(branch);
320
+ if (protectedNames.some((name) => name === branch || sanitizeBranchName(name) === target)) {
321
+ throw new RefusedPushError(branch, 'protected');
322
+ }
233
323
  const staging = `${STAGING_PREFIX}${branch}`;
234
324
  const git = this.git(options.signal);
235
325
  try {
@@ -5,6 +5,7 @@ import { BranchMetadataCorruptError, BranchMetadataFileManager, getBranchMetadat
5
5
  import { sanitizeBranchName, RESERVED_SETTINGS_BRANCH_PREFIX } from '../paths/branch-name.js';
6
6
  import { getErrorMessage, redactCredentials } from '../utils/error.js';
7
7
  import { isNonFastForwardRejection, isStaleLeaseRejection, workflowPushRefusalFile, } from '../utils/git.js';
8
+ import { RefusedPushError, assertPlainBranchName } from './github-mirror.js';
8
9
  import { clearHistoryRewrittenMarker, readPublishedSha } from './history-rewrite.js';
9
10
  import { writeWorkerStatus } from '../task-queue/worker-status.js';
10
11
  import { workerLog, workerLogError, workerLogWarn } from './log.js';
@@ -565,6 +566,16 @@ export async function pushBranchToGitHub(ctx, branch, signal) {
565
566
  // commit remote.git held when it was read, so what the marker logic at the end compares is
566
567
  // what GitHub received.
567
568
  await assertSharedRepoConfig(ctx.remoteGitPath, 'bare');
569
+ // Before `branch` reaches any git: `readPublishedSha` below would read a `<rev>:<path>` form as a
570
+ // tree lookup in remote.git. A refusal is permanent; a failure to run git is retried.
571
+ try {
572
+ await assertPlainBranchName(branch);
573
+ }
574
+ catch (err) {
575
+ if (err instanceof RefusedPushError)
576
+ throw new PermanentTaskError(err.message);
577
+ throw err;
578
+ }
568
579
  // Resolve the tokenized URL ONCE, here: all three pushes below use this
569
580
  // const and none calls ctx.buildGitHubUrl() again. A correctness
570
581
  // requirement, not tidiness, since resolution is async: the retry push sits
@@ -591,12 +602,19 @@ export async function pushBranchToGitHub(ctx, branch, signal) {
591
602
  // only after receiving the whole pack: a push killed before that changes nothing, and one
592
603
  // killed after it is found already done by the re-run.
593
604
  const outcome = await ctx.githubMirror().exclusive(async (mirror) => {
594
- const push = (lease) => mirror.pushToGitHub(githubUrl, branch, outgoingSha, { lease, signal });
605
+ const push = (lease) => mirror.pushToGitHub(githubUrl, branch, outgoingSha, {
606
+ lease,
607
+ signal,
608
+ protectedBranches: [ctx.baseBranch],
609
+ });
595
610
  try {
596
611
  await push(marker);
597
612
  return 'pushed';
598
613
  }
599
614
  catch (err) {
615
+ // A retry can never make the branch not protected.
616
+ if (err instanceof RefusedPushError)
617
+ throw new PermanentTaskError(err.message);
600
618
  const message = getErrorMessage(err);
601
619
  throwIfWorkflowRefusal(branch, message);
602
620
  // A refused lease means GitHub is not at the commit we rewrote, so the
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "@codemirror/language, @lezer/highlight: workaround — @mdxeditor/editor uses cm6-theme-basic-light which peer-requires these but mdxeditor doesn't declare them as dependencies",
3
3
  "name": "canopycms",
4
- "version": "0.0.68-int.111",
4
+ "version": "0.0.68-int.112",
5
5
  "description": "CanopyCMS core package: schema-driven content, branch-aware editing, and editor UI for Next.js.",
6
6
  "license": "MIT",
7
7
  "repository": {