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.
@@ -0,0 +1,204 @@
1
+ # ๐Ÿ“ฆ computeVersion
2
+
3
+ `computeVersion()` is the core engine of **Release Suite**. It is responsible for analyzing Git history and determining whether a new semantic version should be released.
4
+
5
+ This document defines its **official, immutable contract**, behavior, limitations, and CLI integration rules.
6
+
7
+ ---
8
+
9
+ ## ๐ŸŽฏ Purpose
10
+
11
+ - Analyze Git commits since the last release
12
+ - Detect semantic version bumps (`major`, `minor`, `patch`)
13
+ - Decide **if** a release should happen
14
+ - Provide deterministic, machine-readable output
15
+
16
+ `computeVersion()` **never mutates files**, **never prints logs**, and **never exits the process**.
17
+
18
+ ---
19
+
20
+ ## ๐Ÿง  Programmatic API
21
+
22
+ ### Signature
23
+
24
+ ```ts
25
+ computeVersion(options?: {
26
+ cwd?: string;
27
+ }): ComputeVersionResult
28
+ ```
29
+
30
+ ### Options
31
+
32
+ | Option | Description |
33
+ | ------ | ---------------------------------------------------------------------------------------- |
34
+ | `cwd` | Working directory where Git and `package.json` are resolved. Defaults to `process.cwd()` |
35
+
36
+ ---
37
+
38
+ ## ๐Ÿ“œ Official Return Contract (Frozen)
39
+
40
+ ### Type Definition
41
+
42
+ ```ts
43
+ type ComputeVersionResult =
44
+ | {
45
+ hasRelease: true;
46
+ baseVersion: string;
47
+ nextVersion: string;
48
+ bump: "major" | "minor" | "patch";
49
+ commitsAnalyzed: number;
50
+ }
51
+ | {
52
+ hasRelease: false;
53
+ baseVersion: string;
54
+ reason: "no-bump-detected" | "no-commits";
55
+ commitsAnalyzed: number;
56
+ };
57
+ ```
58
+
59
+ ---
60
+
61
+ ## ๐ŸŸข Release Detected
62
+
63
+ Returned when at least one commit implies a semantic bump.
64
+
65
+ Example:
66
+
67
+ ```json
68
+ {
69
+ "hasRelease": true,
70
+ "baseVersion": "1.4.2",
71
+ "nextVersion": "1.5.0",
72
+ "bump": "minor",
73
+ "commitsAnalyzed": 8
74
+ }
75
+ ```
76
+
77
+ ---
78
+
79
+ ## ๐ŸŸก No Release Detected
80
+
81
+ ### No commits since last release
82
+
83
+ ```json
84
+ {
85
+ "hasRelease": false,
86
+ "reason": "no-commits",
87
+ "baseVersion": "1.4.2",
88
+ "commitsAnalyzed": 0
89
+ }
90
+ ```
91
+
92
+ ### Commits found, but no semantic bump
93
+
94
+ ```json
95
+ {
96
+ "hasRelease": false,
97
+ "reason": "no-bump-detected",
98
+ "baseVersion": "1.4.2",
99
+ "commitsAnalyzed": 5
100
+ }
101
+ ```
102
+
103
+ ---
104
+
105
+ ## ๐Ÿงช Semantic Bump Rules
106
+
107
+ The highest bump found **wins**:
108
+
109
+ | Commit Type | Bump |
110
+ | -------------------------- | ------- |
111
+ | `feat!`, `BREAKING CHANGE` | `major` |
112
+ | `feat` | `minor` |
113
+ | `fix`, `perf`, `refactor` | `patch` |
114
+
115
+ Custom prefixes and emojis are supported as long as they resolve to these semantic meanings.
116
+
117
+ ---
118
+
119
+ ## ๐Ÿ”€ Squash & Merge Strategy
120
+
121
+ `computeVersion()` works in **both**:
122
+
123
+ - Full commit history (merge commits)
124
+ - Squash & merge workflows
125
+
126
+ ### โš ๏ธ Important Recommendation
127
+
128
+ If your repository uses **Squash & Merge**, configure GitHub to:
129
+
130
+ > **โ€œUse Pull request title and commit detailsโ€**
131
+
132
+ And enforce **Conventional Commits** in PR titles:
133
+
134
+ ```text
135
+ fix: normalize path resolution
136
+ feat!: drop legacy API support
137
+ ```
138
+
139
+ This ensures `computeVersion()` can reliably detect semantic intent.
140
+
141
+ ---
142
+
143
+ ## ๐Ÿ–ฅ CLI Integration
144
+
145
+ The CLI wrapper (`rs-compute-version`) is a thin layer on top of `computeVersion()`.
146
+
147
+ ### Flags
148
+
149
+ | Flag | Description |
150
+ | ----------- | ---------------------------------------- |
151
+ | `--json` | Outputs the full result as JSON |
152
+ | `--ci` | Enables CI-friendly logging (future use) |
153
+ | `--preview` | Semantic alias (no behavior change) |
154
+
155
+ ---
156
+
157
+ ## ๐Ÿšฆ CLI Exit Codes (Contract)
158
+
159
+ | Exit Code | Meaning |
160
+ | --------- | ----------------------------- |
161
+ | `0` | Release generated |
162
+ | `10` | No bump detected |
163
+ | `2` | No commits since last release |
164
+ | `1` | Unexpected error |
165
+
166
+ > CI pipelines **must** rely on exit codes, not stdout parsing.
167
+
168
+ ---
169
+
170
+ ## ๐Ÿšซ Explicit Non-Goals
171
+
172
+ `computeVersion()` does **not**:
173
+
174
+ - Modify `package.json`
175
+ - Create Git tags
176
+ - Generate changelogs
177
+ - Access GitHub APIs
178
+ - Enforce commit conventions
179
+
180
+ These responsibilities belong to other tools in Release Suite.
181
+
182
+ ---
183
+
184
+ ## ๐ŸงŠ Contract Stability
185
+
186
+ This contract is considered **stable and frozen**.
187
+
188
+ Any breaking change requires:
189
+
190
+ - Major version bump of `release-suite`
191
+ - Explicit migration notes
192
+ - CI-safe transition plan
193
+
194
+ ---
195
+
196
+ ## โœ… Summary
197
+
198
+ - Deterministic
199
+ - Side-effect free
200
+ - CI-safe
201
+ - Fully testable
202
+ - Explicit failure modes
203
+
204
+ `computeVersion()` is designed to be boring โ€” and reliable.
package/eslint.config.js CHANGED
@@ -1,47 +1,89 @@
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
- ];
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
+ // =====================
11
+ // BIN (CLI)
12
+ // =====================
13
+ {
14
+ files: ["bin/**/*.js"],
15
+ plugins: {
16
+ import: pluginImport,
17
+ },
18
+ languageOptions: {
19
+ ecmaVersion: "latest",
20
+ sourceType: "module",
21
+ globals: {
22
+ ...globals.node,
23
+ },
24
+ },
25
+ rules: {
26
+ "no-console": "off",
27
+ "no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
28
+ "prefer-const": "warn",
29
+ eqeqeq: ["error", "always"],
30
+ curly: ["error", "all"],
31
+ "import/order": [
32
+ "warn",
33
+ {
34
+ groups: [
35
+ "builtin",
36
+ "external",
37
+ "internal",
38
+ "parent",
39
+ "sibling",
40
+ "index",
41
+ ],
42
+ alphabetize: { order: "asc", caseInsensitive: true },
43
+ },
44
+ ],
45
+ },
46
+ },
47
+
48
+ // =====================
49
+ // LIB (reusable code)
50
+ // =====================
51
+ {
52
+ files: ["lib/**/*.js"],
53
+ plugins: {
54
+ import: pluginImport,
55
+ },
56
+ languageOptions: {
57
+ ecmaVersion: "latest",
58
+ sourceType: "module",
59
+ globals: {
60
+ ...globals.node,
61
+ },
62
+ },
63
+ rules: {
64
+ "no-console": "warn",
65
+ "no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
66
+ "prefer-const": "warn",
67
+ eqeqeq: ["error", "always"],
68
+ curly: ["error", "all"],
69
+ "import/order": [
70
+ "warn",
71
+ {
72
+ groups: [
73
+ "builtin",
74
+ "external",
75
+ "internal",
76
+ "parent",
77
+ "sibling",
78
+ "index",
79
+ ],
80
+ alphabetize: { order: "asc", caseInsensitive: true },
81
+ },
82
+ ],
83
+ },
84
+ },
85
+ prettierConfig,
86
+ {
87
+ ignores: ["dist/**/*.js"],
88
+ },
89
+ ];
package/lib/git.js ADDED
@@ -0,0 +1,73 @@
1
+ import { run } from "./utils.js";
2
+
3
+ /* ===========================
4
+ * Git helpers
5
+ * =========================== */
6
+
7
+ /**
8
+ * Return the most recent Git tag reachable from HEAD, with a leading "v" prefix removed.
9
+ *
10
+ * Runs `git describe --tags --abbrev=0` in the provided working directory and strips a
11
+ * single leading "v" from the tag name (e.g. "v1.2.3" -> "1.2.3").
12
+ *
13
+ * @param {string} cwd - The working directory in which to run the Git command.
14
+ * @returns {string|null} The most recent tag without a leading "v", or null if no tag is found
15
+ * or the Git command fails.
16
+ */
17
+ export function getLastTag(cwd) {
18
+ try {
19
+ const tag = run("git describe --tags --abbrev=0", cwd);
20
+ return tag.replace(/^v/, "");
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Retrieve commits from Git using a compact, machine-friendly format.
28
+ *
29
+ * Executes `git log <range> --pretty=format:%H%x1f%s%x1f%b` in the given working directory,
30
+ * splits the output by newline, and returns an array of non-empty lines.
31
+ *
32
+ * Each array element is a single string formatted as:
33
+ * "<commit-hash>\x1F<subject>\x1F<body>"
34
+ * where "\x1F" is the ASCII unit separator (0x1F) used to delimit fields.
35
+ *
36
+ * If the git command fails (e.g., not a repository, invalid range, or other error),
37
+ * an empty array is returned.
38
+ *
39
+ * @param {string} range - The git log range to query (e.g. "HEAD", "v1.0.0..HEAD", "master..feature").
40
+ * @param {string} [cwd] - Optional working directory path in which to run the git command.
41
+ * @returns {string[]} Array of commit entries, each as "<hash>\x1F<subject>\x1F<body>". Empty array on failure.
42
+ *
43
+ * @example
44
+ * // Possible return:
45
+ * // ["a1b2c3d4e5f6g7h8i9j0\u001FAdd feature X\u001FImplementation details...", ...]
46
+ */
47
+ export function getCommits(range, cwd) {
48
+ try {
49
+ return run(
50
+ `git log ${range} --pretty=format:%H%x1f%s%x1f%b`,
51
+ cwd
52
+ )
53
+ .split("\n")
54
+ .filter(Boolean);
55
+ } catch {
56
+ return [];
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Parse a commit line encoded with ASCII Unit Separator characters into its parts.
62
+ *
63
+ * The input is expected to contain fields separated by '\x1f' in the order:
64
+ * hash, subject, body. If subject or body are missing, they default to an empty string.
65
+ *
66
+ * @param {string} line - Raw commit line with fields delimited by '\x1f'.
67
+ * @returns {{hash: string, subject: string, body: string}} An object containing the
68
+ * commit hash, subject, and body.
69
+ */
70
+ export function parseCommit(line) {
71
+ const [hash, subject = "", body = ""] = line.split("\x1f");
72
+ return { hash, subject, body };
73
+ }
package/lib/utils.js ADDED
@@ -0,0 +1,45 @@
1
+ import { execSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ /* ===========================
6
+ * Utilities
7
+ * =========================== */
8
+
9
+ /**
10
+ * Execute a shell command synchronously and return its stdout as a trimmed UTF-8 string.
11
+ *
12
+ * @param {string} cmd - The command to execute (passed to child_process.execSync).
13
+ * @param {string} [cwd] - Optional working directory in which to run the command.
14
+ * @returns {string} The command's stdout, decoded as UTF-8 and trimmed of surrounding whitespace.
15
+ * @throws {Error|import('child_process').ExecSyncError} If the command fails or exits with a non-zero status.
16
+ * @see {@link https://nodejs.org/api/child_process.html#child_processexecsynccommand-options|child_process.execSync}
17
+ */
18
+ export function run(cmd, cwd) {
19
+ return execSync(cmd, { encoding: "utf8", cwd }).trim();
20
+ }
21
+
22
+ /**
23
+ * Read the "version" field from a package.json file in the given directory.
24
+ *
25
+ * Synchronously reads and parses `<cwd>/package.json` and returns its `version`.
26
+ * Any errors (missing file, invalid JSON, missing `version`, etc.) are caught
27
+ * and a default version string of `"0.0.0"` is returned.
28
+ *
29
+ * @param {string} cwd - Path to the directory containing package.json.
30
+ * @returns {string} The package version, or `"0.0.0"` if it cannot be read.
31
+ *
32
+ * @example
33
+ * // returns "1.2.3" if /my/project/package.json contains { "version": "1.2.3" }
34
+ * const v = readPackageVersion('/my/project');
35
+ */
36
+ export function readPackageVersion(cwd) {
37
+ try {
38
+ const pkg = JSON.parse(
39
+ fs.readFileSync(path.join(cwd, "package.json"), "utf8")
40
+ );
41
+ return pkg.version;
42
+ } catch {
43
+ return "0.0.0";
44
+ }
45
+ }
@@ -0,0 +1,110 @@
1
+ /* ===========================
2
+ * Semver detection
3
+ * =========================== */
4
+
5
+ const COMMIT_RE =
6
+ /^(feat|fix|refactor|docs|chore|style|test|build|perf|ci|cleanup|remove)(\(.+\))?(!)?:/i;
7
+
8
+ /**
9
+ * Normalize a subject string by removing leading emoji and trimming whitespace.
10
+ *
11
+ * This function:
12
+ * - Strips a leading colon-style emoji shortcode (e.g. ":smile:"). Consecutive shortcodes
13
+ * without intervening spaces (e.g. ":a::b: ...") are removed as a single leading block.
14
+ * - Removes leading Unicode emoji in the U+1F300โ€“U+1FAFF range (one or more), using a
15
+ * Unicode-aware match.
16
+ * - Trims surrounding whitespace from the resulting string.
17
+ *
18
+ * @param {string} subject - The input subject (e.g. a commit/PR subject).
19
+ * @returns {string} The normalized subject with any leading emoji/shortcodes removed and trimmed.
20
+ *
21
+ * @example
22
+ * normalizeSubject(':sparkles: Add new feature') // 'Add new feature'
23
+ * @example
24
+ * normalizeSubject('๐Ÿš€โœจ Deploy') // 'Deploy'
25
+ * @example
26
+ * normalizeSubject(' :a::b:Multiple emojis at start ') // 'Multiple emojis at start'
27
+ */
28
+ function normalizeSubject(subject) {
29
+ return subject
30
+ // remove emoji at start
31
+ .replace(/^:\S+:\s*/, "")
32
+ // remove unicode emoji at start
33
+ .replace(/^[\u{1F300}-\u{1FAFF}]+\s*/u, "")
34
+ .trim();
35
+ }
36
+
37
+ /**
38
+ * Determine the semantic version bump implied by a commit message.
39
+ *
40
+ * The function inspects the commit subject and body using a conventional-commit
41
+ * pattern (COMMIT_RE) and the presence of "BREAKING CHANGE":
42
+ * - Returns "major" if the body contains "BREAKING CHANGE" (case-insensitive)
43
+ * or the commit header contains the conventional "!" breaking-change marker.
44
+ * - Returns "minor" if the header matches COMMIT_RE and the commit type is "feat".
45
+ * - Returns "patch" if the header matches COMMIT_RE and the commit type is "fix".
46
+ * - Returns "none" if no relevant indicators are present.
47
+ *
48
+ * @param {Object} params - Destructured input object.
49
+ * @param {string} params.subject - Commit subject/summary line to be matched against COMMIT_RE.
50
+ * @param {string} params.body - Commit body text used to detect "BREAKING CHANGE".
51
+ * @returns {'major'|'minor'|'patch'|'none'} The semantic version bump type.
52
+ */
53
+ export function detectBumpType({ subject, body }) {
54
+ const cleanSubject = normalizeSubject(subject);
55
+
56
+ // Ignore revert commits entirely
57
+ if (/^revert\b/i.test(cleanSubject)) return "none";
58
+
59
+ const match = cleanSubject.match(COMMIT_RE);
60
+
61
+ const breaking =
62
+ /BREAKING CHANGE/i.test(body) || (match && match[3] === "!");
63
+
64
+ if (breaking) return "major";
65
+ if (!match) return "none";
66
+
67
+ const type = match[1].toLowerCase();
68
+ if (type === "feat") return "minor";
69
+ if (type === "fix") return "patch";
70
+
71
+ return "none";
72
+ }
73
+
74
+ /**
75
+ * Increment a semantic version string.
76
+ *
77
+ * Given a version string in the form "major.minor.patch", this function
78
+ * parses the numeric components (non-numeric or missing parts are treated as 0)
79
+ * and returns a new version string with the requested part bumped:
80
+ * - "major": increments major, resets minor and patch to 0
81
+ * - "minor": increments minor, resets patch to 0
82
+ * - any other value (including omitted): increments patch
83
+ *
84
+ * Parsing details:
85
+ * - Each segment is parsed with parseInt(..., 10); if parsing yields NaN,
86
+ * that segment is treated as 0.
87
+ *
88
+ * @param {string} base - The base version string (e.g. "1.2.3").
89
+ * @param {string} [bump] - The part to bump: "major", "minor", or "patch".
90
+ * If omitted or any other value, the patch is bumped.
91
+ * @returns {string} The new version string in "major.minor.patch" format.
92
+ *
93
+ * @example
94
+ * bumpVersion("1.2.3", "patch"); // => "1.2.4"
95
+ * @example
96
+ * bumpVersion("1.2.3", "minor"); // => "1.3.0"
97
+ * @example
98
+ * bumpVersion("1.2.3", "major"); // => "2.0.0"
99
+ * @example
100
+ * bumpVersion("1", "patch"); // => "1.0.1" (missing parts treated as 0)
101
+ * @example
102
+ * bumpVersion("a.b.c", "minor"); // => "0.1.0" (non-numeric parts treated as 0)
103
+ */
104
+ export function bumpVersion(base, bump) {
105
+ const [major, minor, patch] = base.split(".").map(n => parseInt(n, 10) || 0);
106
+
107
+ if (bump === "major") return `${major + 1}.0.0`;
108
+ if (bump === "minor") return `${major}.${minor + 1}.0`;
109
+ return `${major}.${minor}.${patch + 1}`;
110
+ }