@oorabona/release-it-preset 0.3.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/LICENSE +21 -0
- package/README.md +833 -0
- package/bin/cli.js +180 -0
- package/bin/run-script.js +72 -0
- package/config/changelog-only.js +41 -0
- package/config/default.js +54 -0
- package/config/helpers.js +52 -0
- package/config/hotfix.js +51 -0
- package/config/manual-changelog.js +64 -0
- package/config/no-changelog.js +40 -0
- package/config/republish.js +60 -0
- package/config/retry-publish.js +40 -0
- package/dist/scripts/check-config.js +285 -0
- package/dist/scripts/check-pr-status.js +164 -0
- package/dist/scripts/extract-changelog.js +66 -0
- package/dist/scripts/init-project.js +191 -0
- package/dist/scripts/lib/commit-parser.js +67 -0
- package/dist/scripts/lib/git-utils.js +33 -0
- package/dist/scripts/lib/semver-utils.js +26 -0
- package/dist/scripts/lib/string-utils.js +12 -0
- package/dist/scripts/populate-unreleased-changelog.js +236 -0
- package/dist/scripts/republish-changelog.js +187 -0
- package/dist/scripts/retry-publish.js +98 -0
- package/dist/scripts/validate-release.js +288 -0
- package/dist/types/check-config.d.ts +65 -0
- package/dist/types/check-pr-status.d.ts +51 -0
- package/dist/types/extract-changelog.d.ts +23 -0
- package/dist/types/init-project.d.ts +37 -0
- package/dist/types/lib/commit-parser.d.ts +44 -0
- package/dist/types/lib/git-utils.d.ts +20 -0
- package/dist/types/lib/semver-utils.d.ts +18 -0
- package/dist/types/lib/string-utils.d.ts +10 -0
- package/dist/types/populate-unreleased-changelog.d.ts +56 -0
- package/dist/types/republish-changelog.d.ts +39 -0
- package/dist/types/retry-publish.d.ts +33 -0
- package/dist/types/validate-release.d.ts +43 -0
- package/package.json +93 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Republish changelog script
|
|
4
|
+
*
|
|
5
|
+
* This script handles republishing an existing version without bumping.
|
|
6
|
+
* Used when a git tag exists but the package was never published to npm.
|
|
7
|
+
*
|
|
8
|
+
* Unlike normal changelog update, this script:
|
|
9
|
+
* - Uses the existing tag version from package.json
|
|
10
|
+
* - Moves [Unreleased] content to the correct version entry
|
|
11
|
+
* - Handles the case where the version entry might already exist
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* tsx republish-changelog.ts
|
|
15
|
+
*
|
|
16
|
+
* Environment variables:
|
|
17
|
+
* CHANGELOG_FILE - Path to changelog file (default: CHANGELOG.md)
|
|
18
|
+
* GITHUB_REPOSITORY - GitHub repo (owner/repo) for commit links
|
|
19
|
+
* GIT_REMOTE - Git remote name (default: origin)
|
|
20
|
+
*/
|
|
21
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { execSync } from 'node:child_process';
|
|
24
|
+
import { getGitHubRepoUrl } from './lib/git-utils.js';
|
|
25
|
+
import { escapeRegExp } from './lib/string-utils.js';
|
|
26
|
+
import { validateAndNormalizeSemver } from './lib/semver-utils.js';
|
|
27
|
+
export function updateReferenceLinks(changelog, versionLabels, linkTarget, unreleasedLine) {
|
|
28
|
+
const lines = changelog.split(/\r?\n/);
|
|
29
|
+
const updatedLines = [];
|
|
30
|
+
let foundUnreleasedLink = false;
|
|
31
|
+
const foundLabels = new Set();
|
|
32
|
+
const uniqueLabels = [...new Set(versionLabels)];
|
|
33
|
+
const labelRegexes = uniqueLabels.map((label) => ({
|
|
34
|
+
label,
|
|
35
|
+
regex: new RegExp(`^\\[${escapeRegExp(label)}]:`, 'i'),
|
|
36
|
+
}));
|
|
37
|
+
for (const ln of lines) {
|
|
38
|
+
if (/^\[Unreleased\]:/i.test(ln)) {
|
|
39
|
+
updatedLines.push(unreleasedLine);
|
|
40
|
+
foundUnreleasedLink = true;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const matching = labelRegexes.find(({ regex }) => regex.test(ln));
|
|
44
|
+
if (matching) {
|
|
45
|
+
updatedLines.push(`[${matching.label}]: ${linkTarget}`);
|
|
46
|
+
foundLabels.add(matching.label);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
updatedLines.push(ln);
|
|
50
|
+
}
|
|
51
|
+
if (!foundUnreleasedLink) {
|
|
52
|
+
updatedLines.push(unreleasedLine);
|
|
53
|
+
}
|
|
54
|
+
const addedVersionLinks = [];
|
|
55
|
+
for (const label of uniqueLabels) {
|
|
56
|
+
if (!foundLabels.has(label)) {
|
|
57
|
+
updatedLines.push(`[${label}]: ${linkTarget}`);
|
|
58
|
+
addedVersionLinks.push(label);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
changelog: updatedLines.join('\n'),
|
|
63
|
+
addedUnreleasedLink: !foundUnreleasedLink,
|
|
64
|
+
addedVersionLinks,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function findExistingVersionHeading(changelog, normalizedVersion) {
|
|
68
|
+
const escapedVersion = escapeRegExp(normalizedVersion);
|
|
69
|
+
const existingHeading = new RegExp(`^##\\s*\\[(v?${escapedVersion})\\]`, 'im').exec(changelog);
|
|
70
|
+
return existingHeading?.[1] ?? null;
|
|
71
|
+
}
|
|
72
|
+
function getFirstVersionHeading(changelog) {
|
|
73
|
+
const headingRegex = /^##\s*\[(.*?)\]/gim;
|
|
74
|
+
for (const match of changelog.matchAll(headingRegex)) {
|
|
75
|
+
const label = match[1]?.trim();
|
|
76
|
+
if (label && !/^unreleased$/i.test(label)) {
|
|
77
|
+
return label;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
function inferVersionHeadingLabel(versionInput, normalizedVersion, changelog) {
|
|
83
|
+
const firstHeading = getFirstVersionHeading(changelog);
|
|
84
|
+
if (firstHeading) {
|
|
85
|
+
return /^v/i.test(firstHeading) ? `v${normalizedVersion}` : normalizedVersion;
|
|
86
|
+
}
|
|
87
|
+
return versionInput.trim().toLowerCase().startsWith('v') ? `v${normalizedVersion}` : normalizedVersion;
|
|
88
|
+
}
|
|
89
|
+
export function republishChangelog(version, deps) {
|
|
90
|
+
const changelogPath = join(deps.getCwd(), deps.getEnv('CHANGELOG_FILE') || 'CHANGELOG.md');
|
|
91
|
+
deps.log(`ℹ️ Republishing version: ${version}`);
|
|
92
|
+
// Validate semver format
|
|
93
|
+
const normalizedVersion = validateAndNormalizeSemver(version);
|
|
94
|
+
const date = deps.getDate();
|
|
95
|
+
const tag = version.startsWith('v') ? version : `v${normalizedVersion}`;
|
|
96
|
+
const versionLabels = [`v${normalizedVersion}`, normalizedVersion];
|
|
97
|
+
const repoUrl = getGitHubRepoUrl({
|
|
98
|
+
execSync: deps.execSync,
|
|
99
|
+
getEnv: deps.getEnv,
|
|
100
|
+
warn: deps.warn,
|
|
101
|
+
});
|
|
102
|
+
let changelog = deps.readFileSync(changelogPath, 'utf8');
|
|
103
|
+
const unreleasedBlock = /^(?<prefix>[^\n]*?##\s*\[?Unreleased\]?[^\n]*\n)(?<content>[\s\S]*?)(?=^##\s|^\s*---\s*$|$(?![\s\S]))/im;
|
|
104
|
+
const match = changelog.match(unreleasedBlock);
|
|
105
|
+
if (!match || !match.groups) {
|
|
106
|
+
throw new Error('No [Unreleased] section found in CHANGELOG.md');
|
|
107
|
+
}
|
|
108
|
+
const unreleasedContent = match.groups.content.trim();
|
|
109
|
+
const escapedVersion = escapeRegExp(normalizedVersion);
|
|
110
|
+
const existingHeadingLabel = findExistingVersionHeading(changelog, normalizedVersion);
|
|
111
|
+
const versionExists = Boolean(existingHeadingLabel);
|
|
112
|
+
const versionHeadingLabel = existingHeadingLabel ?? inferVersionHeadingLabel(version, normalizedVersion, changelog);
|
|
113
|
+
if (versionExists && !unreleasedContent) {
|
|
114
|
+
deps.log(`ℹ️ Version ${tag} already exists in changelog and [Unreleased] is empty. Nothing to do.`);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (versionExists && unreleasedContent) {
|
|
118
|
+
deps.warn(`⚠️ Version ${tag} already exists in changelog but [Unreleased] has content.`);
|
|
119
|
+
deps.log(`ℹ️ Updating existing ${tag} entry with unreleased content...`);
|
|
120
|
+
const versionEntryRegex = new RegExp(`(^##\\s*\\[?(?:v?${escapedVersion})\\]?[^\\n]*\\n)((?:[\\s\\S]*?)(?=^##\\s|^\\s*---\\s*$|$(?![\\s\\S])))`, 'im');
|
|
121
|
+
const versionMatch = changelog.match(versionEntryRegex);
|
|
122
|
+
if (versionMatch) {
|
|
123
|
+
const newVersionContent = `${versionMatch[1]}\n${unreleasedContent}\n\n`;
|
|
124
|
+
changelog = changelog.replace(versionEntryRegex, newVersionContent);
|
|
125
|
+
changelog = changelog.replace(unreleasedBlock, `${match.groups.prefix}\n`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
if (!unreleasedContent) {
|
|
130
|
+
throw new Error('[Unreleased] section is empty. Use populate-unreleased-changelog.ts first or add content manually.');
|
|
131
|
+
}
|
|
132
|
+
deps.log(`📝 Moving [Unreleased] content to ${tag} entry`);
|
|
133
|
+
const newEntryLines = [];
|
|
134
|
+
newEntryLines.push(`## [${versionHeadingLabel}] - ${date}`);
|
|
135
|
+
newEntryLines.push('');
|
|
136
|
+
newEntryLines.push(unreleasedContent);
|
|
137
|
+
newEntryLines.push('');
|
|
138
|
+
changelog = changelog.replace(unreleasedBlock, `${match.groups.prefix}\n${newEntryLines.join('\n')}\n`);
|
|
139
|
+
}
|
|
140
|
+
let linkTarget;
|
|
141
|
+
let unreleasedLine;
|
|
142
|
+
if (repoUrl.includes('github.com')) {
|
|
143
|
+
linkTarget = `${repoUrl}/releases/tag/${tag}`;
|
|
144
|
+
unreleasedLine = `[Unreleased]: ${repoUrl}/compare/${tag}...HEAD`;
|
|
145
|
+
}
|
|
146
|
+
else if (repoUrl.includes('gitlab')) {
|
|
147
|
+
linkTarget = `${repoUrl}/-/tags/${tag}`;
|
|
148
|
+
unreleasedLine = `[Unreleased]: ${repoUrl}/-/compare/${tag}...HEAD`;
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
linkTarget = repoUrl;
|
|
152
|
+
unreleasedLine = `[Unreleased]: ${repoUrl}`;
|
|
153
|
+
}
|
|
154
|
+
const versionLinkLabels = [...new Set([tag, versionHeadingLabel])];
|
|
155
|
+
const updateResult = updateReferenceLinks(changelog, versionLinkLabels, linkTarget, unreleasedLine);
|
|
156
|
+
changelog = updateResult.changelog;
|
|
157
|
+
deps.writeFileSync(changelogPath, changelog, 'utf8');
|
|
158
|
+
deps.log(`✅ CHANGELOG.md updated for republish of ${tag}${repoUrl ? ` (${repoUrl})` : ''}`);
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* CLI entry point - only runs when script is executed directly
|
|
162
|
+
*/
|
|
163
|
+
/* c8 ignore start */
|
|
164
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
165
|
+
async function main() {
|
|
166
|
+
try {
|
|
167
|
+
const pkg = await import(join(process.cwd(), 'package.json'), { with: { type: 'json' } });
|
|
168
|
+
const version = pkg.default.version;
|
|
169
|
+
republishChangelog(version, {
|
|
170
|
+
execSync,
|
|
171
|
+
readFileSync,
|
|
172
|
+
writeFileSync,
|
|
173
|
+
getEnv: (key) => process.env[key],
|
|
174
|
+
getCwd: () => process.cwd(),
|
|
175
|
+
getDate: () => new Date().toISOString().split('T')[0],
|
|
176
|
+
log: console.log,
|
|
177
|
+
warn: console.warn,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
console.error(`❌ ${error instanceof Error ? error.message : error}`);
|
|
182
|
+
process.exit(1);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
main();
|
|
186
|
+
}
|
|
187
|
+
/* c8 ignore end */
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Retry publish script
|
|
4
|
+
*
|
|
5
|
+
* This script retries publishing an existing version to npm and GitHub Releases.
|
|
6
|
+
* Used when the initial publish failed but the Git tag was already created.
|
|
7
|
+
*
|
|
8
|
+
* Safety features:
|
|
9
|
+
* - Checks out the exact commit of the latest tag
|
|
10
|
+
* - Doesn't modify any Git history
|
|
11
|
+
* - Restores the original branch after publishing
|
|
12
|
+
* - Verifies the tag exists before proceeding
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* tsx retry-publish.ts
|
|
16
|
+
* # Then run: release-it --config retry-publish.js
|
|
17
|
+
*/
|
|
18
|
+
import { execSync } from 'node:child_process';
|
|
19
|
+
import { readFileSync } from 'node:fs';
|
|
20
|
+
export function retryPublish(deps) {
|
|
21
|
+
deps.log('🔄 Starting retry publish process...');
|
|
22
|
+
const currentBranch = deps.execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim();
|
|
23
|
+
deps.log(`ℹ️ Current branch: ${currentBranch}`);
|
|
24
|
+
let latestTag;
|
|
25
|
+
try {
|
|
26
|
+
latestTag = deps.execSync('git describe --tags --abbrev=0', { encoding: 'utf8' }).trim();
|
|
27
|
+
deps.log(`ℹ️ Latest tag found: ${latestTag}`);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
throw new Error('No tags found in repository');
|
|
31
|
+
}
|
|
32
|
+
let tagCommit;
|
|
33
|
+
try {
|
|
34
|
+
tagCommit = deps.execSync(`git rev-parse ${latestTag}`, { encoding: 'utf8' }).trim();
|
|
35
|
+
deps.log(`ℹ️ Tag ${latestTag} points to commit: ${tagCommit.substring(0, 8)}`);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
throw new Error(`Tag ${latestTag} not found`);
|
|
39
|
+
}
|
|
40
|
+
let hasUncommittedChanges = false;
|
|
41
|
+
try {
|
|
42
|
+
deps.execSync('git diff --quiet', { stdio: 'pipe' });
|
|
43
|
+
deps.execSync('git diff --cached --quiet', { stdio: 'pipe' });
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
hasUncommittedChanges = true;
|
|
47
|
+
deps.warn('⚠️ You have uncommitted changes. They will be preserved.');
|
|
48
|
+
}
|
|
49
|
+
let packageVersion;
|
|
50
|
+
try {
|
|
51
|
+
const packageJson = JSON.parse(deps.readFileSync('package.json', 'utf8'));
|
|
52
|
+
if (typeof packageJson.version === 'string' && packageJson.version.trim()) {
|
|
53
|
+
packageVersion = packageJson.version.trim();
|
|
54
|
+
const normalizedTag = latestTag.replace(/^v/, '');
|
|
55
|
+
if (packageVersion !== normalizedTag) {
|
|
56
|
+
deps.warn(`⚠️ package.json version (${packageVersion}) does not match latest tag (${normalizedTag}).`);
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
deps.log(`ℹ️ package.json version (${packageVersion}) matches the latest tag.`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
deps.warn(
|
|
65
|
+
/* c8 ignore next */
|
|
66
|
+
`⚠️ Unable to inspect package.json version: ${error instanceof Error ? error.message : error}`);
|
|
67
|
+
}
|
|
68
|
+
deps.log('✅ Pre-flight checks passed. Ready to retry publish.');
|
|
69
|
+
deps.log(`📦 This will republish from tag ${latestTag} to npm and GitHub Releases`);
|
|
70
|
+
deps.log('🔒 No Git history will be modified');
|
|
71
|
+
deps.log('💡 Next command: release-it --config node_modules/@oorabona/release-it-preset/config/retry-publish.js');
|
|
72
|
+
return {
|
|
73
|
+
currentBranch,
|
|
74
|
+
latestTag,
|
|
75
|
+
tagCommit,
|
|
76
|
+
hasUncommittedChanges,
|
|
77
|
+
packageVersion,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* CLI entry point - only runs when script is executed directly
|
|
82
|
+
*/
|
|
83
|
+
/* c8 ignore start */
|
|
84
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
85
|
+
try {
|
|
86
|
+
retryPublish({
|
|
87
|
+
execSync,
|
|
88
|
+
readFileSync,
|
|
89
|
+
log: console.log,
|
|
90
|
+
warn: console.warn,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
console.error(`❌ Pre-flight checks failed: ${error instanceof Error ? error.message : error}`);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/* c8 ignore end */
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Validate project is ready for release
|
|
4
|
+
*
|
|
5
|
+
* This script checks:
|
|
6
|
+
* - CHANGELOG.md exists and is well-formatted
|
|
7
|
+
* - [Unreleased] section has content
|
|
8
|
+
* - Working directory is clean (unless --allow-dirty)
|
|
9
|
+
* - npm authentication works (npm whoami)
|
|
10
|
+
* - Current branch is allowed (if GIT_REQUIRE_BRANCH is set)
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* tsx validate-release.ts [--allow-dirty]
|
|
14
|
+
*
|
|
15
|
+
* Exit codes:
|
|
16
|
+
* 0 - All validations passed
|
|
17
|
+
* 1 - Validation failed
|
|
18
|
+
*/
|
|
19
|
+
import { execSync } from 'node:child_process';
|
|
20
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
21
|
+
export function parseArgs(argv) {
|
|
22
|
+
return {
|
|
23
|
+
allowDirty: argv.includes('--allow-dirty'),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export function validateChangelogExists(deps) {
|
|
27
|
+
const path = deps.getEnv('CHANGELOG_FILE') || 'CHANGELOG.md';
|
|
28
|
+
if (!deps.existsSync(path)) {
|
|
29
|
+
return {
|
|
30
|
+
name: 'CHANGELOG.md exists',
|
|
31
|
+
passed: false,
|
|
32
|
+
message: `File not found: ${path}`,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
name: 'CHANGELOG.md exists',
|
|
37
|
+
passed: true,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export function validateChangelogFormat(deps) {
|
|
41
|
+
const path = deps.getEnv('CHANGELOG_FILE') || 'CHANGELOG.md';
|
|
42
|
+
if (!deps.existsSync(path)) {
|
|
43
|
+
return {
|
|
44
|
+
name: 'CHANGELOG.md format',
|
|
45
|
+
passed: false,
|
|
46
|
+
message: 'File not found',
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
const content = deps.readFileSync(path, 'utf8');
|
|
50
|
+
// Check for Keep a Changelog format markers
|
|
51
|
+
const hasTitle = /^# /.test(content);
|
|
52
|
+
const hasUnreleased = /## \[Unreleased\]/.test(content);
|
|
53
|
+
const hasKeepAChangelogLink = /keepachangelog\.com/i.test(content);
|
|
54
|
+
if (!hasTitle) {
|
|
55
|
+
return {
|
|
56
|
+
name: 'CHANGELOG.md format',
|
|
57
|
+
passed: false,
|
|
58
|
+
message: 'Missing title (# Changelog)',
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
if (!hasUnreleased) {
|
|
62
|
+
return {
|
|
63
|
+
name: 'CHANGELOG.md format',
|
|
64
|
+
passed: false,
|
|
65
|
+
message: 'Missing [Unreleased] section',
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
if (!hasKeepAChangelogLink) {
|
|
69
|
+
return {
|
|
70
|
+
name: 'CHANGELOG.md format',
|
|
71
|
+
passed: false,
|
|
72
|
+
message: 'Not using Keep a Changelog format (missing keepachangelog.com reference)',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
name: 'CHANGELOG.md format',
|
|
77
|
+
passed: true,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
export function validateUnreleasedHasContent(deps) {
|
|
81
|
+
const path = deps.getEnv('CHANGELOG_FILE') || 'CHANGELOG.md';
|
|
82
|
+
if (!deps.existsSync(path)) {
|
|
83
|
+
return {
|
|
84
|
+
name: '[Unreleased] has content',
|
|
85
|
+
passed: false,
|
|
86
|
+
message: 'CHANGELOG.md not found',
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const content = deps.readFileSync(path, 'utf8');
|
|
90
|
+
const unreleasedMatch = content.match(/## \[Unreleased\]([\s\S]*?)(?=## \[|$)/);
|
|
91
|
+
if (!unreleasedMatch) {
|
|
92
|
+
return {
|
|
93
|
+
name: '[Unreleased] has content',
|
|
94
|
+
passed: false,
|
|
95
|
+
message: '[Unreleased] section not found',
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
const unreleasedContent = unreleasedMatch[1].trim();
|
|
99
|
+
if (!unreleasedContent || unreleasedContent === 'No changes yet.') {
|
|
100
|
+
return {
|
|
101
|
+
name: '[Unreleased] has content',
|
|
102
|
+
passed: false,
|
|
103
|
+
message: '[Unreleased] section is empty',
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
// Check if there's at least one change entry (starts with -)
|
|
107
|
+
const hasChanges = /^-/m.test(unreleasedContent);
|
|
108
|
+
if (!hasChanges) {
|
|
109
|
+
return {
|
|
110
|
+
name: '[Unreleased] has content',
|
|
111
|
+
passed: false,
|
|
112
|
+
message: '[Unreleased] section has no change entries',
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
name: '[Unreleased] has content',
|
|
117
|
+
passed: true,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
export function validateWorkingDirectoryClean(deps, options) {
|
|
121
|
+
if (options.allowDirty) {
|
|
122
|
+
return {
|
|
123
|
+
name: 'Working directory clean',
|
|
124
|
+
passed: true,
|
|
125
|
+
message: 'Skipped (--allow-dirty)',
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
const status = deps.execSync('git status --porcelain', { encoding: 'utf8' }).trim();
|
|
130
|
+
if (status) {
|
|
131
|
+
return {
|
|
132
|
+
name: 'Working directory clean',
|
|
133
|
+
passed: false,
|
|
134
|
+
message: 'Uncommitted changes detected. Use --allow-dirty to skip this check.',
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
name: 'Working directory clean',
|
|
139
|
+
passed: true,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
return {
|
|
144
|
+
name: 'Working directory clean',
|
|
145
|
+
passed: false,
|
|
146
|
+
message: `Failed to check git status: ${error}`,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
export function validateNpmAuth(deps) {
|
|
151
|
+
try {
|
|
152
|
+
const username = deps.execSync('npm whoami', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
153
|
+
return {
|
|
154
|
+
name: 'npm authentication',
|
|
155
|
+
passed: true,
|
|
156
|
+
message: `Logged in as: ${username}`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
const tokenEnvVars = ['NPM_TOKEN', 'NODE_AUTH_TOKEN', 'NPM_CONFIG__AUTH', 'NPM_CONFIG_TOKEN'];
|
|
161
|
+
const hasAutomationToken = tokenEnvVars.some((name) => {
|
|
162
|
+
const value = deps.getEnv(name);
|
|
163
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
164
|
+
});
|
|
165
|
+
if (hasAutomationToken) {
|
|
166
|
+
return {
|
|
167
|
+
name: 'npm authentication',
|
|
168
|
+
passed: true,
|
|
169
|
+
message: 'Token-based authentication detected (skipped npm whoami).',
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
const ciEnv = deps.getEnv('CI');
|
|
173
|
+
if (ciEnv && ciEnv.toLowerCase() === 'true') {
|
|
174
|
+
return {
|
|
175
|
+
name: 'npm authentication',
|
|
176
|
+
passed: false,
|
|
177
|
+
message: 'npm whoami failed in CI and no auth token detected. Ensure NPM_TOKEN is configured.',
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
name: 'npm authentication',
|
|
182
|
+
passed: false,
|
|
183
|
+
message: 'Not authenticated. Run: npm login',
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
export function validateBranch(deps) {
|
|
188
|
+
const requiredBranch = deps.getEnv('GIT_REQUIRE_BRANCH');
|
|
189
|
+
if (!requiredBranch) {
|
|
190
|
+
return {
|
|
191
|
+
name: 'Branch check',
|
|
192
|
+
passed: true,
|
|
193
|
+
message: 'Skipped (GIT_REQUIRE_BRANCH not set)',
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
const currentBranch = deps.execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim();
|
|
198
|
+
if (currentBranch !== requiredBranch) {
|
|
199
|
+
return {
|
|
200
|
+
name: 'Branch check',
|
|
201
|
+
passed: false,
|
|
202
|
+
message: `Current branch is "${currentBranch}", but "${requiredBranch}" is required`,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
name: 'Branch check',
|
|
207
|
+
passed: true,
|
|
208
|
+
message: `On required branch: ${currentBranch}`,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
return {
|
|
213
|
+
name: 'Branch check',
|
|
214
|
+
passed: false,
|
|
215
|
+
message: `Failed to get current branch: ${error}`,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
export function validateGitRepo(deps) {
|
|
220
|
+
try {
|
|
221
|
+
deps.execSync('git rev-parse --git-dir', { stdio: 'pipe' });
|
|
222
|
+
return {
|
|
223
|
+
name: 'Git repository',
|
|
224
|
+
passed: true,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return {
|
|
229
|
+
name: 'Git repository',
|
|
230
|
+
passed: false,
|
|
231
|
+
message: 'Not a git repository',
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
export function validateRelease(deps, options) {
|
|
236
|
+
return [
|
|
237
|
+
validateGitRepo(deps),
|
|
238
|
+
validateChangelogExists(deps),
|
|
239
|
+
validateChangelogFormat(deps),
|
|
240
|
+
validateUnreleasedHasContent(deps),
|
|
241
|
+
validateWorkingDirectoryClean(deps, options),
|
|
242
|
+
validateBranch(deps),
|
|
243
|
+
validateNpmAuth(deps),
|
|
244
|
+
];
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* CLI entry point - only runs when script is executed directly
|
|
248
|
+
*/
|
|
249
|
+
/* c8 ignore start */
|
|
250
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
251
|
+
async function main() {
|
|
252
|
+
const options = parseArgs(process.argv.slice(2));
|
|
253
|
+
console.log('🔍 Validating release readiness...\n');
|
|
254
|
+
const deps = {
|
|
255
|
+
execSync,
|
|
256
|
+
existsSync,
|
|
257
|
+
readFileSync,
|
|
258
|
+
getEnv: (key) => process.env[key],
|
|
259
|
+
};
|
|
260
|
+
const results = validateRelease(deps, options);
|
|
261
|
+
let allPassed = true;
|
|
262
|
+
for (const result of results) {
|
|
263
|
+
const icon = result.passed ? '✅' : '❌';
|
|
264
|
+
const status = result.passed ? 'PASS' : 'FAIL';
|
|
265
|
+
console.log(`${icon} ${result.name}: ${status}`);
|
|
266
|
+
if (result.message) {
|
|
267
|
+
console.log(` ${result.message}`);
|
|
268
|
+
}
|
|
269
|
+
if (!result.passed) {
|
|
270
|
+
allPassed = false;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
console.log();
|
|
274
|
+
if (allPassed) {
|
|
275
|
+
console.log('✨ All validations passed! Ready to release.');
|
|
276
|
+
process.exit(0);
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
console.log('❌ Some validations failed. Please fix the issues above before releasing.');
|
|
280
|
+
process.exit(1);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
main().catch((error) => {
|
|
284
|
+
console.error('❌ Validation failed:', error);
|
|
285
|
+
process.exit(1);
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
/* c8 ignore end */
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Check and display release configuration and project status
|
|
4
|
+
*
|
|
5
|
+
* This script displays:
|
|
6
|
+
* - Environment variables and their values
|
|
7
|
+
* - Repository information
|
|
8
|
+
* - Git tags and latest version
|
|
9
|
+
* - Commits since last tag
|
|
10
|
+
* - Configuration files status
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* tsx check-config.ts
|
|
14
|
+
*/
|
|
15
|
+
import type { ExecSyncOptions } from 'node:child_process';
|
|
16
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
17
|
+
export interface CheckConfigDeps {
|
|
18
|
+
execSync: (command: string, options?: ExecSyncOptions) => Buffer | string;
|
|
19
|
+
existsSync: typeof existsSync;
|
|
20
|
+
readFileSync: typeof readFileSync;
|
|
21
|
+
getEnv: (key: string) => string | undefined;
|
|
22
|
+
log: (message: string) => void;
|
|
23
|
+
}
|
|
24
|
+
export declare function safeExec(command: string, deps: CheckConfigDeps): string | null;
|
|
25
|
+
export declare function getEnvVar(name: string, deps: CheckConfigDeps, defaultValue?: string): string;
|
|
26
|
+
export interface CheckConfigResult {
|
|
27
|
+
envVars: Record<string, string>;
|
|
28
|
+
repoUrl: string | null;
|
|
29
|
+
currentBranch: string | null;
|
|
30
|
+
latestTag: string | null;
|
|
31
|
+
commitCount: number;
|
|
32
|
+
filesStatus: Record<string, boolean>;
|
|
33
|
+
npmUsername: string | null;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Get current git branch
|
|
37
|
+
*/
|
|
38
|
+
export declare function getCurrentBranch(deps: CheckConfigDeps): string | null;
|
|
39
|
+
/**
|
|
40
|
+
* Get latest git tag
|
|
41
|
+
*/
|
|
42
|
+
export declare function getLatestTag(deps: CheckConfigDeps): string | null;
|
|
43
|
+
/**
|
|
44
|
+
* Get commit count since tag (or all commits if no tag)
|
|
45
|
+
*/
|
|
46
|
+
export declare function getCommitCount(deps: CheckConfigDeps, latestTag: string | null): number;
|
|
47
|
+
/**
|
|
48
|
+
* Get files status (existence check)
|
|
49
|
+
*/
|
|
50
|
+
export declare function getFilesStatus(deps: CheckConfigDeps): Record<string, boolean>;
|
|
51
|
+
/**
|
|
52
|
+
* Get npm username (if logged in)
|
|
53
|
+
*/
|
|
54
|
+
export declare function getNpmUsername(deps: CheckConfigDeps): string | null;
|
|
55
|
+
/**
|
|
56
|
+
* Get all environment variables with defaults
|
|
57
|
+
*/
|
|
58
|
+
export declare function getEnvironmentVariables(deps: CheckConfigDeps): Record<string, string>;
|
|
59
|
+
export declare function checkConfig(deps: CheckConfigDeps): CheckConfigResult;
|
|
60
|
+
export declare function displayEnvironmentVariables(envVars: Record<string, string>): void;
|
|
61
|
+
export declare function displayRepositoryInfo(deps: CheckConfigDeps, repoUrl: string | null, currentBranch: string | null): void;
|
|
62
|
+
export declare function displayVersionAndTags(deps: CheckConfigDeps, latestTag: string | null): void;
|
|
63
|
+
export declare function displayCommitsSinceLastTag(deps: CheckConfigDeps, latestTag: string | null): void;
|
|
64
|
+
export declare function displayConfigurationFiles(deps: CheckConfigDeps, filesStatus: Record<string, boolean>): void;
|
|
65
|
+
export declare function displayNpmStatus(deps: CheckConfigDeps, npmUsername: string | null): void;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Check pull request hygiene (changelog + commits)
|
|
4
|
+
*
|
|
5
|
+
* This script inspects the current git workspace to determine whether:
|
|
6
|
+
* - The changelog (respecting CHANGELOG_FILE env override) has been modified
|
|
7
|
+
* - A `[skip-changelog]` marker appears in commit messages
|
|
8
|
+
* - Commits follow the conventional commit format
|
|
9
|
+
*
|
|
10
|
+
* Results are written to `$GITHUB_OUTPUT` when available so GitHub Actions steps
|
|
11
|
+
* can consume them without relying on continue-on-error semantics.
|
|
12
|
+
*/
|
|
13
|
+
import type { ExecSyncOptions } from 'node:child_process';
|
|
14
|
+
export type ChangelogStatus = 'updated' | 'skipped' | 'missing';
|
|
15
|
+
export interface PrCheckResult {
|
|
16
|
+
baseRef: string | null;
|
|
17
|
+
headRef: string;
|
|
18
|
+
changedFiles: string[];
|
|
19
|
+
commits: string[];
|
|
20
|
+
changelogStatus: ChangelogStatus;
|
|
21
|
+
skipChangelogMarker: boolean;
|
|
22
|
+
hasConventionalCommits: boolean;
|
|
23
|
+
}
|
|
24
|
+
export interface PrCheckDeps {
|
|
25
|
+
execSync: (command: string, options?: ExecSyncOptions) => Buffer | string;
|
|
26
|
+
getEnv: (key: string) => string | undefined;
|
|
27
|
+
writeOutput: (name: string, value: string) => void;
|
|
28
|
+
log: (message: string) => void;
|
|
29
|
+
warn: (message: string) => void;
|
|
30
|
+
}
|
|
31
|
+
export declare function safeExec(command: string, deps: PrCheckDeps): string | null;
|
|
32
|
+
export declare function normalizeBaseRef(baseRef: string | null | undefined): string | null;
|
|
33
|
+
export declare function parseArgs(argv: string[]): {
|
|
34
|
+
base: string | null;
|
|
35
|
+
head: string | null;
|
|
36
|
+
};
|
|
37
|
+
export declare function splitList(value: string | null): string[];
|
|
38
|
+
export declare function hasSkipChangelog(commits: string[]): boolean;
|
|
39
|
+
export declare function hasConventionalCommits(commits: string[]): boolean;
|
|
40
|
+
export declare function evaluateChangelogStatus(changedFiles: string[], changelogPath: string, commits: string[]): {
|
|
41
|
+
status: ChangelogStatus;
|
|
42
|
+
skipMarker: boolean;
|
|
43
|
+
};
|
|
44
|
+
export declare function getDiffRange(baseRef: string | null, headRef: string): string;
|
|
45
|
+
export declare function runPrCheck(args: {
|
|
46
|
+
base?: string | null;
|
|
47
|
+
head?: string | null;
|
|
48
|
+
}, deps: PrCheckDeps): PrCheckResult;
|
|
49
|
+
export declare function createDefaultDeps(): PrCheckDeps;
|
|
50
|
+
export declare function writeOutputs(result: PrCheckResult, deps: PrCheckDeps): void;
|
|
51
|
+
export declare function renderSummary(result: PrCheckResult, deps: PrCheckDeps): void;
|