release-suite 0.1.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.
@@ -0,0 +1,94 @@
1
+ name: Create Release PR
2
+
3
+ on:
4
+ pull_request:
5
+ types: [closed]
6
+ branches:
7
+ - main
8
+
9
+ permissions:
10
+ contents: write
11
+ pull-requests: write
12
+
13
+ concurrency:
14
+ group: release-pr-${{ github.ref }}
15
+ cancel-in-progress: true
16
+
17
+ jobs:
18
+ release-pr:
19
+ # Only runs when:
20
+ # - The PR has been merged
21
+ # - And the branch is NOT release/v/*
22
+ if: >
23
+ github.event.pull_request.merged == true &&
24
+ !startsWith(github.event.pull_request.head.ref, 'release/v/')
25
+ runs-on: ubuntu-latest
26
+
27
+ steps:
28
+ - uses: actions/checkout@v4
29
+ with:
30
+ fetch-depth: 0
31
+ persist-credentials: false
32
+
33
+ # Avoids loops: ignores commits created by bots
34
+ - name: Skip if last commit is from bot
35
+ run: |
36
+ last_author="$(git log -1 --pretty=format:'%an')"
37
+ echo "Last author: $last_author"
38
+ if [[ "$last_author" == *"github-actions"* ]]; then
39
+ echo "Commit created by bot. Canceling workflow."
40
+ exit 0
41
+ fi
42
+
43
+ - uses: actions/setup-node@v4
44
+ with:
45
+ node-version: 24
46
+
47
+ - name: Install dependencies
48
+ run: npm ci
49
+
50
+ - name: Install release-suite locally (self usage)
51
+ run: npm install .
52
+
53
+ # Compute next version to release
54
+ - name: Compute next version
55
+ id: semver
56
+ run: |
57
+ VERSION=$(rs-compute-version || echo "")
58
+ echo "version=$VERSION" >> $GITHUB_OUTPUT
59
+
60
+ - name: Stop if no release needed
61
+ if: steps.semver.outputs.version == ''
62
+ run: exit 0
63
+
64
+ - name: Bump package.json
65
+ run: npm version ${{ steps.semver.outputs.version }} --no-git-tag-version
66
+
67
+ - name: Build
68
+ run: npm run build --if-present
69
+
70
+ - name: Generate changelog
71
+ run: rs-generate-changelog
72
+
73
+ # Automatically create branches, commits, and PRs with peter-evans
74
+ - name: Create Release PR
75
+ uses: peter-evans/create-pull-request@v6
76
+ with:
77
+ token: ${{ secrets.GITHUB_TOKEN }}
78
+ commit-message: ":bricks: chore(release): prepare version ${{ steps.semver.outputs.version }} [skip ci]"
79
+ branch: release/v/${{ steps.semver.outputs.version }}
80
+ title: ":bricks: chore(release): ${{ steps.semver.outputs.version }}"
81
+ body: |
82
+ This PR contains the release artifacts:
83
+
84
+ - Updated `package.json` and `package-lock.json` with version ${{ steps.semver.outputs.version }}
85
+ - Updated `CHANGELOG.md` with latest changes
86
+ - Generated `/dist` directory with build files (if applicable)
87
+
88
+ Upon approving & merging, the publish will run automatically.
89
+ labels: |
90
+ release
91
+ add-paths: |
92
+ package.json
93
+ CHANGELOG.md
94
+ dist/**
@@ -0,0 +1,73 @@
1
+ name: Publish Release
2
+
3
+ on:
4
+ pull_request:
5
+ types: [closed]
6
+ branches:
7
+ - main
8
+
9
+ permissions:
10
+ contents: write
11
+ id-token: write # ๐Ÿ” REQUIRED for OIDC
12
+ packages: write
13
+ pull-requests: write
14
+
15
+ concurrency:
16
+ group: publish-release
17
+ cancel-in-progress: false
18
+
19
+ jobs:
20
+ publish:
21
+ # Only runs when:
22
+ # - The PR has been merged
23
+ # - And the branch is release/v/*
24
+ if: >
25
+ github.event.pull_request.merged == true &&
26
+ startsWith(github.event.pull_request.head.ref, 'release/v/')
27
+ runs-on: ubuntu-latest
28
+
29
+ steps:
30
+ - name: Checkout repository
31
+ uses: actions/checkout@v4
32
+ with:
33
+ fetch-depth: 0
34
+
35
+ - name: Setup Node.js
36
+ uses: actions/setup-node@v4
37
+ with:
38
+ node-version: 24
39
+ registry-url: https://registry.npmjs.org/
40
+
41
+ - name: Install dependencies
42
+ run: npm ci
43
+
44
+ - name: Install release-suite locally (self usage)
45
+ run: npm install .
46
+
47
+ - name: Build
48
+ run: npm run build --if-present
49
+
50
+ - name: Create Git Tag
51
+ run: rs-create-tag
52
+
53
+ # Publish to npm using Trusted Publishing (OIDC)
54
+ - name: Publish to npm (Trusted Publishing)
55
+ run: npm publish
56
+
57
+ # Generate release notes for GitHub Release
58
+ - name: Generate GitHub Release Notes
59
+ run: rs-generate-release-notes
60
+ env:
61
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
62
+
63
+ # Create GitHub Release with notes and attach built assets
64
+ - name: Create GitHub Release + Tag
65
+ env:
66
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
67
+ run: |
68
+ VERSION=$(node -p "require('./package.json').version")
69
+
70
+ gh release create "$VERSION" \
71
+ --title "$VERSION" \
72
+ --notes-file RELEASE_NOTES.md \
73
+ ./dist/* || true
package/.prettierrc ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "tabWidth": 2,
3
+ "useTabs": false,
4
+ "printWidth": 100,
5
+ "trailingComma": "none"
6
+ }
package/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## 0.1.0
2
+
3
+ ### ๐Ÿ”ง Chore
4
+
5
+ - Init: project created
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Jonas Souza
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,183 @@
1
+ # ๐ŸŽ‰ Release Suite
2
+
3
+ Semantic versioning tools for Git-based projects, providing automated version computation, changelog generation and release notes creation.
4
+
5
+ ## ๐Ÿš€ Features
6
+
7
+ - Automatic version bump based on commit messages
8
+ - Conventional commit parsing (custom prefixes supported)
9
+ - Auto-generated `CHANGELOG.md`
10
+ - Auto-generated `RELEASE_NOTES.md` using GitHub CLI (gh)
11
+ - Local preview mode (`CHANGELOG.preview.md`, `RELEASE_NOTES.preview.md`)
12
+ - CI/CD ready for GitHub Actions
13
+ - No commit rules enforced on the main project
14
+ - Trusted Publishing (OIDC) โ€” no npm tokens required
15
+
16
+ ## โš™๏ธ Installation
17
+
18
+ ```bash
19
+ npm install release-suite --save-dev
20
+ ```
21
+
22
+ ## ๐Ÿ–ฅ๏ธ CLI Commands
23
+
24
+ | Command | Description |
25
+ | --------------------------- | --------------------------------------------------------- |
26
+ | `rs-compute-version` | Computes next semantic version based on git commits |
27
+ | `rs-generate-changelog` | Generates `CHANGELOG.md` |
28
+ | `rs-generate-release-notes` | Generates `RELEASE_NOTES.md` using GitHub PRs |
29
+ | `rs-preview` | Generates preview changelog & release notes |
30
+ | `rs-create-tag` | Create and push a git tag based on `package.json` version |
31
+
32
+ ## ๐Ÿง  Usage
33
+
34
+ Add to your project's `package.json`:
35
+
36
+ ```json
37
+ {
38
+ "scripts": {
39
+ "version:compute": "rs-compute-version",
40
+ "changelog": "rs-generate-changelog",
41
+ "release:notes": "rs-generate-release-notes",
42
+ "preview": "rs-preview create",
43
+ "preview:clear": "rs-preview remove",
44
+ "create-tag": "rs-create-tag",
45
+ "create-tag:compute": "rs-create-tag --compute",
46
+ "create-tag:dry": "rs-create-tag --dry-run"
47
+ }
48
+ }
49
+ ```
50
+
51
+ ## ๐Ÿค– CI/CD Usage (GitHub Actions)
52
+
53
+ This project is designed to be used in automated pipelines.
54
+
55
+ Typical flow:
56
+
57
+ 1. Create a Release PR (compute version, changelog, build)
58
+ 2. Review and merge the Release PR into `main`
59
+ 3. Publish the release (tag, npm, GitHub Release)
60
+
61
+ Example workflows:
62
+ - create-release-pr.yml
63
+ - publish-on-merge.yml
64
+
65
+ ## ๐Ÿ” Release Flow
66
+
67
+ This project follows a **two-step release strategy** designed for safety,
68
+ automation and reusability.
69
+
70
+ ### 1๏ธโƒฃ Prepare Release (Create Release PR)
71
+
72
+ Triggered when:
73
+
74
+ - A PR is merged into `main`
75
+
76
+ Actions:
77
+
78
+ - Computes next semantic version
79
+ - Updates `package.json`
80
+ - Generates `CHANGELOG.md`
81
+ - Builds the project (if applicable)
82
+ - Opens a **Release PR** (`release/v/x.y.z`)
83
+
84
+ ### 2๏ธโƒฃ Publish Release
85
+
86
+ Triggered when:
87
+
88
+ - A Release PR (`release/v/*`) is merged into `main`
89
+
90
+ Actions:
91
+
92
+ - Creates a Git tag
93
+ - Publishes to npm using **Trusted Publishing (OIDC)**
94
+ - Generates GitHub Release Notes
95
+ - Uploads build artifacts (`dist/**`)
96
+
97
+ ---
98
+
99
+ ### ๐Ÿ“Š Flow Diagram
100
+
101
+ ```mermaid
102
+ flowchart TD
103
+ A[Feature / Fix PR] -->|Merge| B[main]
104
+ B -->|create-release-pr.yml| C[Create Release PR]
105
+ C -->|release/v/x.y.z| D[Review & Merge]
106
+ D -->|publish-on-merge.yml| E[Publish Release]
107
+ E --> F[npm Publish]
108
+ E --> G[GitHub Release]
109
+ ```
110
+
111
+ โœ”๏ธ Fully automated releases
112
+ โœ”๏ธ No npm tokens or secrets required (OIDC)
113
+ โœ”๏ธ No release loops
114
+ โœ”๏ธ Safe for concurrent merges
115
+ โœ”๏ธ Reusable in any project
116
+
117
+ ## ๐Ÿ“ฆ Publishing to npm (Trusted Publishing)
118
+
119
+ This project uses **npm [Trusted Publishing](https://docs.npmjs.com/trusted-publishers) with GitHub Actions (OIDC)**.
120
+
121
+ - No npm tokens or secrets are required
122
+ - Publishing is handled entirely by GitHub Actions
123
+ - Triggered automatically when a Release PR is merged into `main`
124
+
125
+ ## ๐Ÿ” Preview Mode
126
+
127
+ Generate preview files without touching your real changelog:
128
+
129
+ ```bash
130
+ npm run preview
131
+ ```
132
+
133
+ Remove previews:
134
+
135
+ ```bash
136
+ npm run preview:clear
137
+ ```
138
+
139
+ ## ๐Ÿ›  Development (Maintainers)
140
+
141
+ When working inside the `release-suite` repository itself, the CLI binaries
142
+ are **not available via npm or npx**, since they are not installed as a dependency.
143
+
144
+ In this case, run the scripts directly with Node.js:
145
+
146
+ ```bash
147
+ node bin/compute-version.js
148
+ node bin/generate-changelog.js
149
+ node bin/generate-release-notes.js
150
+ node bin/preview.js create
151
+ node bin/create-tag.js
152
+ ```
153
+
154
+ To test the CLI as a real consumer, you can use:
155
+
156
+ ```bash
157
+ npm link
158
+ # or
159
+ npm install ../release-suite
160
+ ```
161
+
162
+ ## ๐Ÿ“„ License
163
+
164
+ This project is licensed under the [MIT License](./LICENSE).
165
+
166
+ ---
167
+
168
+ ## โœจ Author
169
+
170
+ <table>
171
+ <tr>
172
+ <td align="center">
173
+ <a href="https://jonasmzsouza.github.io/">
174
+ <img style="border-radius: 50%;" src="https://avatars.githubusercontent.com/u/61324433?v=4" width="100px;" alt=""/>
175
+ <br />
176
+ <sub><b>Jonas Souza</b></sub>
177
+ </a>
178
+ </td>
179
+ </tr>
180
+ </table>
181
+
182
+ ๐Ÿ’ผ [LinkedIn](https://linkedin.com/in/jonasmzsouza)
183
+ ๐Ÿ’ป [GitHub](https://github.com/jonasmzsouza)
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env node
2
+ import { execSync } from "node:child_process";
3
+ import fs from "node:fs";
4
+
5
+ function run(cmd) {
6
+ return execSync(cmd, { encoding: "utf8" }).trim();
7
+ }
8
+
9
+ const isPreview = process.env.PREVIEW_MODE === "true";
10
+
11
+ function getPackageVersion() {
12
+ try {
13
+ const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"));
14
+ return pkg.version;
15
+ } catch {
16
+ return "0.0.0";
17
+ }
18
+ }
19
+
20
+ function getLastTag() {
21
+ try {
22
+ const tag = run("git describe --tags --abbrev=0");
23
+ return tag.replace(/^v/, "");
24
+ } catch {
25
+ const pkgVersion = getPackageVersion();
26
+ if (isPreview) {
27
+ console.log(
28
+ `โš  No previous tag found. Using package.json version (${pkgVersion}) as base.`
29
+ );
30
+ }
31
+ return pkgVersion;
32
+ }
33
+ }
34
+
35
+ function getCommitsSince(version) {
36
+ try {
37
+ // If version came from package.json (no tag), include all commits
38
+ const hasTag = (() => {
39
+ try {
40
+ run("git describe --tags --abbrev=0");
41
+ return true;
42
+ } catch {
43
+ return false;
44
+ }
45
+ })();
46
+
47
+ const range = hasTag ? `${version}..HEAD` : "HEAD";
48
+
49
+ return run(
50
+ `git log ${range} --pretty=format:%H%x1f%s%x1f%b`
51
+ )
52
+ .split("\n")
53
+ .filter(Boolean);
54
+ } catch {
55
+ return [];
56
+ }
57
+ }
58
+
59
+ function parseCommitLine(line) {
60
+ const [hash, subject = "", body = ""] = line.split("\x1f");
61
+ return { hash, subject, body };
62
+ }
63
+
64
+ function detectType(subject, body) {
65
+ const re =
66
+ /^(:\S+: )?(feat|fix|refactor|docs|chore|style|test|build|perf|ci|raw|cleanup|remove)(\(.+\))?(!)?:/i;
67
+
68
+ const m = subject.match(re);
69
+ const isBreaking =
70
+ /BREAKING CHANGE/i.test(body) || (m && m[4] === "!");
71
+
72
+ if (isBreaking) return "major";
73
+ if (m) {
74
+ const t = m[2].toLowerCase();
75
+ if (t === "feat") return "minor";
76
+ if (t === "fix") return "patch";
77
+ }
78
+ return "none";
79
+ }
80
+
81
+ function bumpVersion(type, version) {
82
+ const [major, minor, patch] = version
83
+ .split(".")
84
+ .map(n => parseInt(n, 10) || 0);
85
+
86
+ if (type === "major") return `${major + 1}.0.0`;
87
+ if (type === "minor") return `${major}.${minor + 1}.0`;
88
+ return `${major}.${minor}.${patch + 1}`;
89
+ }
90
+
91
+ function main() {
92
+ const baseVersion = getLastTag();
93
+ const commits = getCommitsSince(baseVersion).map(parseCommitLine);
94
+
95
+ if (!commits.length) process.exit(0);
96
+
97
+ let bump = null;
98
+
99
+ for (const c of commits) {
100
+ const t = detectType(c.subject, c.body);
101
+ if (t === "major") {
102
+ bump = "major";
103
+ break;
104
+ }
105
+ if (t === "minor" && bump !== "major") bump = "minor";
106
+ if (t === "patch" && !bump) bump = "patch";
107
+ }
108
+
109
+ if (!bump) {
110
+ if (isPreview) {
111
+ console.log("Mode: PREVIEW");
112
+ console.log("Base version:", baseVersion);
113
+ console.log("Commits analyzed:", commits.length);
114
+ console.log("No version bump detected.");
115
+ }
116
+ process.exit(0);
117
+ }
118
+
119
+ const nextVersion = bumpVersion(bump, baseVersion);
120
+
121
+ if (isPreview) {
122
+ console.log("Mode: PREVIEW");
123
+ console.log("Base version:", baseVersion);
124
+ console.log("Commits analyzed:", commits.length);
125
+ console.log("Highest bump detected:", bump);
126
+ console.log("Next version:", nextVersion);
127
+ } else {
128
+ console.log(nextVersion);
129
+ }
130
+ }
131
+
132
+ main();
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+ import { execSync } from "node:child_process";
3
+ import fs from "node:fs";
4
+
5
+ function run(cmd, silent = false) {
6
+ return execSync(cmd, {
7
+ stdio: silent ? "pipe" : "inherit",
8
+ encoding: "utf8",
9
+ }).trim();
10
+ }
11
+
12
+ const args = process.argv.slice(2);
13
+ const DRY_RUN = args.includes("--dry-run");
14
+ const USE_COMPUTED = args.includes("--compute");
15
+
16
+ let version;
17
+
18
+ if (USE_COMPUTED) {
19
+ console.log("๐Ÿ”ข Computing version dynamically...");
20
+ try {
21
+ version = run("node bin/compute-version.js", true);
22
+ } catch {
23
+ console.error("โŒ Failed to compute version.");
24
+ process.exit(1);
25
+ }
26
+
27
+ if (!version) {
28
+ console.log("โ„น No version bump detected. Skipping tag creation.");
29
+ process.exit(0);
30
+ }
31
+ } else {
32
+ console.log("๐Ÿ“ฆ Using version from package.json...");
33
+ try {
34
+ const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"));
35
+ version = pkg.version;
36
+ } catch {
37
+ console.error("โŒ Failed to read package.json version.");
38
+ process.exit(1);
39
+ }
40
+ }
41
+
42
+ const tag = version;
43
+
44
+ console.log(`๐Ÿ”– Release version: ${tag}`);
45
+
46
+ // check if tag exists
47
+ try {
48
+ run(`git rev-parse ${tag}`, true);
49
+ console.error(`โŒ Tag ${tag} already exists.`);
50
+ process.exit(1);
51
+ } catch {
52
+ // OK
53
+ }
54
+
55
+ if (DRY_RUN) {
56
+ console.log("๐Ÿงช Dry-run mode enabled.");
57
+ console.log(`Would create and push tag: ${tag}`);
58
+ console.log(`VERSION=${tag}`);
59
+ process.exit(0);
60
+ }
61
+
62
+ run(`git tag ${tag}`);
63
+ run(`git push origin ${tag}`);
64
+
65
+ console.log(`โœ” Tag ${tag} created and pushed`);
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env node
2
+ import { execSync } from "node:child_process";
3
+ import fs from "node:fs";
4
+
5
+ function run(cmd) {
6
+ return execSync(cmd, { encoding: "utf8" }).trim();
7
+ }
8
+
9
+ function getAllTags() {
10
+ try {
11
+ return run("git tag --sort=-creatordate")
12
+ .split("\n")
13
+ .filter(Boolean);
14
+ } catch {
15
+ return [];
16
+ }
17
+ }
18
+
19
+ function getCommitsBetween(from, to) {
20
+ const range = from ? `${from}..${to}` : to;
21
+ try {
22
+ return run(`git log ${range} --pretty=format:%H%x1f%s%x1f%b`)
23
+ .split("\n")
24
+ .filter(Boolean);
25
+ } catch {
26
+ return [];
27
+ }
28
+ }
29
+
30
+ function parseCommit(line) {
31
+ const [hash, subject = "", body = ""] = line.split("\x1f");
32
+ return { hash, subject: subject.trim(), body: body.trim() };
33
+ }
34
+
35
+ function cleanSubject(subject) {
36
+ let s = subject.replace(/^(:\S+: )?/, "");
37
+ s = s.replace(
38
+ /^(feat|fix|refactor|docs|chore|style|test|build|perf|ci|raw|cleanup|remove)(\(.+\))?(!)?:\s*/i,
39
+ ""
40
+ );
41
+ return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
42
+ }
43
+
44
+ function categorize(commits) {
45
+ const buckets = {
46
+ breaking: [],
47
+ feat: [],
48
+ fix: [],
49
+ refactor: [],
50
+ chore: [],
51
+ docs: [],
52
+ style: [],
53
+ test: [],
54
+ build: [],
55
+ perf: [],
56
+ ci: [],
57
+ raw: [],
58
+ cleanup: [],
59
+ remove: [],
60
+ };
61
+
62
+ const reType =
63
+ /^(:\S+: )?(feat|fix|refactor|docs|chore|style|test|build|perf|ci|raw|cleanup|remove)(\(.+\))?(!)?:/i;
64
+
65
+ for (const c of commits) {
66
+ const { subject, body } = c;
67
+
68
+ if (/BREAKING CHANGE/i.test(body) || /!:/i.test(subject)) {
69
+ buckets.breaking.push({ desc: cleanSubject(subject), hash: c.hash });
70
+ continue;
71
+ }
72
+
73
+ const match = subject.match(reType);
74
+ const desc = cleanSubject(subject);
75
+
76
+ if (!match) {
77
+ buckets.chore.push({ desc: desc || subject, hash: c.hash });
78
+ continue;
79
+ }
80
+
81
+ const type = match[2].toLowerCase();
82
+ (buckets[type] || buckets.chore).push({ desc, hash: c.hash });
83
+ }
84
+
85
+ return buckets;
86
+ }
87
+
88
+ function buildSection(version, buckets) {
89
+ const out = [];
90
+ out.push(`## ${version}\n`);
91
+
92
+ const sections = [
93
+ ["breaking", "### ๐Ÿ’ฅ Breaking Changes"],
94
+ ["feat", "### โœจ Features"],
95
+ ["fix", "### ๐Ÿ› Fixes"],
96
+ ["refactor", "### โš™๏ธ Refactor"],
97
+ ["chore", "### ๐Ÿ”ง Chore"],
98
+ ["docs", "### ๐Ÿ“š Docs"],
99
+ ["style", "### ๐ŸŽจ Style"],
100
+ ["test", "### ๐Ÿงช Tests"],
101
+ ["build", "### ๐Ÿ›  Build"],
102
+ ["perf", "### โšก Performance"],
103
+ ["ci", "### ๐Ÿ” CI"],
104
+ ["raw", "### ๐Ÿ—ƒ Raw"],
105
+ ["cleanup", "### ๐Ÿงน Cleanup"],
106
+ ["remove", "### ๐Ÿ—‘ Remove"],
107
+ ];
108
+
109
+ let hasContent = false;
110
+
111
+ for (const [key, title] of sections) {
112
+ if (buckets[key].length) {
113
+ hasContent = true;
114
+ out.push(`${title}\n`);
115
+ for (const c of buckets[key]) out.push(`- ${c.desc}`);
116
+ out.push("");
117
+ }
118
+ }
119
+
120
+ if (!hasContent) out.push("_No changes._\n");
121
+
122
+ return out.join("\n");
123
+ }
124
+
125
+ function changelogHasVersion(file, version) {
126
+ if (!fs.existsSync(file)) return false;
127
+ const content = fs.readFileSync(file, "utf8");
128
+ const safe = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
129
+ return new RegExp(`^##\\s+${safe}\\b`, "m").test(content);
130
+ }
131
+
132
+ function main() {
133
+ const isPreview = process.env.PREVIEW_MODE === "true";
134
+ const CHANGELOG_FILE = isPreview
135
+ ? "CHANGELOG.preview.md"
136
+ : "CHANGELOG.md";
137
+
138
+ const tags = getAllTags();
139
+ const sections = [];
140
+
141
+ if (tags.length === 0) {
142
+ const pkgVersion = (() => {
143
+ try {
144
+ const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"));
145
+ return pkg.version || "0.1.0";
146
+ } catch {
147
+ return "0.1.0";
148
+ }
149
+ })();
150
+
151
+ if (!changelogHasVersion(CHANGELOG_FILE, pkgVersion)) {
152
+ const commits = getCommitsBetween(null, "HEAD").map(parseCommit);
153
+ const buckets = categorize(commits);
154
+ sections.push(buildSection(pkgVersion, buckets));
155
+ }
156
+ } else {
157
+ for (let i = 0; i < tags.length; i++) {
158
+ const tag = tags[i];
159
+ const previous = tags[i + 1] || null;
160
+
161
+ if (changelogHasVersion(CHANGELOG_FILE, tag)) continue;
162
+
163
+ const commits = getCommitsBetween(previous, tag).map(parseCommit);
164
+ const buckets = categorize(commits);
165
+ sections.push(buildSection(tag, buckets));
166
+ }
167
+ }
168
+
169
+ if (!sections.length) {
170
+ console.log("โ„น No new versions to add.");
171
+ return;
172
+ }
173
+
174
+ if (isPreview) {
175
+ fs.writeFileSync(CHANGELOG_FILE, sections.join("\n"), "utf8");
176
+ } else {
177
+ const existing = fs.existsSync(CHANGELOG_FILE)
178
+ ? "\n" + fs.readFileSync(CHANGELOG_FILE, "utf8")
179
+ : "";
180
+ fs.writeFileSync(
181
+ CHANGELOG_FILE,
182
+ sections.join("\n") + existing,
183
+ "utf8"
184
+ );
185
+ }
186
+
187
+ console.log(
188
+ isPreview ? "CHANGELOG preview generated." : "CHANGELOG updated."
189
+ );
190
+ }
191
+
192
+ main();
@@ -0,0 +1,123 @@
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
+ );
package/bin/preview.js ADDED
@@ -0,0 +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
+ }
@@ -0,0 +1,47 @@
1
+ // eslint.config.js
2
+ import js from "@eslint/js";
3
+ import prettierConfig from "eslint-config-prettier";
4
+ import pluginImport from "eslint-plugin-import";
5
+ import globals from "globals";
6
+
7
+ export default [
8
+ js.configs.recommended,
9
+ {
10
+ files: ["bin/**/*.js"],
11
+ plugins: {
12
+ import: pluginImport,
13
+ },
14
+ languageOptions: {
15
+ ecmaVersion: "latest",
16
+ sourceType: "module",
17
+ globals: {
18
+ ...globals.node,
19
+ },
20
+ },
21
+ rules: {
22
+ "no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
23
+ "no-console": "off",
24
+ "prefer-const": "warn",
25
+ eqeqeq: ["error", "always"],
26
+ curly: ["error", "all"],
27
+ "import/order": [
28
+ "warn",
29
+ {
30
+ groups: [
31
+ "builtin",
32
+ "external",
33
+ "internal",
34
+ "parent",
35
+ "sibling",
36
+ "index",
37
+ ],
38
+ alphabetize: { order: "asc", caseInsensitive: true },
39
+ },
40
+ ],
41
+ },
42
+ },
43
+ prettierConfig,
44
+ {
45
+ ignores: ["dist/**/*.js"],
46
+ },
47
+ ];
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "release-suite",
3
+ "version": "0.1.0",
4
+ "description": "Semantic versioning tools for Git-based projects, providing automated version computation, changelog generation and release notes creation.",
5
+ "type": "module",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "bin": {
10
+ "rs-compute-version": "bin/compute-version.js",
11
+ "rs-generate-changelog": "bin/generate-changelog.js",
12
+ "rs-generate-release-notes": "bin/generate-release-notes.js",
13
+ "rs-preview": "bin/preview.js",
14
+ "rs-create-tag": "bin/create-tag.js"
15
+ },
16
+ "scripts": {
17
+ "lint": "eslint bin/",
18
+ "lint:fix": "eslint bin/ --fix",
19
+ "version:compute": "node bin/compute-version.js",
20
+ "changelog": "node bin/generate-changelog.js",
21
+ "release:notes": "node bin/generate-release-notes.js",
22
+ "preview": "node bin/preview.js create",
23
+ "preview:clear": "node bin/preview.js remove",
24
+ "create-tag": "node bin/create-tag.js",
25
+ "create-tag:compute": "node bin/create-tag.js --compute",
26
+ "create-tag:dry": "node bin/create-tag.js --dry-run",
27
+ "test": "echo \"Error: no test specified\" && exit 1"
28
+ },
29
+ "engines": {
30
+ "node": ">=18"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "https://github.com/jonasmzsouza/release-suite"
35
+ },
36
+ "keywords": [
37
+ "release",
38
+ "tools",
39
+ "semantic",
40
+ "versioning",
41
+ "changelog",
42
+ "release-notes",
43
+ "publish",
44
+ "npm",
45
+ "nodejs"
46
+ ],
47
+ "author": {
48
+ "name": "Jonas Souza",
49
+ "email": "jonasmzsouza@gmail.com"
50
+ },
51
+ "license": "MIT",
52
+ "bugs": {
53
+ "url": "https://github.com/jonasmzsouza/release-suite/issues"
54
+ },
55
+ "devDependencies": {
56
+ "esbuild": "^0.25.11",
57
+ "eslint": "9.38.0",
58
+ "eslint-config-prettier": "^10.1.8",
59
+ "eslint-plugin-import": "^2.32.0",
60
+ "globals": "^16.5.0"
61
+ }
62
+ }