release-suite 0.1.0 → 1.0.1

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,206 @@
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
+ - name: Stage dist if exists
91
+ if: steps.dist.outputs.exists == 'true'
92
+ run: git add dist
93
+
94
+ # Automatically create branches, commits, and PRs with peter-evans
95
+ - name: Create Release PR
96
+ if: steps.compute.outputs.status == '0' && steps.compute.outputs.version != ''
97
+ uses: peter-evans/create-pull-request@v6
98
+ with:
99
+ token: ${{ secrets.GITHUB_TOKEN }}
100
+ commit-message: ":bricks: chore(release): prepare version ${{ steps.compute.outputs.version }} [skip ci]"
101
+ branch: release/${{ steps.compute.outputs.version }}
102
+ title: ":bricks: chore(release): ${{ steps.compute.outputs.version }}"
103
+ body: |
104
+ This PR contains the release artifacts:
105
+
106
+ - Updated `package.json` and `package-lock.json` with version ${{ steps.compute.outputs.version }}
107
+ - Updated `CHANGELOG.md` with latest changes
108
+ - Generated `/dist` directory with build files (if applicable)
109
+
110
+ Upon approving & merging, the publish will run automatically.
111
+ labels: |
112
+ release
113
+ add-paths: |
114
+ package.json
115
+ CHANGELOG.md
116
+
117
+ ```
118
+
119
+ ## `publish-on-merge.yml`
120
+
121
+ ```yml
122
+ name: Publish Release
123
+
124
+ on:
125
+ push:
126
+ branches:
127
+ - main
128
+
129
+ permissions:
130
+ contents: write
131
+ id-token: write # 🔐 REQUIRED for OIDC
132
+ packages: write
133
+ pull-requests: write
134
+
135
+ concurrency:
136
+ group: publish-release
137
+ cancel-in-progress: false
138
+
139
+ jobs:
140
+ publish:
141
+ # Only runs when:
142
+ # - The commit message starts with ":bricks: chore(release):"
143
+ if: >
144
+ startsWith(github.event.head_commit.message, ':bricks: chore(release):')
145
+ runs-on: ubuntu-latest
146
+ steps:
147
+ - name: Checkout repository
148
+ uses: actions/checkout@v4
149
+ with:
150
+ fetch-depth: 0
151
+
152
+ - name: Setup Node.js
153
+ uses: actions/setup-node@v4
154
+ with:
155
+ node-version: 24
156
+ registry-url: https://registry.npmjs.org/
157
+
158
+ - name: Install dependencies
159
+ run: npm ci
160
+
161
+ - name: Install release-suite locally (self usage)
162
+ run: npm install .
163
+
164
+ - name: Create Git Tag
165
+ id: tag
166
+ run: |
167
+ set +e
168
+ RESULT=$(npx create-tag)
169
+ STATUS=$?
170
+ TAG=$(echo "$RESULT" | jq -r '.tag // empty')
171
+
172
+ echo "$RESULT"
173
+ echo "status=$STATUS" >> $GITHUB_OUTPUT
174
+ echo "tag=$TAG" >> $GITHUB_OUTPUT
175
+
176
+ # Publish to npm using Trusted Publishing (OIDC)
177
+ - name: Publish to npm (Trusted Publishing)
178
+ if: steps.tag.outputs.status == '0' && steps.tag.outputs.tag != ''
179
+ run: npm publish
180
+
181
+ # Generate release notes for GitHub Release
182
+ - name: Generate GitHub Release Notes
183
+ if: steps.tag.outputs.status == '0' && steps.tag.outputs.tag != ''
184
+ run: npx rs-generate-release-notes
185
+ env:
186
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
187
+
188
+ # Create GitHub Release with notes and attach built assets
189
+ - name: Create GitHub Release + Tag
190
+ if: steps.tag.outputs.status == '0' && steps.tag.outputs.tag != ''
191
+ env:
192
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
193
+ run: |
194
+ VERSION=$(node -p "require('./package.json').version")
195
+
196
+ ASSETS=()
197
+ if [ -d dist ]; then
198
+ ASSETS=(dist/**)
199
+ fi
200
+
201
+ gh release create "$VERSION" \
202
+ --title "$VERSION" \
203
+ --notes-file RELEASE_NOTES.md \
204
+ "${ASSETS[@]}"
205
+
206
+ ```