canopycms 0.0.68-int.111 → 0.0.68-int.113

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/branch-health.js +14 -9
  2. package/dist/cli/cli.js +4 -0
  3. package/dist/cli/generate-ai-content.js +2 -0
  4. package/dist/editor/Editor.js +2 -2
  5. package/dist/editor/components/StagedChangesDrawer.d.ts +37 -0
  6. package/dist/editor/components/StagedChangesDrawer.js +75 -0
  7. package/dist/editor/group-manager/GroupCard.d.ts +2 -0
  8. package/dist/editor/group-manager/GroupCard.js +2 -2
  9. package/dist/editor/group-manager/GroupForm.js +1 -1
  10. package/dist/editor/group-manager/InternalGroupsTab.d.ts +1 -0
  11. package/dist/editor/group-manager/InternalGroupsTab.js +2 -2
  12. package/dist/editor/group-manager/hooks/useGroupState.d.ts +2 -0
  13. package/dist/editor/group-manager/hooks/useGroupState.js +18 -1
  14. package/dist/editor/group-manager/index.js +9 -4
  15. package/dist/editor/group-manager/types.d.ts +3 -1
  16. package/dist/editor/hooks/usePermissionManager.js +4 -3
  17. package/dist/editor/permission-manager/index.js +9 -3
  18. package/dist/editor/permission-manager/types.d.ts +3 -1
  19. package/dist/git-manager.d.ts +10 -0
  20. package/dist/git-manager.js +6 -5
  21. package/dist/utils/error.js +6 -0
  22. package/dist/version.d.ts +1 -1
  23. package/dist/version.js +1 -1
  24. package/dist/worker/cms-worker.d.ts +39 -124
  25. package/dist/worker/cms-worker.js +232 -259
  26. package/dist/worker/git-sync.d.ts +7 -5
  27. package/dist/worker/git-sync.js +32 -29
  28. package/dist/worker/github-auth.d.ts +7 -7
  29. package/dist/worker/github-auth.js +12 -14
  30. package/dist/worker/github-gateway.d.ts +144 -0
  31. package/dist/worker/github-gateway.js +370 -0
  32. package/dist/worker/github-mirror.d.ts +84 -10
  33. package/dist/worker/github-mirror.js +237 -21
  34. package/dist/worker/rebase.d.ts +1 -1
  35. package/dist/worker/rebase.js +1 -6
  36. package/dist/worker/shared-repo-git.d.ts +6 -0
  37. package/dist/worker/shared-repo-git.js +8 -0
  38. package/dist/worker/task-runner.d.ts +2 -2
  39. package/dist/worker/task-runner.js +77 -127
  40. package/dist/worker/worker-context.d.ts +12 -40
  41. package/package.json +1 -1
@@ -4,18 +4,17 @@ import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import lockfile from 'proper-lockfile';
6
6
  import { recoverOrphanedTasks, cmsTaskQueueLogger } from '../task-queue/cms-task-queue.js';
7
- import { createCanopyOctokit } from '../github-service.js';
8
- import { isTransientAuthFailure, resolveWorkerGitHubAuth, } from './github-auth.js';
9
- import { ensureRemoteGitConfig } from '../git-manager.js';
7
+ import { resolveWorkerGitHubAuth } from './github-auth.js';
8
+ import { createLocalGitHubGateway, } from './github-gateway.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';
15
15
  import { CANOPYCMS_VERSION } from '../version.js';
16
16
  import { workerLog, workerLogWarn, workerLogError } from './log.js';
17
17
  import { DEFAULT_SCHEMA_HOLD_MAX_MS, readCarriedBaseHold } from './schema-gate.js';
18
- import { GitHubMirror } from './github-mirror.js';
19
18
  import { SharedRepoRefusalError, UntrustedRepoConfigError, assertSharedRepoConfig, sharedRepoGit, } from './shared-repo-git.js';
20
19
  import { executeTask, orphanRecoveryMaxAgeMs, processTaskQueue, pushBranchToGitHub, updateBranchMetadata, } from './task-runner.js';
21
20
  import { pollMergeState, runRebaseCycle } from './rebase.js';
@@ -33,28 +32,49 @@ export { workerLog, workerLogWarn, workerLogError, installWorkerLogger } from '.
33
32
  // see github-auth.ts) and needs the shape to inject and the key normalizer to
34
33
  // apply.
35
34
  export { normalizeGitHubAppPrivateKey, DEFAULT_GIT_TOKEN_MINT_TIMEOUT_MS, DEFAULT_GITHUB_TOKEN_REFRESH_MIN_INTERVAL_MS, } from './github-auth.js';
36
- /** `target` with symlinks resolved as far as it exists, so a link cannot hide where it lands. */
37
- async function realpathOfNearest(target) {
38
- const missing = [];
39
- let current = path.resolve(target);
40
- for (;;) {
41
- try {
42
- return path.join(await fs.realpath(current), ...missing.reverse());
43
- }
44
- catch {
45
- const parent = path.dirname(current);
46
- if (parent === current)
47
- return path.resolve(target);
48
- missing.push(path.basename(current));
49
- current = parent;
50
- }
51
- }
52
- }
53
35
  /** Per workspace, so two workers on one host (tests, dev) never share a mirror. */
54
36
  function defaultStateDirectory(workspacePath) {
55
37
  const key = createHash('sha256').update(path.resolve(workspacePath)).digest('hex').slice(0, 16);
56
38
  return path.join(os.tmpdir(), `canopycms-worker-${key}`);
57
39
  }
40
+ /** How many refs a refusal to replace remote.git names before summarising the rest. */
41
+ const MAX_REFS_LISTED = 20;
42
+ /** A refusal to replace a poisoned remote.git because it holds work GitHub does not have. */
43
+ class RemoteGitKeptError extends Error {
44
+ }
45
+ /**
46
+ * Every ref in the bare repo at `gitDir` outside the GitHub tracking namespace, which only ever
47
+ * holds GitHub's own branches, mapped to the object it names. A ref missing from this list is one
48
+ * a replacement would discard unchecked, so it throws when git reports anything on stderr
49
+ * (`for-each-ref` skips a ref it cannot read with only a warning and exit 0) and when git is killed
50
+ * by a signal, which simple-git otherwise resolves as empty output. A dangling symbolic ref is
51
+ * skipped without a warning, and names nothing to lose.
52
+ */
53
+ async function listRemoteGitRefs(gitDir) {
54
+ let stderr = '';
55
+ const output = await sharedRepoGit(gitDir, 'bare', { errors: failOnSignalExit })
56
+ .outputHandler((_command, _stdout, err) => {
57
+ err.on('data', (chunk) => {
58
+ stderr += String(chunk);
59
+ });
60
+ })
61
+ .raw(['for-each-ref', '--format=%(objectname) %(refname)']);
62
+ if (stderr.trim() !== '')
63
+ throw new Error(stderr.trim());
64
+ const refs = new Map();
65
+ for (const line of output.split('\n')) {
66
+ const space = line.indexOf(' ');
67
+ if (space === -1)
68
+ continue;
69
+ const ref = line.slice(space + 1);
70
+ if (!ref.startsWith(GITHUB_TRACKING_REF_PREFIX))
71
+ refs.set(ref, line.slice(0, space));
72
+ }
73
+ return refs;
74
+ }
75
+ function sameRefs(a, b) {
76
+ return a.size === b.size && [...a].every(([ref, id]) => b.get(ref) === id);
77
+ }
58
78
  const DEFAULT_TASK_TIMEOUT = 60_000;
59
79
  const DEFAULT_MAX_RETRIES = 3;
60
80
  const DEFAULT_LOCK_STALE_MS = 60_000;
@@ -207,7 +227,6 @@ export class CmsWorker {
207
227
  this.drainDeadlineMs = config.drainDeadlineMs ?? DEFAULT_DRAIN_DEADLINE_MS;
208
228
  this.contentRoot = config.contentRoot ?? 'content';
209
229
  this.schemaHoldMaxMs = config.schemaHoldMaxMs ?? DEFAULT_SCHEMA_HOLD_MAX_MS;
210
- this.githubMirror = new GitHubMirror(this.stateDirectory, this.remoteGitPath, this.taskTimeoutMs);
211
230
  }
212
231
  /**
213
232
  * This worker's self-reported status object, initialized on first use.
@@ -265,13 +284,11 @@ export class CmsWorker {
265
284
  * Built FRESH on every call, with instance-backed members as functions rather
266
285
  * than copied values. See WorkerContext's INVARIANT: a context that captured
267
286
  * any of them at construction time would hand the extracted code the pre-test
268
- * value, which for `buildGitHubUrl` means a test's push going to github.com
269
- * for real instead of its local fixture repo.
287
+ * value, which for the GitHub gateway means a test's push going to
288
+ * github.com for real instead of its local fixture repo.
270
289
  */
271
290
  ctx() {
272
291
  return {
273
- githubOwner: this.config.githubOwner,
274
- githubRepo: this.config.githubRepo,
275
292
  baseBranch: this.baseBranch,
276
293
  sanitizedBaseBranch: this.sanitizedBaseBranch,
277
294
  taskDir: this.taskDir,
@@ -283,10 +300,7 @@ export class CmsWorker {
283
300
  maxTasksPerCycle: this.maxTasksPerCycle,
284
301
  maxRetries: this.maxRetries,
285
302
  log: this.log,
286
- octokit: () => this.octokitClient(),
287
- buildGitHubUrl: () => this.buildGitHubUrl(),
288
- githubMirror: () => this.githubMirror,
289
- refreshGitHubCredential: () => this.refreshGitHubCredential(),
303
+ github: () => this.github(),
290
304
  branchWorkspacePath: (branchRefName) => this.branchWorkspacePath(branchRefName),
291
305
  executeTask: (task, signal) => this.executeTask(task, signal),
292
306
  pushBranchToGitHub: (branch, signal) => this.pushBranchToGitHub(branch, signal),
@@ -366,7 +380,7 @@ export class CmsWorker {
366
380
  // Same shape, same reason: a half-configured credential throws HERE,
367
381
  // inside the try, rather than out of `new CmsWorker(...)` where nothing
368
382
  // could record it.
369
- this.ensureGitHubAuth();
383
+ const github = this.github();
370
384
  // Same again. NaN would never expire a schema hold, which is the one
371
385
  // thing the bound exists to prevent.
372
386
  if (!Number.isFinite(this.schemaHoldMaxMs) || this.schemaHoldMaxMs < 0) {
@@ -374,10 +388,8 @@ export class CmsWorker {
374
388
  }
375
389
  // BEFORE ensureRemoteGit(): its clone is the first thing to use the
376
390
  // credential, and its catch blames the repository rather than the
377
- // credential. See preflightGitHubAppAuth().
378
- await this.preflightGitHubAppAuth();
379
- await this.ensureStateDirectoryIsPrivate();
380
- await this.githubMirror.ensure();
391
+ // credential. See GitHubGateway.prepare().
392
+ await github.prepare();
381
393
  await this.resolveBaseBranch();
382
394
  await this.ensureRemoteGit();
383
395
  await this.recordBaseBranchInRemoteHead();
@@ -410,7 +422,7 @@ export class CmsWorker {
410
422
  report.lastFatalError = {
411
423
  // [REDACT] Persisted to worker-status.json and served to the browser by
412
424
  // the admin panel -- must never carry the bot token a poisoned or
413
- // failed git URL (buildGitHubUrl()) can embed.
425
+ // failed git URL (the gateway's) can embed.
414
426
  message: redactCredentials(getErrorMessage(err)),
415
427
  at: new Date().toISOString(),
416
428
  phase: 'startup',
@@ -430,11 +442,7 @@ export class CmsWorker {
430
442
  const taskInterval = this.config.taskPollInterval ?? 5_000;
431
443
  const gitInterval = this.config.gitSyncInterval ?? 5 * 60_000;
432
444
  this.scheduleLoop('task queue', () => this.processTaskQueue(), taskInterval);
433
- // The wrapper, not syncGit() itself: a failed sync is where a rotated or
434
- // revoked GitHub credential is noticed. start()'s own initial syncGit()
435
- // above stays unwrapped -- there is no stale credential to refresh one line
436
- // after reading it at boot.
437
- this.scheduleLoop('git sync', () => this.syncGitWithCredentialRefresh(), gitInterval);
445
+ this.scheduleLoop('git sync', () => this.syncGit(), gitInterval);
438
446
  if (this.config.refreshAuthCache) {
439
447
  const cacheInterval = this.config.authCacheRefreshInterval ?? 15 * 60_000;
440
448
  this.scheduleLoop('auth cache refresh', () => this.refreshAuthCache(), cacheInterval);
@@ -675,7 +683,9 @@ export class CmsWorker {
675
683
  /**
676
684
  * An unconfigured base branch is the one remote.git's HEAD names, which is what the Lambda
677
685
  * 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'.
686
+ * branch, which the clone then records as that HEAD. So it is too when remote.git has no branch
687
+ * outside the settings namespace: no base branch is there for HEAD to name, and ensureRemoteGit
688
+ * replaces it from GitHub unless something in it is at stake. Never assumes 'main'.
679
689
  */
680
690
  async resolveBaseBranch() {
681
691
  if (this.resolvedBaseBranch)
@@ -698,15 +708,13 @@ export class CmsWorker {
698
708
  // Before the first git to read it, and on its own: a refusal is recorded as itself, not as a
699
709
  // base branch to configure or a remote.git to delete.
700
710
  if (remoteGitExists)
701
- await assertSharedRepoConfig(this.remoteGitPath, 'bare');
711
+ await this.assertRemoteGitConfig();
702
712
  let name;
703
713
  try {
704
- name = remoteGitExists
714
+ const fromRemoteGit = remoteGitExists && (await this.hasContentBranch(this.remoteGitPath));
715
+ name = fromRemoteGit
705
716
  ? await readHeadBranch(this.remoteGitPath, sharedRepoGit(this.remoteGitPath, 'bare'))
706
- : (await this.octokitClient().repos.get({
707
- owner: this.config.githubOwner,
708
- repo: this.config.githubRepo,
709
- })).data.default_branch;
717
+ : await this.github().defaultBranch();
710
718
  }
711
719
  catch (err) {
712
720
  throw undetermined(err);
@@ -714,6 +722,33 @@ export class CmsWorker {
714
722
  this.setBaseBranch(name);
715
723
  workerLog(`Base branch: '${name}' (detected; CANOPYCMS_BASE_BRANCH is not set)`);
716
724
  }
725
+ /**
726
+ * Check remote.git's config at boot. A refusal is never a case for replacement: it reports that
727
+ * something wrote there, and listing the refs a replacement would lose means running git under
728
+ * that config. The message says so, so System health does not read it as a routine failure.
729
+ */
730
+ async assertRemoteGitConfig() {
731
+ try {
732
+ await assertSharedRepoConfig(this.remoteGitPath, 'bare');
733
+ }
734
+ catch (err) {
735
+ if (!(err instanceof SharedRepoRefusalError))
736
+ throw err;
737
+ throw new SharedRepoRefusalError(`${err.message.replace(/\.$/, '')}. This needs an operator: the worker never repairs or ` +
738
+ `replaces a remote.git whose config was tampered with or cannot be read.`);
739
+ }
740
+ }
741
+ /** Whether the bare repo at `gitDir` has a branch that is not a settings branch. */
742
+ async hasContentBranch(gitDir) {
743
+ const branches = await sharedRepoGit(gitDir, 'bare', { errors: failOnSignalExit }).raw([
744
+ 'for-each-ref',
745
+ '--format=%(refname:strip=2)',
746
+ 'refs/heads/',
747
+ ]);
748
+ return branches
749
+ .split('\n')
750
+ .some((branch) => branch !== '' && !isSettingsBranchName(branch, this.ensureSettingsBranch()));
751
+ }
717
752
  /**
718
753
  * Point remote.git's HEAD at the base branch this worker uses, so a Lambda left to detect it
719
754
  * (GitManager.detectBaseBranch) reads the same name. Only the worker writes it, at boot under
@@ -843,7 +878,7 @@ export class CmsWorker {
843
878
  * pushed) succeeds and leaves a refs-less repo that `fs.stat` cannot tell from a healthy one, so
844
879
  * left unchecked it silently poisons remote.git and the stat short-circuit means it never heals.
845
880
  * 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.
881
+ * path, where a poisoned remote.git is replaced (`replacePoisonedRemoteGit`).
847
882
  */
848
883
  async ensureRemoteGit() {
849
884
  let exists;
@@ -857,7 +892,7 @@ export class CmsWorker {
857
892
  if (exists) {
858
893
  // At boot, so a refusal lands in worker-status.json as a startup failure, and first: the
859
894
  // scrub is git reading this config too.
860
- await assertSharedRepoConfig(this.remoteGitPath, 'bare');
895
+ await this.assertRemoteGitConfig();
861
896
  // SELF-HEAL, before anything else changes this repo: a remote.git cloned from GitHub by an
862
897
  // older worker recorded the token-bearing clone URL in its config.
863
898
  await this.scrubPersistedRemote(this.remoteGitPath);
@@ -866,31 +901,135 @@ export class CmsWorker {
866
901
  }
867
902
  catch (err) {
868
903
  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.`);
904
+ await this.replacePoisonedRemoteGit();
905
+ return;
873
906
  }
874
907
  await this.applyRemoteGitConfig(this.remoteGitPath);
875
908
  return; // Already exists and has the base branch
876
909
  }
877
910
  workerLog('Initializing remote.git from GitHub...');
911
+ const stagingPath = await this.seedRemoteGitStaging();
912
+ await fs.rename(stagingPath, this.remoteGitPath);
913
+ workerLog('remote.git initialized');
914
+ }
915
+ /**
916
+ * Replace an existing remote.git that has no base branch with a fresh seed from GitHub, but only
917
+ * when every ref in it names a commit a GitHub branch contains, so replacing it loses no commit;
918
+ * a branch name GitHub lacks, such as a merged branch deleted there, goes. Otherwise refuse,
919
+ * naming the refs at stake: unpushed work such as a settings branch.
920
+ *
921
+ * The refs are compared in the same mirror session that fetches GitHub and seeds the
922
+ * replacement, and listed again just before the swap, since the Lambda can push into remote.git
923
+ * while the seed runs. The swap is two renames, so the name remote.git resolves to the old repo,
924
+ * the new one, or for an instant nothing, never a half-built or half-deleted one.
925
+ */
926
+ async replacePoisonedRemoteGit() {
927
+ const poisoned = `remote.git at ${this.remoteGitPath} has no branch '${this.baseBranch}' (cloned while the ` +
928
+ `GitHub repository was empty, or the base branch is set to one it never had)`;
929
+ const notReplaced = (reason) => new Error(`${poisoned}, and it was not replaced: ${reason}`);
930
+ let refs;
931
+ try {
932
+ refs = await listRemoteGitRefs(this.remoteGitPath);
933
+ }
934
+ catch (err) {
935
+ throw notReplaced(`its refs could not all be read (${redactCredentials(getErrorMessage(err)).trim()}), so ` +
936
+ `the worker cannot tell whether replacing it would lose work. Deleting ` +
937
+ `${this.remoteGitPath} and restarting the worker re-clones it and discards every ref in it.`);
938
+ }
939
+ // Only a base branch the listing confirms absent: a check that failed for any other reason
940
+ // is no evidence the repo is poisoned.
941
+ if (refs.has(`refs/heads/${this.baseBranch}`)) {
942
+ throw new Error(`remote.git at ${this.remoteGitPath} has branch '${this.baseBranch}', but git could not ` +
943
+ `verify it, so the worker will not replace remote.git. Restarting the worker checks again.`);
944
+ }
945
+ workerLog(`remote.git has no branch '${this.baseBranch}': replacing it if GitHub has every ref in it`);
946
+ const stagingPath = await this.seedRemoteGitStaging(async (github) => {
947
+ const onGitHub = await github.onGitHub([...refs.values()]);
948
+ const unpushed = [];
949
+ for (const [ref, id] of refs) {
950
+ if (!onGitHub.has(id))
951
+ unpushed.push(ref);
952
+ }
953
+ if (unpushed.length === 0)
954
+ return;
955
+ const shown = unpushed.slice(0, MAX_REFS_LISTED);
956
+ const more = unpushed.length - shown.length;
957
+ throw new RemoteGitKeptError(`${poisoned}, and it holds ${unpushed.length === 1 ? 'a ref' : `${unpushed.length} refs`} ` +
958
+ `GitHub does not have, so the worker will not replace it: ${shown.join(', ')}` +
959
+ `${more > 0 ? ` and ${more} more` : ''}. Deleting ${this.remoteGitPath} and restarting ` +
960
+ `the worker re-clones it and discards ${unpushed.length === 1 ? 'that ref' : 'those refs'}.`);
961
+ }).catch((err) => {
962
+ if (err instanceof RemoteGitKeptError)
963
+ throw err;
964
+ throw notReplaced(redactCredentials(getErrorMessage(err)));
965
+ });
966
+ const discardStaging = () => fs.rm(stagingPath, { recursive: true, force: true });
967
+ let current;
968
+ try {
969
+ current = await listRemoteGitRefs(this.remoteGitPath);
970
+ }
971
+ catch (err) {
972
+ await discardStaging();
973
+ throw notReplaced(`its refs could not all be read again (${getErrorMessage(err).trim()}). Restarting the ` +
974
+ `worker checks again.`);
975
+ }
976
+ if (!sameRefs(refs, current)) {
977
+ await discardStaging();
978
+ throw notReplaced('its refs changed while the worker seeded its replacement. Restarting the worker checks again.');
979
+ }
980
+ const replaced = `${this.remoteGitPath}.replaced-${Date.now()}`;
981
+ try {
982
+ await fs.rename(this.remoteGitPath, replaced);
983
+ }
984
+ catch (err) {
985
+ await discardStaging();
986
+ throw err;
987
+ }
988
+ try {
989
+ await fs.rename(stagingPath, this.remoteGitPath);
990
+ }
991
+ catch (err) {
992
+ await fs.rename(replaced, this.remoteGitPath);
993
+ await discardStaging();
994
+ throw err;
995
+ }
996
+ // A push that resolved remote.git before the rename can still land in the old repo. One that
997
+ // lands before this listing keeps it; a later one is lost from remote.git, though the clone
998
+ // that pushed still has its commit.
999
+ const after = await listRemoteGitRefs(replaced).catch(() => null);
1000
+ if (after !== null && sameRefs(refs, after)) {
1001
+ // remote.git is already replaced, so a push still writing here costs only this directory.
1002
+ await fs
1003
+ .rm(replaced, { recursive: true, force: true, maxRetries: 3 })
1004
+ .catch((err) => {
1005
+ workerLogWarn(`Could not remove the replaced remote.git at ${replaced}: ${getErrorMessage(err)}`);
1006
+ });
1007
+ }
1008
+ else {
1009
+ workerLogError(`Kept the replaced remote.git at ${replaced}: its refs changed as it was swapped out, so ` +
1010
+ `it may hold work GitHub does not have.`);
1011
+ }
1012
+ workerLog('remote.git replaced');
1013
+ }
1014
+ /**
1015
+ * Seed a new bare repo from GitHub at `<remote.git>.cloning`, verified and configured, and
1016
+ * return its path for the caller to rename into place. `check` runs in the mirror session right
1017
+ * after the GitHub fetch, and its throw stops the seed. Throws with the staging directory gone.
1018
+ */
1019
+ async seedRemoteGitStaging(check) {
878
1020
  // Seeded under a TEMP name and renamed into place only once verified, so a crash mid-seed
879
1021
  // leaves only this staging directory, which the next boot deletes, rather than a poisoned
880
1022
  // `remote.git` that fs.stat cannot distinguish from a healthy one.
881
1023
  const stagingPath = `${this.remoteGitPath}.cloning`;
882
1024
  await fs.rm(stagingPath, { recursive: true, force: true });
883
1025
  try {
884
- const githubUrl = await this.buildGitHubUrl();
885
- await this.githubMirror.exclusive(async (mirror) => {
886
- await mirror.fetchFromGitHub(githubUrl);
887
- if ((await mirror.branchTip(this.baseBranch)) === null) {
888
- throw new Error(`GitHub has no branch '${this.baseBranch}'`);
889
- }
890
- await fs.mkdir(stagingPath);
891
- const staging = sharedRepoGit(stagingPath, 'bare');
892
- await staging.raw(['init', '--quiet', '--bare']);
893
- await mirror.seedBareRepository(stagingPath);
1026
+ await this.github().seedBareRepository(stagingPath, {
1027
+ baseBranch: this.baseBranch,
1028
+ beforeSeed: check,
1029
+ createRepository: async () => {
1030
+ await fs.mkdir(stagingPath);
1031
+ await sharedRepoGit(stagingPath, 'bare').raw(['init', '--quiet', '--bare']);
1032
+ },
894
1033
  });
895
1034
  // The staging path is predictable, and so writable by the Lambda while seeding runs.
896
1035
  await assertSharedRepoConfig(stagingPath, 'bare');
@@ -899,11 +1038,15 @@ export class CmsWorker {
899
1038
  await this.applyRemoteGitConfig(stagingPath);
900
1039
  }
901
1040
  catch (err) {
902
- workerLogError(`remote.git seeding failed: ${redactCredentials(getErrorMessage(err))}`);
1041
+ if (!(err instanceof RemoteGitKeptError)) {
1042
+ workerLogError(`remote.git seeding failed: ${redactCredentials(getErrorMessage(err))}`);
1043
+ }
903
1044
  // Deleting before throwing is what makes this recoverable: the next
904
1045
  // start() sees no remote.git and re-clones, instead of sticking forever
905
1046
  // behind a poisoned bare repo fs.stat alone cannot detect.
906
1047
  await fs.rm(stagingPath, { recursive: true, force: true });
1048
+ if (err instanceof RemoteGitKeptError)
1049
+ throw err;
907
1050
  if (err instanceof SharedRepoRefusalError) {
908
1051
  // Not the refusal's own advice: the directory it names is gone.
909
1052
  throw new SharedRepoRefusalError(`Refusing to run git in ${stagingPath}: something wrote to it while the worker seeded ` +
@@ -915,22 +1058,7 @@ export class CmsWorker {
915
1058
  }
916
1059
  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
1060
  }
918
- await fs.rename(stagingPath, this.remoteGitPath);
919
- workerLog('remote.git initialized');
920
- }
921
- /**
922
- * Refuse a state directory on the shared filesystem: the mirror there would be as writable by
923
- * the Lambda as remote.git is.
924
- */
925
- async ensureStateDirectoryIsPrivate() {
926
- const workspace = await realpathOfNearest(this.config.workspacePath);
927
- const state = await realpathOfNearest(this.stateDirectory);
928
- const relative = path.relative(workspace, state);
929
- if (relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))) {
930
- throw new Error(`The worker's state directory (${this.stateDirectory}) is inside its shared workspace ` +
931
- `(${this.config.workspacePath}). It holds the repository the GitHub credential is used ` +
932
- `in, so it must be somewhere the CMS Lambda cannot write.`);
933
- }
1061
+ return stagingPath;
934
1062
  }
935
1063
  // --- Task-queue cluster (worker/task-runner.ts) ------------------------
936
1064
  //
@@ -959,113 +1087,28 @@ export class CmsWorker {
959
1087
  return pushBranchToGitHub(this.ctx(), branch, signal);
960
1088
  }
961
1089
  /**
962
- * Resolve which GitHub credential this worker uses, once, and build the
963
- * Octokit client from it.
964
- *
965
- * DEFERRED out of the constructor deliberately, exactly as
966
- * `ensureSettingsBranch()` is and for the reason that method records:
967
- * `resolveWorkerGitHubAuth` throws for a half-configured credential (both
968
- * set, neither set, an unusable mint timeout or refresh interval), and a
969
- * throw during `new CmsWorker(...)` lands BEFORE start()'s catch, which
970
- * records `lastFatalError` for every entrypoint.
971
- *
972
- * Idempotent, and it does NOT replace an `octokit` a test has already
973
- * assigned onto the instance — see the field's comment.
974
- */
975
- ensureGitHubAuth() {
976
- if (!this.githubAuth) {
977
- this.githubAuth = resolveWorkerGitHubAuth(this.config);
978
- }
979
- if (!this.octokit) {
980
- this.octokit = createCanopyOctokit(this.githubAuth.octokitAuth);
981
- }
982
- return this.githubAuth;
983
- }
984
- /**
985
- * The Octokit client, built on first use. Every read goes through here rather
986
- * than touching the field, which the constructor does not populate: a method
987
- * reached without start() would otherwise see `undefined`.
988
- * `rebaseActiveBranches()` is exactly that case — apps/test-app's e2e route
989
- * calls it directly, and its `pollMergeState` dispatch reads `ctx.octokit()`.
990
- */
991
- octokitClient() {
992
- this.ensureGitHubAuth();
993
- return this.octokit;
994
- }
995
- /**
996
- * The single seam through which every git-over-HTTPS credential reaches a git
997
- * command. The only other consumer is Octokit, built from the same resolution
998
- * by `ensureGitHubAuth()` above.
999
- *
1000
- * Async because under GitHub App auth `resolveGitToken` mints an installation
1001
- * token lasting about an hour. NOTHING may cache what this returns — a URL
1002
- * built from an installation token goes stale with it — and resolving per use
1003
- * is cheap, since `@octokit/auth-app` answers from its own cache until the
1004
- * token is near expiry.
1005
- *
1006
- * A mint failure propagates AS THROWN, carrying the `.status` that
1007
- * `isPermanentTaskFailure` classifies on — see github-auth.ts.
1008
- *
1009
- * Do NOT add a parallel token accessor alongside it. Every instance-backed
1010
- * WorkerContext member stays a function precisely so tests can replace it
1011
- * through the instance (worker-context.ts's INVARIANT); a second credential
1012
- * path would be one nothing stubs.
1090
+ * The worker's GitHub gateway, created on first use: inside start()'s try, or wherever a test
1091
+ * entry point first reaches it. Creating it resolves the credential, which throws for a
1092
+ * half-configured one (both set, neither set, an unusable mint timeout or refresh interval) --
1093
+ * never in the constructor, for the reason `ensureSettingsBranch()` gives. Keeps a gateway a
1094
+ * test installed.
1013
1095
  */
1014
- async buildGitHubUrl() {
1015
- const token = await this.ensureGitHubAuth().resolveGitToken();
1016
- return `https://x-access-token:${token}@github.com/${this.config.githubOwner}/${this.config.githubRepo}.git`;
1096
+ github() {
1097
+ this.gateway ??= createLocalGitHubGateway(this.localGitHubGatewayOptions());
1098
+ return this.gateway;
1017
1099
  }
1018
- /**
1019
- * Prove the GitHub App credential works before anything depends on it.
1020
- *
1021
- * Without this the first failure comes out of `ensureRemoteGit`'s bare clone
1022
- * below, whose catch blames the repository ("may be empty, or the base branch
1023
- * may not exist") and sends an operator holding a bad private key looking for
1024
- * a problem that does not exist. Called from start()'s try, so the failure is
1025
- * also recorded as `lastFatalError` and reaches the admin panel. No-op on the
1026
- * token path: a PAT is a literal, so the first real request checks everything
1027
- * this could.
1028
- *
1029
- * FATAL UNLESS THE FAILURE POSITIVELY LOOKS TRANSIENT. Both halves are
1030
- * load-bearing. Not always fatal, because the two credential paths must
1031
- * degrade alike: on the token path a GitHub 502 during boot is absorbed (a
1032
- * warm `remote.git` short-circuits `ensureRemoteGit`, `Promise.allSettled`
1033
- * swallows the initial `syncGit`) so the worker starts and its loops retry,
1034
- * while rethrowing every error class would make the App path exit and systemd
1035
- * crash-loop it until GitHub recovered — each iteration telling the operator
1036
- * to check their private key.
1037
- *
1038
- * But fail CLOSED, via `isTransientAuthFailure` rather than the inverse of
1039
- * `isPermanentTaskFailure`: that classifier defaults a status-less error to
1040
- * transient, which is right on the task path (bounded by `maxRetries`) and
1041
- * wrong here (bounded by nothing). A key that never reaches GitHub at all —
1042
- * the wrong type, or too mangled to sign with — fails locally and
1043
- * status-lessly, so defaulting to transient would boot a worker with a dead
1044
- * credential, record no `lastFatalError`, and show healthy in the admin panel
1045
- * while every task and every sync failed.
1046
- */
1047
- async preflightGitHubAppAuth() {
1048
- if (!this.config.githubAppAuth)
1049
- return;
1050
- try {
1051
- await this.ensureGitHubAuth().resolveGitToken();
1052
- }
1053
- catch (err) {
1054
- // [REDACT] Both messages below reach worker-status.json and the browser.
1055
- const detail = redactCredentials(getErrorMessage(err));
1056
- if (isTransientAuthFailure(err)) {
1057
- workerLogWarn(`Could not verify GitHub App authentication at startup: ${detail}. ` +
1058
- 'Continuing — this looks transient, and the credential is minted again on first use.');
1059
- return;
1060
- }
1061
- // Re-thrown WITH context, unlike buildGitHubUrl() above, which must
1062
- // preserve the error identity for task classification. Nothing classifies
1063
- // a startup failure -- start()'s catch records the message and the
1064
- // process exits -- so the operator-facing wording wins here.
1065
- throw new Error(`GitHub App authentication failed: ${detail}. ` +
1066
- 'Check the app id, the installation id, and that the private key belongs to that app.');
1067
- }
1068
- workerLog('GitHub App authentication verified');
1100
+ /** What the in-process gateway is built from; test-utils/worker-gateway.ts builds from it too. */
1101
+ localGitHubGatewayOptions() {
1102
+ return {
1103
+ githubOwner: this.config.githubOwner,
1104
+ githubRepo: this.config.githubRepo,
1105
+ auth: resolveWorkerGitHubAuth(this.config),
1106
+ githubApp: Boolean(this.config.githubAppAuth),
1107
+ stateDirectory: this.stateDirectory,
1108
+ workspacePath: this.config.workspacePath,
1109
+ remoteGitPath: this.remoteGitPath,
1110
+ timeoutMs: this.taskTimeoutMs,
1111
+ };
1069
1112
  }
1070
1113
  /**
1071
1114
  * The workspace directory for a branch named by its GIT REF name -- the form
@@ -1130,78 +1173,8 @@ export class CmsWorker {
1130
1173
  async syncGit() {
1131
1174
  return syncGit(this.ctx());
1132
1175
  }
1133
- /**
1134
- * `syncGit`, plus "the credential may have rotated" on the way out.
1135
- *
1136
- * One of `refreshGitHubCredential`'s two call sites, and the one that works
1137
- * when nobody is publishing: it fetches from GitHub every `gitSyncInterval`
1138
- * whether or not anyone is editing, so a credential that has stopped working
1139
- * surfaces here even with no push queued for days.
1140
- *
1141
- * The SYNC failure is what propagates to `scheduleLoop`'s catch;
1142
- * `refreshGitHubCredential` never throws, so nothing it does can replace it.
1143
- */
1144
- async syncGitWithCredentialRefresh() {
1145
- try {
1146
- await this.syncGit();
1147
- }
1148
- catch (err) {
1149
- await this.refreshGitHubCredential();
1150
- throw err;
1151
- }
1152
- }
1153
- /**
1154
- * Re-read the GitHub credential, because an operation using it just failed.
1155
- *
1156
- * **Two call sites, each covering what the other cannot.** The git-sync loop
1157
- * (`syncGitWithCredentialRefresh`) notices a dead credential when nobody is
1158
- * publishing. `processTaskQueue`'s per-task catch is what saves a publish: a
1159
- * push task spends its retry budget on a 5s/10s/20s backoff, well inside one
1160
- * 5-minute sync interval, so with the sync loop as the only trigger a publish
1161
- * meeting a rotated token fails permanently while the working one is already
1162
- * in the secret store. Every consumer reaches the credential through
1163
- * `ensureGitHubAuth()`, which reads it per use, so a refresh from either site
1164
- * repairs all of them for their NEXT use — but not an attempt already failed.
1165
- *
1166
- * NOT gated on the error looking auth-shaped, at either site: a `git
1167
- * fetch`/`push` rejected for a dead token throws a plain simple-git error
1168
- * (exit 128, no HTTP `.status`) that `isPermanentTaskFailure` reads as
1169
- * transient, so a gate keyed on it would never fire. Two floors bound the
1170
- * cost instead — core's `refreshGitHubTokenMinIntervalMs` (default 60s,
1171
- * enforced by `refreshCredential` in github-auth.ts) and whatever floor the
1172
- * provider keeps (the AWS one reads at most once per five minutes) — and both
1173
- * call sites share both, so a read issued by one throttles the other. On the
1174
- * GitHub App path the refresh is a no-op.
1175
- *
1176
- * **Never throws**: both callers are already reporting the failure that must
1177
- * reach the log. **Bounded by `taskTimeoutMs`**, because the task loop awaits
1178
- * it and a read that never settled would stop every publish queued behind it
1179
- * (an adopter's provider may have no bound at all; the AWS one can take 87s
1180
- * for one `getSecret`). A losing read is not cancelled and may land later, but
1181
- * `refreshCredential` discards a result older than one already applied.
1182
- */
1183
- async refreshGitHubCredential() {
1184
- let timer;
1185
- const timedOut = new Promise((_, reject) => {
1186
- timer = setTimeout(() => reject(new Error(`the re-read did not settle within ${this.taskTimeoutMs}ms`)), this.taskTimeoutMs);
1187
- });
1188
- try {
1189
- // `ensureGitHubAuth()` inside the try: it throws for a half-configured
1190
- // credential, and that has to be logged like any other refresh failure.
1191
- await Promise.race([this.ensureGitHubAuth().refreshCredential(), timedOut]);
1192
- }
1193
- catch (err) {
1194
- // [REDACT] The message can name the secret and, on a malformed-secret
1195
- // path, quote what was read. Console only, but the rule is uniform --
1196
- // see redactCredentials in utils/error.ts.
1197
- workerLogError('Failed to re-read the GitHub credential after a failure:', redactCredentials(getErrorMessage(err)));
1198
- }
1199
- finally {
1200
- clearTimeout(timer);
1201
- }
1202
- }
1203
- async pushSettingsBranches(git, trackedNames) {
1204
- return pushSettingsBranches(this.ctx(), git, trackedNames);
1176
+ async pushSettingsBranches(git, trackedTips) {
1177
+ return pushSettingsBranches(this.ctx(), git, trackedTips);
1205
1178
  }
1206
1179
  async refreshBaseBranchWorkspace() {
1207
1180
  return refreshBaseBranchWorkspace(this.ctx());