release-suite 0.1.0 → 1.0.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.
@@ -1,123 +1,135 @@
1
- #!/usr/bin/env node
2
- import { execSync } from "node:child_process";
3
- import fs from "node:fs";
4
-
5
- const run = (cmd) => execSync(cmd, { encoding: "utf8" }).trim();
6
-
7
- const isPreview = process.env.PREVIEW_MODE === "true";
8
- const RELEASE_NOTES_FILE = isPreview
9
- ? "RELEASE_NOTES.preview.md"
10
- : "RELEASE_NOTES.md";
11
-
12
- function ensureGhCLI() {
13
- try {
14
- run("gh --version");
15
- } catch {
16
- console.error("❌ GitHub CLI (gh) is required but not installed.");
17
- console.error(" Install: https://cli.github.com/");
18
- process.exit(2);
19
- }
20
- }
21
-
22
- function getDefaultBranch() {
23
- try {
24
- return run("git remote show origin | sed -n 's/.*HEAD branch: //p'");
25
- } catch {
26
- return "main";
27
- }
28
- }
29
-
30
- function normalizeRepoURL(url) {
31
- if (!url) return null;
32
- url = url.replace(/\.git$/, "");
33
- if (url.startsWith("git@")) {
34
- const m = url.match(/^git@(.*?):(.*)$/);
35
- if (m) return `https://${m[1]}/${m[2]}`;
36
- }
37
- return url;
38
- }
39
-
40
- if (!isPreview) {
41
- ensureGhCLI();
42
- } else {
43
- console.log(" Preview mode: GH CLI not required.");
44
- }
45
-
46
- const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"));
47
- const version = pkg.version;
48
- const repoURL = normalizeRepoURL(pkg.repository?.url);
49
-
50
- let lastTag = "";
51
- try {
52
- lastTag = run("git describe --tags --abbrev=0");
53
- } catch {
54
- console.log("⚠ No previous tags found — first release?");
55
- lastTag = "";
56
- }
57
-
58
- console.log("Last tag:", lastTag || "(none)");
59
-
60
- const baseBranch = getDefaultBranch();
61
-
62
- let prList = [];
63
-
64
- if (!isPreview) {
65
- let prQuery =
66
- `gh pr list --state merged --base ${baseBranch} ` +
67
- `--json number,title,author,url`;
68
-
69
- if (lastTag) prQuery += ` --search "merged:>${lastTag}"`;
70
-
71
- try {
72
- prList = JSON.parse(run(prQuery));
73
- } catch {
74
- prList = [];
75
- }
76
- }
77
-
78
- let notes = `# What's Changed\n\n`;
79
-
80
- if (!prList.length) {
81
- notes += "_No changes since last release._\n\n";
82
- }
83
-
84
- for (const pr of prList) {
85
- notes += `- ${pr.title} by @${pr.author.login} in ${pr.url}\n`;
86
-
87
- let messages = [];
88
- if (!isPreview) {
89
- try {
90
- messages = run(
91
- `gh pr view ${pr.number} --json commits --jq '.commits[].messageHeadline'`
92
- )
93
- .split("\n")
94
- .filter(Boolean);
95
- } catch (err) {
96
- console.error(`⚠ Could not fetch commits for PR #${pr.number}: ${err && err.message ? err.message : err}`);
97
- }
98
- }
99
-
100
- for (const msg of messages) {
101
- notes += ` - ${msg}\n`;
102
- }
103
-
104
- notes += "\n";
105
- }
106
-
107
- const compareLink = repoURL
108
- ? lastTag
109
- ? `${repoURL}/compare/${lastTag}...${version}`
110
- : repoURL
111
- : "";
112
-
113
- if (compareLink) {
114
- notes += `**Full Changelog**: ${compareLink}\n`;
115
- }
116
-
117
- fs.writeFileSync(RELEASE_NOTES_FILE, notes, "utf8");
118
-
119
- console.log(
120
- isPreview
121
- ? "✔ Generated RELEASE_NOTES.preview.md"
122
- : "✔ Generated RELEASE_NOTES.md"
123
- );
1
+ #!/usr/bin/env node
2
+ import { execSync } from "node:child_process";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const run = (cmd, cwd = process.cwd()) => execSync(cmd, { encoding: "utf8", cwd }).trim();
8
+
9
+ function ensureGhCLI() {
10
+ try {
11
+ run("gh --version");
12
+ } catch {
13
+ throw new Error("GitHub CLI (gh) is required but not installed.");
14
+ }
15
+ }
16
+
17
+ function getDefaultBranch(cwd = process.cwd()) {
18
+ try {
19
+ const out = run("git remote show origin", cwd);
20
+ const m = out.match(/HEAD branch: (\S+)/);
21
+ return (m && m[1]) || "main";
22
+ } catch {
23
+ return "main";
24
+ }
25
+ }
26
+
27
+ function normalizeRepoURL(url) {
28
+ if (!url) return null;
29
+ url = url.replace(/\.git$/, "");
30
+ if (url.startsWith("git@")) {
31
+ const m = url.match(/^git@(.*?):(.*)$/);
32
+ if (m) return `https://${m[1]}/${m[2]}`;
33
+ }
34
+ return url;
35
+ }
36
+
37
+ export function generateReleaseNotes({ isPreview = process.env.PREVIEW_MODE === "true", cwd = process.cwd() } = {}) {
38
+ if (!isPreview) {
39
+ try {
40
+ ensureGhCLI();
41
+ } catch (err) {
42
+ console.error(`❌ ${err.message}`);
43
+ console.error(" GitHub CLI (gh) is required but not installed.");
44
+ console.error(" Install: https://cli.github.com/");
45
+ process.exit(2);
46
+ }
47
+ } else {
48
+ console.log("ℹ Preview mode: GH CLI not required.");
49
+ }
50
+
51
+ const pkg = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8"));
52
+ const version = pkg.version;
53
+ const repoURL = normalizeRepoURL(pkg.repository?.url);
54
+
55
+ let lastTag = "";
56
+ try {
57
+ lastTag = run("git describe --tags --abbrev=0", cwd);
58
+ } catch {
59
+ console.log("⚠ No previous tags found — first release?");
60
+ lastTag = "";
61
+ }
62
+
63
+ console.log("Last tag:", lastTag || "(none)");
64
+
65
+ const baseBranch = getDefaultBranch(cwd);
66
+
67
+ let prList = [];
68
+
69
+ if (!isPreview) {
70
+ let prQuery =
71
+ `gh pr list --state merged --base ${baseBranch} ` +
72
+ `--json number,title,author,url`;
73
+
74
+ if (lastTag) prQuery += ` --search "merged:>${lastTag}"`;
75
+
76
+ try {
77
+ prList = JSON.parse(run(prQuery, cwd));
78
+ } catch {
79
+ prList = [];
80
+ }
81
+ }
82
+
83
+ let notes = `# What's Changed\n\n`;
84
+
85
+ if (!prList.length) {
86
+ notes += "_No changes since last release._\n\n";
87
+ }
88
+
89
+ for (const pr of prList) {
90
+ notes += `- ${pr.title} by @${pr.author.login} in ${pr.url}\n`;
91
+
92
+ let messages = [];
93
+ if (!isPreview) {
94
+ try {
95
+ messages = run(
96
+ `gh pr view ${pr.number} --json commits --jq '.commits[].messageHeadline'`,
97
+ cwd
98
+ )
99
+ .split("\n")
100
+ .filter(Boolean);
101
+ } catch (err) {
102
+ console.error(`⚠ Could not fetch commits for PR #${pr.number}: ${err && err.message ? err.message : err}`);
103
+ }
104
+ }
105
+
106
+ for (const msg of messages) {
107
+ notes += ` - ${msg}\n`;
108
+ }
109
+
110
+ notes += "\n";
111
+ }
112
+
113
+ const compareLink = repoURL
114
+ ? lastTag
115
+ ? `${repoURL}/compare/${lastTag}...${version}`
116
+ : repoURL
117
+ : "";
118
+
119
+ if (compareLink) {
120
+ notes += `**Full Changelog**: ${compareLink}\n`;
121
+ }
122
+
123
+ const target = path.join(cwd, isPreview ? "RELEASE_NOTES.preview.md" : "RELEASE_NOTES.md");
124
+ fs.writeFileSync(target, notes, "utf8");
125
+
126
+ console.log(isPreview ? "✔ Generated RELEASE_NOTES.preview.md" : "✔ Generated RELEASE_NOTES.md");
127
+ }
128
+
129
+ function main() {
130
+ const isPreview = process.env.PREVIEW_MODE === "true";
131
+ generateReleaseNotes({ isPreview });
132
+ }
133
+
134
+ const __filename = fileURLToPath(import.meta.url);
135
+ if (process.argv[1] === __filename) main();
package/bin/preview.js CHANGED
@@ -1,47 +1,47 @@
1
- #!/usr/bin/env node
2
- import { execSync } from "node:child_process";
3
- import fs from "node:fs";
4
-
5
- process.env.PREVIEW_MODE = "true";
6
-
7
- const run = (cmd) => execSync(cmd, { encoding: "utf8" }).trim();
8
-
9
- const filesMap = {
10
- changelog: "CHANGELOG.preview.md",
11
- notes: "RELEASE_NOTES.preview.md",
12
- };
13
-
14
- const action = process.argv[2];
15
-
16
- if (!["create", "remove"].includes(action)) {
17
- console.log("Usage: preview.js [create|remove]");
18
- process.exit(1);
19
- }
20
-
21
- if (action === "create") {
22
- console.log("🔧 Generating preview files...");
23
-
24
- const versionOutput = run("node bin/compute-version.js");
25
-
26
- if (versionOutput) {
27
- console.log("🔖 Computed version:");
28
- console.log(versionOutput);
29
- }
30
-
31
- run("node bin/generate-changelog.js");
32
- run("node bin/generate-release-notes.js");
33
-
34
- console.log("✅ Preview ready:");
35
- console.log(" -", filesMap.changelog);
36
- console.log(" -", filesMap.notes);
37
- process.exit(0);
38
- }
39
-
40
- if (action === "remove") {
41
- console.log("🧹 Removing preview files...");
42
- for (const f of Object.values(filesMap)) {
43
- if (fs.existsSync(f)) fs.unlinkSync(f);
44
- }
45
- console.log("✔ Preview cleared.");
46
- process.exit(0);
47
- }
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import { computeVersion } from "./compute-version.js";
4
+ import { generateChangelog } from "./generate-changelog.js";
5
+ import { generateReleaseNotes } from "./generate-release-notes.js";
6
+
7
+ process.env.PREVIEW_MODE = "true";
8
+
9
+ const filesMap = {
10
+ changelog: "CHANGELOG.preview.md",
11
+ notes: "RELEASE_NOTES.preview.md",
12
+ };
13
+
14
+ const action = process.argv[2];
15
+
16
+ if (!["create", "remove"].includes(action)) {
17
+ console.log("Usage: preview.js [create|remove]");
18
+ process.exit(1);
19
+ }
20
+
21
+ if (action === "create") {
22
+ console.log("🔧 Generating preview files...");
23
+
24
+ const versionOutput = computeVersion({ isPreview: true });
25
+
26
+ if (versionOutput) {
27
+ console.log("🔖 Computed version:");
28
+ console.log(versionOutput);
29
+ }
30
+
31
+ generateChangelog({ isPreview: true });
32
+ generateReleaseNotes({ isPreview: true });
33
+
34
+ console.log("✅ Preview ready:");
35
+ console.log(" -", filesMap.changelog);
36
+ console.log(" -", filesMap.notes);
37
+ process.exit(0);
38
+ }
39
+
40
+ if (action === "remove") {
41
+ console.log("🧹 Removing preview files...");
42
+ for (const f of Object.values(filesMap)) {
43
+ if (fs.existsSync(f)) fs.unlinkSync(f);
44
+ }
45
+ console.log("✔ Preview cleared.");
46
+ process.exit(0);
47
+ }
package/docs/api.md ADDED
@@ -0,0 +1,28 @@
1
+ # API Documentation
2
+
3
+ Each `bin/*.js` script now also exposes a programmatic API so you can call
4
+ the core logic directly from Node without spawning child processes. This
5
+ is useful for integration tests, tooling, or when you need to orchestrate
6
+ the actions from another script.
7
+
8
+ Examples:
9
+
10
+ ```js
11
+ import { computeVersion } from "release-suite/bin/compute-version.js";
12
+ import { generateChangelog } from "release-suite/bin/generate-changelog.js";
13
+ import { generateReleaseNotes } from "release-suite/bin/generate-release-notes.js";
14
+
15
+ const result = computeVersion({ cwd: process.cwd() });
16
+ await generateChangelog({ isPreview: true, cwd: process.cwd() });
17
+ await generateReleaseNotes({ isPreview: true, cwd: process.cwd() });
18
+ ```
19
+
20
+ Notes:
21
+
22
+ - `cwd` controls the directory where git/package.json operations run (pass your consumer project's root).
23
+ - `isPreview: true` writes preview files (`CHANGELOG.preview.md`, `RELEASE_NOTES.preview.md`) and relaxes some external requirements (e.g., `gh`).
24
+
25
+ ## computeVersion()
26
+
27
+ > ℹ️ `computeVersion()` follows a strict, immutable contract.
28
+ > See [`compute-version.md`](compute-version.md).
package/docs/ci.md ADDED
@@ -0,0 +1,210 @@
1
+ # CI/CD Examples
2
+
3
+ ## `create-release-pr.yml`
4
+
5
+ ```yml
6
+ name: Create Release PR
7
+
8
+ on:
9
+ pull_request:
10
+ types: [closed]
11
+ branches:
12
+ - main
13
+
14
+ permissions:
15
+ contents: write
16
+ pull-requests: write
17
+
18
+ concurrency:
19
+ group: release-pr-${{ github.ref }}
20
+ cancel-in-progress: true
21
+
22
+ jobs:
23
+ release-pr:
24
+ # Only runs when:
25
+ # - The PR has been merged
26
+ # - The PR does NOT have the "release" label
27
+ if: >
28
+ github.event.pull_request.merged == true &&
29
+ !contains(join(github.event.pull_request.labels.*.name, ','), 'release')
30
+ runs-on: ubuntu-latest
31
+ steps:
32
+ - uses: actions/checkout@v4
33
+ with:
34
+ fetch-depth: 0
35
+ persist-credentials: false
36
+
37
+ # Avoids loops: ignores commits created by bots
38
+ - name: Skip if last commit is from bot
39
+ run: |
40
+ last_author="$(git log -1 --pretty=format:'%an')"
41
+ echo "Last author: $last_author"
42
+ if [[ "$last_author" == *"github-actions"* ]]; then
43
+ echo "Commit created by bot. Canceling workflow."
44
+ exit 0
45
+ fi
46
+
47
+ - uses: actions/setup-node@v4
48
+ with:
49
+ node-version: 24
50
+
51
+ - name: Install dependencies
52
+ run: npm ci
53
+
54
+ - name: Install release-suite locally (self usage)
55
+ run: npm install .
56
+
57
+ # Compute next version to release
58
+ - name: Compute next version
59
+ id: compute
60
+ run: |
61
+ set +e
62
+ RESULT=$(npx rs-compute-version --ci --json)
63
+ STATUS=$?
64
+ VERSION=$(echo "$RESULT" | jq -r '.nextVersion // empty')
65
+
66
+ echo "$RESULT"
67
+ echo "status=$STATUS" >> $GITHUB_OUTPUT
68
+ echo "version=$VERSION" >> $GITHUB_OUTPUT
69
+
70
+ - name: Bump package.json
71
+ if: steps.compute.outputs.status == '0' && steps.compute.outputs.version != ''
72
+ run: npm version ${{ steps.compute.outputs.version }} --no-git-tag-version
73
+
74
+ - name: Build
75
+ run: npm run build --if-present
76
+
77
+ - name: Generate changelog
78
+ if: steps.compute.outputs.status == '0' && steps.compute.outputs.version != ''
79
+ run: npx rs-generate-changelog
80
+
81
+ - name: Detect dist directory
82
+ id: dist
83
+ run: |
84
+ if [ -d dist ]; then
85
+ echo "exists=true" >> "$GITHUB_OUTPUT"
86
+ else
87
+ echo "exists=false" >> "$GITHUB_OUTPUT"
88
+ fi
89
+
90
+ # Automatically create branches, commits, and PRs with peter-evans
91
+ - name: Create Release PR
92
+ if: steps.compute.outputs.status == '0' && steps.compute.outputs.version != ''
93
+ uses: peter-evans/create-pull-request@v6
94
+ with:
95
+ token: ${{ secrets.GITHUB_TOKEN }}
96
+ commit-message: ":bricks: chore(release): prepare version ${{ steps.compute.outputs.version }} [skip ci]"
97
+ branch: release/${{ steps.compute.outputs.version }}
98
+ title: ":bricks: chore(release): ${{ steps.compute.outputs.version }}"
99
+ body: |
100
+ This PR contains the release artifacts:
101
+
102
+ - Updated `package.json` and `package-lock.json` with version ${{ steps.compute.outputs.version }}
103
+ - Updated `CHANGELOG.md` with latest changes
104
+ - Generated `/dist` directory with build files (if applicable)
105
+
106
+ Upon approving & merging, the publish will run automatically.
107
+ labels: |
108
+ release
109
+ add-paths: |
110
+ package.json
111
+ CHANGELOG.md
112
+ ${{ steps.dist.outputs.exists == 'true' && 'dist/**' || '' }}
113
+
114
+ ```
115
+
116
+ ## `publish-on-merge.yml`
117
+
118
+ ```yml
119
+ name: Publish Release
120
+
121
+ on:
122
+ pull_request:
123
+ types: [closed]
124
+ branches:
125
+ - main
126
+
127
+ permissions:
128
+ contents: write
129
+ id-token: write # 🔐 REQUIRED for OIDC
130
+ packages: write
131
+ pull-requests: write
132
+
133
+ concurrency:
134
+ group: publish-release
135
+ cancel-in-progress: false
136
+
137
+ jobs:
138
+ publish:
139
+ # Only runs when:
140
+ # - The PR has been merged
141
+ # - The PR has the "release" label
142
+ # - The PR title starts with ":bricks: chore(release):"
143
+ # - The PR was created by a bot
144
+ if: >
145
+ github.event.pull_request.merged == true &&
146
+ startsWith(github.event.pull_request.title, ':bricks: chore(release):') &&
147
+ contains(join(github.event.pull_request.labels.*.name, ','), 'release') &&
148
+ github.event.pull_request.user.type == 'Bot'
149
+ runs-on: ubuntu-latest
150
+ steps:
151
+ - name: Checkout repository
152
+ uses: actions/checkout@v4
153
+ with:
154
+ fetch-depth: 0
155
+
156
+ - name: Setup Node.js
157
+ uses: actions/setup-node@v4
158
+ with:
159
+ node-version: 24
160
+ registry-url: https://registry.npmjs.org/
161
+
162
+ - name: Install dependencies
163
+ run: npm ci
164
+
165
+ - name: Install release-suite locally (self usage)
166
+ run: npm install .
167
+
168
+ - name: Create Git Tag
169
+ id: tag
170
+ run: |
171
+ set +e
172
+ RESULT=$(npx create-tag)
173
+ STATUS=$?
174
+ TAG=$(echo "$RESULT" | jq -r '.tag // empty')
175
+
176
+ echo "$RESULT"
177
+ echo "status=$STATUS" >> $GITHUB_OUTPUT
178
+ echo "tag=$TAG" >> $GITHUB_OUTPUT
179
+
180
+ # Publish to npm using Trusted Publishing (OIDC)
181
+ - name: Publish to npm (Trusted Publishing)
182
+ if: steps.tag.outputs.status == '0' && steps.tag.outputs.tag != ''
183
+ run: npm publish
184
+
185
+ # Generate release notes for GitHub Release
186
+ - name: Generate GitHub Release Notes
187
+ if: steps.tag.outputs.status == '0' && steps.tag.outputs.tag != ''
188
+ run: npx rs-generate-release-notes
189
+ env:
190
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
191
+
192
+ # Create GitHub Release with notes and attach built assets
193
+ - name: Create GitHub Release + Tag
194
+ if: steps.tag.outputs.status == '0' && steps.tag.outputs.tag != ''
195
+ env:
196
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
197
+ run: |
198
+ VERSION=$(node -p "require('./package.json').version")
199
+
200
+ ASSETS=()
201
+ if [ -d dist ]; then
202
+ ASSETS=(dist/**)
203
+ fi
204
+
205
+ gh release create "$VERSION" \
206
+ --title "$VERSION" \
207
+ --notes-file RELEASE_NOTES.md \
208
+ "${ASSETS[@]}"
209
+
210
+ ```