canopycms 0.8.1 → 0.8.2
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 +102 -47
- package/dist/api/admin-branch-health.d.ts +11 -0
- package/dist/api/admin-branch-health.js +36 -0
- package/dist/api/admin.d.ts +8 -1
- package/dist/api/branch-merge.js +1 -1
- package/dist/api/branch-review.js +20 -3
- package/dist/api/branch-status.js +30 -19
- package/dist/api/branch.d.ts +8 -3
- package/dist/api/branch.js +32 -3
- package/dist/api/client.d.ts +3 -1
- package/dist/api/client.js +4 -0
- package/dist/api/github-sync.d.ts +2 -0
- package/dist/api/github-sync.js +6 -1
- package/dist/api/pr-comment-sync.js +3 -1
- package/dist/branch-metadata-file.d.ts +66 -0
- package/dist/branch-metadata-file.js +14 -0
- package/dist/branch-metadata.d.ts +4 -2
- package/dist/branch-metadata.js +10 -2
- package/dist/cli/cli.js +28 -8
- package/dist/cli/generate-ai-content.js +13 -3
- package/dist/cli/init.js +2 -2
- package/dist/editor/BranchManager.d.ts +8 -4
- package/dist/editor/BranchManager.js +43 -18
- package/dist/editor/Editor.js +3 -2
- package/dist/editor/admin/SystemHealthPanel.js +65 -31
- package/dist/editor/admin/UnpublishedAtMergeAlert.d.ts +16 -0
- package/dist/editor/admin/UnpublishedAtMergeAlert.js +50 -0
- package/dist/editor/admin/useSystemHealth.d.ts +2 -0
- package/dist/editor/admin/useSystemHealth.js +21 -0
- package/dist/editor/branch-lock.d.ts +2 -0
- package/dist/editor/branch-lock.js +5 -3
- package/dist/editor/branch-status.d.ts +25 -1
- package/dist/editor/branch-status.js +75 -0
- package/dist/editor/components/EditorHeader.d.ts +6 -2
- package/dist/editor/components/EditorHeader.js +51 -30
- package/dist/editor/components/EntryHeader.d.ts +3 -1
- package/dist/editor/components/EntryHeader.js +2 -1
- package/dist/editor/components/TooltipDetail.d.ts +14 -0
- package/dist/editor/components/TooltipDetail.js +18 -0
- package/dist/editor/copy.d.ts +7 -0
- package/dist/editor/copy.js +9 -0
- package/dist/editor/hooks/useBranchManager.d.ts +8 -3
- package/dist/editor/hooks/useBranchManager.js +48 -5
- package/dist/pr-comments.d.ts +2 -0
- package/dist/pr-comments.js +20 -14
- package/dist/resource-generation.js +2 -3
- package/dist/submission-attribution.d.ts +1 -1
- package/dist/submission-attribution.js +20 -2
- package/dist/task-queue/task-queue.d.ts +5 -1
- package/dist/task-queue/task-queue.js +4 -2
- package/dist/types.d.ts +61 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/branch-merge.d.ts +68 -0
- package/dist/worker/branch-merge.js +228 -0
- package/dist/worker/canopy-state.d.ts +2 -0
- package/dist/worker/canopy-state.js +1 -1
- package/dist/worker/cms-worker.d.ts +1 -0
- package/dist/worker/cms-worker.js +5 -3
- package/dist/worker/git-sync.js +15 -8
- package/dist/worker/github-commits.d.ts +106 -0
- package/dist/worker/github-commits.js +485 -0
- package/dist/worker/github-gateway-http.js +3 -2
- package/dist/worker/github-gateway-wire.d.ts +22 -13
- package/dist/worker/github-gateway-wire.js +4 -1
- package/dist/worker/github-gateway.d.ts +16 -5
- package/dist/worker/github-gateway.js +22 -5
- package/dist/worker/github-mirror.d.ts +8 -0
- package/dist/worker/github-mirror.js +12 -1
- package/dist/worker/github-transfer.d.ts +3 -1
- package/dist/worker/github-transfer.js +3 -2
- package/dist/worker/history-rewrite.d.ts +13 -1
- package/dist/worker/history-rewrite.js +27 -3
- package/dist/worker/rebase.d.ts +13 -7
- package/dist/worker/rebase.js +351 -95
- package/dist/worker/task-runner.d.ts +1 -1
- package/dist/worker/task-runner.js +172 -34
- package/dist/worker/worker-context.d.ts +2 -0
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import { completeTask, dequeueTask, failTask, recoverOrphanedTasks, releaseTask, retryTask, } from '../task-queue/cms-task-queue.js';
|
|
3
3
|
import { isNoCommitsBetweenError, isRefAlreadyGoneError } from '../github-service.js';
|
|
4
|
-
import { BranchMetadataCorruptError, BranchMetadataFileManager, getBranchMetadataFileManager, } from '../branch-metadata.js';
|
|
4
|
+
import { BranchMetadataCorruptError, BranchMetadataFileManager, awaitsSubmitRetry, getBranchMetadataFileManager, } from '../branch-metadata.js';
|
|
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, workflowPushRefusalFile } from '../utils/git.js';
|
|
@@ -13,6 +13,7 @@ import { pushToGitHub } from './github-transfer.js';
|
|
|
13
13
|
import { clearHistoryRewrittenMarker, readPublishedSha } from './history-rewrite.js';
|
|
14
14
|
import { writeWorkerStatus } from '../task-queue/worker-status.js';
|
|
15
15
|
import { SYNC_PR_COMMENTS_ACTION, refreshCommentsBlock, renderPrCommentsBody } from '../pr-comments.js';
|
|
16
|
+
import { COMMIT_ID, REWRITTEN_PUSH_REFUSAL, SEEN_COMMIT_GONE_PUSH_REFUSAL, enqueueReviewBlockSync, } from './github-commits.js';
|
|
16
17
|
import { workerLog, workerLogError, workerLogWarn } from './log.js';
|
|
17
18
|
import { assertSharedRepoConfig } from './shared-repo-git.js';
|
|
18
19
|
/**
|
|
@@ -35,6 +36,15 @@ class TaskAbortedForShutdownError extends Error {
|
|
|
35
36
|
*/
|
|
36
37
|
class NothingToSubmitTaskError extends PermanentTaskError {
|
|
37
38
|
}
|
|
39
|
+
/** The task a submit queues (api/github-sync.ts). */
|
|
40
|
+
const SUBMIT_ACTION = 'push-and-create-or-update-pr';
|
|
41
|
+
/**
|
|
42
|
+
* GitHub's branch moved past what this deployment published. An immediate retry of the same push
|
|
43
|
+
* cannot land, but the next sync brings those commits in (worker/github-commits.ts), so a submit's
|
|
44
|
+
* task, which also updates the PR, runs again after it.
|
|
45
|
+
*/
|
|
46
|
+
class GitHubMovedTaskError extends PermanentTaskError {
|
|
47
|
+
}
|
|
38
48
|
/**
|
|
39
49
|
* Classify a task failure as permanent (fail fast) or transient (retry).
|
|
40
50
|
*
|
|
@@ -211,7 +221,14 @@ export async function processTaskQueue(ctx) {
|
|
|
211
221
|
const permanent = isPermanentTaskFailure(err);
|
|
212
222
|
const retryCount = task.retryCount ?? 0;
|
|
213
223
|
const maxRetries = task.maxRetries ?? ctx.maxRetries;
|
|
214
|
-
|
|
224
|
+
const waitsForSync = err instanceof GitHubMovedTaskError;
|
|
225
|
+
if (waitsForSync && retryCount < maxRetries) {
|
|
226
|
+
await retryTask(ctx.taskDir, task.id, persistedMessage, ctx.log, {
|
|
227
|
+
delayMs: ctx.gitSyncIntervalMs,
|
|
228
|
+
});
|
|
229
|
+
workerLog(` Will retry after the next sync (attempt ${retryCount + 1}/${maxRetries})`);
|
|
230
|
+
}
|
|
231
|
+
else if (!permanent && retryCount < maxRetries) {
|
|
215
232
|
await retryTask(ctx.taskDir, task.id, persistedMessage, ctx.log);
|
|
216
233
|
workerLog(` Will retry (attempt ${retryCount + 1}/${maxRetries})`);
|
|
217
234
|
}
|
|
@@ -220,7 +237,7 @@ export async function processTaskQueue(ctx) {
|
|
|
220
237
|
await updateBranchMetadataOnFailure(ctx, task, persistedMessage, {
|
|
221
238
|
unlock: err instanceof NothingToSubmitTaskError,
|
|
222
239
|
});
|
|
223
|
-
workerLogError(permanent
|
|
240
|
+
workerLogError(permanent && !waitsForSync
|
|
224
241
|
? ' Permanently failed (non-retryable error)'
|
|
225
242
|
: ` Permanently failed after ${maxRetries} retries`);
|
|
226
243
|
}
|
|
@@ -315,6 +332,10 @@ export async function executeTask(ctx, task, signal) {
|
|
|
315
332
|
// list->tiebreak->update/create logic and the draft->ready conversion
|
|
316
333
|
// live in one place.
|
|
317
334
|
const branch = requireString(payload, 'branch');
|
|
335
|
+
if (await isSupersededSubmit(ctx, task, branch)) {
|
|
336
|
+
workerLog(`Not retrying the submit of ${branch}: the branch has moved on from it`);
|
|
337
|
+
return { superseded: true };
|
|
338
|
+
}
|
|
318
339
|
const base = optionalString(payload, 'baseBranch', ctx.baseBranch);
|
|
319
340
|
// Defense-in-depth: refuse head===base even if the 'submittableBranch'
|
|
320
341
|
// API guard and the syncSubmitPr backstop were both bypassed.
|
|
@@ -460,6 +481,7 @@ export async function executeTask(ctx, task, signal) {
|
|
|
460
481
|
branch: editorBranchOf(payload, branch),
|
|
461
482
|
editorUrl,
|
|
462
483
|
body: pr.body,
|
|
484
|
+
reviewBlockOnly: payload.reviewBlockOnly === true,
|
|
463
485
|
warn: workerLogWarn,
|
|
464
486
|
});
|
|
465
487
|
if (body === undefined)
|
|
@@ -472,6 +494,34 @@ export async function executeTask(ctx, task, signal) {
|
|
|
472
494
|
throw new PermanentTaskError(`Unknown task action: ${action}`);
|
|
473
495
|
}
|
|
474
496
|
}
|
|
497
|
+
/**
|
|
498
|
+
* Whether a submit no longer describes the branch: it merged, so running it would open a pull
|
|
499
|
+
* request nothing tracks; it was withdrawn or sent back, so running it would ready the PR someone
|
|
500
|
+
* pulled back; or it was submitted again, so running it would overwrite the newer title and body.
|
|
501
|
+
* A first run can start before the API has recorded the submit it queued, which reads as a branch
|
|
502
|
+
* still holding an older one. A drain's release and an orphan's recovery keep `retryCount`, so a
|
|
503
|
+
* first run can also start long after, and only `submittedAt` tells the two apart.
|
|
504
|
+
*/
|
|
505
|
+
async function isSupersededSubmit(ctx, task, branch) {
|
|
506
|
+
const { submittedAt, requeuedFrom } = task.payload;
|
|
507
|
+
if (typeof submittedAt !== 'string')
|
|
508
|
+
return false;
|
|
509
|
+
const live = await BranchMetadataFileManager.loadOnly(ctx.branchWorkspacePath(branch));
|
|
510
|
+
if (live === null)
|
|
511
|
+
return false;
|
|
512
|
+
const { status, submittedAt: recorded } = live.branch;
|
|
513
|
+
if (status === 'archived')
|
|
514
|
+
return true;
|
|
515
|
+
const firstRun = !task.retryCount && requeuedFrom === undefined;
|
|
516
|
+
if (firstRun && (recorded === undefined || recorded < submittedAt))
|
|
517
|
+
return false;
|
|
518
|
+
return (status !== 'submitted' && status !== 'approved') || recorded !== submittedAt;
|
|
519
|
+
}
|
|
520
|
+
/** A submit that isn't the branch's latest, whose outcome says nothing about the latest one. */
|
|
521
|
+
function isOlderSubmit(task, recorded) {
|
|
522
|
+
const { submittedAt } = task.payload;
|
|
523
|
+
return (task.action === SUBMIT_ACTION && typeof submittedAt === 'string' && recorded !== submittedAt);
|
|
524
|
+
}
|
|
475
525
|
/**
|
|
476
526
|
* The name links and a fallback title give the branch: the short name the API queued
|
|
477
527
|
* (branch-param.ts), else the ref, which the editor resolves too.
|
|
@@ -515,35 +565,66 @@ export async function updateBranchMetadata(ctx, task, result) {
|
|
|
515
565
|
}
|
|
516
566
|
try {
|
|
517
567
|
const meta = getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath);
|
|
518
|
-
|
|
568
|
+
// A superseded submit synced nothing. A branch taken back to editing waits on no other task
|
|
569
|
+
// and owes no pull request step, so it would read as syncing or failed until its next submit.
|
|
570
|
+
// An archived one has no next submit, and would read as syncing or failed for good.
|
|
571
|
+
if (result.superseded === true) {
|
|
572
|
+
await meta.update((existing) => existing?.branch.name === branch &&
|
|
573
|
+
(existing.branch.status === 'editing'
|
|
574
|
+
? existing.branch.syncStatus === 'pending-sync' ||
|
|
575
|
+
existing.branch.submitUnfinished === true
|
|
576
|
+
: existing.branch.status === 'archived' &&
|
|
577
|
+
(existing.branch.syncStatus === 'pending-sync' ||
|
|
578
|
+
existing.branch.syncStatus === 'sync-failed'))
|
|
579
|
+
? {
|
|
580
|
+
branch: {
|
|
581
|
+
syncStatus: 'synced',
|
|
582
|
+
syncFailureReason: undefined,
|
|
583
|
+
submitUnfinished: undefined,
|
|
584
|
+
},
|
|
585
|
+
}
|
|
586
|
+
: null, { keepUpdatedAt: true });
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
let found = false;
|
|
590
|
+
// Bookkeeping: the save that queued this already said when the branch last changed.
|
|
591
|
+
await meta.update((existing) => {
|
|
519
592
|
if (existing?.branch.name !== branch)
|
|
520
593
|
return null;
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
594
|
+
found = true;
|
|
595
|
+
const updates = isOlderSubmit(task, existing.branch.submittedAt) ||
|
|
596
|
+
(task.action !== SUBMIT_ACTION && awaitsSubmitRetry(existing.branch))
|
|
597
|
+
? {}
|
|
598
|
+
: {
|
|
599
|
+
syncStatus: 'synced',
|
|
600
|
+
// save()'s merge only overwrites keys present in this update, so a prior
|
|
601
|
+
// syncFailureReason would survive this successful sync forever unless
|
|
602
|
+
// cleared explicitly (same pattern as rebaseFailure elsewhere).
|
|
603
|
+
syncFailureReason: undefined,
|
|
604
|
+
submitUnfinished: undefined,
|
|
605
|
+
};
|
|
606
|
+
// A pull request opened as the branch merged is not this branch's, whose record is the merge.
|
|
607
|
+
const archived = existing.branch.status === 'archived';
|
|
608
|
+
if (archived && result.prNumber) {
|
|
609
|
+
workerLogWarn(`Not recording pull request #${result.prNumber} on merged ${branch}`);
|
|
610
|
+
}
|
|
611
|
+
if (result.prUrl && !archived)
|
|
529
612
|
updates.pullRequestUrl = result.prUrl;
|
|
530
|
-
if (result.prNumber) {
|
|
613
|
+
if (result.prNumber && !archived) {
|
|
531
614
|
updates.pullRequestNumber = result.prNumber;
|
|
532
615
|
// As soon as the PR exists the field reads 'open', rather than staying
|
|
533
616
|
// absent until the next poll cycle observes it. Exception: the git-sync
|
|
534
|
-
// loop may have
|
|
535
|
-
//
|
|
536
|
-
|
|
537
|
-
const terminal = existing.branch.status === 'archived' ||
|
|
538
|
-
existing.branch.pullRequestState === 'merged' ||
|
|
617
|
+
// loop may have closed this PR while the task was in flight, and a late
|
|
618
|
+
// completion must not downgrade that terminal state back to 'open'.
|
|
619
|
+
const terminal = existing.branch.pullRequestState === 'merged' ||
|
|
539
620
|
existing.branch.pullRequestState === 'closed';
|
|
540
621
|
if (!terminal) {
|
|
541
622
|
updates.pullRequestState = 'open';
|
|
542
623
|
}
|
|
543
624
|
}
|
|
544
|
-
return { branch: updates };
|
|
545
|
-
});
|
|
546
|
-
if (
|
|
625
|
+
return Object.keys(updates).length > 0 ? { branch: updates } : null;
|
|
626
|
+
}, { keepUpdatedAt: true });
|
|
627
|
+
if (!found)
|
|
547
628
|
noMetadataToRecord(branch, `${task.action} outcome`);
|
|
548
629
|
}
|
|
549
630
|
catch (err) {
|
|
@@ -588,9 +669,39 @@ async function updateBranchMetadataOnFailure(ctx, task, error, options) {
|
|
|
588
669
|
existing.branch.pullRequestNumber === undefined &&
|
|
589
670
|
existing.branch.submittedAt === submittedAt)) !== null;
|
|
590
671
|
if (!unlocked) {
|
|
591
|
-
|
|
592
|
-
|
|
672
|
+
let found = false;
|
|
673
|
+
let revoked = false;
|
|
674
|
+
const saved = await meta.update((existing) => {
|
|
675
|
+
if (existing?.branch.name !== branch)
|
|
676
|
+
return null;
|
|
677
|
+
found = true;
|
|
678
|
+
// An older submit running late has no step left to finish, and what it failed at says
|
|
679
|
+
// nothing about the latest one.
|
|
680
|
+
if (isOlderSubmit(task, existing.branch.submittedAt))
|
|
681
|
+
return null;
|
|
682
|
+
// Nothing to submit means this submit left nothing out, so it flags nothing. A flag a
|
|
683
|
+
// merge already set stands, for an admin to dismiss.
|
|
684
|
+
if (options.unlock || task.action !== SUBMIT_ACTION)
|
|
685
|
+
return { branch: failed };
|
|
686
|
+
// Merged while this ran: nothing is left to finish it, as buildMergedBranchUpdate records.
|
|
687
|
+
if (existing.branch.status === 'archived') {
|
|
688
|
+
return { branch: { ...failed, unpublishedAtMerge: true } };
|
|
689
|
+
}
|
|
690
|
+
// An approval can land while a submit is pending. It covers a pull request that won't
|
|
691
|
+
// get what was approved, as for commits from GitHub (github-commits.ts).
|
|
692
|
+
revoked = existing.branch.status === 'approved';
|
|
693
|
+
return {
|
|
694
|
+
branch: {
|
|
695
|
+
...failed,
|
|
696
|
+
submitUnfinished: true,
|
|
697
|
+
...(revoked ? { status: 'submitted', review: undefined } : {}),
|
|
698
|
+
},
|
|
699
|
+
};
|
|
700
|
+
}, { keepUpdatedAt: true });
|
|
701
|
+
if (!found)
|
|
593
702
|
noMetadataToRecord(branch, `${task.action} failure`);
|
|
703
|
+
if (revoked && saved !== null)
|
|
704
|
+
await enqueueReviewBlockSync(ctx, branch, saved);
|
|
594
705
|
}
|
|
595
706
|
}
|
|
596
707
|
catch (err) {
|
|
@@ -642,8 +753,18 @@ export async function pushBranchToGitHub(ctx, branch, signal) {
|
|
|
642
753
|
// that commit: it moves GitHub off the commit we rewrote away, and
|
|
643
754
|
// refuses in every other case (including a commit someone else pushed).
|
|
644
755
|
const branchPath = ctx.branchWorkspacePath(branch);
|
|
645
|
-
|
|
756
|
+
// An unreadable branch.json throws: without it nothing could say a push is safe.
|
|
757
|
+
const readMetadata = () => BranchMetadataFileManager.loadOnly(branchPath).catch((err) => {
|
|
758
|
+
throw err instanceof BranchMetadataCorruptError ? new Error(err.parseCause) : err;
|
|
759
|
+
});
|
|
760
|
+
const metaFile = await readMetadata();
|
|
646
761
|
const marker = metaFile?.branch.historyRewrittenFrom;
|
|
762
|
+
// github-commits.ts recorded it: this push would restore what someone removed from GitHub.
|
|
763
|
+
if (metaFile?.branch.rewrittenOnGitHub !== undefined) {
|
|
764
|
+
workerLogWarn(`Not pushing ${branch}: GitHub's branch no longer contains ` +
|
|
765
|
+
`${metaFile.branch.rewrittenOnGitHub}`);
|
|
766
|
+
throw new PermanentTaskError(REWRITTEN_PUSH_REFUSAL);
|
|
767
|
+
}
|
|
647
768
|
const readSha = async () => {
|
|
648
769
|
const sha = await readPublishedSha(ctx, branch);
|
|
649
770
|
if (sha === null) {
|
|
@@ -651,6 +772,14 @@ export async function pushBranchToGitHub(ctx, branch, signal) {
|
|
|
651
772
|
}
|
|
652
773
|
return sha;
|
|
653
774
|
};
|
|
775
|
+
// Read after the commit it goes with, each attempt: a merge records what it took from GitHub
|
|
776
|
+
// before `remote.git` holds it, so this names everything that commit brought in. A push under a
|
|
777
|
+
// marker has a lease instead, and the marker's stamp names the commit it's taking to GitHub. One
|
|
778
|
+
// armed since this task began pushes without that lease, so it keeps the check.
|
|
779
|
+
const readSeen = async () => {
|
|
780
|
+
const seen = (await readMetadata())?.branch.lastGitHubTip;
|
|
781
|
+
return marker === undefined && seen !== undefined && COMMIT_ID.test(seen) ? seen : undefined;
|
|
782
|
+
};
|
|
654
783
|
// The commit GitHub was sent: the transfer reads it again if the branch moves under it.
|
|
655
784
|
let outgoingSha;
|
|
656
785
|
let outcome;
|
|
@@ -659,6 +788,7 @@ export async function pushBranchToGitHub(ctx, branch, signal) {
|
|
|
659
788
|
({ sha: outgoingSha, outcome } = await pushToGitHub(ctx, {
|
|
660
789
|
branch,
|
|
661
790
|
lease: marker,
|
|
791
|
+
readSeen,
|
|
662
792
|
prerequisiteBranches: [branch, ctx.baseBranch],
|
|
663
793
|
}, readSha, signal));
|
|
664
794
|
}
|
|
@@ -668,12 +798,17 @@ export async function pushBranchToGitHub(ctx, branch, signal) {
|
|
|
668
798
|
throw new PermanentTaskError(err.message);
|
|
669
799
|
if (!(err instanceof GitHubPushError))
|
|
670
800
|
throw err;
|
|
801
|
+
// The next sync sees why, and records the rewrite if that's what it was.
|
|
802
|
+
if (err.kind === 'seen-commit-gone') {
|
|
803
|
+
workerLogWarn(`Not pushing ${branch}: ${err.message}`);
|
|
804
|
+
throw new GitHubMovedTaskError(SEEN_COMMIT_GONE_PUSH_REFUSAL);
|
|
805
|
+
}
|
|
671
806
|
throwIfWorkflowRefusal(branch, err.message);
|
|
672
807
|
// A refused lease was already retried plain, and that retry rejected too: only a
|
|
673
808
|
// non-fast-forward then means the branch has genuinely diverged.
|
|
674
809
|
if (err.kind === 'rejected-after-stale-lease') {
|
|
675
810
|
if (isNonFastForwardRejection(err.message)) {
|
|
676
|
-
throw new
|
|
811
|
+
throw new GitHubMovedTaskError(`Push rejected for branch "${branch}": GitHub's tip is neither the commit this ` +
|
|
677
812
|
`deployment last published nor an ancestor of what it is pushing, so the branch ` +
|
|
678
813
|
`has genuinely diverged and nothing was overwritten. Something else moved it on ` +
|
|
679
814
|
`GitHub -- a direct push, or another CanopyCMS deployment sharing this repository.`);
|
|
@@ -687,7 +822,7 @@ export async function pushBranchToGitHub(ctx, branch, signal) {
|
|
|
687
822
|
// advise renaming the branch: one reaching this point usually has an open
|
|
688
823
|
// PR, which renaming would orphan.
|
|
689
824
|
if (isNonFastForwardRejection(err.message)) {
|
|
690
|
-
throw new
|
|
825
|
+
throw new GitHubMovedTaskError(`Push rejected for branch "${branch}": GitHub's tip is not what this deployment last ` +
|
|
691
826
|
`published, so the branch has diverged and needs reconciling. Something else moved it ` +
|
|
692
827
|
`on GitHub -- a direct push, or another CanopyCMS deployment sharing this repository.`);
|
|
693
828
|
}
|
|
@@ -697,7 +832,7 @@ export async function pushBranchToGitHub(ctx, branch, signal) {
|
|
|
697
832
|
// The lease was refused, so GitHub is provably not at the marker: it
|
|
698
833
|
// has moved past the rewritten commit and the marker is spent.
|
|
699
834
|
await clearHistoryRewrittenMarker(ctx, branchPath, branch);
|
|
700
|
-
await recordPushedToGitHub(ctx, branchPath, branch);
|
|
835
|
+
await recordPushedToGitHub(ctx, branchPath, branch, outgoingSha);
|
|
701
836
|
logPushOutcome(ctx, branch, outcome, 'GitHub had already moved past the rewritten commit');
|
|
702
837
|
return;
|
|
703
838
|
}
|
|
@@ -708,7 +843,7 @@ export async function pushBranchToGitHub(ctx, branch, signal) {
|
|
|
708
843
|
if (marker && outgoingSha !== marker) {
|
|
709
844
|
await clearHistoryRewrittenMarker(ctx, branchPath, branch);
|
|
710
845
|
}
|
|
711
|
-
await recordPushedToGitHub(ctx, branchPath, branch);
|
|
846
|
+
await recordPushedToGitHub(ctx, branchPath, branch, outgoingSha);
|
|
712
847
|
logPushOutcome(ctx, branch, outcome);
|
|
713
848
|
}
|
|
714
849
|
/** An unmoved ref is routine (a task re-run after a crash), so it is logged at debug level. */
|
|
@@ -722,12 +857,13 @@ function logPushOutcome(ctx, branch, outcome, note) {
|
|
|
722
857
|
}
|
|
723
858
|
}
|
|
724
859
|
/**
|
|
725
|
-
* Stamp `pushedToGitHubAt`, branch delete's proof that the GitHub branch is this one
|
|
726
|
-
*
|
|
727
|
-
*
|
|
728
|
-
* GitHub.
|
|
860
|
+
* Stamp `pushedToGitHubAt`, branch delete's proof that the GitHub branch is this one, and
|
|
861
|
+
* `lastGitHubTip`, what github-commits.ts tells a rewrite on GitHub by. Recorded when the push
|
|
862
|
+
* lands, whatever the PR call after it does. Best-effort: a branch deleted meanwhile has no
|
|
863
|
+
* metadata to stamp, and a failed stamp only leaves a later delete to skip GitHub. Bookkeeping, so
|
|
864
|
+
* it leaves `updatedAt`, which the card shows as an edit.
|
|
729
865
|
*/
|
|
730
|
-
async function recordPushedToGitHub(ctx, branchPath, branch) {
|
|
866
|
+
async function recordPushedToGitHub(ctx, branchPath, branch, sha) {
|
|
731
867
|
if (isSettingsBranch(branch))
|
|
732
868
|
return;
|
|
733
869
|
try {
|
|
@@ -737,7 +873,9 @@ async function recordPushedToGitHub(ctx, branchPath, branch) {
|
|
|
737
873
|
return;
|
|
738
874
|
}
|
|
739
875
|
try {
|
|
740
|
-
const saved = await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).
|
|
876
|
+
const saved = await getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath).update((existing) => existing?.branch.name === branch
|
|
877
|
+
? { branch: { pushedToGitHubAt: new Date().toISOString(), lastGitHubTip: sha } }
|
|
878
|
+
: null, { keepUpdatedAt: true });
|
|
741
879
|
if (saved === null)
|
|
742
880
|
noMetadataToRecord(branch, 'GitHub push');
|
|
743
881
|
}
|
|
@@ -50,6 +50,8 @@ export interface WorkerContext {
|
|
|
50
50
|
readonly schemaHoldMaxMs: number;
|
|
51
51
|
/** Per-task timeout in ms; also simple-git's inactivity block timeout. */
|
|
52
52
|
readonly taskTimeoutMs: number;
|
|
53
|
+
/** How long the sync loop rests between cycles, in ms. */
|
|
54
|
+
readonly gitSyncIntervalMs: number;
|
|
53
55
|
/** Max tasks to process per `processTaskQueue` cycle. */
|
|
54
56
|
readonly maxTasksPerCycle: number;
|
|
55
57
|
/** Default max retries for a failed task that does not carry its own. */
|
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.8.
|
|
4
|
+
"version": "0.8.2",
|
|
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": {
|