@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.
@@ -15,11 +15,11 @@
15
15
  */
16
16
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
17
17
  import { createInterface } from 'node:readline';
18
- import { dirname, join } from 'node:path';
19
- import { fileURLToPath } from 'node:url';
18
+ import { join } from 'node:path';
20
19
  import { runScript } from './lib/run-script.js';
21
20
  import { parsePnpmWorkspaceYaml, parseWorkspacesFromPackageJson, resolvePackagePaths, } from './lib/workspace-detect.js';
22
21
  import { ValidationError } from './lib/errors.js';
22
+ import { readWorkflowTemplate } from './lib/workflow-template.js';
23
23
  // Single source of truth for workflow name validation within this file.
24
24
  // NOTE: bin/validators.js cannot be imported here — TypeScript compiles scripts/ to
25
25
  // dist/scripts/ so a relative '../bin/' import resolves to 'dist/bin/' at runtime
@@ -173,22 +173,14 @@ export async function writeWorkflow(options, deps) {
173
173
  deps.log(` To integrate manually, review the template and merge into your existing workflow.`);
174
174
  return false;
175
175
  }
176
- // Resolve template path: try compiled position first, fall back to source position.
177
- // compiled: dist/scripts/init-project.js → ../../scripts/templates/... (2 hops up)
178
- // source: scripts/init-project.ts → ./templates/... (sibling dir)
179
- const __filename = fileURLToPath(import.meta.url);
180
- const __dirname = dirname(__filename);
181
- const compiledPath = join(__dirname, '..', '..', 'scripts', 'templates', 'workflows', 'release.yml.template');
182
- const sourcePath = join(__dirname, 'templates', 'workflows', 'release.yml.template');
183
- const templatePath = deps.existsSync(compiledPath) ? compiledPath : sourcePath;
184
176
  let templateContent;
185
177
  try {
186
- templateContent = deps.readFileSync(templatePath, 'utf8');
178
+ templateContent = readWorkflowTemplate(deps).content;
187
179
  }
188
180
  catch (error) {
189
- throw new ValidationError(`Failed to read workflow template at "${templatePath}".\n` +
190
- `The template ships in scripts/templates/ — if it is missing, reinstall the package.\n` +
191
- `Original error: ${error}`);
181
+ throw new ValidationError(error instanceof Error
182
+ ? error.message
183
+ : 'Failed to read workflow template. The template ships in scripts/templates/.');
192
184
  }
193
185
  // Ensure .github/workflows/ exists
194
186
  if (!deps.existsSync(workflowDir)) {
@@ -1,6 +1,100 @@
1
1
  /**
2
2
  * Semantic versioning utility functions
3
3
  */
4
+ const SEMVER_PATTERN = String.raw `v?\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?`;
5
+ const exactRangeRegex = new RegExp(String.raw `^=?${SEMVER_PATTERN}$`);
6
+ const caretRangeRegex = new RegExp(String.raw `^\^\s*(${SEMVER_PATTERN})$`);
7
+ const tildeRangeRegex = new RegExp(String.raw `^~\s*(${SEMVER_PATTERN})$`);
8
+ const greaterThanOrEqualRangeRegex = new RegExp(String.raw `^>=\s*(${SEMVER_PATTERN})$`);
9
+ const workspaceProtocolPassthroughRanges = new Set(['*', '^', '~']);
10
+ function hasPrereleaseOrBuildMetadata(version) {
11
+ const normalized = version.replace(/^v/, '');
12
+ return normalized.includes('-') || normalized.includes('+');
13
+ }
14
+ function parseVersion(version) {
15
+ if (!isValidSemver(version)) {
16
+ return null;
17
+ }
18
+ const normalized = version.replace(/^v/, '');
19
+ const [major, minor, patch] = normalized.split(/[+-]/)[0].split('.').map(Number);
20
+ return { major, minor, patch, normalized };
21
+ }
22
+ function compareVersions(a, b) {
23
+ if (a.major !== b.major)
24
+ return a.major - b.major;
25
+ if (a.minor !== b.minor)
26
+ return a.minor - b.minor;
27
+ return a.patch - b.patch;
28
+ }
29
+ function includesCaretRange(version, base) {
30
+ if (compareVersions(version, base) < 0) {
31
+ return false;
32
+ }
33
+ if (base.major > 0) {
34
+ return version.major === base.major;
35
+ }
36
+ if (base.minor > 0) {
37
+ return version.major === 0 && version.minor === base.minor;
38
+ }
39
+ return version.major === 0 && version.minor === 0 && version.patch === base.patch;
40
+ }
41
+ function includesTildeRange(version, base) {
42
+ return compareVersions(version, base) >= 0 && version.major === base.major && version.minor === base.minor;
43
+ }
44
+ function normalizeWorkspaceProtocolRange(trimmed) {
45
+ if (!trimmed.startsWith('workspace:')) {
46
+ return trimmed;
47
+ }
48
+ const workspaceRange = trimmed.slice('workspace:'.length).trim();
49
+ if (workspaceProtocolPassthroughRanges.has(workspaceRange)) {
50
+ return '*';
51
+ }
52
+ if (exactRangeRegex.test(workspaceRange) ||
53
+ caretRangeRegex.test(workspaceRange) ||
54
+ tildeRangeRegex.test(workspaceRange) ||
55
+ greaterThanOrEqualRangeRegex.test(workspaceRange)) {
56
+ return workspaceRange;
57
+ }
58
+ return null;
59
+ }
60
+ function evaluateRangePart(range, version) {
61
+ const trimmed = range.trim();
62
+ if (!trimmed) {
63
+ return null;
64
+ }
65
+ const normalizedRange = normalizeWorkspaceProtocolRange(trimmed);
66
+ if (normalizedRange === null) {
67
+ return null;
68
+ }
69
+ if (normalizedRange === '*') {
70
+ return true;
71
+ }
72
+ const semverOperand = normalizedRange.replace(/^(?:=|\^|~|>=)\s*/, '');
73
+ if (isValidSemver(semverOperand) && hasPrereleaseOrBuildMetadata(semverOperand)) {
74
+ return null;
75
+ }
76
+ const exactMatch = normalizedRange.match(exactRangeRegex);
77
+ if (exactMatch) {
78
+ const exact = parseVersion(normalizedRange.replace(/^=/, ''));
79
+ return exact ? exact.normalized === version.normalized : null;
80
+ }
81
+ const caretMatch = normalizedRange.match(caretRangeRegex);
82
+ if (caretMatch) {
83
+ const base = parseVersion(caretMatch[1]);
84
+ return base ? includesCaretRange(version, base) : null;
85
+ }
86
+ const tildeMatch = normalizedRange.match(tildeRangeRegex);
87
+ if (tildeMatch) {
88
+ const base = parseVersion(tildeMatch[1]);
89
+ return base ? includesTildeRange(version, base) : null;
90
+ }
91
+ const greaterThanOrEqualMatch = normalizedRange.match(greaterThanOrEqualRangeRegex);
92
+ if (greaterThanOrEqualMatch) {
93
+ const base = parseVersion(greaterThanOrEqualMatch[1]);
94
+ return base ? compareVersions(version, base) >= 0 : null;
95
+ }
96
+ return null;
97
+ }
4
98
  /**
5
99
  * Validate if a string is a valid semantic version
6
100
  *
@@ -24,3 +118,36 @@ export function validateAndNormalizeSemver(version) {
24
118
  }
25
119
  return version.replace(/^v/, '');
26
120
  }
121
+ /**
122
+ * Check whether a dependency range includes a concrete workspace package version.
123
+ *
124
+ * This intentionally supports only the small zero-dependency subset doctor needs:
125
+ * workspace: protocol ranges, exact versions, ^, ~, >=, and OR-joined (`||`)
126
+ * combinations of those forms. Unknown syntax returns null so advisory checks can
127
+ * skip it without producing false warnings.
128
+ *
129
+ * @param range Dependency range string from package.json
130
+ * @param version Concrete workspace package version
131
+ * @returns true/false when evaluated, null when the syntax is outside the supported subset
132
+ */
133
+ export function rangeIncludesVersion(range, version) {
134
+ const parsedVersion = parseVersion(version);
135
+ if (!parsedVersion) {
136
+ return null;
137
+ }
138
+ if (hasPrereleaseOrBuildMetadata(version)) {
139
+ return null;
140
+ }
141
+ const parts = range.split(/\s*\|\|\s*/);
142
+ let sawUnknown = false;
143
+ for (const part of parts) {
144
+ const result = evaluateRangePart(part, parsedVersion);
145
+ if (result === true) {
146
+ return true;
147
+ }
148
+ if (result === null) {
149
+ sawUnknown = true;
150
+ }
151
+ }
152
+ return sawUnknown ? null : false;
153
+ }
@@ -0,0 +1,34 @@
1
+ import { dirname, join } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ export const GENERATED_WORKFLOW_MARKER = '# Generated by: release-it-preset init --with-workflows';
4
+ const WORKFLOW_TEMPLATE_RELATIVE_PATH = ['workflows', 'release.yml.template'];
5
+ export function getWorkflowTemplateCandidates(moduleUrl = import.meta.url) {
6
+ const moduleDir = dirname(fileURLToPath(moduleUrl));
7
+ return [
8
+ join(moduleDir, '..', 'templates', ...WORKFLOW_TEMPLATE_RELATIVE_PATH),
9
+ join(moduleDir, '..', '..', '..', 'scripts', 'templates', ...WORKFLOW_TEMPLATE_RELATIVE_PATH),
10
+ ];
11
+ }
12
+ export function readWorkflowTemplate(deps, moduleUrl = import.meta.url) {
13
+ const candidates = getWorkflowTemplateCandidates(moduleUrl);
14
+ const templatePath = candidates.find((candidate) => deps.existsSync(candidate)) ?? candidates[0];
15
+ try {
16
+ return {
17
+ path: templatePath,
18
+ content: deps.readFileSync(templatePath, 'utf8'),
19
+ };
20
+ }
21
+ catch (error) {
22
+ throw new Error([
23
+ `Failed to read workflow template at "${templatePath}".`,
24
+ 'The template ships in scripts/templates/ — if it is missing, reinstall the package.',
25
+ `Original error: ${error}`,
26
+ ].join('\n'));
27
+ }
28
+ }
29
+ export function hasGeneratedWorkflowMarker(content) {
30
+ return content.includes(GENERATED_WORKFLOW_MARKER);
31
+ }
32
+ export function normalizeWorkflowContent(content) {
33
+ return content.replace(/\r\n/g, '\n').replace(/\r/g, '\n').replace(/\n*$/, '\n');
34
+ }
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Annotate [Unreleased] entries from typed pull request changelog blocks.
4
+ */
5
+ import type { ExecSyncOptions } from 'node:child_process';
6
+ import { readFileSync, writeFileSync } from 'node:fs';
7
+ export interface AnnotateChangelogDeps {
8
+ execSync: (command: string, options?: ExecSyncOptions) => Buffer | string;
9
+ readFileSync: typeof readFileSync;
10
+ writeFileSync: typeof writeFileSync;
11
+ getEnv: (key: string) => string | undefined;
12
+ log: (message: string) => void;
13
+ warn: (message: string) => void;
14
+ }
15
+ export interface PullRequestInfo {
16
+ number: number;
17
+ body?: string | null;
18
+ merged_at?: string | null;
19
+ }
20
+ export interface ChangelogEntry {
21
+ section: string;
22
+ text: string;
23
+ rawLine: string;
24
+ shaList: string[];
25
+ prNumber: number | null;
26
+ order: number;
27
+ }
28
+ export interface ChangelogNote {
29
+ section: string;
30
+ text: string;
31
+ }
32
+ interface UnreleasedBlock {
33
+ prefix: string;
34
+ body: string;
35
+ suffix: string;
36
+ }
37
+ type SectionItem = {
38
+ kind: 'note';
39
+ line: string;
40
+ } | {
41
+ kind: 'entry';
42
+ entry: ChangelogEntry;
43
+ };
44
+ interface ParsedSection {
45
+ heading: string | null;
46
+ items: SectionItem[];
47
+ }
48
+ interface ParsedUnreleased {
49
+ entries: ChangelogEntry[];
50
+ sections: ParsedSection[];
51
+ }
52
+ interface CandidateGroup {
53
+ key: string;
54
+ kind: 'pr' | 'sha';
55
+ ref: string;
56
+ entries: ChangelogEntry[];
57
+ primarySha: string | null;
58
+ }
59
+ interface ResolvedGroup {
60
+ pr: PullRequestInfo;
61
+ entries: ChangelogEntry[];
62
+ primarySha: string | null;
63
+ }
64
+ export declare function extractUnreleasedBlock(changelog: string, changelogPath?: string): UnreleasedBlock;
65
+ export declare function normalizeSectionHeading(rawHeading: string): string;
66
+ export declare function extractCommitShas(value: string): string[];
67
+ export declare function extractPrNumber(value: string): number | null;
68
+ export declare function choosePrimarySha(values: Array<string | null | undefined>): string | null;
69
+ export declare function parseUnreleasedEntries(body: string): ParsedUnreleased;
70
+ export declare function groupEntriesForLookup(entries: ChangelogEntry[]): {
71
+ groups: CandidateGroup[];
72
+ passthrough: ChangelogEntry[];
73
+ };
74
+ export declare function fetchPullRequestByNumber(prNumber: number, ownerRepo: string, deps: AnnotateChangelogDeps): PullRequestInfo | null;
75
+ export declare function fetchPullRequestBySha(sha: string, ownerRepo: string, deps: AnnotateChangelogDeps): PullRequestInfo | null;
76
+ export declare function resolvePullRequestGroups(groups: CandidateGroup[], ownerRepo: string, deps: AnnotateChangelogDeps): {
77
+ resolved: ResolvedGroup[];
78
+ unresolved: ChangelogEntry[];
79
+ };
80
+ export declare function extractStructuredChangelogNotes(body: string | null | undefined, options?: {
81
+ prNumber?: number;
82
+ warn?: (message: string) => void;
83
+ }): ChangelogNote[];
84
+ export declare function renderAnnotatedBody(parsed: ParsedUnreleased, resolvedGroups: ResolvedGroup[], repoUrl: string, deps: AnnotateChangelogDeps): {
85
+ body: string;
86
+ appliedPrCount: number;
87
+ } | null;
88
+ export declare function annotateChangelog(deps: AnnotateChangelogDeps): void;
89
+ export {};
@@ -12,12 +12,14 @@
12
12
  */
13
13
  import type { ExecSyncOptions } from 'node:child_process';
14
14
  export type ChangelogStatus = 'updated' | 'skipped' | 'missing';
15
+ export type ChangelogBlockStatus = 'present' | 'absent' | 'unknown';
15
16
  export interface PrCheckResult {
16
17
  baseRef: string | null;
17
18
  headRef: string;
18
19
  changedFiles: string[];
19
20
  commits: string[];
20
21
  changelogStatus: ChangelogStatus;
22
+ changelogBlock: ChangelogBlockStatus;
21
23
  skipChangelogMarker: boolean;
22
24
  hasConventionalCommits: boolean;
23
25
  }
@@ -41,6 +43,7 @@ export declare function evaluateChangelogStatus(changedFiles: string[], changelo
41
43
  status: ChangelogStatus;
42
44
  skipMarker: boolean;
43
45
  };
46
+ export declare function evaluateChangelogBlockStatus(deps: PrCheckDeps): ChangelogBlockStatus;
44
47
  export declare function getDiffRange(baseRef: string | null, headRef: string): string;
45
48
  export declare function runPrCheck(args: {
46
49
  base?: string | null;
@@ -13,7 +13,7 @@
13
13
  * node dist/scripts/doctor.js --json
14
14
  */
15
15
  import type { ExecSyncOptions } from 'node:child_process';
16
- import { existsSync, readFileSync } from 'node:fs';
16
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
17
17
  export type CheckStatus = 'PASS' | 'WARN' | 'FAIL';
18
18
  export interface CheckResult {
19
19
  name: string;
@@ -58,12 +58,18 @@ export interface DoctorReport {
58
58
  export interface DoctorDeps {
59
59
  execSync: (command: string, options?: ExecSyncOptions) => Buffer | string;
60
60
  existsSync: typeof existsSync;
61
+ readdirSync: typeof readdirSync;
61
62
  readFileSync: typeof readFileSync;
62
63
  getEnv: (key: string) => string | undefined;
64
+ cwd: () => string;
63
65
  }
64
66
  export declare function safeExec(command: string, deps: DoctorDeps): string | null;
65
67
  export declare function collectEnvironment(deps: DoctorDeps): EnvironmentSection;
66
68
  export declare function inspectRepository(deps: DoctorDeps): RepositorySection;
69
+ export declare function validatePublishWorkflowFreshness(deps: DoctorDeps): CheckResult | null;
70
+ export declare function workflowHasIdTokenWritePermission(content: string): boolean;
71
+ export declare function validateNpmProvenanceReadiness(deps: DoctorDeps): CheckResult | null;
72
+ export declare function validateWorkspaceDependencyRanges(deps: DoctorDeps): CheckResult | null;
67
73
  export declare function validateConfiguration(deps: DoctorDeps): ConfigurationSection;
68
74
  /**
69
75
  * Runs Check A (peer range satisfaction) and Check B (major version advisor).
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Semantic versioning utility functions
3
3
  */
4
+ type RangeEvaluation = boolean | null;
4
5
  /**
5
6
  * Validate if a string is a valid semantic version
6
7
  *
@@ -16,3 +17,17 @@ export declare function isValidSemver(version: string): boolean;
16
17
  * @returns Normalized version without 'v' prefix
17
18
  */
18
19
  export declare function validateAndNormalizeSemver(version: string): string;
20
+ /**
21
+ * Check whether a dependency range includes a concrete workspace package version.
22
+ *
23
+ * This intentionally supports only the small zero-dependency subset doctor needs:
24
+ * workspace: protocol ranges, exact versions, ^, ~, >=, and OR-joined (`||`)
25
+ * combinations of those forms. Unknown syntax returns null so advisory checks can
26
+ * skip it without producing false warnings.
27
+ *
28
+ * @param range Dependency range string from package.json
29
+ * @param version Concrete workspace package version
30
+ * @returns true/false when evaluated, null when the syntax is outside the supported subset
31
+ */
32
+ export declare function rangeIncludesVersion(range: string, version: string): RangeEvaluation;
33
+ export {};
@@ -0,0 +1,14 @@
1
+ import type { PathLike } from 'node:fs';
2
+ export declare const GENERATED_WORKFLOW_MARKER = "# Generated by: release-it-preset init --with-workflows";
3
+ export interface WorkflowTemplateDeps {
4
+ existsSync: (path: PathLike) => boolean;
5
+ readFileSync: (path: PathLike, encoding: BufferEncoding) => string | Buffer;
6
+ }
7
+ export interface WorkflowTemplateReadResult {
8
+ path: string;
9
+ content: string;
10
+ }
11
+ export declare function getWorkflowTemplateCandidates(moduleUrl?: string): string[];
12
+ export declare function readWorkflowTemplate(deps: WorkflowTemplateDeps, moduleUrl?: string): WorkflowTemplateReadResult;
13
+ export declare function hasGeneratedWorkflowMarker(content: string): boolean;
14
+ export declare function normalizeWorkflowContent(content: string): string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oorabona/release-it-preset",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Release tooling for solo and small-team JS maintainers: human-curated changelogs, OIDC zero-config publishing, recovery presets, monorepo support, pre-release diagnostics.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -97,10 +97,12 @@
97
97
  },
98
98
  "devDependencies": {
99
99
  "@biomejs/biome": "^2.4.13",
100
- "@types/node": "^25.6.0",
100
+ "@release-it-plugins/workspaces": "^5.0.3",
101
+ "@types/node": "^25.9.2",
101
102
  "@vitest/coverage-v8": "^4.1.5",
102
103
  "nano-staged": "^1.0.2",
103
104
  "release-it": "^20.0.1",
105
+ "release-it19": "npm:release-it@^19.2.4",
104
106
  "rimraf": "^6.1.3",
105
107
  "tsx": "^4.21.0",
106
108
  "typescript": "^6.0.3",
@@ -110,5 +112,10 @@
110
112
  "engines": {
111
113
  "node": ">=20.19.0"
112
114
  },
113
- "packageManager": "pnpm@10.17.1"
115
+ "packageManager": "pnpm@10.17.1",
116
+ "pnpm": {
117
+ "overrides": {
118
+ "undici@<6.24.0": ">=6.24.0 <7"
119
+ }
120
+ }
114
121
  }
@@ -21,8 +21,7 @@ on:
21
21
  required: false
22
22
 
23
23
  permissions:
24
- contents: write
25
- id-token: write # Required for npm OIDC trusted publishing
24
+ contents: read
26
25
 
27
26
  env:
28
27
  NODE_VERSION: '24' # npm >= 11.5.1 ships with Node 24, required for OIDC trusted publishing
@@ -30,6 +29,9 @@ env:
30
29
  jobs:
31
30
  publish:
32
31
  runs-on: ubuntu-latest
32
+ permissions:
33
+ contents: write
34
+ id-token: write # Required for npm OIDC trusted publishing
33
35
  steps:
34
36
  - name: Checkout code
35
37
  uses: actions/checkout@v6