@oorabona/release-it-preset 1.2.0 → 1.4.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.
@@ -13,8 +13,11 @@
13
13
  * node dist/scripts/doctor.js --json
14
14
  */
15
15
  import { execSync } from 'node:child_process';
16
- import { existsSync, readFileSync } from 'node:fs';
17
- import { isValidSemver } from './lib/semver-utils.js';
16
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { isValidSemver, rangeIncludesVersion } from './lib/semver-utils.js';
19
+ import { parsePnpmWorkspaceYaml, parseWorkspacesFromPackageJson, resolvePackagePaths, } from './lib/workspace-detect.js';
20
+ import { hasGeneratedWorkflowMarker, normalizeWorkflowContent, readWorkflowTemplate, } from './lib/workflow-template.js';
18
21
  // ---------------------------------------------------------------------------
19
22
  // Helpers
20
23
  // ---------------------------------------------------------------------------
@@ -254,6 +257,911 @@ function detectWorkspaceIntegration(deps) {
254
257
  ].join('\n'),
255
258
  };
256
259
  }
260
+ const WORKFLOW_DIR = join('.github', 'workflows');
261
+ const WORKFLOW_FILE_NAME_REGEX = /^[A-Za-z0-9._-]+\.ya?ml$/;
262
+ function listWorkflowFiles(deps) {
263
+ if (!deps.existsSync(WORKFLOW_DIR)) {
264
+ return { files: [], skippedFileNames: [], unreadableFilePaths: [] };
265
+ }
266
+ let entries;
267
+ try {
268
+ entries = deps.readdirSync(WORKFLOW_DIR);
269
+ }
270
+ catch (error) {
271
+ return {
272
+ files: [],
273
+ skippedFileNames: [],
274
+ unreadableFilePaths: [],
275
+ error: error instanceof Error ? error.message : 'workflow directory could not be read',
276
+ };
277
+ }
278
+ if (!Array.isArray(entries)) {
279
+ return {
280
+ files: [],
281
+ skippedFileNames: [],
282
+ unreadableFilePaths: [],
283
+ error: 'workflow directory listing did not return file names',
284
+ };
285
+ }
286
+ const files = [];
287
+ const skippedFileNames = [];
288
+ const unreadableFilePaths = [];
289
+ for (const entry of entries) {
290
+ if (typeof entry !== 'string' || !WORKFLOW_FILE_NAME_REGEX.test(entry)) {
291
+ skippedFileNames.push(String(entry));
292
+ continue;
293
+ }
294
+ const path = join(WORKFLOW_DIR, entry);
295
+ try {
296
+ files.push({
297
+ path,
298
+ content: deps.readFileSync(path, 'utf8'),
299
+ });
300
+ }
301
+ catch {
302
+ unreadableFilePaths.push(path);
303
+ }
304
+ }
305
+ return { files, skippedFileNames, unreadableFilePaths };
306
+ }
307
+ function formatSkippedWorkflowFiles(skippedFileNames) {
308
+ if (skippedFileNames.length === 0) {
309
+ return [];
310
+ }
311
+ return [
312
+ 'Skipped workflow file name(s) outside the generated-template allowlist:',
313
+ ...skippedFileNames.map((name) => `- ${name}`),
314
+ ];
315
+ }
316
+ function formatUnreadableWorkflowFiles(unreadableFilePaths) {
317
+ if (unreadableFilePaths.length === 0) {
318
+ return [];
319
+ }
320
+ return [
321
+ 'Unreadable workflow file(s) were not evaluated:',
322
+ ...unreadableFilePaths.map((path) => `- ${path}`),
323
+ ];
324
+ }
325
+ function classifyPublishWorkflowFreshness(context) {
326
+ if (context.scan.error)
327
+ return 'WORKFLOW_DIR_UNREADABLE';
328
+ if (context.scan.files.length === 0) {
329
+ return context.scan.unreadableFilePaths.length > 0 ? 'WORKFLOW_FILES_UNREADABLE' : 'NO_WORKFLOW_FILES';
330
+ }
331
+ if (context.generatedWorkflowPaths.length === 0) {
332
+ return context.scan.unreadableFilePaths.length > 0 ? 'WORKFLOW_FILES_UNREADABLE' : 'NO_GENERATED_WORKFLOW';
333
+ }
334
+ if (context.templateError)
335
+ return 'TEMPLATE_UNAVAILABLE';
336
+ if (context.templateMissingMarker)
337
+ return 'TEMPLATE_MISSING_MARKER';
338
+ if (context.staleWorkflowPaths.length > 0)
339
+ return 'GENERATED_WORKFLOWS_STALE';
340
+ // Skipped files (name outside the supported pattern) could hold a stale
341
+ // generated workflow — a clean PASS must never hide unscanned files.
342
+ if (context.scan.unreadableFilePaths.length > 0 || context.scan.skippedFileNames.length > 0) {
343
+ return 'WORKFLOW_FILES_UNREADABLE';
344
+ }
345
+ return 'GENERATED_WORKFLOWS_FRESH';
346
+ }
347
+ const PUBLISH_WORKFLOW_FRESHNESS_DECISIONS = {
348
+ WORKFLOW_DIR_UNREADABLE: (context) => ({
349
+ name: 'publish workflow freshness',
350
+ status: 'WARN',
351
+ value: 'workflow directory not evaluated',
352
+ detail: context.scan.error,
353
+ }),
354
+ WORKFLOW_FILES_UNREADABLE: (context) => ({
355
+ name: 'publish workflow freshness',
356
+ status: 'WARN',
357
+ value: 'workflow files partially evaluated',
358
+ detail: [
359
+ 'One or more workflow files could not be read, so generated workflow freshness was not fully evaluated.',
360
+ ...formatUnreadableWorkflowFiles(context.scan.unreadableFilePaths),
361
+ ...formatSkippedWorkflowFiles(context.scan.skippedFileNames),
362
+ ].join('\n'),
363
+ }),
364
+ // Omitted, not PASS: no workflow files at all means the domain is absent
365
+ // (same not-applicable convention as the workspace ranges check).
366
+ NO_WORKFLOW_FILES: () => null,
367
+ NO_GENERATED_WORKFLOW: (context) => ({
368
+ name: 'publish workflow freshness',
369
+ status: 'PASS',
370
+ value: 'custom workflow(s) not evaluated',
371
+ detail: [
372
+ 'No workflow generated by release-it-preset init --with-workflows was detected.',
373
+ 'Only files carrying the generated workflow marker are compared to the shipped template.',
374
+ ...formatSkippedWorkflowFiles(context.scan.skippedFileNames),
375
+ ...formatUnreadableWorkflowFiles(context.scan.unreadableFilePaths),
376
+ ].join('\n'),
377
+ }),
378
+ TEMPLATE_UNAVAILABLE: (context) => ({
379
+ name: 'publish workflow freshness',
380
+ status: 'WARN',
381
+ value: 'canonical template unavailable',
382
+ detail: context.templateError,
383
+ }),
384
+ TEMPLATE_MISSING_MARKER: () => ({
385
+ name: 'publish workflow freshness',
386
+ status: 'WARN',
387
+ value: 'canonical template not evaluated',
388
+ detail: 'The shipped workflow template does not carry the generated workflow marker.',
389
+ }),
390
+ GENERATED_WORKFLOWS_FRESH: (context) => ({
391
+ name: 'publish workflow freshness',
392
+ status: 'PASS',
393
+ value: `${context.generatedWorkflowPaths.length} generated workflow(s) fresh`,
394
+ detail: [
395
+ ...formatSkippedWorkflowFiles(context.scan.skippedFileNames),
396
+ ...formatUnreadableWorkflowFiles(context.scan.unreadableFilePaths),
397
+ ].join('\n') || undefined,
398
+ }),
399
+ GENERATED_WORKFLOWS_STALE: (context) => ({
400
+ name: 'publish workflow freshness',
401
+ status: 'WARN',
402
+ value: `${context.staleWorkflowPaths.length} generated workflow(s) stale`,
403
+ detail: [
404
+ 'Generated workflow file(s) differ from the shipped release workflow template:',
405
+ ...context.staleWorkflowPaths.map((path) => `- ${path}`),
406
+ 'Run release-it-preset init --with-workflows in a scratch directory and merge the generated workflow updates.',
407
+ ...formatSkippedWorkflowFiles(context.scan.skippedFileNames),
408
+ ...formatUnreadableWorkflowFiles(context.scan.unreadableFilePaths),
409
+ ].join('\n'),
410
+ }),
411
+ };
412
+ export function validatePublishWorkflowFreshness(deps) {
413
+ const scan = listWorkflowFiles(deps);
414
+ const generatedWorkflowPaths = scan.files
415
+ .filter((file) => hasGeneratedWorkflowMarker(file.content))
416
+ .map((file) => file.path);
417
+ const staleWorkflowPaths = [];
418
+ let templateError;
419
+ let templateMissingMarker = false;
420
+ if (generatedWorkflowPaths.length > 0) {
421
+ try {
422
+ const template = readWorkflowTemplate(deps).content;
423
+ if (!hasGeneratedWorkflowMarker(template)) {
424
+ templateMissingMarker = true;
425
+ }
426
+ else {
427
+ const normalizedTemplate = normalizeWorkflowContent(template);
428
+ for (const file of scan.files) {
429
+ if (hasGeneratedWorkflowMarker(file.content) &&
430
+ normalizeWorkflowContent(file.content) !== normalizedTemplate) {
431
+ staleWorkflowPaths.push(file.path);
432
+ }
433
+ }
434
+ }
435
+ }
436
+ catch (error) {
437
+ templateError = error instanceof Error ? error.message : 'canonical workflow template unavailable';
438
+ }
439
+ }
440
+ const context = {
441
+ scan,
442
+ generatedWorkflowPaths,
443
+ staleWorkflowPaths,
444
+ templateError,
445
+ templateMissingMarker,
446
+ };
447
+ const state = classifyPublishWorkflowFreshness(context);
448
+ return PUBLISH_WORKFLOW_FRESHNESS_DECISIONS[state](context);
449
+ }
450
+ function stripYamlComment(line) {
451
+ const commentIndex = line.indexOf('#');
452
+ return (commentIndex >= 0 ? line.slice(0, commentIndex) : line).trimEnd();
453
+ }
454
+ function countIndent(line) {
455
+ return line.match(/^\s*/)?.[0].length ?? 0;
456
+ }
457
+ function toYamlLines(content) {
458
+ const rawLines = content.split(/\r?\n/);
459
+ return {
460
+ rawLines,
461
+ lines: rawLines
462
+ .map((rawLine, index) => {
463
+ const text = stripYamlComment(rawLine);
464
+ return {
465
+ index,
466
+ text,
467
+ trimmed: text.trim(),
468
+ indent: countIndent(text),
469
+ };
470
+ })
471
+ .filter((line) => line.trimmed !== ''),
472
+ };
473
+ }
474
+ function parseYamlMapping(trimmed) {
475
+ if (trimmed.startsWith('- ')) {
476
+ return null;
477
+ }
478
+ const match = trimmed.match(/^("[^"]+"|'[^']+'|[A-Za-z0-9_-]+)\s*:\s*(.*)$/);
479
+ if (!match) {
480
+ return null;
481
+ }
482
+ const rawKey = match[1];
483
+ const key = (rawKey.startsWith('"') && rawKey.endsWith('"')) ||
484
+ (rawKey.startsWith("'") && rawKey.endsWith("'"))
485
+ ? rawKey.slice(1, -1)
486
+ : rawKey;
487
+ return {
488
+ key,
489
+ value: match[2].trim(),
490
+ };
491
+ }
492
+ function parseWorkflowJobs(content) {
493
+ const { rawLines, lines } = toYamlLines(content);
494
+ const jobsLines = lines.filter((line) => {
495
+ const mapping = parseYamlMapping(line.trimmed);
496
+ return line.indent === 0 && mapping?.key === 'jobs';
497
+ });
498
+ if (jobsLines.length === 0) {
499
+ return { ok: false, reason: 'top-level jobs block not detected' };
500
+ }
501
+ if (jobsLines.length > 1) {
502
+ return { ok: false, reason: 'multiple top-level jobs blocks detected' };
503
+ }
504
+ const jobsLine = jobsLines[0];
505
+ const jobsMapping = parseYamlMapping(jobsLine.trimmed);
506
+ if (!jobsMapping || jobsMapping.value !== '') {
507
+ return { ok: false, reason: 'top-level jobs block uses an inline or dynamic value' };
508
+ }
509
+ const firstAfterJobs = lines.findIndex((line) => line.index > jobsLine.index);
510
+ const jobsBlockEnd = firstAfterJobs === -1
511
+ ? rawLines.length
512
+ : (lines.slice(firstAfterJobs).find((line) => line.indent <= jobsLine.indent)?.index ??
513
+ rawLines.length);
514
+ const jobLines = lines.filter((line) => line.index > jobsLine.index && line.index < jobsBlockEnd);
515
+ if (jobLines.length === 0) {
516
+ return { ok: false, reason: 'jobs block is empty' };
517
+ }
518
+ const jobIndent = jobLines[0].indent;
519
+ if (jobIndent <= jobsLine.indent) {
520
+ return { ok: false, reason: 'jobs block has no nested job definitions' };
521
+ }
522
+ const jobStarts = [];
523
+ for (const line of jobLines) {
524
+ if (line.indent !== jobIndent) {
525
+ continue;
526
+ }
527
+ const mapping = parseYamlMapping(line.trimmed);
528
+ if (!mapping) {
529
+ return { ok: false, reason: 'jobs block contains an unsupported child entry' };
530
+ }
531
+ if (mapping.value !== '') {
532
+ return { ok: false, reason: `job "${mapping.key}" uses an inline or dynamic value` };
533
+ }
534
+ jobStarts.push({ id: mapping.key, index: line.index });
535
+ }
536
+ if (jobStarts.length === 0) {
537
+ return { ok: false, reason: 'jobs block has no supported job definitions' };
538
+ }
539
+ const jobs = jobStarts.map((job, index) => {
540
+ const nextJob = jobStarts[index + 1];
541
+ const endIndex = nextJob?.index ?? jobsBlockEnd;
542
+ return {
543
+ id: job.id,
544
+ content: rawLines.slice(job.index, endIndex).join('\n'),
545
+ indent: jobIndent,
546
+ };
547
+ });
548
+ return { ok: true, jobs };
549
+ }
550
+ function stripYamlQuotes(value) {
551
+ const trimmed = value.trim();
552
+ if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
553
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
554
+ return trimmed.slice(1, -1);
555
+ }
556
+ return trimmed;
557
+ }
558
+ function inlinePermissionsMapHasIdTokenWrite(value) {
559
+ const trimmed = value.trim();
560
+ if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) {
561
+ return null;
562
+ }
563
+ return /(?:^|,)\s*['"]?id-token['"]?\s*:\s*['"]?write['"]?\s*(?:,|$)/.test(trimmed.slice(1, -1));
564
+ }
565
+ function scalarPermissionsValueHasIdTokenWrite(value) {
566
+ const scalar = stripYamlQuotes(value);
567
+ if (scalar === 'write-all') {
568
+ return 'GRANTED';
569
+ }
570
+ if (scalar === 'read-all') {
571
+ return 'MISSING';
572
+ }
573
+ return null;
574
+ }
575
+ function isDynamicYamlValue(value) {
576
+ return (value.startsWith('*') ||
577
+ value.startsWith('&') ||
578
+ value.startsWith('[') ||
579
+ value.includes('${{'));
580
+ }
581
+ function evaluatePermissionsValue(value) {
582
+ const scalarResult = scalarPermissionsValueHasIdTokenWrite(value);
583
+ if (scalarResult !== null) {
584
+ return scalarResult;
585
+ }
586
+ const inlineResult = inlinePermissionsMapHasIdTokenWrite(value);
587
+ if (inlineResult !== null) {
588
+ return inlineResult ? 'GRANTED' : 'MISSING';
589
+ }
590
+ return isDynamicYamlValue(value) ? 'NOT_EVALUATED' : 'MISSING';
591
+ }
592
+ function evaluatePermissionsLine(lines, permissionsLine) {
593
+ const permissions = parseYamlMapping(permissionsLine.trimmed);
594
+ if (!permissions) {
595
+ return 'NOT_EVALUATED';
596
+ }
597
+ if (permissions.value !== '') {
598
+ return evaluatePermissionsValue(permissions.value);
599
+ }
600
+ const permissionChildLines = lines.filter((line) => line.index > permissionsLine.index);
601
+ const permissionBlockLines = [];
602
+ for (const line of permissionChildLines) {
603
+ if (line.indent <= permissionsLine.indent) {
604
+ break;
605
+ }
606
+ permissionBlockLines.push(line);
607
+ }
608
+ if (permissionBlockLines.length === 0) {
609
+ return 'MISSING';
610
+ }
611
+ const permissionChildIndent = permissionBlockLines[0].indent;
612
+ for (const line of permissionBlockLines) {
613
+ if (line.indent !== permissionChildIndent) {
614
+ continue;
615
+ }
616
+ const mapping = parseYamlMapping(line.trimmed);
617
+ if (!mapping || mapping.key === '<<' || isDynamicYamlValue(mapping.value)) {
618
+ return 'NOT_EVALUATED';
619
+ }
620
+ if (mapping.key === 'id-token' && stripYamlQuotes(mapping.value) === 'write') {
621
+ return 'GRANTED';
622
+ }
623
+ }
624
+ return 'MISSING';
625
+ }
626
+ function evaluateWorkflowIdTokenWritePermission(content) {
627
+ const { lines } = toYamlLines(content);
628
+ const permissionsLines = lines.filter((line) => {
629
+ const mapping = parseYamlMapping(line.trimmed);
630
+ return line.indent === 0 && mapping?.key === 'permissions';
631
+ });
632
+ if (permissionsLines.length === 0) {
633
+ return 'MISSING';
634
+ }
635
+ if (permissionsLines.length > 1) {
636
+ return 'NOT_EVALUATED';
637
+ }
638
+ return evaluatePermissionsLine(lines, permissionsLines[0]);
639
+ }
640
+ function evaluateJobIdTokenWritePermission(job) {
641
+ const { lines } = toYamlLines(job.content);
642
+ const childLines = lines.filter((line) => line.index > 0 && line.indent > job.indent);
643
+ if (childLines.length === 0) {
644
+ return 'INHERIT';
645
+ }
646
+ const childIndent = childLines[0].indent;
647
+ const permissionsLines = childLines.filter((line) => {
648
+ const mapping = parseYamlMapping(line.trimmed);
649
+ return line.indent === childIndent && mapping?.key === 'permissions';
650
+ });
651
+ if (permissionsLines.length === 0) {
652
+ return 'INHERIT';
653
+ }
654
+ if (permissionsLines.length > 1) {
655
+ return 'NOT_EVALUATED';
656
+ }
657
+ return evaluatePermissionsLine(childLines, permissionsLines[0]);
658
+ }
659
+ function resolveJobIdTokenWritePermission(job, workflowPermission) {
660
+ const jobPermission = evaluateJobIdTokenWritePermission(job);
661
+ return jobPermission === 'INHERIT' ? workflowPermission : jobPermission;
662
+ }
663
+ export function workflowHasIdTokenWritePermission(content) {
664
+ const parsed = parseWorkflowJobs(content);
665
+ if (!parsed.ok) {
666
+ return false;
667
+ }
668
+ const workflowPermission = evaluateWorkflowIdTokenWritePermission(content);
669
+ return parsed.jobs.some((job) => resolveJobIdTokenWritePermission(job, workflowPermission) === 'GRANTED');
670
+ }
671
+ function contentWithoutYamlComments(content) {
672
+ return content
673
+ .split(/\r?\n/)
674
+ .map((line) => stripYamlComment(line))
675
+ .join('\n');
676
+ }
677
+ function workflowHasNpmPublishIntent(content) {
678
+ const body = contentWithoutYamlComments(content);
679
+ return (/\bNPM_PUBLISH\s*:\s*(?:['"]?true['"]?|\$\{\{)/.test(body) ||
680
+ /\bretry-publish(?![-\w])/.test(body) ||
681
+ /\bnpm\s+publish\b/.test(body));
682
+ }
683
+ function evaluateWorkflowNpmProvenance(content) {
684
+ if (!workflowHasNpmPublishIntent(content)) {
685
+ return {
686
+ publishJobIds: [],
687
+ idTokenJobIds: [],
688
+ missingJobIds: [],
689
+ };
690
+ }
691
+ const parsed = parseWorkflowJobs(content);
692
+ if (!parsed.ok) {
693
+ return {
694
+ publishJobIds: [],
695
+ idTokenJobIds: [],
696
+ missingJobIds: [],
697
+ notEvaluatedReason: parsed.reason,
698
+ };
699
+ }
700
+ const publishJobs = parsed.jobs.filter((job) => workflowHasNpmPublishIntent(job.content));
701
+ if (publishJobs.length === 0) {
702
+ return {
703
+ publishJobIds: [],
704
+ idTokenJobIds: [],
705
+ missingJobIds: [],
706
+ notEvaluatedReason: 'npm-publish signal is present, but no concrete publishing job could be identified',
707
+ };
708
+ }
709
+ const idTokenJobIds = [];
710
+ const missingJobIds = [];
711
+ const notEvaluatedJobIds = [];
712
+ const workflowPermission = evaluateWorkflowIdTokenWritePermission(content);
713
+ for (const job of publishJobs) {
714
+ const permission = resolveJobIdTokenWritePermission(job, workflowPermission);
715
+ if (permission === 'GRANTED') {
716
+ idTokenJobIds.push(job.id);
717
+ }
718
+ else if (permission === 'MISSING') {
719
+ missingJobIds.push(job.id);
720
+ }
721
+ else {
722
+ notEvaluatedJobIds.push(job.id);
723
+ }
724
+ }
725
+ return {
726
+ publishJobIds: publishJobs.map((job) => job.id),
727
+ idTokenJobIds,
728
+ missingJobIds,
729
+ notEvaluatedReason: notEvaluatedJobIds.length > 0
730
+ ? `resolved permissions not evaluated for: ${notEvaluatedJobIds.join(', ')}`
731
+ : undefined,
732
+ };
733
+ }
734
+ function classifyNpmProvenanceReadiness(context) {
735
+ if (context.npmPublish !== 'true')
736
+ return 'NPM_PUBLISH_DISABLED';
737
+ if (context.scan.error)
738
+ return 'WORKFLOW_DIR_UNREADABLE';
739
+ if (context.scan.files.length === 0 && context.scan.unreadableFilePaths.length === 0) {
740
+ return 'NO_WORKFLOW_FILES';
741
+ }
742
+ if (context.scan.unreadableFilePaths.length > 0)
743
+ return 'WORKFLOW_FILES_UNREADABLE';
744
+ if (context.notEvaluatedWorkflowDetails.length > 0)
745
+ return 'PUBLISHING_JOB_NOT_EVALUATED';
746
+ if (context.publishJobRefs.length === 0)
747
+ return 'NO_NPM_PUBLISH_WORKFLOW';
748
+ if (context.missingJobRefs.length > 0)
749
+ return 'ID_TOKEN_WRITE_MISSING';
750
+ // A skipped file (name outside the supported pattern) could contain an
751
+ // ungated publishing job — a clean PASS must never hide unscanned files.
752
+ if (context.scan.skippedFileNames.length > 0)
753
+ return 'WORKFLOW_FILES_SKIPPED';
754
+ return 'ID_TOKEN_WRITE_FOUND';
755
+ }
756
+ function npmProvenanceMissingDetail(context) {
757
+ return [
758
+ 'NPM_PUBLISH=true is set, but no evaluated publishing job declares permissions: id-token: write.',
759
+ 'Add id-token: write under top-level permissions or jobs.<publishing-job>.permissions; job-level permissions override workflow-level permissions.',
760
+ ...context.missingJobRefs.map((ref) => `- missing on ${ref}`),
761
+ ...formatSkippedWorkflowFiles(context.scan.skippedFileNames),
762
+ ...formatUnreadableWorkflowFiles(context.scan.unreadableFilePaths),
763
+ ].join('\n');
764
+ }
765
+ const NPM_PROVENANCE_READINESS_DECISIONS = {
766
+ // Omitted, not PASS: matches the not-applicable convention of the
767
+ // workspace ranges check and the documented "when NPM_PUBLISH=true" gate.
768
+ NPM_PUBLISH_DISABLED: () => null,
769
+ WORKFLOW_DIR_UNREADABLE: (context) => ({
770
+ name: 'npm provenance readiness',
771
+ status: 'WARN',
772
+ value: 'workflow directory not evaluated',
773
+ detail: context.scan.error,
774
+ }),
775
+ WORKFLOW_FILES_UNREADABLE: (context) => ({
776
+ name: 'npm provenance readiness',
777
+ status: 'WARN',
778
+ value: 'workflow files not evaluated',
779
+ detail: [
780
+ 'One or more workflow files could not be read, so npm provenance readiness was not fully evaluated.',
781
+ ...formatUnreadableWorkflowFiles(context.scan.unreadableFilePaths),
782
+ ...formatSkippedWorkflowFiles(context.scan.skippedFileNames),
783
+ ].join('\n'),
784
+ }),
785
+ NO_WORKFLOW_FILES: (context) => ({
786
+ name: 'npm provenance readiness',
787
+ status: 'WARN',
788
+ value: 'id-token: write not detected',
789
+ detail: npmProvenanceMissingDetail(context),
790
+ }),
791
+ NO_NPM_PUBLISH_WORKFLOW: (context) => ({
792
+ name: 'npm provenance readiness',
793
+ status: 'WARN',
794
+ value: 'npm publish workflow not detected',
795
+ detail: [
796
+ 'NPM_PUBLISH=true is set, but no allowlisted workflow file contains a supported npm-publish signal.',
797
+ 'Supported signals: NPM_PUBLISH, retry-publish, or npm publish.',
798
+ ...formatSkippedWorkflowFiles(context.scan.skippedFileNames),
799
+ ...formatUnreadableWorkflowFiles(context.scan.unreadableFilePaths),
800
+ ].join('\n'),
801
+ }),
802
+ WORKFLOW_FILES_SKIPPED: (context) => ({
803
+ name: 'npm provenance readiness',
804
+ status: 'WARN',
805
+ value: 'workflow files partially evaluated',
806
+ detail: [
807
+ 'Evaluated publishing jobs all resolve to id-token: write, but some workflow files were skipped because their name is outside the supported pattern, so they may contain an unverified publishing job.',
808
+ ...formatSkippedWorkflowFiles(context.scan.skippedFileNames),
809
+ 'Rename the skipped file(s) to a simple name (letters, digits, dot, underscore, dash) or review their permissions manually.',
810
+ ].join('\n'),
811
+ }),
812
+ PUBLISHING_JOB_NOT_EVALUATED: (context) => ({
813
+ name: 'npm provenance readiness',
814
+ status: 'WARN',
815
+ value: 'publishing job permissions not evaluated',
816
+ detail: [
817
+ 'NPM_PUBLISH=true is set, but one or more npm-publish workflow structures or permissions could not be evaluated.',
818
+ 'Doctor passes this check when the publishing job resolves to permissions: id-token: write, either directly or by inheriting workflow-level permissions.',
819
+ 'Not evaluated:',
820
+ ...context.notEvaluatedWorkflowDetails.map((detail) => `- ${detail}`),
821
+ ...context.missingJobRefs.map((ref) => `- missing on ${ref}`),
822
+ ...formatSkippedWorkflowFiles(context.scan.skippedFileNames),
823
+ ].join('\n'),
824
+ }),
825
+ ID_TOKEN_WRITE_FOUND: (context) => ({
826
+ name: 'npm provenance readiness',
827
+ status: 'PASS',
828
+ value: `id-token: write detected on ${context.idTokenJobRefs.length} publishing job(s)`,
829
+ detail: context.idTokenJobRefs.map((ref) => `- ${ref}`).join('\n'),
830
+ }),
831
+ ID_TOKEN_WRITE_MISSING: (context) => ({
832
+ name: 'npm provenance readiness',
833
+ status: 'WARN',
834
+ value: 'id-token: write not detected',
835
+ detail: npmProvenanceMissingDetail(context),
836
+ }),
837
+ };
838
+ export function validateNpmProvenanceReadiness(deps) {
839
+ const scan = listWorkflowFiles(deps);
840
+ const publishJobRefs = [];
841
+ const idTokenJobRefs = [];
842
+ const missingJobRefs = [];
843
+ const notEvaluatedWorkflowDetails = [];
844
+ for (const file of scan.files) {
845
+ const evaluation = evaluateWorkflowNpmProvenance(file.content);
846
+ publishJobRefs.push(...evaluation.publishJobIds.map((jobId) => `${file.path}#${jobId}`));
847
+ idTokenJobRefs.push(...evaluation.idTokenJobIds.map((jobId) => `${file.path}#${jobId}`));
848
+ missingJobRefs.push(...evaluation.missingJobIds.map((jobId) => `${file.path}#${jobId}`));
849
+ if (evaluation.notEvaluatedReason) {
850
+ notEvaluatedWorkflowDetails.push(`${file.path}: ${evaluation.notEvaluatedReason}`);
851
+ }
852
+ }
853
+ const context = {
854
+ scan,
855
+ publishJobRefs,
856
+ idTokenJobRefs,
857
+ missingJobRefs,
858
+ notEvaluatedWorkflowDetails,
859
+ npmPublish: deps.getEnv('NPM_PUBLISH'),
860
+ };
861
+ const state = classifyNpmProvenanceReadiness(context);
862
+ return NPM_PROVENANCE_READINESS_DECISIONS[state](context);
863
+ }
864
+ const DEPENDENCY_FIELDS = [
865
+ 'dependencies',
866
+ 'devDependencies',
867
+ 'peerDependencies',
868
+ 'optionalDependencies',
869
+ ];
870
+ function isStringRecord(value) {
871
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
872
+ return false;
873
+ }
874
+ return Object.values(value).every((v) => typeof v === 'string');
875
+ }
876
+ function collectDependencyRanges(pkg) {
877
+ const dependencies = [];
878
+ for (const field of DEPENDENCY_FIELDS) {
879
+ const entries = pkg[field];
880
+ if (!isStringRecord(entries)) {
881
+ continue;
882
+ }
883
+ for (const [name, range] of Object.entries(entries)) {
884
+ dependencies.push({ field, name, range });
885
+ }
886
+ }
887
+ return dependencies;
888
+ }
889
+ function isSupportedWorkspacePattern(pattern) {
890
+ if (/[?{}[\]]/.test(pattern)) {
891
+ return false;
892
+ }
893
+ const starCount = (pattern.match(/\*/g) ?? []).length;
894
+ if (starCount === 0) {
895
+ return true;
896
+ }
897
+ return starCount === 1 && pattern.endsWith('/*');
898
+ }
899
+ function classifyWorkspacePatterns(patterns) {
900
+ const supportedPatterns = [];
901
+ const unsupportedPatterns = [];
902
+ const negatedPatterns = [];
903
+ for (const pattern of patterns) {
904
+ if (pattern.startsWith('!')) {
905
+ negatedPatterns.push(pattern);
906
+ }
907
+ else if (isSupportedWorkspacePattern(pattern)) {
908
+ supportedPatterns.push(pattern);
909
+ }
910
+ else {
911
+ unsupportedPatterns.push(pattern);
912
+ }
913
+ }
914
+ return { supportedPatterns, unsupportedPatterns, negatedPatterns };
915
+ }
916
+ function formatUnsupportedPatternDetails(patterns) {
917
+ if (patterns.length === 0) {
918
+ return [];
919
+ }
920
+ return [
921
+ 'Unsupported workspace pattern(s) were not evaluated:',
922
+ ...patterns.map((pattern) => `- ${pattern}: pattern not supported by this check`),
923
+ ];
924
+ }
925
+ function formatNegatedPatternDetails(patterns) {
926
+ return [
927
+ 'Negated workspace pattern(s) are not supported by this check; internal dependency ranges were not evaluated.',
928
+ ...patterns.map((pattern) => `- ${pattern}: exclusion pattern not supported`),
929
+ 'Remove negated workspace patterns or verify internal dependency ranges manually.',
930
+ ].join('\n');
931
+ }
932
+ function formatUnreadableManifestDetail(unreadableManifestCount) {
933
+ return unreadableManifestCount > 0
934
+ ? `${unreadableManifestCount} manifest(s) unreadable; those packages were not evaluated.`
935
+ : undefined;
936
+ }
937
+ function readWorkspacePatterns(deps) {
938
+ if (deps.existsSync('pnpm-workspace.yaml')) {
939
+ try {
940
+ const content = deps.readFileSync('pnpm-workspace.yaml', 'utf8');
941
+ return { patterns: parsePnpmWorkspaceYaml(content) };
942
+ }
943
+ catch (error) {
944
+ const reason = error instanceof Error ? error.message : 'unknown parser error';
945
+ return {
946
+ patterns: [],
947
+ error: [
948
+ 'pnpm-workspace.yaml could not be parsed; internal dependency ranges were not verified.',
949
+ reason,
950
+ ].join('\n'),
951
+ };
952
+ }
953
+ }
954
+ if (!deps.existsSync('package.json')) {
955
+ return null;
956
+ }
957
+ try {
958
+ const content = deps.readFileSync('package.json', 'utf8');
959
+ const patterns = parseWorkspacesFromPackageJson(content);
960
+ return patterns.length > 0 ? { patterns } : null;
961
+ }
962
+ catch (error) {
963
+ return {
964
+ patterns: [],
965
+ error: [
966
+ 'package.json workspaces field could not be read; internal dependency ranges were not verified.',
967
+ error instanceof Error ? error.message : 'unknown parser error',
968
+ ].join('\n'),
969
+ };
970
+ }
971
+ }
972
+ function readWorkspacePackages(packageDirs, deps) {
973
+ const packages = [];
974
+ let unreadableManifestCount = 0;
975
+ for (const packageDir of packageDirs) {
976
+ try {
977
+ const raw = deps.readFileSync(join(packageDir, 'package.json'), 'utf8');
978
+ const parsed = JSON.parse(raw);
979
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
980
+ unreadableManifestCount += 1;
981
+ continue;
982
+ }
983
+ const pkg = parsed;
984
+ if (typeof pkg.name !== 'string' || typeof pkg.version !== 'string') {
985
+ unreadableManifestCount += 1;
986
+ continue;
987
+ }
988
+ if (!isValidSemver(pkg.version)) {
989
+ unreadableManifestCount += 1;
990
+ continue;
991
+ }
992
+ packages.push({
993
+ name: pkg.name,
994
+ version: pkg.version,
995
+ dependencies: collectDependencyRanges(pkg),
996
+ });
997
+ }
998
+ catch {
999
+ unreadableManifestCount += 1;
1000
+ }
1001
+ }
1002
+ return { packages, unreadableManifestCount };
1003
+ }
1004
+ export function validateWorkspaceDependencyRanges(deps) {
1005
+ const workspacePatterns = readWorkspacePatterns(deps);
1006
+ if (workspacePatterns === null) {
1007
+ return null;
1008
+ }
1009
+ if (workspacePatterns.error) {
1010
+ return {
1011
+ name: 'Workspace dependency ranges',
1012
+ status: 'WARN',
1013
+ value: 'workspace configuration not evaluated',
1014
+ detail: workspacePatterns.error,
1015
+ };
1016
+ }
1017
+ const { patterns } = workspacePatterns;
1018
+ if (patterns.length === 0) {
1019
+ return {
1020
+ name: 'Workspace dependency ranges',
1021
+ status: 'PASS',
1022
+ value: 'no workspace packages declared',
1023
+ };
1024
+ }
1025
+ const { supportedPatterns, unsupportedPatterns, negatedPatterns } = classifyWorkspacePatterns(patterns);
1026
+ if (negatedPatterns.length > 0) {
1027
+ return {
1028
+ name: 'Workspace dependency ranges',
1029
+ status: 'WARN',
1030
+ value: 'not evaluated',
1031
+ detail: formatNegatedPatternDetails(negatedPatterns),
1032
+ };
1033
+ }
1034
+ let packageDirs;
1035
+ try {
1036
+ packageDirs =
1037
+ supportedPatterns.length > 0
1038
+ ? resolvePackagePaths(supportedPatterns, deps.cwd(), {
1039
+ existsSync: deps.existsSync,
1040
+ readdirSync: deps.readdirSync,
1041
+ })
1042
+ : [];
1043
+ }
1044
+ catch (error) {
1045
+ return {
1046
+ name: 'Workspace dependency ranges',
1047
+ status: 'WARN',
1048
+ value: 'not evaluated',
1049
+ detail: [
1050
+ error instanceof Error ? error.message : 'Could not resolve workspace package paths',
1051
+ ...formatUnsupportedPatternDetails(unsupportedPatterns),
1052
+ ].join('\n'),
1053
+ };
1054
+ }
1055
+ if (packageDirs.length === 0) {
1056
+ if (unsupportedPatterns.length > 0) {
1057
+ return {
1058
+ name: 'Workspace dependency ranges',
1059
+ status: 'WARN',
1060
+ value: 'workspace ranges partially evaluated',
1061
+ detail: [
1062
+ supportedPatterns.length > 0
1063
+ ? 'Supported workspace pattern(s) did not resolve any package directories; ranges were not evaluated.'
1064
+ : 'No supported workspace patterns were available; ranges were not evaluated.',
1065
+ ...formatUnsupportedPatternDetails(unsupportedPatterns),
1066
+ ].join('\n'),
1067
+ };
1068
+ }
1069
+ return {
1070
+ name: 'Workspace dependency ranges',
1071
+ status: 'WARN',
1072
+ value: 'workspace packages not resolved — ranges not evaluated',
1073
+ detail: [
1074
+ 'Declared workspace pattern(s) did not resolve any package directories.',
1075
+ ...supportedPatterns.map((pattern) => `- ${pattern}`),
1076
+ ].join('\n'),
1077
+ };
1078
+ }
1079
+ const { packages, unreadableManifestCount } = readWorkspacePackages(packageDirs, deps);
1080
+ if (packages.length === 0) {
1081
+ return {
1082
+ name: 'Workspace dependency ranges',
1083
+ status: 'WARN',
1084
+ value: 'workspace manifests unreadable — ranges not evaluated',
1085
+ detail: [
1086
+ 'Resolved workspace package path(s) did not contain readable, valid package.json manifests.',
1087
+ formatUnreadableManifestDetail(unreadableManifestCount),
1088
+ ...formatUnsupportedPatternDetails(unsupportedPatterns),
1089
+ ].filter(Boolean).join('\n'),
1090
+ };
1091
+ }
1092
+ const versionByName = new Map(packages.map((pkg) => [pkg.name, pkg.version]));
1093
+ const staleRanges = [];
1094
+ const skippedRanges = [];
1095
+ let coherentRangeCount = 0;
1096
+ let internalRangeCount = 0;
1097
+ for (const pkg of packages) {
1098
+ for (const dependency of pkg.dependencies) {
1099
+ const internalVersion = versionByName.get(dependency.name);
1100
+ if (!internalVersion) {
1101
+ continue;
1102
+ }
1103
+ internalRangeCount += 1;
1104
+ const includesVersion = rangeIncludesVersion(dependency.range, internalVersion);
1105
+ if (includesVersion === true) {
1106
+ coherentRangeCount += 1;
1107
+ }
1108
+ else if (includesVersion === false) {
1109
+ staleRanges.push(`${pkg.name} ${dependency.field}.${dependency.name}="${dependency.range}" does not include ${internalVersion}`);
1110
+ }
1111
+ else {
1112
+ skippedRanges.push(`${pkg.name} ${dependency.field}.${dependency.name}="${dependency.range}"`);
1113
+ }
1114
+ }
1115
+ }
1116
+ if (staleRanges.length > 0) {
1117
+ return {
1118
+ name: 'Workspace dependency ranges',
1119
+ status: 'WARN',
1120
+ value: `${staleRanges.length} stale internal range(s)`,
1121
+ detail: [
1122
+ ...staleRanges.slice(0, 10).map((item) => `- ${item}`),
1123
+ staleRanges.length > 10 ? `- ...and ${staleRanges.length - 10} more` : '',
1124
+ 'Update the range to include the current internal package version, or use the workspace: protocol.',
1125
+ formatUnreadableManifestDetail(unreadableManifestCount),
1126
+ ...formatUnsupportedPatternDetails(unsupportedPatterns),
1127
+ ].filter(Boolean).join('\n'),
1128
+ };
1129
+ }
1130
+ const partialEvaluationDetails = [
1131
+ formatUnreadableManifestDetail(unreadableManifestCount),
1132
+ ...formatUnsupportedPatternDetails(unsupportedPatterns),
1133
+ ].filter(Boolean);
1134
+ if (partialEvaluationDetails.length > 0) {
1135
+ return {
1136
+ name: 'Workspace dependency ranges',
1137
+ status: 'WARN',
1138
+ value: 'workspace ranges partially evaluated',
1139
+ detail: [
1140
+ internalRangeCount === 0
1141
+ ? 'No internal package dependencies were found in readable workspace manifests.'
1142
+ : `${coherentRangeCount}/${internalRangeCount} internal range(s) coherent in readable workspace manifests.`,
1143
+ ...partialEvaluationDetails,
1144
+ ].join('\n'),
1145
+ };
1146
+ }
1147
+ if (internalRangeCount === 0) {
1148
+ return {
1149
+ name: 'Workspace dependency ranges',
1150
+ status: 'PASS',
1151
+ value: 'no internal package dependencies',
1152
+ };
1153
+ }
1154
+ return {
1155
+ name: 'Workspace dependency ranges',
1156
+ status: 'PASS',
1157
+ value: skippedRanges.length > 0
1158
+ ? `${coherentRangeCount}/${internalRangeCount} internal range(s) coherent; ${skippedRanges.length} skipped`
1159
+ : `${coherentRangeCount}/${internalRangeCount} internal range(s) coherent`,
1160
+ detail: skippedRanges.length > 0
1161
+ ? `Skipped unsupported range syntax: ${skippedRanges.slice(0, 5).join(', ')}`
1162
+ : undefined,
1163
+ };
1164
+ }
257
1165
  export function validateConfiguration(deps) {
258
1166
  const checks = [];
259
1167
  const changelogPath = deps.getEnv('CHANGELOG_FILE') ?? 'CHANGELOG.md';
@@ -267,41 +1175,54 @@ export function validateConfiguration(deps) {
267
1175
  }
268
1176
  else {
269
1177
  checks.push({ name: `${changelogPath} exists`, status: 'PASS', value: 'yes' });
270
- const content = deps.readFileSync(changelogPath, 'utf8');
271
- const hasKacHeader = /^# Changelog/m.test(content);
272
- if (!hasKacHeader) {
273
- checks.push({
274
- name: 'Keep a Changelog format',
275
- status: 'FAIL',
276
- value: 'invalid',
277
- detail: 'CHANGELOG.md must start with "# Changelog" (Keep a Changelog format)',
278
- });
279
- }
280
- else {
281
- checks.push({ name: 'Keep a Changelog format', status: 'PASS', value: 'valid' });
1178
+ let content = null;
1179
+ try {
1180
+ content = deps.readFileSync(changelogPath, 'utf8');
282
1181
  }
283
- const unreleasedMatch = content.match(/## \[Unreleased\]([\s\S]*?)(?=## \[|$)/);
284
- if (!unreleasedMatch) {
1182
+ catch (error) {
285
1183
  checks.push({
286
- name: '[Unreleased] section',
287
- status: 'FAIL',
288
- value: 'missing',
289
- detail: 'Add "## [Unreleased]" section run: release-it-preset update',
1184
+ name: 'Keep a Changelog format',
1185
+ status: 'WARN',
1186
+ value: 'not evaluated',
1187
+ detail: `${changelogPath} could not be read: ${error instanceof Error ? error.message : String(error)}`,
290
1188
  });
291
1189
  }
292
- else {
293
- const unreleasedContent = unreleasedMatch[1].trim();
294
- const hasChanges = /^-/m.test(unreleasedContent);
295
- if (!unreleasedContent || !hasChanges) {
1190
+ if (content !== null) {
1191
+ const hasKacHeader = /^# Changelog/m.test(content);
1192
+ if (!hasKacHeader) {
1193
+ checks.push({
1194
+ name: 'Keep a Changelog format',
1195
+ status: 'FAIL',
1196
+ value: 'invalid',
1197
+ detail: 'CHANGELOG.md must start with "# Changelog" (Keep a Changelog format)',
1198
+ });
1199
+ }
1200
+ else {
1201
+ checks.push({ name: 'Keep a Changelog format', status: 'PASS', value: 'valid' });
1202
+ }
1203
+ const unreleasedMatch = content.match(/## \[Unreleased\]([\s\S]*?)(?=## \[|$)/);
1204
+ if (!unreleasedMatch) {
296
1205
  checks.push({
297
1206
  name: '[Unreleased] section',
298
- status: 'WARN',
299
- value: 'empty',
300
- detail: 'No entries yet — run: release-it-preset update',
1207
+ status: 'FAIL',
1208
+ value: 'missing',
1209
+ detail: 'Add "## [Unreleased]" section — run: release-it-preset update',
301
1210
  });
302
1211
  }
303
1212
  else {
304
- checks.push({ name: '[Unreleased] section', status: 'PASS', value: 'has content' });
1213
+ const unreleasedContent = unreleasedMatch[1].trim();
1214
+ const hasChanges = /^-/m.test(unreleasedContent);
1215
+ if (!unreleasedContent || !hasChanges) {
1216
+ checks.push({
1217
+ name: '[Unreleased] section',
1218
+ status: 'WARN',
1219
+ value: 'empty',
1220
+ detail: 'No entries yet — run: release-it-preset update',
1221
+ });
1222
+ }
1223
+ else {
1224
+ checks.push({ name: '[Unreleased] section', status: 'PASS', value: 'has content' });
1225
+ }
305
1226
  }
306
1227
  }
307
1228
  }
@@ -396,6 +1317,18 @@ export function validateConfiguration(deps) {
396
1317
  }
397
1318
  }
398
1319
  checks.push(detectWorkspaceIntegration(deps));
1320
+ const workspaceDependencyRanges = validateWorkspaceDependencyRanges(deps);
1321
+ if (workspaceDependencyRanges) {
1322
+ checks.push(workspaceDependencyRanges);
1323
+ }
1324
+ const publishWorkflowFreshness = validatePublishWorkflowFreshness(deps);
1325
+ if (publishWorkflowFreshness) {
1326
+ checks.push(publishWorkflowFreshness);
1327
+ }
1328
+ const npmProvenanceReadiness = validateNpmProvenanceReadiness(deps);
1329
+ if (npmProvenanceReadiness) {
1330
+ checks.push(npmProvenanceReadiness);
1331
+ }
399
1332
  for (const result of validateReleaseItPeer(deps)) {
400
1333
  checks.push(result);
401
1334
  }
@@ -654,8 +1587,10 @@ if (import.meta.url === `file://${process.argv[1]}`) {
654
1587
  const deps = {
655
1588
  execSync,
656
1589
  existsSync,
1590
+ readdirSync,
657
1591
  readFileSync,
658
1592
  getEnv: (key) => process.env[key],
1593
+ cwd: () => process.cwd(),
659
1594
  };
660
1595
  const report = runDoctor(deps);
661
1596
  if (isJson) {