versionary 0.2.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 (63) hide show
  1. package/README.md +187 -11
  2. package/dist/{simple → app/release}/pr.d.ts +7 -4
  3. package/dist/{simple → app/release}/pr.js +89 -74
  4. package/dist/app/release/recovery.d.ts +17 -0
  5. package/dist/app/release/recovery.js +101 -0
  6. package/dist/{simple → app/release}/release.js +37 -24
  7. package/dist/app/release/state.d.ts +10 -0
  8. package/dist/{simple → app/release}/state.js +16 -4
  9. package/dist/app/release/verify.d.ts +2 -0
  10. package/dist/app/release/verify.js +5 -0
  11. package/dist/cli/index.js +12 -10
  12. package/dist/config/load-config.js +2 -12
  13. package/dist/config/schema.d.ts +1 -0
  14. package/dist/config/schema.js +1 -0
  15. package/dist/domain/release/changelog.d.ts +12 -0
  16. package/dist/domain/release/changelog.js +123 -0
  17. package/dist/{simple → domain/release}/plan.d.ts +4 -3
  18. package/dist/{simple → domain/release}/plan.js +32 -15
  19. package/dist/domain/release/semver.d.ts +15 -0
  20. package/dist/domain/release/semver.js +111 -0
  21. package/dist/domain/strategy/node.d.ts +2 -0
  22. package/dist/domain/strategy/node.js +37 -0
  23. package/dist/domain/strategy/resolve.d.ts +3 -0
  24. package/dist/domain/strategy/resolve.js +11 -0
  25. package/dist/domain/strategy/simple.d.ts +2 -0
  26. package/dist/domain/strategy/simple.js +28 -0
  27. package/dist/domain/strategy/types.d.ts +7 -0
  28. package/dist/domain/strategy/types.js +2 -0
  29. package/dist/index.d.ts +5 -3
  30. package/dist/index.js +5 -1
  31. package/dist/infra/git/commits.d.ts +65 -0
  32. package/dist/infra/git/commits.js +436 -0
  33. package/dist/infra/scm/github/plugin.d.ts +1 -0
  34. package/dist/infra/scm/github/plugin.js +5 -0
  35. package/dist/infra/scm/runtime.d.ts +2 -0
  36. package/dist/infra/scm/runtime.js +24 -0
  37. package/dist/infra/scm/types.d.ts +1 -0
  38. package/dist/infra/scm/types.js +2 -0
  39. package/dist/plugins/runtime.d.ts +1 -2
  40. package/dist/plugins/runtime.js +3 -22
  41. package/dist/scm/github-plugin.js +133 -40
  42. package/dist/strategies/node.d.ts +1 -0
  43. package/dist/strategies/node.js +5 -0
  44. package/dist/strategies/resolve.d.ts +1 -0
  45. package/dist/strategies/resolve.js +5 -0
  46. package/dist/strategies/simple.d.ts +1 -0
  47. package/dist/strategies/simple.js +5 -0
  48. package/dist/strategies/types.d.ts +1 -0
  49. package/dist/strategies/types.js +2 -0
  50. package/dist/types/config.d.ts +1 -0
  51. package/dist/types/plugins.d.ts +1 -0
  52. package/dist/verify/verify-project.js +3 -1
  53. package/package.json +1 -1
  54. package/dist/simple/changelog.d.ts +0 -3
  55. package/dist/simple/changelog.js +0 -46
  56. package/dist/simple/git.d.ts +0 -10
  57. package/dist/simple/git.js +0 -114
  58. package/dist/simple/semver.d.ts +0 -8
  59. package/dist/simple/semver.js +0 -25
  60. package/dist/simple/state.d.ts +0 -3
  61. /package/dist/{simple → app/release}/release.d.ts +0 -0
  62. /package/dist/{simple → infra/git}/repo-url.d.ts +0 -0
  63. /package/dist/{simple → infra/git}/repo-url.js +0 -0
package/README.md CHANGED
@@ -1,9 +1,74 @@
1
- # versionary
1
+ # Versionary
2
2
 
3
3
  Versionary is a software-agnostic automated release tool focused on SemVer,
4
4
  conventional commits, release PR workflows, and extensibility.
5
5
 
6
- Configuration is loaded from `versionary.jsonc` by default.
6
+ ## Why this exists
7
+
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`).
7
72
 
8
73
  Schema URL for editor support:
9
74
 
@@ -15,16 +80,40 @@ For a quick trial, use:
15
80
 
16
81
  - `version-file` (default `version.txt`) as version source
17
82
  - `changelog-file` (default `CHANGELOG.md`) as release notes output
18
- - stable release branch (`release-branch`, default:
19
- `versionary/release`) so release PRs are updated in-place
20
- - `baseline-file` (default `.versionary-manifest.json`) tracks baseline SHA for deterministic commit
21
- ranges independent of tags
22
- - review mode (`review-mode`): `review` (PR/MR style) or `direct` (no
23
- review request)
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)
24
96
  - optional monorepo planning with `monorepo-mode` and `packages`:
25
97
  - `independent` computes package bumps per path
26
98
  - `fixed` computes one shared bump across configured package paths
27
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
116
+
28
117
  Commands:
29
118
 
30
119
  - `pnpm verify`
@@ -39,8 +128,23 @@ Commands:
39
128
  via SCM plugin capability. `pnpm run` is the recommended CI entrypoint and
40
129
  auto-dispatches between PR/update and release publish.
41
130
 
42
- For first-run bootstrapping, set `bootstrap-sha` (similar to release-please). Subsequent runs use
43
- the baseline state file.
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.
44
148
 
45
149
  ## Built-in plugins
46
150
 
@@ -48,8 +152,80 @@ Versionary ships with built-in SCM plugin support:
48
152
 
49
153
  - `github` (default): review request + release metadata
50
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
+
51
226
  Package publication is intentionally out of scope in the current release flow.
52
- Use separate CI workflows for publishing after Versionary has prepared/tagged the release.
227
+ Use separate CI workflows for publishing after Versionary has prepared/tagged
228
+ the release.
53
229
 
54
230
  ## Install from GitHub
55
231
 
@@ -1,4 +1,5 @@
1
- import type { CommitInfo } from "./git.js";
1
+ import { type SimplePlan } from "../../domain/release/plan.js";
2
+ import type { ParsedCommit } from "../../infra/git/commits.js";
2
3
  export declare function splitSafeDirtyFiles(files: string[]): {
3
4
  ignored: string[];
4
5
  blocking: string[];
@@ -7,9 +8,11 @@ export declare function prepareSimpleReleasePr(cwd?: string): {
7
8
  branch: string;
8
9
  title: string;
9
10
  version: string;
10
- commits: CommitInfo[];
11
+ previousVersion: string;
12
+ commits: ParsedCommit[];
13
+ plan: SimplePlan;
11
14
  };
12
- export declare function renderSimpleReviewRequestBody(version: string, commits: CommitInfo[], cwd?: string): string;
13
- export declare function openOrUpdateSimpleReviewRequest(cwd: string, branch: string, title: string, version: string, commits: CommitInfo[]): Promise<string>;
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>;
14
17
  export declare function pushReleaseBranch(cwd: string, branch: string): void;
15
18
  export declare function isReleaseCommitMessage(subject: string): boolean;
@@ -9,16 +9,14 @@ exports.renderSimpleReviewRequestBody = renderSimpleReviewRequestBody;
9
9
  exports.openOrUpdateSimpleReviewRequest = openOrUpdateSimpleReviewRequest;
10
10
  exports.pushReleaseBranch = pushReleaseBranch;
11
11
  exports.isReleaseCommitMessage = isReleaseCommitMessage;
12
- const node_fs_1 = __importDefault(require("node:fs"));
13
- const node_path_1 = __importDefault(require("node:path"));
14
12
  const node_child_process_1 = require("node:child_process");
15
- const load_config_js_1 = require("../config/load-config.js");
16
- const capabilities_js_1 = require("../plugins/capabilities.js");
17
- const runtime_js_1 = require("../plugins/runtime.js");
18
- const plan_js_1 = require("./plan.js");
19
- const git_js_1 = require("./git.js");
20
- const repo_url_js_1 = require("./repo-url.js");
21
- const changelog_js_1 = require("./changelog.js");
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");
22
20
  const state_js_1 = require("./state.js");
23
21
  const SAFE_DIRTY_FILES = new Set([
24
22
  "pnpm-lock.yaml",
@@ -69,94 +67,111 @@ function ensureCleanWorktree(cwd) {
69
67
  }
70
68
  function prepareSimpleReleasePr(cwd = process.cwd()) {
71
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
72
  if (!plan.nextVersion) {
73
73
  throw new Error("No releasable commits found. Nothing to open a release PR for.");
74
74
  }
75
75
  ensureCleanWorktree(cwd);
76
- const versionPath = node_path_1.default.join(cwd, plan.versionFile);
77
- node_fs_1.default.writeFileSync(versionPath, `${plan.nextVersion}\n`, "utf8");
76
+ const updatedVersionFiles = strategy.writeVersion(cwd, loaded.config, plan.nextVersion);
78
77
  const section = (0, changelog_js_1.renderSimpleChangelog)(plan);
79
78
  (0, changelog_js_1.prependChangelog)(cwd, plan.changelogFile, section);
80
79
  const branch = plan.releaseBranchPrefix;
81
80
  const title = `chore(release): v${plan.nextVersion}`;
82
- (0, node_child_process_1.execFileSync)("git", ["checkout", "-B", branch], { cwd, stdio: ["ignore", "pipe", "ignore"] });
83
- (0, node_child_process_1.execFileSync)("git", ["add", plan.versionFile, plan.changelogFile], {
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], {
84
87
  cwd,
85
88
  stdio: ["ignore", "pipe", "ignore"],
86
89
  });
87
- (0, node_child_process_1.execFileSync)("git", ["commit", "-m", title], { cwd, stdio: ["ignore", "pipe", "ignore"] });
88
- (0, state_js_1.writeBaselineSha)(cwd);
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);
89
124
  (0, node_child_process_1.execFileSync)("git", ["add", (0, state_js_1.getBaselineStatePath)(cwd)], {
90
125
  cwd,
91
126
  stdio: ["ignore", "pipe", "ignore"],
92
127
  });
93
- (0, node_child_process_1.execFileSync)("git", ["commit", "--amend", "--no-edit"], { cwd, stdio: ["ignore", "pipe", "ignore"] });
128
+ (0, node_child_process_1.execFileSync)("git", ["commit", "--amend", "--no-edit"], {
129
+ cwd,
130
+ stdio: ["ignore", "pipe", "ignore"],
131
+ });
94
132
  return {
95
133
  branch,
96
134
  title,
97
135
  version: plan.nextVersion,
136
+ previousVersion: plan.currentVersion,
98
137
  commits: plan.commits,
138
+ plan,
99
139
  };
100
140
  }
101
- function formatCommitMessage(subject) {
102
- const conventional = subject.match(/^[a-z]+(?:\(([^)]+)\))?!?:\s+(.+)$/iu);
103
- if (!conventional) {
104
- return { label: "", message: subject };
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.`;
105
166
  }
106
- const scope = conventional[1]?.trim();
107
- const message = conventional[2]?.trim() ?? subject;
108
- const label = scope ? `**${scope}:** ` : "";
109
- return { label, message };
110
- }
111
- function renderSimpleReviewRequestBody(version, commits, cwd = process.cwd()) {
112
- const breaking = [];
113
- const features = [];
114
- const fixes = [];
115
- const commitBaseUrl = (0, repo_url_js_1.resolveRepositoryWebBaseUrl)(cwd);
116
- for (const commit of commits) {
117
- const subject = commit.subject;
118
- const { label, message } = formatCommitMessage(subject);
119
- const hash = commit.hash.slice(0, 7);
120
- const hashLabel = commitBaseUrl ? `[\`${hash}\`](${commitBaseUrl}/commit/${commit.hash})` : `\`${hash}\``;
121
- const type = (0, git_js_1.inferReleaseTypeFromSubject)(subject);
122
- if (!type) {
123
- continue;
124
- }
125
- const item = `- ${label}${message} (${hashLabel})`;
126
- if (type === "major") {
127
- breaking.push(item);
128
- continue;
129
- }
130
- if (type === "minor") {
131
- features.push(item);
132
- continue;
133
- }
134
- fixes.push(item);
135
- }
136
- const sections = [];
137
- if (breaking.length > 0) {
138
- sections.push("### Breaking changes", ...breaking, "");
139
- }
140
- if (features.length > 0) {
141
- sections.push("### Features", ...features, "");
142
- }
143
- if (fixes.length > 0) {
144
- sections.push("### Fixes", ...fixes, "");
145
- }
146
- return [
147
- ":robot: I have created a release PR for this repository.",
148
- "",
149
- `## Version`,
150
- "",
151
- `This PR prepares **v${version}**.`,
152
- "",
153
- "## Release notes preview",
154
- "",
155
- ...sections,
156
- "This PR was generated by Versionary.",
157
- ].join("\n");
167
+ return (0, changelog_js_1.renderSimpleReleaseNotes)({
168
+ currentVersion: previousVersion,
169
+ nextVersion: version,
170
+ commits,
171
+ cwd,
172
+ }, { includeFooter: true });
158
173
  }
159
- async function openOrUpdateSimpleReviewRequest(cwd, branch, title, version, commits) {
174
+ async function openOrUpdateSimpleReviewRequest(cwd, branch, title, version, previousVersion, commits, plan = null) {
160
175
  const loaded = (0, load_config_js_1.loadConfig)(cwd);
161
176
  const releaseFlow = loaded.config["review-mode"] ?? "direct";
162
177
  if (releaseFlow !== "review") {
@@ -175,7 +190,7 @@ async function openOrUpdateSimpleReviewRequest(cwd, branch, title, version, comm
175
190
  baseBranch: process.env.VERSIONARY_BASE_BRANCH ?? "main",
176
191
  headBranch: branch,
177
192
  title,
178
- body: renderSimpleReviewRequestBody(version, commits, cwd),
193
+ body: renderSimpleReviewRequestBody(version, previousVersion, commits, plan, cwd),
179
194
  labels: ["release"],
180
195
  }, {
181
196
  cwd,
@@ -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
+ }