@davidvornholt/standards 0.10.0 → 0.10.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/src/cli.ts CHANGED
@@ -4,10 +4,11 @@
4
4
  // davidvornholt/standards template into a consumer repo and detects local
5
5
  // tampering with them. See the standards repository README for the design.
6
6
  //
7
- // This script is intentionally zero-dependency (Bun + Node built-ins only) and
8
- // does NOT use Effect: `bunx` must be able to execute the published package
9
- // before a consumer has dependencies. That is the documented exception to the
10
- // repo's Effect standard for standalone bootstrap tooling.
7
+ // This bootstrap executable keeps its runtime surface minimal and does not use
8
+ // Effect: `bunx` must be able to execute the published package before a
9
+ // consumer has dependencies. Its strict YAML parser is a declared runtime
10
+ // dependency. This is the documented exception to the repo's Effect standard
11
+ // for standalone bootstrap tooling.
11
12
 
12
13
  import { execFileSync } from 'node:child_process';
13
14
  import { createHash } from 'node:crypto';
@@ -32,21 +33,28 @@ import {
32
33
  sep,
33
34
  } from 'node:path';
34
35
  import process from 'node:process';
36
+ import {
37
+ composeDependabot,
38
+ DEPENDABOT_BASE_FILE,
39
+ DEPENDABOT_FILE,
40
+ DEPENDABOT_LOCAL_FILE,
41
+ } from './dependabot-compose';
42
+ import { inspectDependabot } from './dependabot-inspect';
35
43
  import { CANONICAL_SETTINGS_FILE, LOCAL_SETTINGS_FILE } from './github-api';
36
44
  import { runGithubApply, runGithubCheck } from './github-commands';
37
45
  import { loadGithubSettings } from './github-settings';
46
+ import { isNonEmptyString, isRecord } from './github-settings-parse';
38
47
  import { collectStructureProblems } from './structure-check';
39
48
  import type { StructureProfile } from './structure-profile';
40
49
  import { hasSafeCommand } from './structure-script';
41
50
 
42
- const { YAML: BunYaml } = await import('bun');
43
-
44
51
  const DEFAULT_UPSTREAM = 'github:davidvornholt/standards';
45
52
 
46
53
  // Characters of a sha256 hex digest shown in drift reports; enough to identify.
47
54
  const HASH_PREVIEW_LENGTH = 12;
48
55
 
49
56
  const GITHUB_PREFIX = 'github:';
57
+ const SKIP_GITHUB_CHECK_ENV = 'STANDARDS_SKIP_GITHUB_CHECK';
50
58
 
51
59
  // Never mirrored, even under a managed directory path: build output, VCS
52
60
  // metadata, and installed dependencies would otherwise pollute the lock when
@@ -79,6 +87,7 @@ type Source = {
79
87
 
80
88
  type Command =
81
89
  | 'check'
90
+ | 'dependabot'
82
91
  | 'doctor'
83
92
  | 'github'
84
93
  | 'help'
@@ -93,6 +102,7 @@ type CliOptions = {
93
102
  readonly from: string | undefined;
94
103
  readonly ref: string | undefined;
95
104
  readonly apply: boolean;
105
+ readonly write: boolean;
96
106
  readonly profile: StructureProfile;
97
107
  };
98
108
 
@@ -403,6 +413,12 @@ const runInit = async (
403
413
  ): Promise<void> => {
404
414
  const seeds = await seedTargets(src.dir, manifest.seedDir);
405
415
  assertDisjoint(manifest.paths, [...seeds.keys()]);
416
+ const prospectiveDependabot = await prepareProspectiveDependabot(
417
+ manifest,
418
+ src.dir,
419
+ consumer,
420
+ seeds.get(DEPENDABOT_LOCAL_FILE) ?? null,
421
+ );
406
422
  await Promise.all(
407
423
  [...seeds].map(async ([rel, abs]) => {
408
424
  const dest = join(consumer, rel);
@@ -423,6 +439,7 @@ const runInit = async (
423
439
  dryRun: false,
424
440
  });
425
441
  reportMirror(result, false);
442
+ await applyProspectiveDependabot(consumer, prospectiveDependabot, false);
426
443
  await writeLock(consumer, {
427
444
  upstream: manifest.upstream,
428
445
  sha: src.sha,
@@ -441,6 +458,12 @@ const runSync = async (
441
458
  ): Promise<void> => {
442
459
  const seeds = await seedTargets(src.dir, manifest.seedDir);
443
460
  assertDisjoint(manifest.paths, [...seeds.keys()]);
461
+ const prospectiveDependabot = await prepareProspectiveDependabot(
462
+ manifest,
463
+ src.dir,
464
+ consumer,
465
+ null,
466
+ );
444
467
  const lock = await readLock(consumer);
445
468
  const result = await mirror({
446
469
  manifest,
@@ -450,6 +473,7 @@ const runSync = async (
450
473
  dryRun,
451
474
  });
452
475
  reportMirror(result, dryRun);
476
+ await applyProspectiveDependabot(consumer, prospectiveDependabot, dryRun);
453
477
  if (dryRun) {
454
478
  return;
455
479
  }
@@ -510,177 +534,159 @@ const runCheck = async (consumer: string): Promise<boolean> => {
510
534
  const readTextIfPresent = async (path: string): Promise<string | null> =>
511
535
  existsSync(path) ? readFile(path, 'utf8') : null;
512
536
 
513
- const DEPENDABOT_BASELINE_ECOSYSTEMS = ['bun', 'github-actions'] as const;
514
- const DEPENDABOT_SCHEDULE_INTERVALS = new Set([
515
- 'daily',
516
- 'weekly',
517
- 'monthly',
518
- 'quarterly',
519
- 'semiannually',
520
- 'yearly',
521
- 'cron',
522
- ]);
523
-
524
- const isRecord = (value: unknown): value is Record<string, unknown> =>
525
- typeof value === 'object' && value !== null && !Array.isArray(value);
526
-
527
- const isNonEmptyString = (value: unknown): value is string =>
528
- typeof value === 'string' && value.length > 0;
529
-
530
- type DependabotUpdateInspection = {
531
- readonly problems: ReadonlyArray<string>;
532
- readonly rootEcosystem: string | null;
537
+ type DependabotSources = {
538
+ readonly base: string | null;
539
+ readonly local: string | null;
540
+ readonly current: string | null;
533
541
  };
534
542
 
535
- const inspectDependabotSchedule = (
536
- schedule: unknown,
537
- label: string,
538
- ): ReadonlyArray<string> => {
539
- if (!(isRecord(schedule) && isNonEmptyString(schedule.interval))) {
540
- return [`${label} must define schedule.interval`];
541
- }
542
- if (!DEPENDABOT_SCHEDULE_INTERVALS.has(schedule.interval)) {
543
- return [`${label} has an unsupported schedule.interval`];
544
- }
545
- if (schedule.interval === 'cron' && !isNonEmptyString(schedule.cronjob)) {
546
- return [`${label} must define schedule.cronjob for a cron interval`];
547
- }
548
- return [];
549
- };
550
-
551
- type DependabotGroupInspection = {
543
+ const readDependabotSources = async (
544
+ consumer: string,
545
+ ): Promise<DependabotSources> => ({
546
+ base: await readTextIfPresent(join(consumer, DEPENDABOT_BASE_FILE)),
547
+ local: await readTextIfPresent(join(consumer, DEPENDABOT_LOCAL_FILE)),
548
+ current: await readTextIfPresent(join(consumer, DEPENDABOT_FILE)),
549
+ });
550
+
551
+ // Compose the generated Dependabot config from its on-disk sources, folding
552
+ // Dependabot semantic validation into the same problem list.
553
+ const composedDependabot = (
554
+ sources: DependabotSources,
555
+ ): {
556
+ readonly composed: string | null;
552
557
  readonly problems: ReadonlyArray<string>;
553
- readonly scheduledGroups: ReadonlySet<string>;
554
- };
555
-
556
- const inspectDependabotGroups = (
557
- groups: unknown,
558
- ): DependabotGroupInspection => {
559
- if (groups === undefined) {
560
- return { problems: [], scheduledGroups: new Set() };
561
- }
562
- if (!isRecord(groups)) {
558
+ } => {
559
+ if (sources.base === null) {
563
560
  return {
561
+ composed: null,
564
562
  problems: [
565
- '.github/dependabot.yml multi-ecosystem-groups must be a mapping',
563
+ `${DEPENDABOT_BASE_FILE} must exist; run \`bun standards sync\` to mirror it in`,
566
564
  ],
567
- scheduledGroups: new Set(),
568
565
  };
569
566
  }
570
-
571
- const problems: Array<string> = [];
572
- const scheduledGroups = new Set<string>();
573
- for (const [name, group] of Object.entries(groups)) {
574
- const label = `.github/dependabot.yml multi-ecosystem-groups.${name}`;
575
- const groupProblems = isRecord(group)
576
- ? inspectDependabotSchedule(group.schedule, label)
577
- : [`${label} must be a mapping`];
578
- problems.push(...groupProblems);
579
- if (groupProblems.length === 0) {
580
- scheduledGroups.add(name);
581
- }
567
+ const result = composeDependabot(sources.base, sources.local);
568
+ if (result.composed === null) {
569
+ return result;
582
570
  }
583
- return { problems, scheduledGroups };
571
+ const problems = inspectDependabot(result.composed);
572
+ return problems.length > 0 ? { composed: null, problems } : result;
584
573
  };
585
574
 
586
- const inspectDependabotUpdate = (
587
- update: unknown,
588
- index: number,
589
- scheduledGroups: ReadonlySet<string>,
590
- ): DependabotUpdateInspection => {
591
- const label = `.github/dependabot.yml updates[${index}]`;
592
- if (!isRecord(update)) {
593
- return { problems: [`${label} must be a mapping`], rootEcosystem: null };
594
- }
595
-
596
- const {
597
- directory,
598
- directories,
599
- schedule,
600
- 'multi-ecosystem-group': multiEcosystemGroup,
601
- 'package-ecosystem': ecosystem,
602
- } = update;
603
- const problems: Array<string> = [];
604
- if (!isNonEmptyString(ecosystem)) {
605
- problems.push(`${label} must define package-ecosystem`);
575
+ const dependabotProblems = async (
576
+ consumer: string,
577
+ ): Promise<ReadonlyArray<string>> => {
578
+ const sources = await readDependabotSources(consumer);
579
+ const { composed, problems } = composedDependabot(sources);
580
+ if (composed === null) {
581
+ return problems;
606
582
  }
607
-
608
- const hasDirectory = isNonEmptyString(directory);
609
- const hasDirectories =
610
- Array.isArray(directories) &&
611
- directories.length > 0 &&
612
- directories.every(isNonEmptyString);
613
- if (hasDirectory === hasDirectories) {
614
- problems.push(
615
- `${label} must define exactly one of directory or directories`,
616
- );
583
+ if (sources.current !== composed) {
584
+ return [
585
+ `${DEPENDABOT_FILE} does not match its composed sources; regenerate it with \`bun standards dependabot --write\``,
586
+ ];
617
587
  }
588
+ return [];
589
+ };
618
590
 
619
- if (schedule === undefined && isNonEmptyString(multiEcosystemGroup)) {
620
- if (!scheduledGroups.has(multiEcosystemGroup)) {
621
- problems.push(
622
- `${label} must reference a scheduled multi-ecosystem group`,
623
- );
624
- }
625
- } else {
626
- problems.push(...inspectDependabotSchedule(schedule, label));
627
- }
591
+ const writeComposedDependabot = async (
592
+ consumer: string,
593
+ composed: string,
594
+ ): Promise<void> => {
595
+ const dest = join(consumer, DEPENDABOT_FILE);
596
+ await mkdir(dirname(dest), { recursive: true });
597
+ await writeFile(dest, composed);
598
+ };
628
599
 
629
- const targetsRoot =
630
- directory === '/' ||
631
- (Array.isArray(directories) && directories.includes('/'));
632
- return {
633
- problems,
634
- rootEcosystem:
635
- isNonEmptyString(ecosystem) && targetsRoot ? ecosystem : null,
636
- };
600
+ const composeProblemsError = (problems: ReadonlyArray<string>): Error =>
601
+ new Error(
602
+ [
603
+ `cannot compose ${DEPENDABOT_FILE}:`,
604
+ ...problems.map((problem) => ` - ${problem}`),
605
+ ].join('\n'),
606
+ );
607
+
608
+ type ProspectiveDependabot = {
609
+ readonly composed: string;
610
+ readonly current: string | null;
637
611
  };
638
612
 
639
- const inspectDependabot = (raw: string): ReadonlyArray<string> => {
640
- const problems: Array<string> = [];
641
- let config: unknown;
642
- try {
643
- config = BunYaml.parse(raw);
644
- } catch {
645
- return ['.github/dependabot.yml must contain valid YAML'];
613
+ // Validate the incoming canonical base against the effective local overlay
614
+ // before init/sync mutates any consumer-owned, canonical, generated, or lock
615
+ // file. For init, an existing overlay wins; otherwise the overlay seed is what
616
+ // the command is about to install.
617
+ const prepareProspectiveDependabot = async (
618
+ manifest: Manifest,
619
+ srcDir: string,
620
+ consumer: string,
621
+ localSeed: string | null,
622
+ ): Promise<ProspectiveDependabot> => {
623
+ const incoming = await listManaged(srcDir, manifest.paths);
624
+ const basePath = incoming.get(DEPENDABOT_BASE_FILE);
625
+ if (basePath === undefined) {
626
+ throw new Error(
627
+ `source content must manage ${DEPENDABOT_BASE_FILE}; @davidvornholt/standards 0.10.1 requires a 0.10.1-compatible content ref`,
628
+ );
629
+ }
630
+ const existingLocal = await readTextIfPresent(
631
+ join(consumer, DEPENDABOT_LOCAL_FILE),
632
+ );
633
+ const local =
634
+ existingLocal ??
635
+ (localSeed === null ? null : await readFile(localSeed, 'utf8'));
636
+ const current = await readTextIfPresent(join(consumer, DEPENDABOT_FILE));
637
+ const sources = {
638
+ base: await readFile(basePath, 'utf8'),
639
+ local,
640
+ current,
641
+ };
642
+ const { composed, problems } = composedDependabot(sources);
643
+ if (composed === null) {
644
+ throw composeProblemsError(problems);
646
645
  }
646
+ return { composed, current };
647
+ };
647
648
 
648
- if (!isRecord(config)) {
649
- return ['.github/dependabot.yml must contain a YAML mapping'];
649
+ const applyProspectiveDependabot = async (
650
+ consumer: string,
651
+ prospective: ProspectiveDependabot,
652
+ dryRun: boolean,
653
+ ): Promise<void> => {
654
+ if (prospective.current === prospective.composed) {
655
+ return;
650
656
  }
651
- if (config.version !== 2) {
652
- problems.push('.github/dependabot.yml must use version: 2');
657
+ if (dryRun) {
658
+ console.log(` would generate ${DEPENDABOT_FILE}`);
659
+ return;
653
660
  }
661
+ await writeComposedDependabot(consumer, prospective.composed);
662
+ console.log(` generated ${DEPENDABOT_FILE}`);
663
+ };
654
664
 
655
- const { updates, 'multi-ecosystem-groups': multiEcosystemGroups } = config;
656
- if (!Array.isArray(updates)) {
657
- problems.push('.github/dependabot.yml must define an updates list');
658
- return problems;
665
+ const runDependabotCheck = async (consumer: string): Promise<boolean> => {
666
+ const problems = await dependabotProblems(consumer);
667
+ if (problems.length > 0) {
668
+ console.error(`standards dependabot: ${problems.length} problem(s):`);
669
+ console.error(problems.map((problem) => ` - ${problem}`).join('\n'));
670
+ return false;
659
671
  }
672
+ console.log(
673
+ `standards dependabot: ${DEPENDABOT_FILE} matches its composed sources`,
674
+ );
675
+ return true;
676
+ };
660
677
 
661
- const groupInspection = inspectDependabotGroups(multiEcosystemGroups);
662
- problems.push(...groupInspection.problems);
663
- const rootEcosystems = new Set<string>();
664
- for (const [index, update] of updates.entries()) {
665
- const inspection = inspectDependabotUpdate(
666
- update,
667
- index,
668
- groupInspection.scheduledGroups,
669
- );
670
- problems.push(...inspection.problems);
671
- if (inspection.rootEcosystem !== null) {
672
- rootEcosystems.add(inspection.rootEcosystem);
673
- }
678
+ const runDependabotWrite = async (consumer: string): Promise<void> => {
679
+ const sources = await readDependabotSources(consumer);
680
+ const { composed, problems } = composedDependabot(sources);
681
+ if (composed === null) {
682
+ throw composeProblemsError(problems);
674
683
  }
675
-
676
- for (const ecosystem of DEPENDABOT_BASELINE_ECOSYSTEMS) {
677
- if (!rootEcosystems.has(ecosystem)) {
678
- problems.push(
679
- `.github/dependabot.yml must include a root-directory ${ecosystem} ecosystem`,
680
- );
681
- }
684
+ if (sources.current === composed) {
685
+ console.log(`standards dependabot: ${DEPENDABOT_FILE} is up to date`);
686
+ return;
682
687
  }
683
- return problems;
688
+ await writeComposedDependabot(consumer, composed);
689
+ console.log(`standards dependabot: generated ${DEPENDABOT_FILE}`);
684
690
  };
685
691
 
686
692
  const inspectPackageJson = (packageRaw: string): ReadonlyArray<string> => {
@@ -726,14 +732,7 @@ const runDoctor = async (consumer: string): Promise<boolean> => {
726
732
  problems.push('AGENTS.local.md must exist for project-specific guidance');
727
733
  }
728
734
 
729
- const dependabot = await readTextIfPresent(
730
- join(consumer, '.github/dependabot.yml'),
731
- );
732
- if (dependabot === null) {
733
- problems.push('.github/dependabot.yml must exist');
734
- } else {
735
- problems.push(...inspectDependabot(dependabot));
736
- }
735
+ problems.push(...(await dependabotProblems(consumer)));
737
736
 
738
737
  const packagePath = join(consumer, 'package.json');
739
738
  const packageRaw = await readTextIfPresent(packagePath);
@@ -773,13 +772,14 @@ const runDoctor = async (consumer: string): Promise<boolean> => {
773
772
  const USAGE = `Usage: standards <command> [options]
774
773
 
775
774
  Commands:
776
- init Bootstrap a consumer repo: seed repo-owned files, mirror canonical files, write the lock
777
- sync Mirror canonical files from upstream and rewrite the lock
778
- check Verify canonical files, extension seams, monorepo structure, and GitHub settings
779
- doctor Validate extension seams only
780
- structure Validate monorepo structure rules only
781
- github Compare (--check) or converge (--apply) live GitHub settings
782
- help Show this help
775
+ init Bootstrap a consumer repo: seed repo-owned files, mirror canonical files, write the lock
776
+ sync Mirror canonical files from upstream, regenerate the composed Dependabot config, and rewrite the lock
777
+ check Verify canonical files, extension seams, monorepo structure, and GitHub settings
778
+ doctor Validate extension seams only
779
+ structure Validate monorepo structure rules only
780
+ dependabot Verify (--check) or regenerate (--write) the composed .github/dependabot.yml
781
+ github Compare (--check) or converge (--apply) live GitHub settings
782
+ help Show this help
783
783
 
784
784
  Options:
785
785
  --dir <path> Consumer directory to operate on (default: current directory)
@@ -787,12 +787,14 @@ Options:
787
787
  --from <src> Upstream override for init/sync (GitHub repo or local path)
788
788
  --ref <ref> Upstream tag, branch, or full commit sha for init/sync (remote Git/GitHub sources only; default: main)
789
789
  --dry-run Preview a sync without writing anything
790
- --check With github: compare live settings to the declaration (default)
791
- --apply With github: converge the live repository (needs admin auth)`;
790
+ --check With github/dependabot: compare against the declared sources (default)
791
+ --apply With github: converge the live repository (needs admin auth)
792
+ --write With dependabot: regenerate the composed .github/dependabot.yml`;
792
793
 
793
794
  const commandFromArg = (arg: string): Command => {
794
795
  if (
795
796
  arg === 'check' ||
797
+ arg === 'dependabot' ||
796
798
  arg === 'doctor' ||
797
799
  arg === 'github' ||
798
800
  arg === 'help' ||
@@ -832,6 +834,44 @@ const nextOptionValue = (
832
834
  return value;
833
835
  };
834
836
 
837
+ type ParsedFlags = {
838
+ readonly command: Command | undefined;
839
+ readonly checkFlag: boolean;
840
+ readonly apply: boolean;
841
+ readonly write: boolean;
842
+ readonly ref: string | undefined;
843
+ readonly profile: StructureProfile | undefined;
844
+ };
845
+
846
+ // Every option is only meaningful with specific commands; reject the rest so a
847
+ // typo fails loudly instead of silently doing the default thing.
848
+ const assertOptionUsage = (flags: ParsedFlags): void => {
849
+ const { command, checkFlag, apply, write, ref, profile } = flags;
850
+ if (checkFlag && command !== 'github' && command !== 'dependabot') {
851
+ throw new Error(
852
+ '--check is only valid with the github and dependabot commands',
853
+ );
854
+ }
855
+ if (apply && command !== 'github') {
856
+ throw new Error('--apply is only valid with the github command');
857
+ }
858
+ if (apply && checkFlag) {
859
+ throw new Error('github accepts exactly one of --check or --apply');
860
+ }
861
+ if (write && command !== 'dependabot') {
862
+ throw new Error('--write is only valid with the dependabot command');
863
+ }
864
+ if (write && checkFlag) {
865
+ throw new Error('dependabot accepts exactly one of --check or --write');
866
+ }
867
+ if (ref !== undefined && command !== 'init' && command !== 'sync') {
868
+ throw new Error('--ref is only valid with the init and sync commands');
869
+ }
870
+ if (profile !== undefined && command !== 'structure') {
871
+ throw new Error('--profile is only valid with the structure command');
872
+ }
873
+ };
874
+
835
875
  const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
836
876
  let command: Command | undefined;
837
877
  let consumer = process.cwd();
@@ -840,6 +880,7 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
840
880
  let ref: string | undefined;
841
881
  let checkFlag = false;
842
882
  let apply = false;
883
+ let write = false;
843
884
  let profile: StructureProfile | undefined;
844
885
 
845
886
  for (let index = 0; index < argv.length; index += 1) {
@@ -851,6 +892,9 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
851
892
  case '--check':
852
893
  checkFlag = true;
853
894
  break;
895
+ case '--write':
896
+ write = true;
897
+ break;
854
898
  case '--dir':
855
899
  consumer = nextOptionValue(argv, index);
856
900
  index += 1;
@@ -879,21 +923,7 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
879
923
  }
880
924
  }
881
925
 
882
- if (checkFlag && command !== 'github') {
883
- throw new Error('--check is only valid with the github command');
884
- }
885
- if (apply && command !== 'github') {
886
- throw new Error('--apply is only valid with the github command');
887
- }
888
- if (apply && checkFlag) {
889
- throw new Error('github accepts exactly one of --check or --apply');
890
- }
891
- if (ref !== undefined && command !== 'init' && command !== 'sync') {
892
- throw new Error('--ref is only valid with the init and sync commands');
893
- }
894
- if (profile !== undefined && command !== 'structure') {
895
- throw new Error('--profile is only valid with the structure command');
896
- }
926
+ assertOptionUsage({ command, checkFlag, apply, write, ref, profile });
897
927
 
898
928
  return {
899
929
  command,
@@ -902,6 +932,7 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
902
932
  from,
903
933
  ref,
904
934
  apply,
935
+ write,
905
936
  profile: profile ?? 'consumer',
906
937
  };
907
938
  };
@@ -934,13 +965,27 @@ const runCheckCommand = async (consumer: string): Promise<boolean> => {
934
965
  // closed: once .github/settings.json exists, an unreachable API or an
935
966
  // unreadable origin is a failure, not a skip.
936
967
  const githubIsConverged = existsSync(join(consumer, CANONICAL_SETTINGS_FILE))
937
- ? await runGithubCheck(consumer)
968
+ ? await runGithubCheckGate(consumer)
938
969
  : true;
939
970
  return (
940
971
  driftIsClean && integrationIsValid && structureIsValid && githubIsConverged
941
972
  );
942
973
  };
943
974
 
975
+ // The canonical workflow sets this only for its unprivileged quality job,
976
+ // where a separate isolated job runs the same live check with the admin-read
977
+ // token. Absent that exact workflow seam, local and explicit checks remain
978
+ // fail-closed.
979
+ const runGithubCheckGate = (consumer: string): Promise<boolean> => {
980
+ if (process.env[SKIP_GITHUB_CHECK_ENV] === 'true') {
981
+ console.log(
982
+ `standards github: live settings check skipped because ${SKIP_GITHUB_CHECK_ENV}=true; the canonical workflow's isolated github-settings job must own this check`,
983
+ );
984
+ return Promise.resolve(true);
985
+ }
986
+ return runGithubCheck(consumer);
987
+ };
988
+
944
989
  // Consumer-owned sync policy, checked in next to the canonical (read-only)
945
990
  // standards-sync workflow it configures — versioned and reviewable, unlike
946
991
  // repository Actions variables. All fields are optional; a missing file means
@@ -1069,7 +1114,7 @@ const runSyncCommand = async (
1069
1114
 
1070
1115
  // Commands whose success is reported through the exit code.
1071
1116
  const runGateCommand = (
1072
- command: 'check' | 'doctor' | 'github' | 'structure',
1117
+ command: 'check' | 'dependabot' | 'doctor' | 'github' | 'structure',
1073
1118
  consumer: string,
1074
1119
  apply: boolean,
1075
1120
  profile: StructureProfile,
@@ -1077,19 +1122,21 @@ const runGateCommand = (
1077
1122
  if (command === 'check') {
1078
1123
  return runCheckCommand(consumer);
1079
1124
  }
1125
+ if (command === 'dependabot') {
1126
+ return runDependabotCheck(consumer);
1127
+ }
1080
1128
  if (command === 'doctor') {
1081
1129
  return runDoctor(consumer);
1082
1130
  }
1083
1131
  if (command === 'structure') {
1084
1132
  return runStructure(consumer, profile);
1085
1133
  }
1086
- return apply ? runGithubApply(consumer) : runGithubCheck(consumer);
1134
+ return apply ? runGithubApply(consumer) : runGithubCheckGate(consumer);
1087
1135
  };
1088
1136
 
1089
1137
  const main = async (): Promise<void> => {
1090
- const { command, consumer, dryRun, from, ref, apply, profile } = parseArgs(
1091
- process.argv.slice(2),
1092
- );
1138
+ const { command, consumer, dryRun, from, ref, apply, write, profile } =
1139
+ parseArgs(process.argv.slice(2));
1093
1140
 
1094
1141
  if (command === undefined) {
1095
1142
  console.error('standards: a command is required\n');
@@ -1113,6 +1160,11 @@ const main = async (): Promise<void> => {
1113
1160
  return;
1114
1161
  }
1115
1162
 
1163
+ if (command === 'dependabot' && write) {
1164
+ await runDependabotWrite(consumer);
1165
+ return;
1166
+ }
1167
+
1116
1168
  if (!(await runGateCommand(command, consumer, apply, profile))) {
1117
1169
  process.exitCode = 1;
1118
1170
  }