mandrel 1.62.0 → 1.64.0
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/.agents/scripts/agents-bootstrap-github.js +40 -48
- package/.agents/scripts/bootstrap.js +74 -60
- package/.agents/scripts/check-action-pinning.js +260 -0
- package/.agents/scripts/check-arch-cycles.js +38 -14
- package/.agents/scripts/epic-deliver-prepare.js +149 -104
- package/.agents/scripts/lib/baseline-snapshot.js +245 -141
- package/.agents/scripts/lib/bootstrap/branch-protection.js +8 -8
- package/.agents/scripts/lib/bootstrap/gh-preflight.js +3 -3
- package/.agents/scripts/lib/bootstrap/hitl-confirm.js +2 -2
- package/.agents/scripts/lib/bootstrap/merge-methods.js +7 -7
- package/.agents/scripts/lib/bootstrap/preflight.js +18 -15
- package/.agents/scripts/lib/bootstrap/project-bootstrap.js +5 -5
- package/.agents/scripts/lib/bootstrap/prompt.js +5 -1
- package/.agents/scripts/lib/detect-package-manager.js +2 -2
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +171 -137
- package/.agents/scripts/lib/onboard/init-tail.js +60 -69
- package/.agents/scripts/lib/orchestration/code-review.js +206 -168
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/creation.js +71 -5
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist.js +16 -2
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/component-drift.js +101 -1
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/crap-drift.js +20 -42
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/maintainability-drift.js +12 -32
- package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +97 -60
- package/.agents/scripts/lib/orchestration/model-attribution.js +73 -45
- package/.agents/scripts/lib/orchestration/review-providers/parse-findings.js +97 -49
- package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +73 -69
- package/.agents/scripts/lib/orchestration/story-close-recovery.js +109 -79
- package/.agents/scripts/lib/signals/detectors/common.js +107 -0
- package/.agents/scripts/lib/signals/detectors/hotspot.js +12 -18
- package/.agents/scripts/lib/signals/detectors/retry.js +3 -40
- package/.agents/scripts/lib/signals/detectors/rework.js +3 -40
- package/.agents/scripts/lib/story-body/story-body.js +102 -76
- package/.agents/scripts/providers/github/blocked-by-add.js +252 -0
- package/.agents/scripts/providers/github/tickets.js +1 -1
- package/.agents/scripts/single-story-init.js +16 -3
- package/.agents/workflows/audit-architecture.md +9 -0
- package/.agents/workflows/helpers/deliver-stories.md +24 -2
- package/.agents/workflows/helpers/single-story-deliver.md +84 -1
- package/README.md +1 -1
- package/docs/CHANGELOG.md +43 -0
- package/lib/cli/init.js +66 -21
- package/lib/cli/sync.js +3 -3
- package/package.json +1 -1
- package/.agents/scripts/lib/onboard/detect-stack.js +0 -300
|
@@ -195,13 +195,13 @@ function runGit(args, cwd) {
|
|
|
195
195
|
* `GhExecError` — so a bare "gh exited with code 1" is actually diagnosable.
|
|
196
196
|
*/
|
|
197
197
|
function logGhError(label, err) {
|
|
198
|
-
Logger.error(`[
|
|
198
|
+
Logger.error(`[Bootstrap] ${label} failed: ${err.message}`);
|
|
199
199
|
if (err.stderr)
|
|
200
|
-
Logger.error(`[
|
|
200
|
+
Logger.error(`[Bootstrap] gh stderr: ${String(err.stderr).trim()}`);
|
|
201
201
|
if (err.stdout)
|
|
202
|
-
Logger.error(`[
|
|
202
|
+
Logger.error(`[Bootstrap] gh stdout: ${String(err.stdout).trim()}`);
|
|
203
203
|
if (Array.isArray(err.args)) {
|
|
204
|
-
Logger.error(`[
|
|
204
|
+
Logger.error(`[Bootstrap] gh args: ${err.args.join(' ')}`);
|
|
205
205
|
}
|
|
206
206
|
}
|
|
207
207
|
|
|
@@ -249,7 +249,7 @@ function ensureGitInitialized(state) {
|
|
|
249
249
|
initialized = true;
|
|
250
250
|
state.gitInitialized = true;
|
|
251
251
|
Logger.info(
|
|
252
|
-
`[
|
|
252
|
+
`[Bootstrap] Initialized git repo (branch ${branch}) at ${cwd}.`,
|
|
253
253
|
);
|
|
254
254
|
}
|
|
255
255
|
|
|
@@ -281,7 +281,7 @@ function ensureGitInitialized(state) {
|
|
|
281
281
|
return { ok: false, error: commit.stderr || 'git commit failed' };
|
|
282
282
|
}
|
|
283
283
|
committed = true;
|
|
284
|
-
Logger.info('[
|
|
284
|
+
Logger.info('[Bootstrap] Created initial commit.');
|
|
285
285
|
}
|
|
286
286
|
return { ok: true, initialized, committed };
|
|
287
287
|
}
|
|
@@ -303,21 +303,21 @@ async function ensureGitRemote(state, execImpl = exec) {
|
|
|
303
303
|
if (runGit(['remote', 'get-url', 'origin'], cwd).ok) return;
|
|
304
304
|
if (!(await repoExists(owner, repo, execImpl))) {
|
|
305
305
|
Logger.warn(
|
|
306
|
-
`[
|
|
306
|
+
`[Bootstrap] No 'origin' remote and ${owner}/${repo} does not exist on GitHub — skipping remote wiring.`,
|
|
307
307
|
);
|
|
308
308
|
return;
|
|
309
309
|
}
|
|
310
310
|
const url = `https://github.com/${owner}/${repo}.git`;
|
|
311
311
|
const add = runGit(['remote', 'add', 'origin', url], cwd);
|
|
312
312
|
if (!add.ok) {
|
|
313
|
-
Logger.warn(`[
|
|
313
|
+
Logger.warn(`[Bootstrap] Could not add 'origin' remote: ${add.stderr}`);
|
|
314
314
|
return;
|
|
315
315
|
}
|
|
316
|
-
Logger.info(`[
|
|
316
|
+
Logger.info(`[Bootstrap] Wired 'origin' → ${url}.`);
|
|
317
317
|
const push = runGit(['push', '-u', 'origin', branch], cwd);
|
|
318
318
|
if (!push.ok) {
|
|
319
319
|
Logger.warn(
|
|
320
|
-
`[
|
|
320
|
+
`[Bootstrap] 'origin' is set but push of '${branch}' failed (resolve manually, e.g. \`git pull --rebase origin ${branch}\`): ${push.stderr}`,
|
|
321
321
|
);
|
|
322
322
|
}
|
|
323
323
|
}
|
|
@@ -359,11 +359,11 @@ async function ensureProjectLinked(state, execImpl = exec) {
|
|
|
359
359
|
args: ['project', 'link', pn, '--owner', owner, '--repo', repo],
|
|
360
360
|
});
|
|
361
361
|
Logger.info(
|
|
362
|
-
`[
|
|
362
|
+
`[Bootstrap] Linked repo ${owner}/${repo} to Project V2 #${pn}.`,
|
|
363
363
|
);
|
|
364
364
|
} catch (err) {
|
|
365
365
|
Logger.warn(
|
|
366
|
-
`[
|
|
366
|
+
`[Bootstrap] Could not link repo ${owner}/${repo} to Project V2 #${pn} (continuing): ${err.message}`,
|
|
367
367
|
);
|
|
368
368
|
}
|
|
369
369
|
}
|
|
@@ -413,7 +413,7 @@ async function createGithubRepo(state, execImpl = exec) {
|
|
|
413
413
|
],
|
|
414
414
|
});
|
|
415
415
|
Logger.info(
|
|
416
|
-
`[
|
|
416
|
+
`[Bootstrap] Created GitHub repo ${slug} (${visibility}) and pushed.`,
|
|
417
417
|
);
|
|
418
418
|
}
|
|
419
419
|
|
|
@@ -480,7 +480,7 @@ async function createGithubProject(state, execImpl = exec) {
|
|
|
480
480
|
if (Number.isInteger(existing)) {
|
|
481
481
|
state.answers.projectNumber = String(existing);
|
|
482
482
|
Logger.info(
|
|
483
|
-
`[
|
|
483
|
+
`[Bootstrap] Reusing existing GitHub Project V2 "${title}" (#${existing}) — no duplicate created.`,
|
|
484
484
|
);
|
|
485
485
|
return existing;
|
|
486
486
|
}
|
|
@@ -510,7 +510,7 @@ async function createGithubProject(state, execImpl = exec) {
|
|
|
510
510
|
);
|
|
511
511
|
}
|
|
512
512
|
state.answers.projectNumber = String(number);
|
|
513
|
-
Logger.info(`[
|
|
513
|
+
Logger.info(`[Bootstrap] Created GitHub Project V2 "${title}" (#${number}).`);
|
|
514
514
|
return number;
|
|
515
515
|
}
|
|
516
516
|
|
|
@@ -539,7 +539,7 @@ export function buildQuestions(defaults, flags, env = process.env, lists = {}) {
|
|
|
539
539
|
key: 'owner',
|
|
540
540
|
flag: 'owner',
|
|
541
541
|
env: 'GH_OWNER',
|
|
542
|
-
message: '
|
|
542
|
+
message: '\n\nGitHub repo owner',
|
|
543
543
|
default: defaults.owner,
|
|
544
544
|
required: true,
|
|
545
545
|
validate: (v) =>
|
|
@@ -549,7 +549,8 @@ export function buildQuestions(defaults, flags, env = process.env, lists = {}) {
|
|
|
549
549
|
key: 'operatorHandle',
|
|
550
550
|
flag: 'operator-handle',
|
|
551
551
|
env: 'GH_OPERATOR_HANDLE',
|
|
552
|
-
message:
|
|
552
|
+
message:
|
|
553
|
+
'GitHub username/handle without preceding@ (default: same as owner)',
|
|
553
554
|
// Default tracks the repo owner; resolved post-collect if left blank.
|
|
554
555
|
default: defaults.owner,
|
|
555
556
|
required: false,
|
|
@@ -562,8 +563,9 @@ export function buildQuestions(defaults, flags, env = process.env, lists = {}) {
|
|
|
562
563
|
key: 'repo',
|
|
563
564
|
flag: 'repo',
|
|
564
565
|
env: 'GH_REPO',
|
|
565
|
-
message:
|
|
566
|
-
|
|
566
|
+
message: 'New GitHub repo name',
|
|
567
|
+
pickerMessage:
|
|
568
|
+
'GitHub repo name - Select existing or press ENTER to create',
|
|
567
569
|
default: defaults.repo,
|
|
568
570
|
required: true,
|
|
569
571
|
picker: {
|
|
@@ -590,8 +592,9 @@ export function buildQuestions(defaults, flags, env = process.env, lists = {}) {
|
|
|
590
592
|
key: 'projectNumber',
|
|
591
593
|
flag: 'project-number',
|
|
592
594
|
env: 'GH_PROJECT_NUMBER',
|
|
593
|
-
message:
|
|
594
|
-
|
|
595
|
+
message: 'New GitHub Project V2 name',
|
|
596
|
+
pickerMessage:
|
|
597
|
+
'GitHub Project V2 name - Select existing or press ENTER to create',
|
|
595
598
|
// Prefer the already-stored numeric project number (an
|
|
596
599
|
// already-provisioned project on a re-run) so `--assume-yes` resolves a
|
|
597
600
|
// numeric answer that `detectCreation` treats as existing — never a
|
|
@@ -750,7 +753,7 @@ export function parseAndValidate(argv, opts = {}) {
|
|
|
750
753
|
// `--assume-yes` path did.)
|
|
751
754
|
if (!interactive && !assumeYes && !approveGithubAdmin) {
|
|
752
755
|
Logger.error(
|
|
753
|
-
'[
|
|
756
|
+
'[Bootstrap] non-TTY run requires --assume-yes or --approve-github-admin ' +
|
|
754
757
|
'(no operator is present to confirm the GitHub-admin mutations).',
|
|
755
758
|
);
|
|
756
759
|
return { ok: false, exit: 1 };
|
|
@@ -760,7 +763,7 @@ export function parseAndValidate(argv, opts = {}) {
|
|
|
760
763
|
const githubAdminApproved = interactive || assumeYes || approveGithubAdmin;
|
|
761
764
|
if (resolveRepoVisibility(flags) === null) {
|
|
762
765
|
Logger.error(
|
|
763
|
-
`[
|
|
766
|
+
`[Bootstrap] invalid --visibility "${flags.visibility}". ` +
|
|
764
767
|
`Expected one of: ${REPO_VISIBILITIES.join(', ')}.`,
|
|
765
768
|
);
|
|
766
769
|
return { ok: false, exit: 1 };
|
|
@@ -783,11 +786,12 @@ export function prepareContext(state, opts = {}) {
|
|
|
783
786
|
const defaults = inferDefaults(projectRoot);
|
|
784
787
|
const silentAccept = resolveSilentAccept(defaults, state.flags);
|
|
785
788
|
|
|
786
|
-
Logger.info('[
|
|
787
|
-
Logger.info(
|
|
788
|
-
Logger.info(`
|
|
789
|
-
Logger.info(`
|
|
790
|
-
Logger.info(`
|
|
789
|
+
Logger.info('[\n');
|
|
790
|
+
Logger.info('[Bootstrap] Checking existing GitHub values:');
|
|
791
|
+
Logger.info(` GitHub Repo Owner ${defaults.owner ?? '(unknown)'}`);
|
|
792
|
+
Logger.info(` GitHub Repo Name ${defaults.repo ?? '(unknown)'}`);
|
|
793
|
+
Logger.info(` Base Branch ${defaults.baseBranch ?? '(unknown)'}`);
|
|
794
|
+
Logger.info(` GitHub Username ${defaults.operatorHandle ?? '(unknown)'}`);
|
|
791
795
|
|
|
792
796
|
return {
|
|
793
797
|
ok: true,
|
|
@@ -811,24 +815,34 @@ export async function runPreflightPhase(state, opts = {}) {
|
|
|
811
815
|
|
|
812
816
|
for (const check of result.checks) {
|
|
813
817
|
if (check.ok) {
|
|
818
|
+
// A non-fatal informational check (it carries `gitInitialized`) shows a
|
|
819
|
+
// glyph reflecting the real state rather than its always-true gate pass:
|
|
820
|
+
// ✓ when the git repo exists, ✗ when it does not (bootstrap initialises
|
|
821
|
+
// it in a later phase regardless — the ✗ never aborts the run).
|
|
822
|
+
const glyph =
|
|
823
|
+
typeof check.gitInitialized === 'boolean' && !check.gitInitialized
|
|
824
|
+
? '✗'
|
|
825
|
+
: '✓';
|
|
814
826
|
Logger.info(
|
|
815
|
-
`[
|
|
827
|
+
`[Bootstrap] ${glyph} ${check.name}${check.detail ? ` — ${check.detail}` : ''}`,
|
|
816
828
|
);
|
|
817
829
|
} else {
|
|
818
|
-
Logger.error(`[
|
|
830
|
+
Logger.error(`[Bootstrap] ✗ ${check.name}: ${check.remedy}`);
|
|
819
831
|
}
|
|
820
832
|
}
|
|
821
833
|
|
|
822
834
|
if (!result.ok) {
|
|
823
835
|
Logger.error(
|
|
824
|
-
'[
|
|
836
|
+
'[Bootstrap] Preflight failed. Resolve the issues above and re-run.',
|
|
825
837
|
);
|
|
826
838
|
return { ok: false, exit: 1 };
|
|
827
839
|
}
|
|
828
840
|
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
841
|
+
// The git-repo state is already reported by the (non-fatal) "Local git
|
|
842
|
+
// initialized" check above — both derive from the same
|
|
843
|
+
// `git rev-parse --is-inside-work-tree` probe — so there is no separate
|
|
844
|
+
// "git initialized" line here (it would duplicate, and contradict, that
|
|
845
|
+
// check). The boolean is still threaded through the payload for later phases.
|
|
832
846
|
return {
|
|
833
847
|
ok: true,
|
|
834
848
|
payload: { preflight: result, gitInitialized: result.gitInitialized },
|
|
@@ -844,15 +858,15 @@ function renderAnswerSummary(
|
|
|
844
858
|
visibility,
|
|
845
859
|
) {
|
|
846
860
|
const newRepoNote = creation.newRepo
|
|
847
|
-
? `
|
|
861
|
+
? ` will be created as ${visibility}`
|
|
848
862
|
: '';
|
|
849
863
|
const lines = [
|
|
850
|
-
'
|
|
864
|
+
'=== Review choices ===',
|
|
851
865
|
` Repo owner ${answers.owner}`,
|
|
852
866
|
` Username/handle ${answers.operatorHandle || '(none)'}`,
|
|
853
867
|
` Repo name ${answers.repo}${newRepoNote}`,
|
|
854
868
|
` Base branch ${answers.baseBranch}`,
|
|
855
|
-
` Project V2 name ${project.name}${creation.newProject ? '
|
|
869
|
+
` Project V2 name ${project.name}${creation.newProject ? ' will be created' : ''}`,
|
|
856
870
|
` Project V2 # ${project.number}`,
|
|
857
871
|
` Local git ${gitInitialized ? 'initialized' : 'will be initialized'}`,
|
|
858
872
|
];
|
|
@@ -943,7 +957,7 @@ export async function collectAndConfirm(state) {
|
|
|
943
957
|
});
|
|
944
958
|
if (missing.length > 0) {
|
|
945
959
|
Logger.error(
|
|
946
|
-
`[
|
|
960
|
+
`[Bootstrap] missing required answers: ${missing.join(', ')}`,
|
|
947
961
|
);
|
|
948
962
|
return { ok: false, exit: 1 };
|
|
949
963
|
}
|
|
@@ -968,7 +982,7 @@ export async function collectAndConfirm(state) {
|
|
|
968
982
|
);
|
|
969
983
|
const correct = await confirmYesNo('Is this correct?', state.interactive);
|
|
970
984
|
if (!correct) {
|
|
971
|
-
Logger.info('[
|
|
985
|
+
Logger.info('[Bootstrap] Okay — let’s try again.');
|
|
972
986
|
// Re-prompt everything on the next pass (drop silent-accept).
|
|
973
987
|
silentAccept = [];
|
|
974
988
|
continue;
|
|
@@ -982,7 +996,7 @@ export async function collectAndConfirm(state) {
|
|
|
982
996
|
);
|
|
983
997
|
if (!approved) {
|
|
984
998
|
Logger.error(
|
|
985
|
-
'[
|
|
999
|
+
'[Bootstrap] Creation declined — cannot continue without the repo/project. Exiting.',
|
|
986
1000
|
);
|
|
987
1001
|
return { ok: false, exit: 1 };
|
|
988
1002
|
}
|
|
@@ -1025,7 +1039,7 @@ function renderDryRunPlan(state) {
|
|
|
1025
1039
|
export function dryRunPlan(state) {
|
|
1026
1040
|
if (!state.flags['dry-run']) return { ok: true, payload: {} };
|
|
1027
1041
|
Logger.info(
|
|
1028
|
-
'[
|
|
1042
|
+
'[Bootstrap] --dry-run: no files, GitHub settings, or labels will be changed.',
|
|
1029
1043
|
);
|
|
1030
1044
|
Logger.info(renderDryRunPlan(state));
|
|
1031
1045
|
return { ok: false, exit: 0 };
|
|
@@ -1062,18 +1076,18 @@ export async function provisionResources(state, deps = {}) {
|
|
|
1062
1076
|
// 1. Local git — initialize + first commit when missing (idempotent).
|
|
1063
1077
|
const git = ensureGitInitialized(state);
|
|
1064
1078
|
if (!git.ok) {
|
|
1065
|
-
Logger.error(`[
|
|
1079
|
+
Logger.error(`[Bootstrap] git initialization failed: ${git.error}`);
|
|
1066
1080
|
return { ok: false, exit: 1 };
|
|
1067
1081
|
}
|
|
1068
1082
|
if (!git.initialized && !git.committed) {
|
|
1069
|
-
Logger.info('[
|
|
1083
|
+
Logger.info('[Bootstrap] git already initialized — leaving as-is.');
|
|
1070
1084
|
}
|
|
1071
1085
|
|
|
1072
1086
|
const { newRepo, newProject } = state.creation;
|
|
1073
1087
|
if (skipGithub) {
|
|
1074
1088
|
if (newRepo || newProject) {
|
|
1075
1089
|
Logger.info(
|
|
1076
|
-
'[
|
|
1090
|
+
'[Bootstrap] --skip-github set; not creating the GitHub repo/project.',
|
|
1077
1091
|
);
|
|
1078
1092
|
}
|
|
1079
1093
|
return { ok: true, payload: {} };
|
|
@@ -1108,7 +1122,7 @@ export async function provisionResources(state, deps = {}) {
|
|
|
1108
1122
|
}
|
|
1109
1123
|
|
|
1110
1124
|
if (!newRepo && !newProject) {
|
|
1111
|
-
Logger.info('[
|
|
1125
|
+
Logger.info('[Bootstrap] No new GitHub resources needed.');
|
|
1112
1126
|
}
|
|
1113
1127
|
|
|
1114
1128
|
// 4. Link the repo to the project board so issues/PRs surface on it
|
|
@@ -1125,7 +1139,7 @@ export async function provisionResources(state, deps = {}) {
|
|
|
1125
1139
|
*/
|
|
1126
1140
|
export async function executeBootstrap(state) {
|
|
1127
1141
|
Logger.info(
|
|
1128
|
-
`[
|
|
1142
|
+
`[Bootstrap] Starting project bootstrap at ${state.projectRoot} (owner=${state.answers.owner} repo=${state.answers.repo} base=${state.answers.baseBranch})`,
|
|
1129
1143
|
);
|
|
1130
1144
|
const approvedGroups = new Set(Object.values(PHASE_GROUPS));
|
|
1131
1145
|
const report = await applyProjectBootstrap({
|
|
@@ -1158,7 +1172,7 @@ export function persistProjectNumber(state) {
|
|
|
1158
1172
|
config = JSON.parse(fs.readFileSync(target, 'utf8'));
|
|
1159
1173
|
} catch (err) {
|
|
1160
1174
|
Logger.error(
|
|
1161
|
-
`[
|
|
1175
|
+
`[Bootstrap] Could not read ${target} to store projectNumber: ${err.message}`,
|
|
1162
1176
|
);
|
|
1163
1177
|
return { ok: true, payload: {} };
|
|
1164
1178
|
}
|
|
@@ -1172,14 +1186,14 @@ export function persistProjectNumber(state) {
|
|
|
1172
1186
|
}
|
|
1173
1187
|
config.github.projectNumber = Number(pn);
|
|
1174
1188
|
fs.writeFileSync(target, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
|
|
1175
|
-
Logger.info(`[
|
|
1189
|
+
Logger.info(`[Bootstrap] Stored github.projectNumber=${pn} in .agentrc.json`);
|
|
1176
1190
|
return { ok: true, payload: {} };
|
|
1177
1191
|
}
|
|
1178
1192
|
|
|
1179
1193
|
/** Step 6b — GitHub-side bootstrap. Honours `--skip-github`. */
|
|
1180
1194
|
export async function executeGithubBootstrap(state) {
|
|
1181
1195
|
if (state.flags['skip-github']) {
|
|
1182
|
-
Logger.info('[
|
|
1196
|
+
Logger.info('[Bootstrap] --skip-github set; skipping GitHub bootstrap.');
|
|
1183
1197
|
return { ok: true, payload: {} };
|
|
1184
1198
|
}
|
|
1185
1199
|
try {
|
|
@@ -1274,7 +1288,7 @@ export async function offerCommitPush(state, deps = {}) {
|
|
|
1274
1288
|
// Non-interactive (--assume-yes / no TTY): never push unprompted. Print the
|
|
1275
1289
|
// exact commands and leave the working tree untouched.
|
|
1276
1290
|
if (!state.interactive) {
|
|
1277
|
-
Logger.info(`\n[
|
|
1291
|
+
Logger.info(`\n[Bootstrap] ${instructions}`);
|
|
1278
1292
|
return { ok: true, payload: { commitPush: { action: 'instructed' } } };
|
|
1279
1293
|
}
|
|
1280
1294
|
|
|
@@ -1283,14 +1297,14 @@ export async function offerCommitPush(state, deps = {}) {
|
|
|
1283
1297
|
state.interactive,
|
|
1284
1298
|
);
|
|
1285
1299
|
if (!accepted) {
|
|
1286
|
-
Logger.info(`\n[
|
|
1300
|
+
Logger.info(`\n[Bootstrap] ${instructions}`);
|
|
1287
1301
|
return { ok: true, payload: { commitPush: { action: 'declined' } } };
|
|
1288
1302
|
}
|
|
1289
1303
|
|
|
1290
1304
|
const staged = stageBootstrapFiles({ projectRoot: cwd, runGit: runGitImpl });
|
|
1291
1305
|
if (!staged.ok) {
|
|
1292
|
-
Logger.warn(`[
|
|
1293
|
-
Logger.info(`\n[
|
|
1306
|
+
Logger.warn(`[Bootstrap] Could not stage the wiring: ${staged.error}`);
|
|
1307
|
+
Logger.info(`\n[Bootstrap] ${instructions}`);
|
|
1294
1308
|
return { ok: true, payload: { commitPush: { action: 'stage-failed' } } };
|
|
1295
1309
|
}
|
|
1296
1310
|
const commit = runGitImpl(
|
|
@@ -1300,20 +1314,20 @@ export async function offerCommitPush(state, deps = {}) {
|
|
|
1300
1314
|
if (!commit.ok) {
|
|
1301
1315
|
// A "nothing to commit" exit is benign — the wiring is already committed.
|
|
1302
1316
|
Logger.warn(
|
|
1303
|
-
`[
|
|
1317
|
+
`[Bootstrap] git commit did not create a commit (already committed?): ${commit.stderr || commit.stdout}`,
|
|
1304
1318
|
);
|
|
1305
|
-
Logger.info(`\n[
|
|
1319
|
+
Logger.info(`\n[Bootstrap] ${instructions}`);
|
|
1306
1320
|
return { ok: true, payload: { commitPush: { action: 'commit-skipped' } } };
|
|
1307
1321
|
}
|
|
1308
|
-
Logger.info('[
|
|
1322
|
+
Logger.info('[Bootstrap] Committed the Mandrel wiring.');
|
|
1309
1323
|
const push = runGitImpl(['push', '-u', 'origin', branch], cwd);
|
|
1310
1324
|
if (!push.ok) {
|
|
1311
1325
|
Logger.warn(
|
|
1312
|
-
`[
|
|
1326
|
+
`[Bootstrap] Commit landed but push of '${branch}' failed (push it manually with \`git push -u origin ${branch}\`): ${push.stderr}`,
|
|
1313
1327
|
);
|
|
1314
1328
|
return { ok: true, payload: { commitPush: { action: 'push-failed' } } };
|
|
1315
1329
|
}
|
|
1316
|
-
Logger.info(`[
|
|
1330
|
+
Logger.info(`[Bootstrap] Pushed '${branch}' to origin.`);
|
|
1317
1331
|
return { ok: true, payload: { commitPush: { action: 'committed-pushed' } } };
|
|
1318
1332
|
}
|
|
1319
1333
|
|
|
@@ -1357,7 +1371,7 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
|
|
|
1357
1371
|
const githubError = result.state?.report?.github?.error;
|
|
1358
1372
|
if (githubError) {
|
|
1359
1373
|
Logger.error(
|
|
1360
|
-
`\n[
|
|
1374
|
+
`\n[Bootstrap] GitHub bootstrap failed: ${githubError}. ` +
|
|
1361
1375
|
'Project-side setup (labels are GitHub-side; the local .agentrc.json / ' +
|
|
1362
1376
|
'quality-gate / workflow files that were applied are recorded in the ' +
|
|
1363
1377
|
'install ledger) completed, but the GitHub label/board/views/protection ' +
|
|
@@ -1368,7 +1382,7 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
|
|
|
1368
1382
|
return 1;
|
|
1369
1383
|
}
|
|
1370
1384
|
|
|
1371
|
-
Logger.info('\n[
|
|
1385
|
+
Logger.info('\n[Bootstrap] Done.');
|
|
1372
1386
|
return 0;
|
|
1373
1387
|
}
|
|
1374
1388
|
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI: third-party GitHub Action pinning gate.
|
|
3
|
+
*
|
|
4
|
+
* Story #4079 (audit::devops). Closes a supply-chain regression window the
|
|
5
|
+
* `ci.yml` / `release-please.yml` comments *claimed* was guarded by a
|
|
6
|
+
* nonexistent `npm run audit-security` gate. There is no such npm script;
|
|
7
|
+
* `audit-security` is only a manual `/audit-security` slash-command lens.
|
|
8
|
+
* Nothing actually enforced that third-party `uses:` refs stay SHA-pinned,
|
|
9
|
+
* so a future edit reverting `trufflehog@<sha>` to `@main` would pass CI
|
|
10
|
+
* silently.
|
|
11
|
+
*
|
|
12
|
+
* This script scans `.github/workflows/*.yml` (and `*.yaml`), extracts every
|
|
13
|
+
* `uses:` ref, and fails the build when a **third-party** action (anything
|
|
14
|
+
* not under the first-party `actions/*` org) is pinned to a floating ref
|
|
15
|
+
* instead of a full 40-char commit SHA. First-party `actions/*` refs are
|
|
16
|
+
* allowed on major-version tags (`@v4`) — Dependabot's `github-actions`
|
|
17
|
+
* ecosystem bumps those in-place, matching the rationale in the workflow
|
|
18
|
+
* file headers.
|
|
19
|
+
*
|
|
20
|
+
* A "floating ref" is any of:
|
|
21
|
+
* - a branch head: `@main`, `@master`
|
|
22
|
+
* - a tag / partial-SHA that is not a full 40-hex-char commit SHA
|
|
23
|
+
* (`@v5`, `@v3.95.3`, `@release`, a 7-char short SHA, …)
|
|
24
|
+
*
|
|
25
|
+
* Contract:
|
|
26
|
+
* - Scans the workflows directory (default `.github/workflows`, override
|
|
27
|
+
* with `--dir <path>`).
|
|
28
|
+
* - Prints `<file>:<lineNo> <ref> — <reason>` for each violation, then a
|
|
29
|
+
* one-line summary even on a clean scan so operators see the "ok" signal.
|
|
30
|
+
* - With `--json`: writes a structured envelope to stdout and skips the
|
|
31
|
+
* human summary.
|
|
32
|
+
* - Exit codes: 0 = no violations; 1 = at least one floating third-party
|
|
33
|
+
* ref. A missing / empty workflows directory exits 0 (nothing to gate).
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import fs from 'node:fs';
|
|
37
|
+
import path from 'node:path';
|
|
38
|
+
import process from 'node:process';
|
|
39
|
+
import { runAsCli } from './lib/cli-utils.js';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Parse argv for `--dir <path>` and `--json`. Exported so unit tests can pin
|
|
43
|
+
* the parser.
|
|
44
|
+
*
|
|
45
|
+
* @param {string[]} argv
|
|
46
|
+
* @returns {{ dir: string | null, json: boolean }}
|
|
47
|
+
*/
|
|
48
|
+
export function parseArgv(argv = []) {
|
|
49
|
+
let dir = null;
|
|
50
|
+
let json = false;
|
|
51
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
52
|
+
const a = argv[i];
|
|
53
|
+
if (a === '--dir') {
|
|
54
|
+
const next = argv[i + 1];
|
|
55
|
+
if (next && !next.startsWith('--')) {
|
|
56
|
+
dir = next;
|
|
57
|
+
i += 1;
|
|
58
|
+
}
|
|
59
|
+
} else if (a === '--json') {
|
|
60
|
+
json = true;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { dir, json };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Is the given ref suffix a full 40-char hex commit SHA?
|
|
68
|
+
*
|
|
69
|
+
* @param {string} ref The portion after the `@` in a `uses:` value.
|
|
70
|
+
* @returns {boolean}
|
|
71
|
+
*/
|
|
72
|
+
export function isFullSha(ref) {
|
|
73
|
+
return /^[0-9a-f]{40}$/i.test(ref);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Is the action a first-party `actions/*` action (e.g. `actions/checkout`)?
|
|
78
|
+
* First-party refs are allowed to float on major-version tags because
|
|
79
|
+
* Dependabot's `github-actions` ecosystem bumps them in-place.
|
|
80
|
+
*
|
|
81
|
+
* Local (`./…`) and reusable-workflow (`owner/repo/.github/workflows/x.yml`)
|
|
82
|
+
* refs and Docker refs (`docker://…`) are out of scope for the SHA-pin gate;
|
|
83
|
+
* `isFirstParty` only matters for `owner/repo[@ref]` registry actions.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} action The portion before the `@` in a `uses:` value.
|
|
86
|
+
* @returns {boolean}
|
|
87
|
+
*/
|
|
88
|
+
export function isFirstParty(action) {
|
|
89
|
+
return /^actions\//.test(action);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Pure helper: scan a single workflow file's text for `uses:` refs and return
|
|
94
|
+
* the violations. A violation is a third-party `owner/repo@ref` where `ref`
|
|
95
|
+
* is not a full 40-char SHA.
|
|
96
|
+
*
|
|
97
|
+
* Skips:
|
|
98
|
+
* - local actions (`uses: ./path`)
|
|
99
|
+
* - Docker refs (`uses: docker://…`)
|
|
100
|
+
* - first-party `actions/*` refs (allowed on major-version tags)
|
|
101
|
+
* - refs with no `@` (pinned by default branch implicitly — flagged as a
|
|
102
|
+
* violation: an unpinned third-party ref floats on the default branch)
|
|
103
|
+
*
|
|
104
|
+
* @param {string} file Relative file label used in violation rows.
|
|
105
|
+
* @param {string} text The file contents.
|
|
106
|
+
* @returns {Array<{ file: string, line: number, action: string, ref: string | null, reason: string }>}
|
|
107
|
+
*/
|
|
108
|
+
export function scanWorkflowText(file, text) {
|
|
109
|
+
const violations = [];
|
|
110
|
+
const lines = text.split(/\r?\n/);
|
|
111
|
+
// Match `uses:` values, optionally quoted. The value runs until whitespace
|
|
112
|
+
// or a `#` comment. Capture the raw value for downstream parsing.
|
|
113
|
+
const usesRe = /^\s*(?:-\s*)?uses:\s*['"]?([^'"#\s]+)['"]?/;
|
|
114
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
115
|
+
const m = usesRe.exec(lines[i]);
|
|
116
|
+
if (!m) continue;
|
|
117
|
+
const value = m[1];
|
|
118
|
+
const lineNo = i + 1;
|
|
119
|
+
// Local actions and Docker refs are out of scope for the SHA-pin gate.
|
|
120
|
+
if (value.startsWith('./') || value.startsWith('docker://')) continue;
|
|
121
|
+
const atIndex = value.indexOf('@');
|
|
122
|
+
const action = atIndex === -1 ? value : value.slice(0, atIndex);
|
|
123
|
+
const ref = atIndex === -1 ? null : value.slice(atIndex + 1);
|
|
124
|
+
// First-party actions/* may float on major-version tags.
|
|
125
|
+
if (isFirstParty(action)) continue;
|
|
126
|
+
if (ref === null) {
|
|
127
|
+
violations.push({
|
|
128
|
+
file,
|
|
129
|
+
line: lineNo,
|
|
130
|
+
action,
|
|
131
|
+
ref: null,
|
|
132
|
+
reason: 'third-party action with no ref floats on the default branch',
|
|
133
|
+
});
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (!isFullSha(ref)) {
|
|
137
|
+
const floating = ref === 'main' || ref === 'master';
|
|
138
|
+
violations.push({
|
|
139
|
+
file,
|
|
140
|
+
line: lineNo,
|
|
141
|
+
action,
|
|
142
|
+
ref,
|
|
143
|
+
reason: floating
|
|
144
|
+
? `third-party action pinned to branch head @${ref} (CWE-1357)`
|
|
145
|
+
: `third-party action @${ref} is not a full 40-char commit SHA`,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return violations;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Enumerate workflow files (`*.yml` / `*.yaml`) directly under `dir`.
|
|
154
|
+
* Returns absolute paths sorted for deterministic output. A missing directory
|
|
155
|
+
* yields an empty list.
|
|
156
|
+
*
|
|
157
|
+
* @param {string} dir Absolute workflows directory.
|
|
158
|
+
* @returns {string[]}
|
|
159
|
+
*/
|
|
160
|
+
export function listWorkflowFiles(dir) {
|
|
161
|
+
let entries;
|
|
162
|
+
try {
|
|
163
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
164
|
+
} catch {
|
|
165
|
+
return [];
|
|
166
|
+
}
|
|
167
|
+
return entries
|
|
168
|
+
.filter((e) => e.isFile() && /\.ya?ml$/i.test(e.name))
|
|
169
|
+
.map((e) => path.join(dir, e.name))
|
|
170
|
+
.sort();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Pure helper: render the human-readable report. One line per violation
|
|
175
|
+
* followed by a one-line summary. The summary carries a `(gate fail)` /
|
|
176
|
+
* `(ok)` marker so the result is visible in CI output.
|
|
177
|
+
*
|
|
178
|
+
* @param {Array<{ file: string, line: number, action: string, ref: string | null, reason: string }>} violations
|
|
179
|
+
* @returns {string}
|
|
180
|
+
*/
|
|
181
|
+
export function renderReport(violations) {
|
|
182
|
+
const lines = [];
|
|
183
|
+
for (const v of violations) {
|
|
184
|
+
const refLabel = v.ref === null ? '(no ref)' : `@${v.ref}`;
|
|
185
|
+
lines.push(`${v.file}:${v.line} ${v.action}${refLabel} — ${v.reason}`);
|
|
186
|
+
}
|
|
187
|
+
const tag = violations.length > 0 ? '(gate fail)' : '(ok)';
|
|
188
|
+
lines.push(`[action-pinning] violations=${violations.length} ${tag}`);
|
|
189
|
+
return lines.join('\n');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Top-level CLI entry. Exported so tests can drive the full pipeline against a
|
|
194
|
+
* fixture workflows directory without touching the repo's real workflows.
|
|
195
|
+
*
|
|
196
|
+
* @param {{
|
|
197
|
+
* argv?: string[],
|
|
198
|
+
* cwd?: string,
|
|
199
|
+
* stdout?: { write: (s: string) => void },
|
|
200
|
+
* stderr?: { write: (s: string) => void },
|
|
201
|
+
* }} [opts]
|
|
202
|
+
* @returns {Promise<number>} exit code: 0 = clean; 1 = floating third-party ref
|
|
203
|
+
*/
|
|
204
|
+
export async function runCli({
|
|
205
|
+
argv = process.argv.slice(2),
|
|
206
|
+
cwd = process.cwd(),
|
|
207
|
+
stdout = process.stdout,
|
|
208
|
+
stderr = process.stderr,
|
|
209
|
+
} = {}) {
|
|
210
|
+
const { dir, json } = parseArgv(argv);
|
|
211
|
+
const resolvedDir = path.resolve(
|
|
212
|
+
cwd,
|
|
213
|
+
dir ?? path.join('.github', 'workflows'),
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const files = listWorkflowFiles(resolvedDir);
|
|
217
|
+
const violations = [];
|
|
218
|
+
for (const file of files) {
|
|
219
|
+
let text;
|
|
220
|
+
try {
|
|
221
|
+
text = fs.readFileSync(file, 'utf-8');
|
|
222
|
+
} catch {
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
violations.push(...scanWorkflowText(path.relative(cwd, file), text));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const exitCode = violations.length > 0 ? 1 : 0;
|
|
229
|
+
|
|
230
|
+
if (json) {
|
|
231
|
+
const envelope = {
|
|
232
|
+
kind: 'action-pinning-report',
|
|
233
|
+
dir: resolvedDir,
|
|
234
|
+
filesScanned: files.length,
|
|
235
|
+
violations,
|
|
236
|
+
exitCode,
|
|
237
|
+
};
|
|
238
|
+
stdout.write(`${JSON.stringify(envelope, null, 2)}\n`);
|
|
239
|
+
} else {
|
|
240
|
+
if (files.length === 0) {
|
|
241
|
+
stderr.write(
|
|
242
|
+
`[action-pinning] ⚠ no workflow files found under ${resolvedDir}\n`,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
stdout.write(`\n--- action-pinning scan ---\n`);
|
|
246
|
+
stdout.write(`${renderReport(violations)}\n`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return exitCode;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function main() {
|
|
253
|
+
return runCli();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
runAsCli(import.meta.url, main, {
|
|
257
|
+
source: 'action-pinning',
|
|
258
|
+
propagateExitCode: true,
|
|
259
|
+
errorPrefix: '[action-pinning] ❌ Fatal error',
|
|
260
|
+
});
|