versionary 0.1.0 → 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.
Files changed (70) hide show
  1. package/README.md +217 -8
  2. package/dist/app/release/pr.d.ts +18 -0
  3. package/dist/app/release/pr.js +209 -0
  4. package/dist/app/release/recovery.d.ts +17 -0
  5. package/dist/app/release/recovery.js +101 -0
  6. package/dist/app/release/release.d.ts +1 -0
  7. package/dist/app/release/release.js +90 -0
  8. package/dist/app/release/state.d.ts +10 -0
  9. package/dist/app/release/state.js +59 -0
  10. package/dist/app/release/verify.d.ts +2 -0
  11. package/dist/app/release/verify.js +5 -0
  12. package/dist/cli/index.js +51 -8
  13. package/dist/config/load-config.js +8 -12
  14. package/dist/config/schema.d.ts +25 -85
  15. package/dist/config/schema.js +20 -81
  16. package/dist/domain/release/changelog.d.ts +12 -0
  17. package/dist/domain/release/changelog.js +123 -0
  18. package/dist/domain/release/plan.d.ts +21 -0
  19. package/dist/domain/release/plan.js +114 -0
  20. package/dist/domain/release/semver.d.ts +15 -0
  21. package/dist/domain/release/semver.js +111 -0
  22. package/dist/domain/strategy/node.d.ts +2 -0
  23. package/dist/domain/strategy/node.js +37 -0
  24. package/dist/domain/strategy/resolve.d.ts +3 -0
  25. package/dist/domain/strategy/resolve.js +11 -0
  26. package/dist/domain/strategy/simple.d.ts +2 -0
  27. package/dist/domain/strategy/simple.js +28 -0
  28. package/dist/domain/strategy/types.d.ts +7 -0
  29. package/dist/domain/strategy/types.js +2 -0
  30. package/dist/index.d.ts +6 -1
  31. package/dist/index.js +10 -1
  32. package/dist/infra/git/commits.d.ts +65 -0
  33. package/dist/infra/git/commits.js +436 -0
  34. package/dist/infra/git/repo-url.d.ts +1 -0
  35. package/dist/infra/git/repo-url.js +27 -0
  36. package/dist/infra/scm/github/plugin.d.ts +1 -0
  37. package/dist/infra/scm/github/plugin.js +5 -0
  38. package/dist/infra/scm/runtime.d.ts +2 -0
  39. package/dist/infra/scm/runtime.js +24 -0
  40. package/dist/infra/scm/types.d.ts +1 -0
  41. package/dist/infra/scm/types.js +2 -0
  42. package/dist/plugins/capabilities.d.ts +3 -0
  43. package/dist/plugins/capabilities.js +10 -0
  44. package/dist/plugins/runtime.d.ts +1 -0
  45. package/dist/plugins/runtime.js +5 -0
  46. package/dist/scm/github-plugin.d.ts +2 -0
  47. package/dist/scm/github-plugin.js +207 -0
  48. package/dist/strategies/node.d.ts +1 -0
  49. package/dist/strategies/node.js +5 -0
  50. package/dist/strategies/resolve.d.ts +1 -0
  51. package/dist/strategies/resolve.js +5 -0
  52. package/dist/strategies/simple.d.ts +1 -0
  53. package/dist/strategies/simple.js +5 -0
  54. package/dist/strategies/types.d.ts +1 -0
  55. package/dist/strategies/types.js +2 -0
  56. package/dist/types/config.d.ts +20 -66
  57. package/dist/types/plugins.d.ts +37 -0
  58. package/dist/types/plugins.js +2 -0
  59. package/dist/verify/verify-project.js +13 -13
  60. package/package.json +15 -14
  61. package/dist/simple/changelog.d.ts +0 -3
  62. package/dist/simple/changelog.js +0 -32
  63. package/dist/simple/git.d.ts +0 -7
  64. package/dist/simple/git.js +0 -61
  65. package/dist/simple/plan.d.ts +0 -12
  66. package/dist/simple/plan.js +0 -37
  67. package/dist/simple/pr.d.ts +0 -5
  68. package/dist/simple/pr.js +0 -45
  69. package/dist/simple/semver.d.ts +0 -8
  70. package/dist/simple/semver.js +0 -25
package/README.md CHANGED
@@ -1,23 +1,231 @@
1
- # versionary
1
+ # Versionary
2
2
 
3
- Versionary is a software-agnostic automated release tool focused on SemVer, conventional commits, release PR workflows, and extensibility.
3
+ Versionary is a software-agnostic automated release tool focused on SemVer,
4
+ conventional commits, release PR workflows, and extensibility.
4
5
 
5
- Configuration is loaded from `versionary.jsonc` by default.
6
+ ## Why this exists
6
7
 
7
- ## Simple mode (MVP)
8
+ Versionary is designed as a practical middle ground between `semantic-release`
9
+ and `release-please`.
10
+
11
+ - Like `semantic-release`, it supports direct release execution.
12
+ - Like `release-please`, it supports a release PR workflow so maintainers can
13
+ preview and review changes before publication.
14
+
15
+ The core idea is to keep versioning, changelog generation, tagging, and SCM
16
+ release metadata in one tool, while leaving package publication (npm, crates.io,
17
+ etc.) to dedicated CI workflows triggered by tags or releases.
18
+
19
+ ## Product direction
20
+
21
+ Versionary is being built to:
22
+
23
+ - support both direct releases and release-PR-gated releases
24
+ - work across repository types (Node, Rust, docs/LaTeX, etc.)
25
+ - stay SCM-agnostic at the core, with integrations via plugin capabilities
26
+ (GitHub first; GitLab/Codeberg later)
27
+ - keep a small, stable core with explicit extension points
28
+ - handle trunk-based development and monorepo workflows cleanly
29
+
30
+ ## Scope and non-goals
31
+
32
+ In scope:
33
+
34
+ - semantic version planning from commits
35
+ - changelog generation
36
+ - release PR automation
37
+ - tags + SCM release metadata (e.g. GitHub Releases)
38
+
39
+ Out of scope (intentional):
40
+
41
+ - publishing artifacts to language registries
42
+ - replacing package-specific publish tooling
43
+
44
+ Use your CI/CD platform for registry publishing, triggered from a created
45
+ release/tag.
46
+
47
+ ## Current status vs roadmap
48
+
49
+ Current implementation focuses on:
50
+
51
+ - strategy-based version updates (`simple`, `node`)
52
+ - release planning and changelog generation
53
+ - review-mode vs direct-mode release flow
54
+ - built-in GitHub SCM plugin capabilities
55
+
56
+ Planned/harder areas include deeper monorepo ergonomics, broader SCM coverage,
57
+ and stronger failure recovery around release steps.
58
+
59
+ ## Architecture layout (current migration)
60
+
61
+ The repository is moving to explicit layered modules:
62
+
63
+ - `src/app/`: command-level application services and orchestration boundaries
64
+ - `src/domain/`: release and strategy domain logic/contracts
65
+ - `src/infra/`: platform integrations (SCM, git/runtime adapters)
66
+
67
+ Legacy `src/simple/` has been removed. Remaining compatibility paths are
68
+ `src/strategies` and `src/scm` while migration is finalized.
69
+
70
+ Configuration is loaded from `versionary.jsonc` by default (or
71
+ `versionary.json`).
72
+
73
+ Schema URL for editor support:
74
+
75
+ - `https://raw.githubusercontent.com/jolars/versionary/main/schemas/versionary-schema.json`
76
+
77
+ ## Config (manifest style)
8
78
 
9
79
  For a quick trial, use:
10
80
 
11
- - `versionary.jsonc` with `"mode": "simple"`
12
- - `version.txt` as the version source
13
- - `CHANGELOG.md` as release notes output
81
+ - `version-file` (default `version.txt`) as version source
82
+ - `changelog-file` (default `CHANGELOG.md`) as release notes output
83
+ - `release-type: "node"` uses `package.json` as version source and updates it
84
+ during release PR prep
85
+ - simple/default strategy keeps `version.txt` as source of truth and does not
86
+ update `package.json`
87
+ - stable release branch (`release-branch`, default: `versionary/release`) so
88
+ release PRs are updated in-place
89
+ - `baseline-file` (default `.versionary-manifest.json`) tracks baseline SHA for
90
+ deterministic commit ranges independent of tags
91
+ - pre-1.0 policy defaults to conservative major handling: for `0.y.z`, breaking
92
+ changes bump to `0.(y+1).0`; set `allow-stable-major: true` to allow explicit
93
+ auto-transition to `1.0.0` on a breaking release
94
+ - review mode (`review-mode`): `review` (PR/MR style) or `direct` (no review
95
+ request)
96
+ - optional monorepo planning with `monorepo-mode` and `packages`:
97
+ - `independent` computes package bumps per path
98
+ - `fixed` computes one shared bump across configured package paths
99
+
100
+ ## Commit parsing and release analysis
101
+
102
+ Release planning is based on Conventional Commit parsing semantics:
103
+
104
+ - parses type/scope/description from commit headers
105
+ - exposes structured parsed fields (`header`, `body`, `footer`, `type`, `scope`,
106
+ `description`, `notes`, `references`, `mentions`, `revert`)
107
+ - separates parser output from release policy mapping (`inferReleaseType*`)
108
+ - recognizes breaking changes from `!` and `BREAKING CHANGE` /
109
+ `BREAKING-CHANGE` footers
110
+ - maps release impact as `feat => minor`, `fix|perf => patch`, breaking => major
111
+ - treats `revert:` as non-releasable commits
112
+ - suppresses commits that are reverted within the analyzed release window so they
113
+ do not affect bump/changelog output
114
+ - emits parser diagnostics for malformed headers/footers/references and ambiguous
115
+ revert messages
14
116
 
15
117
  Commands:
16
118
 
17
119
  - `pnpm verify`
120
+ - `pnpm run` (default orchestration: no-op, create/update release PR, or publish
121
+ release based on context)
18
122
  - `pnpm plan`
19
123
  - `pnpm changelog -- --write`
20
124
  - `pnpm pr`
125
+ - `pnpm release`
126
+
127
+ `pnpm pr` prepares release commit + branch and opens/updates the review request
128
+ via SCM plugin capability. `pnpm run` is the recommended CI entrypoint and
129
+ auto-dispatches between PR/update and release publish.
130
+
131
+ For first-run bootstrapping, set `bootstrap-sha` (similar to release-please).
132
+ Subsequent runs use the baseline state file.
133
+
134
+ ## Release retry and recovery behavior
135
+
136
+ Release publish (`pnpm release` or the publish path in `pnpm run`) is
137
+ idempotent by target tag:
138
+
139
+ - if a tag already exists, Versionary reuses it rather than recreating it
140
+ - if release metadata already exists for the tag (e.g., GitHub Release), it is
141
+ reused
142
+ - if a prior run created/pushed the tag but failed before metadata creation, a
143
+ rerun creates the missing metadata and proceeds
144
+
145
+ Versionary fails fast when recovery is unsafe (for example, local and remote
146
+ tags with the same name point to different SHAs). In these cases, the error
147
+ message includes remediation guidance so CI logs are actionable.
148
+
149
+ ## Built-in plugins
150
+
151
+ Versionary ships with built-in SCM plugin support:
152
+
153
+ - `github` (default): review request + release metadata
154
+
155
+ ### GitHub integration: env, permissions, and flow
156
+
157
+ Required environment for the built-in GitHub plugin:
158
+
159
+ - `GITHUB_REPOSITORY` (format: `owner/repo`)
160
+ - one token env var: `VERSIONARY_PR_TOKEN` or `GH_TOKEN` or `GITHUB_TOKEN`
161
+
162
+ Token precedence is:
163
+
164
+ - `VERSIONARY_PR_TOKEN` > `GH_TOKEN` > `GITHUB_TOKEN`
165
+
166
+ Minimum GitHub token/repo permissions for Versionary-managed metadata:
167
+
168
+ - release PR create/update flow: `contents: write`, `pull-requests: write`
169
+ - release metadata flow (GitHub Release create/read): `contents: write`
170
+
171
+ `review-mode` behavior:
172
+
173
+ - `review`: `pnpm run run` prepares/updates the release branch and creates or
174
+ updates a release PR
175
+ - `direct`: `pnpm run run` prepares/updates the release branch and skips review
176
+ request creation
177
+
178
+ Concise GitHub Actions examples:
179
+
180
+ ```yaml
181
+ # 1) Release PR / update flow (run on push to default branch)
182
+ permissions:
183
+ contents: write
184
+ pull-requests: write
185
+
186
+ steps:
187
+ - uses: actions/checkout@v6
188
+ with:
189
+ fetch-depth: 0
190
+ fetch-tags: true
191
+ - uses: pnpm/action-setup@v6
192
+ - uses: actions/setup-node@v6
193
+ with:
194
+ node-version-file: .nvmrc
195
+ cache: pnpm
196
+ - run: pnpm install --frozen-lockfile --ignore-scripts
197
+ - run: |
198
+ git config user.name "github-actions[bot]"
199
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
200
+ pnpm run run
201
+ env:
202
+ GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
203
+ ```
204
+
205
+ ```yaml
206
+ # 2) Release publish flow after merge (release commit context)
207
+ permissions:
208
+ contents: write
209
+
210
+ steps:
211
+ - uses: actions/checkout@v6
212
+ with:
213
+ fetch-depth: 0
214
+ fetch-tags: true
215
+ - uses: pnpm/action-setup@v6
216
+ - uses: actions/setup-node@v6
217
+ with:
218
+ node-version-file: .nvmrc
219
+ cache: pnpm
220
+ - run: pnpm install --frozen-lockfile --ignore-scripts
221
+ - run: pnpm run run
222
+ env:
223
+ GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
224
+ ```
225
+
226
+ Package publication is intentionally out of scope in the current release flow.
227
+ Use separate CI workflows for publishing after Versionary has prepared/tagged
228
+ the release.
21
229
 
22
230
  ## Install from GitHub
23
231
 
@@ -31,4 +239,5 @@ You can install directly from a git ref:
31
239
  }
32
240
  ```
33
241
 
34
- The package runs a `prepare` build during git installation so the `versionary` CLI binary is available after `pnpm install`.
242
+ The package runs a `prepare` build during git installation so the `versionary`
243
+ CLI binary is available after `pnpm install`.
@@ -0,0 +1,18 @@
1
+ import { type SimplePlan } from "../../domain/release/plan.js";
2
+ import type { ParsedCommit } from "../../infra/git/commits.js";
3
+ export declare function splitSafeDirtyFiles(files: string[]): {
4
+ ignored: string[];
5
+ blocking: string[];
6
+ };
7
+ export declare function prepareSimpleReleasePr(cwd?: string): {
8
+ branch: string;
9
+ title: string;
10
+ version: string;
11
+ previousVersion: string;
12
+ commits: ParsedCommit[];
13
+ plan: SimplePlan;
14
+ };
15
+ export declare function renderSimpleReviewRequestBody(version: string, previousVersion: string, commits: ParsedCommit[], plan?: SimplePlan | null, cwd?: string): string;
16
+ export declare function openOrUpdateSimpleReviewRequest(cwd: string, branch: string, title: string, version: string, previousVersion: string, commits: ParsedCommit[], plan?: SimplePlan | null): Promise<string>;
17
+ export declare function pushReleaseBranch(cwd: string, branch: string): void;
18
+ export declare function isReleaseCommitMessage(subject: string): boolean;
@@ -0,0 +1,209 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.splitSafeDirtyFiles = splitSafeDirtyFiles;
7
+ exports.prepareSimpleReleasePr = prepareSimpleReleasePr;
8
+ exports.renderSimpleReviewRequestBody = renderSimpleReviewRequestBody;
9
+ exports.openOrUpdateSimpleReviewRequest = openOrUpdateSimpleReviewRequest;
10
+ exports.pushReleaseBranch = pushReleaseBranch;
11
+ exports.isReleaseCommitMessage = isReleaseCommitMessage;
12
+ const node_child_process_1 = require("node:child_process");
13
+ const node_path_1 = __importDefault(require("node:path"));
14
+ const load_config_js_1 = require("../../config/load-config.js");
15
+ const changelog_js_1 = require("../../domain/release/changelog.js");
16
+ const plan_js_1 = require("../../domain/release/plan.js");
17
+ const resolve_js_1 = require("../../domain/strategy/resolve.js");
18
+ const capabilities_js_1 = require("../../plugins/capabilities.js");
19
+ const runtime_js_1 = require("../../plugins/runtime.js");
20
+ const state_js_1 = require("./state.js");
21
+ const SAFE_DIRTY_FILES = new Set([
22
+ "pnpm-lock.yaml",
23
+ "package-lock.json",
24
+ "yarn.lock",
25
+ "bun.lockb",
26
+ "npm-shrinkwrap.json",
27
+ ]);
28
+ function listTrackedDirtyFiles(cwd) {
29
+ const status = (0, node_child_process_1.execFileSync)("git", ["status", "--porcelain", "--untracked-files=no"], {
30
+ cwd,
31
+ encoding: "utf8",
32
+ stdio: ["ignore", "pipe", "ignore"],
33
+ });
34
+ return status
35
+ .split("\n")
36
+ .filter((line) => line.length > 0)
37
+ .map((line) => line.slice(3))
38
+ .map((pathPart) => {
39
+ const renameParts = pathPart.split(" -> ");
40
+ return renameParts.at(-1) ?? pathPart;
41
+ })
42
+ .map((filePath) => filePath.trim())
43
+ .filter((filePath) => filePath.length > 0);
44
+ }
45
+ function splitSafeDirtyFiles(files) {
46
+ const ignored = [];
47
+ const blocking = [];
48
+ for (const file of files) {
49
+ const basename = node_path_1.default.basename(file);
50
+ if (SAFE_DIRTY_FILES.has(basename)) {
51
+ ignored.push(file);
52
+ continue;
53
+ }
54
+ blocking.push(file);
55
+ }
56
+ return { ignored, blocking };
57
+ }
58
+ function ensureCleanWorktree(cwd) {
59
+ const dirtyFiles = listTrackedDirtyFiles(cwd);
60
+ const { ignored, blocking } = splitSafeDirtyFiles(dirtyFiles);
61
+ if (blocking.length > 0) {
62
+ throw new Error(`Working tree has tracked modifications before versionary pr:\n${blocking.join("\n")}\nCommit/stash tracked changes first.`);
63
+ }
64
+ if (ignored.length > 0) {
65
+ console.warn(`Ignoring safe tracked changes before versionary pr:\n${ignored.join("\n")}`);
66
+ }
67
+ }
68
+ function prepareSimpleReleasePr(cwd = process.cwd()) {
69
+ const plan = (0, plan_js_1.createSimplePlan)(cwd);
70
+ const loaded = (0, load_config_js_1.loadConfig)(cwd);
71
+ const strategy = (0, resolve_js_1.resolveVersionStrategy)(loaded.config);
72
+ if (!plan.nextVersion) {
73
+ throw new Error("No releasable commits found. Nothing to open a release PR for.");
74
+ }
75
+ ensureCleanWorktree(cwd);
76
+ const updatedVersionFiles = strategy.writeVersion(cwd, loaded.config, plan.nextVersion);
77
+ const section = (0, changelog_js_1.renderSimpleChangelog)(plan);
78
+ (0, changelog_js_1.prependChangelog)(cwd, plan.changelogFile, section);
79
+ const branch = plan.releaseBranchPrefix;
80
+ const title = `chore(release): v${plan.nextVersion}`;
81
+ (0, node_child_process_1.execFileSync)("git", ["checkout", "-B", branch], {
82
+ cwd,
83
+ stdio: ["ignore", "pipe", "ignore"],
84
+ });
85
+ const filesToAdd = [...new Set([...updatedVersionFiles, plan.changelogFile])];
86
+ (0, node_child_process_1.execFileSync)("git", ["add", ...filesToAdd], {
87
+ cwd,
88
+ stdio: ["ignore", "pipe", "ignore"],
89
+ });
90
+ (0, node_child_process_1.execFileSync)("git", ["commit", "-m", title], {
91
+ cwd,
92
+ stdio: ["ignore", "pipe", "ignore"],
93
+ });
94
+ const releaseTargets = plan.packages
95
+ ? plan.packages
96
+ .filter((pkg) => pkg.nextVersion)
97
+ .map((pkg) => ({
98
+ path: pkg.path,
99
+ version: pkg.nextVersion ?? "",
100
+ tag: pkg.path === "."
101
+ ? `v${pkg.nextVersion ?? ""}`
102
+ : `${pkg.path.replaceAll("/", "-")}-v${pkg.nextVersion ?? ""}`,
103
+ notes: (0, changelog_js_1.renderSimpleReleaseNotes)({
104
+ currentVersion: pkg.currentVersion,
105
+ nextVersion: pkg.nextVersion ?? "",
106
+ commits: pkg.commits,
107
+ cwd,
108
+ }, { includeFooter: false }),
109
+ }))
110
+ : [
111
+ {
112
+ path: ".",
113
+ version: plan.nextVersion,
114
+ tag: `v${plan.nextVersion}`,
115
+ notes: (0, changelog_js_1.renderSimpleReleaseNotes)({
116
+ currentVersion: plan.currentVersion,
117
+ nextVersion: plan.nextVersion,
118
+ commits: plan.commits,
119
+ cwd,
120
+ }, { includeFooter: false }),
121
+ },
122
+ ];
123
+ (0, state_js_1.writeBaselineSha)(cwd, undefined, releaseTargets);
124
+ (0, node_child_process_1.execFileSync)("git", ["add", (0, state_js_1.getBaselineStatePath)(cwd)], {
125
+ cwd,
126
+ stdio: ["ignore", "pipe", "ignore"],
127
+ });
128
+ (0, node_child_process_1.execFileSync)("git", ["commit", "--amend", "--no-edit"], {
129
+ cwd,
130
+ stdio: ["ignore", "pipe", "ignore"],
131
+ });
132
+ return {
133
+ branch,
134
+ title,
135
+ version: plan.nextVersion,
136
+ previousVersion: plan.currentVersion,
137
+ commits: plan.commits,
138
+ plan,
139
+ };
140
+ }
141
+ function renderSimpleReviewRequestBody(version, previousVersion, commits, plan = null, cwd = process.cwd()) {
142
+ if (plan?.packages && plan.packages.length > 1) {
143
+ const sections = plan.packages
144
+ .filter((pkg) => pkg.nextVersion)
145
+ .map((pkg) => {
146
+ const notes = (0, changelog_js_1.renderSimpleReleaseNotes)({
147
+ currentVersion: pkg.currentVersion,
148
+ nextVersion: pkg.nextVersion ?? "",
149
+ commits: pkg.commits,
150
+ cwd,
151
+ }, { includeFooter: false });
152
+ const linkedHeader = notes.match(/^##\s+\[([^\]]+)\]\(([^)]+)\)\s+\(([^)]+)\)/u);
153
+ if (linkedHeader) {
154
+ const [, , compareUrl, date] = linkedHeader;
155
+ return notes.replace(/^##\s+\[[^\]]+\]\([^)]+\)\s+\([^)]+\)/u, `## [${pkg.path}: ${pkg.nextVersion ?? ""}](${compareUrl}) (${date})`);
156
+ }
157
+ const plainHeader = notes.match(/^##\s+([^\s]+)\s+\(([^)]+)\)/u);
158
+ if (plainHeader) {
159
+ const [, , date] = plainHeader;
160
+ return notes.replace(/^##\s+[^\s]+\s+\([^)]+\)/u, `## ${pkg.path}: ${pkg.nextVersion ?? ""} (${date})`);
161
+ }
162
+ return notes;
163
+ })
164
+ .join("\n\n");
165
+ return `${sections}\n\nThis PR was generated by Versionary.`;
166
+ }
167
+ return (0, changelog_js_1.renderSimpleReleaseNotes)({
168
+ currentVersion: previousVersion,
169
+ nextVersion: version,
170
+ commits,
171
+ cwd,
172
+ }, { includeFooter: true });
173
+ }
174
+ async function openOrUpdateSimpleReviewRequest(cwd, branch, title, version, previousVersion, commits, plan = null) {
175
+ const loaded = (0, load_config_js_1.loadConfig)(cwd);
176
+ const releaseFlow = loaded.config["review-mode"] ?? "direct";
177
+ if (releaseFlow !== "review") {
178
+ return "Release flow mode is direct; skipping review request creation.";
179
+ }
180
+ const plugins = (0, runtime_js_1.loadRuntimePlugins)();
181
+ const scmPlugins = (0, capabilities_js_1.findPluginsByCapability)(plugins, "scm.reviewRequest");
182
+ if (scmPlugins.length === 0) {
183
+ throw new Error("review-mode is review but no scm.reviewRequest plugin is available.");
184
+ }
185
+ const plugin = scmPlugins[0];
186
+ if (!plugin?.createOrUpdateReviewRequest) {
187
+ throw new Error(`Plugin "${plugin?.name ?? "unknown"}" does not implement createOrUpdateReviewRequest.`);
188
+ }
189
+ const result = await plugin.createOrUpdateReviewRequest({
190
+ baseBranch: process.env.VERSIONARY_BASE_BRANCH ?? "main",
191
+ headBranch: branch,
192
+ title,
193
+ body: renderSimpleReviewRequestBody(version, previousVersion, commits, plan, cwd),
194
+ labels: ["release"],
195
+ }, {
196
+ cwd,
197
+ logger: console,
198
+ });
199
+ return result.url;
200
+ }
201
+ function pushReleaseBranch(cwd, branch) {
202
+ (0, node_child_process_1.execFileSync)("git", ["push", "--force-with-lease", "origin", branch], {
203
+ cwd,
204
+ stdio: ["ignore", "pipe", "ignore"],
205
+ });
206
+ }
207
+ function isReleaseCommitMessage(subject) {
208
+ return /^chore\(release\):\sv\d+\.\d+\.\d+/u.test(subject);
209
+ }
@@ -0,0 +1,17 @@
1
+ import type { VersionaryScmReleaseMetadataResult } from "../../types/plugins.js";
2
+ export type TagStepResult = "created" | "exists";
3
+ export interface ReleaseTargetInput {
4
+ tag: string;
5
+ version: string;
6
+ notes: string;
7
+ }
8
+ export interface ReleaseExecutionContext {
9
+ createReleaseMetadata: (input: ReleaseTargetInput) => Promise<VersionaryScmReleaseMetadataResult>;
10
+ }
11
+ export interface ReleaseTargetOutcome {
12
+ tag: string;
13
+ tagStatus: TagStepResult;
14
+ metadataStatus: "created" | "exists";
15
+ url: string;
16
+ }
17
+ export declare function executeIdempotentReleaseTarget(cwd: string, target: ReleaseTargetInput, context: ReleaseExecutionContext): Promise<ReleaseTargetOutcome>;
@@ -0,0 +1,101 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.executeIdempotentReleaseTarget = executeIdempotentReleaseTarget;
4
+ const node_child_process_1 = require("node:child_process");
5
+ function hasRemoteTag(cwd, tag) {
6
+ try {
7
+ const output = (0, node_child_process_1.execFileSync)("git", ["ls-remote", "--tags", "origin", `refs/tags/${tag}`], {
8
+ cwd,
9
+ encoding: "utf8",
10
+ stdio: ["ignore", "pipe", "ignore"],
11
+ }).trim();
12
+ return output.length > 0;
13
+ }
14
+ catch {
15
+ return false;
16
+ }
17
+ }
18
+ function readRemoteTagSha(cwd, tag) {
19
+ try {
20
+ const output = (0, node_child_process_1.execFileSync)("git", ["ls-remote", "--tags", "origin", `refs/tags/${tag}`], {
21
+ cwd,
22
+ encoding: "utf8",
23
+ stdio: ["ignore", "pipe", "ignore"],
24
+ }).trim();
25
+ if (!output) {
26
+ return null;
27
+ }
28
+ const [sha] = output.split(/\s+/u);
29
+ return sha ?? null;
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ function createTagWithRecovery(cwd, tag) {
36
+ const localTag = (0, node_child_process_1.execFileSync)("git", ["tag", "--list", tag], {
37
+ cwd,
38
+ encoding: "utf8",
39
+ stdio: ["ignore", "pipe", "ignore"],
40
+ }).trim();
41
+ if (localTag.length > 0) {
42
+ const localSha = (0, node_child_process_1.execFileSync)("git", ["rev-parse", `refs/tags/${tag}`], {
43
+ cwd,
44
+ encoding: "utf8",
45
+ stdio: ["ignore", "pipe", "ignore"],
46
+ }).trim();
47
+ const remoteSha = readRemoteTagSha(cwd, tag);
48
+ if (remoteSha && remoteSha !== localSha) {
49
+ throw new Error(`Tag drift detected for "${tag}": local tag (${localSha.slice(0, 7)}) differs from remote (${remoteSha.slice(0, 7)}). Resolve the tag mismatch before retrying release.`);
50
+ }
51
+ return "exists";
52
+ }
53
+ try {
54
+ (0, node_child_process_1.execFileSync)("git", ["tag", tag], {
55
+ cwd,
56
+ stdio: ["ignore", "pipe", "ignore"],
57
+ });
58
+ }
59
+ catch {
60
+ throw new Error(`Failed creating local tag "${tag}". Ensure repository is writable and retry.`);
61
+ }
62
+ try {
63
+ (0, node_child_process_1.execFileSync)("git", ["push", "origin", tag], {
64
+ cwd,
65
+ stdio: ["ignore", "pipe", "ignore"],
66
+ });
67
+ return "created";
68
+ }
69
+ catch {
70
+ if (hasRemoteTag(cwd, tag)) {
71
+ const localSha = (0, node_child_process_1.execFileSync)("git", ["rev-parse", `refs/tags/${tag}`], {
72
+ cwd,
73
+ encoding: "utf8",
74
+ stdio: ["ignore", "pipe", "ignore"],
75
+ }).trim();
76
+ const remoteSha = readRemoteTagSha(cwd, tag);
77
+ if (remoteSha && remoteSha !== localSha) {
78
+ throw new Error(`Tag drift detected for "${tag}": local tag (${localSha.slice(0, 7)}) differs from remote (${remoteSha.slice(0, 7)}). Resolve the tag mismatch before retrying release.`);
79
+ }
80
+ return "exists";
81
+ }
82
+ throw new Error(`Failed pushing tag "${tag}" to origin. Check push permissions and remote connectivity, then retry.`);
83
+ }
84
+ }
85
+ async function executeIdempotentReleaseTarget(cwd, target, context) {
86
+ const tagStatus = createTagWithRecovery(cwd, target.tag);
87
+ const metadata = await context.createReleaseMetadata(target);
88
+ const metadataStatus = metadata.status ?? "created";
89
+ if (tagStatus === "exists" && metadataStatus === "created") {
90
+ console.info(`Recovered drift for ${target.tag}: tag already existed and release metadata has now been created.`);
91
+ }
92
+ if (!metadata.url) {
93
+ throw new Error(`Release metadata for "${target.tag}" did not return a URL. Verify SCM API permissions and retry.`);
94
+ }
95
+ return {
96
+ tag: target.tag,
97
+ tagStatus,
98
+ metadataStatus,
99
+ url: metadata.url,
100
+ };
101
+ }
@@ -0,0 +1 @@
1
+ export declare function runSimpleRelease(cwd?: string): Promise<string>;
@@ -0,0 +1,90 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.runSimpleRelease = runSimpleRelease;
7
+ const node_child_process_1 = require("node:child_process");
8
+ const node_fs_1 = __importDefault(require("node:fs"));
9
+ const node_path_1 = __importDefault(require("node:path"));
10
+ const load_config_js_1 = require("../../config/load-config.js");
11
+ const resolve_js_1 = require("../../domain/strategy/resolve.js");
12
+ const capabilities_js_1 = require("../../plugins/capabilities.js");
13
+ const runtime_js_1 = require("../../plugins/runtime.js");
14
+ const pr_js_1 = require("./pr.js");
15
+ const recovery_js_1 = require("./recovery.js");
16
+ const state_js_1 = require("./state.js");
17
+ function getHeadCommitSubject(cwd) {
18
+ return (0, node_child_process_1.execFileSync)("git", ["log", "-1", "--pretty=%s"], {
19
+ cwd,
20
+ encoding: "utf8",
21
+ stdio: ["ignore", "pipe", "ignore"],
22
+ }).trim();
23
+ }
24
+ function readReleaseNotes(cwd, version, changelogFile) {
25
+ const changelogPath = node_path_1.default.join(cwd, changelogFile);
26
+ if (!node_fs_1.default.existsSync(changelogPath)) {
27
+ return `Automated release for v${version}`;
28
+ }
29
+ const content = node_fs_1.default.readFileSync(changelogPath, "utf8");
30
+ const lines = content.split("\n");
31
+ const start = lines.findIndex((line) => line.startsWith(`## ${version} -`) || line.startsWith(`## [${version}](`));
32
+ if (start < 0) {
33
+ return `Automated release for v${version}`;
34
+ }
35
+ let end = lines.length;
36
+ for (let idx = start + 1; idx < lines.length; idx += 1) {
37
+ if (lines[idx]?.startsWith("## ")) {
38
+ end = idx;
39
+ break;
40
+ }
41
+ }
42
+ const notes = lines
43
+ .slice(start + 1, end)
44
+ .join("\n")
45
+ .trim();
46
+ return notes.length > 0 ? notes : `Automated release for v${version}`;
47
+ }
48
+ async function runSimpleRelease(cwd = process.cwd()) {
49
+ const subject = getHeadCommitSubject(cwd);
50
+ if (!(0, pr_js_1.isReleaseCommitMessage)(subject)) {
51
+ return "No release commit context detected; skipping release stage.";
52
+ }
53
+ const loaded = (0, load_config_js_1.loadConfig)(cwd);
54
+ const strategy = (0, resolve_js_1.resolveVersionStrategy)(loaded.config);
55
+ const changelogFile = loaded.config["changelog-file"] ?? "CHANGELOG.md";
56
+ const version = strategy.readVersion(cwd, loaded.config);
57
+ const defaultTag = `v${version}`;
58
+ const plugins = (0, runtime_js_1.loadRuntimePlugins)();
59
+ const scmPlugins = (0, capabilities_js_1.findPluginsByCapability)(plugins, "scm.releaseMetadata");
60
+ if (scmPlugins.length === 0) {
61
+ throw new Error("No scm.releaseMetadata plugin is available.");
62
+ }
63
+ const plugin = scmPlugins[0];
64
+ if (!plugin?.createReleaseMetadata) {
65
+ throw new Error(`Plugin "${plugin?.name ?? "unknown"}" does not implement createReleaseMetadata.`);
66
+ }
67
+ const releaseTargets = (0, state_js_1.readReleaseTargets)(cwd);
68
+ const targets = releaseTargets.length > 0
69
+ ? releaseTargets
70
+ : [
71
+ {
72
+ path: ".",
73
+ version,
74
+ tag: defaultTag,
75
+ notes: readReleaseNotes(cwd, version, changelogFile),
76
+ },
77
+ ];
78
+ const published = [];
79
+ for (const target of targets) {
80
+ const outcome = await (0, recovery_js_1.executeIdempotentReleaseTarget)(cwd, {
81
+ tag: target.tag,
82
+ version: target.version,
83
+ notes: target.notes ?? readReleaseNotes(cwd, target.version, changelogFile),
84
+ }, {
85
+ createReleaseMetadata: (input) => plugin.createReleaseMetadata(input, { cwd, logger: console }),
86
+ });
87
+ published.push(`${outcome.tag}: ${outcome.url} (tag=${outcome.tagStatus}, metadata=${outcome.metadataStatus})`);
88
+ }
89
+ return `Published releases ${published.join(", ")}`;
90
+ }