@gcunharodrigues/wrxn 0.20.2 → 0.21.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.
package/bin/wrxn.cjs CHANGED
@@ -15,6 +15,7 @@ const connect = require('../lib/connect.cjs');
15
15
  const ship = require('../lib/ship.cjs');
16
16
  const protect = require('../lib/protect.cjs');
17
17
  const release = require('../lib/release.cjs');
18
+ const releaseCut = require('../lib/release-cut.cjs');
18
19
  const brain = require('../lib/brain.cjs');
19
20
  const statusline = require('../lib/statusline.cjs');
20
21
  const { convert } = require('../lib/convert.cjs');
@@ -142,6 +143,16 @@ Usage:
142
143
  running it (a non-destructive preview). Stops at the first failing
143
144
  step (a failed push never opens a PR).
144
145
 
146
+ wrxn release [minor|patch|major] [--base <main>] [--root <dir>]
147
+ one-command deliberate release (ADR 0010): bump package.json+lock,
148
+ commit chore(release): <pkg> X.Y.Z on chore/release-X.Y.Z, then
149
+ delegate push → PR (base main) → arm auto-merge to the ship path. The
150
+ level is auto-computed from conventional commits since the last tag
151
+ (REUSE release-check); pass minor|patch|major to force it. Does NOT
152
+ publish or tag — CD publishes on merge. Repo-agnostic (cwd package.json
153
+ + git). Refuses loud (no mutation) when it can't safely release: not on
154
+ main, dirty tree, behind origin/main, or nothing releasable + no level.
155
+
145
156
  wrxn protect [--root <dir>] apply the wrxn-main-gate server-side ruleset to this repo's origin —
146
157
  the hard gate that replaces the settings.local.json env-flag dance:
147
158
  block direct push to the default branch, require a PR + the wrxn-ci
@@ -544,6 +555,29 @@ async function main(argv) {
544
555
  return 2;
545
556
  }
546
557
 
558
+ if (cmd === 'release') {
559
+ // One-command deliberate release (ADR 0010): compute the bump (explicit level, else auto from
560
+ // conventional commits since the last tag — REUSE release-check) → npm version → commit
561
+ // chore(release) on chore/release-X.Y.Z → DELEGATE push/PR/arm to the ship path. Does NOT publish
562
+ // or tag — CD owns that on merge. Repo-agnostic (operates on the cwd package.json + git). The git/
563
+ // npm/ship side effects run through release-cut's realDeps (real invocation). Refuses loud (exit 2,
564
+ // NO mutation) on: not on main / dirty tree / behind origin/main / nothing releasable + no level.
565
+ const root = path.resolve(args.flags.root || process.cwd());
566
+ const arg = args._[1]; // optional minor|patch|major
567
+ const base = args.flags.base || releaseCut.DEFAULT_BASE;
568
+ const res = releaseCut.cutRelease({ arg, base, root });
569
+ if (res.refused) {
570
+ process.stderr.write(`wrxn: refusing to release — ${res.reason} (nothing changed)\n`);
571
+ return 2;
572
+ }
573
+ if (!res.ok) {
574
+ process.stderr.write(`wrxn: release failed at "${res.step}"${res.detail ? ` — ${res.detail}` : ''}\n`);
575
+ return 2;
576
+ }
577
+ process.stdout.write(`released ${res.version} (${res.bump}, ${res.source}) — committed ${res.branch}, delegated to ship → PR on ${res.base} with auto-merge armed (CD publishes on merge)\n`);
578
+ return 0;
579
+ }
580
+
547
581
  if (cmd === 'protect') {
548
582
  // Apply the wrxn-main-gate server-side ruleset to this repo's origin — the hard gate that replaces
549
583
  // the disarmable settings.local.json env-flag dance (ADR 0007). Repo-agnostic: the slug is read from
@@ -0,0 +1,160 @@
1
+ 'use strict';
2
+
3
+ // wrxn release — one-command deliberate release (PRD #101 / issue #102).
4
+ //
5
+ // Collapses the manual 4-step chore(release) dance into one command: compute the bump, apply
6
+ // `npm version <bump> --no-git-tag-version`, commit `chore(release): <pkg> X.Y.Z` on
7
+ // `chore/release-X.Y.Z`, then DELEGATE push → PR(base main) → arm-auto-merge to the existing
8
+ // `wrxn ship` path. It does NOT publish or tag — CD owns that on merge (ADR 0010).
9
+ //
10
+ // REUSE > CREATE: the bump is computed by lib/release.cjs's `shouldRelease` (the release-check
11
+ // type-gate; its previously-vestigial `bump` is now consumed); the push/PR/arm is the existing
12
+ // lib/ship.cjs `ship`. New code is only the thin orchestration + guards.
13
+ //
14
+ // Side effects go through an INJECTED `deps` boundary (readState / applyBump / commit / ship),
15
+ // defaulting to the real git/npm/gh impls — so the whole flow is unit-testable with NO live push.
16
+ // This mirrors lib/ship.cjs's injected invoker, finer-grained per the tdd skill's mocking.md
17
+ // ("SDK-style boundary functions over one generic fetcher": each boundary returns one shape).
18
+
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const { execFileSync } = require('child_process');
22
+ const { shouldRelease, parseLog } = require('./release.cjs');
23
+ const ship = require('./ship.cjs');
24
+
25
+ const BUMPS = ['major', 'minor', 'patch'];
26
+ const TRUNK = 'main';
27
+ const DEFAULT_BASE = 'main';
28
+
29
+ function refusal(reason) {
30
+ return { ok: false, refused: true, reason };
31
+ }
32
+
33
+ /**
34
+ * Decide the release bump. PURE. An explicit level wins; otherwise it is auto-computed from the
35
+ * conventional commits since the last tag (REUSE lib/release.cjs's release-check classifier — its
36
+ * once-vestigial `bump` output is now consumed). No level AND nothing releasable → refuse.
37
+ * @returns {{ bump:string, source:'explicit'|'auto' } | { refuse:true, reason:string }}
38
+ * @throws on an unknown explicit level (a malformed release is never runnable).
39
+ */
40
+ function decideBump({ arg, commits, lastTag } = {}) {
41
+ if (arg != null && arg !== '') {
42
+ if (!BUMPS.includes(arg)) throw new Error(`unknown release level "${arg}" — expected ${BUMPS.join(' | ')}`);
43
+ return { bump: arg, source: 'explicit' };
44
+ }
45
+ const { release, bump } = shouldRelease(commits || []);
46
+ if (!release) return { refuse: true, reason: `nothing to release since ${lastTag || 'the last release'}` };
47
+ return { bump, source: 'auto' };
48
+ }
49
+
50
+ /**
51
+ * The branch + PR title a release commits and ships, derived from the package name and the
52
+ * post-bump version. PURE. Version is passed through verbatim so prerelease tags (recon-wrxn's
53
+ * `6.0.0-wrxn.N`) survive — npm version is the authority on the new version, not this helper.
54
+ * @returns {{ branch:string, title:string }}
55
+ * @throws when name or version is missing.
56
+ */
57
+ function releaseSpec({ name, version } = {}) {
58
+ if (!name || !version) throw new Error('releaseSpec requires a package name and version');
59
+ return {
60
+ branch: `chore/release-${version}`,
61
+ title: `chore(release): ${name} ${version}`,
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Cut a deliberate release. Guards (each refuses loud, non-zero, NO mutation) → bump decision →
67
+ * npm version → commit → delegate to ship. Side effects run through the injected `deps` boundary.
68
+ * @returns {{ ok:true, ... } | { ok:false, refused?:true, reason?:string, step?:string, detail?:string }}
69
+ */
70
+ function cutRelease({ arg, base = DEFAULT_BASE, root, deps } = {}) {
71
+ const d = deps || realDeps(root || process.cwd());
72
+ const state = d.readState();
73
+ // ── guards ──
74
+ if (state.branch !== TRUNK) return refusal(`not on ${TRUNK} (on "${state.branch}") — release is cut from ${TRUNK}`);
75
+ if (state.dirty) return refusal('the working tree is dirty — commit or stash before releasing');
76
+ if (state.behind > 0) return refusal(`behind origin/${TRUNK} by ${state.behind} — pull before releasing`);
77
+ // ── bump decision (explicit arg wins; else auto from commits since the tag; else refuse) ──
78
+ let decision;
79
+ try {
80
+ decision = decideBump({ arg, commits: state.commits, lastTag: state.lastTag });
81
+ } catch (err) {
82
+ return refusal(err.message);
83
+ }
84
+ if (decision.refuse) return refusal(decision.reason);
85
+ // ── mutate: npm version (the version authority) → commit chore(release) → delegate to ship ──
86
+ const applied = d.applyBump(decision.bump);
87
+ if (!applied.ok) return { ok: false, step: 'npm-version', detail: applied.detail };
88
+ const spec = releaseSpec({ name: state.name, version: applied.newVersion });
89
+ const committed = d.commit({ branch: spec.branch, title: spec.title });
90
+ if (!committed.ok) return { ok: false, step: 'commit', detail: committed.detail };
91
+ const shipped = d.ship({ branch: spec.branch, title: spec.title, base });
92
+ if (!shipped.ok) return { ok: false, step: 'ship', failed: shipped.failed, detail: shipped.detail, branch: spec.branch };
93
+ return {
94
+ ok: true,
95
+ bump: decision.bump,
96
+ source: decision.source,
97
+ version: applied.newVersion,
98
+ branch: spec.branch,
99
+ title: spec.title,
100
+ base,
101
+ };
102
+ }
103
+
104
+ /**
105
+ * The real side-effect boundary (the CLI layer wires this — what makes the release "validated by
106
+ * invocation"). Each method is one SDK-style operation over the cwd repo, repo-agnostic (reads the
107
+ * package.json + git in `root`, so the SAME command releases wrxn-kernel and recon-wrxn):
108
+ * readState — current branch, dirty, behind origin/main, last tag, commits since it, pkg name/version
109
+ * applyBump — `npm version <bump> --no-git-tag-version`; npm is the version authority (returns it)
110
+ * commit — branch chore/release-X.Y.Z, stage package.json (+ lockfile if present), commit
111
+ * ship — REUSE lib/ship.cjs (push → PR base main → arm auto-merge); NOT reimplemented here
112
+ */
113
+ function realDeps(root) {
114
+ const git = (...a) => execFileSync('git', ['-C', root, ...a], { encoding: 'utf8' }).trim();
115
+ return {
116
+ readState() {
117
+ let branch = '';
118
+ try { branch = git('branch', '--show-current'); } catch { branch = ''; }
119
+ let dirty = false;
120
+ try { dirty = git('status', '--porcelain') !== ''; } catch { dirty = false; }
121
+ // Best-effort fetch so "behind" reflects the real remote; never block on a fetch failure
122
+ // (offline / no remote) — the server-side require-up-to-date ruleset is the hard gate.
123
+ try { execFileSync('git', ['-C', root, 'fetch', 'origin', TRUNK, '--quiet'], { stdio: 'ignore' }); } catch { /* offline / no remote */ }
124
+ let behind = 0;
125
+ try { behind = parseInt(git('rev-list', '--count', `HEAD..origin/${TRUNK}`), 10) || 0; } catch { behind = 0; }
126
+ let lastTag = '';
127
+ try { lastTag = git('describe', '--tags', '--abbrev=0'); } catch { lastTag = ''; }
128
+ let commits = [];
129
+ try { commits = parseLog(git('log', '--format=%B%x00', lastTag ? `${lastTag}..HEAD` : 'HEAD')); } catch { commits = []; }
130
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
131
+ return { branch, dirty, behind, lastTag, commits, name: pkg.name, version: pkg.version };
132
+ },
133
+ applyBump(bump) {
134
+ try {
135
+ execFileSync('npm', ['version', bump, '--no-git-tag-version'], { cwd: root, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
136
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
137
+ return { ok: true, newVersion: pkg.version };
138
+ } catch (err) {
139
+ return { ok: false, detail: `npm version ${bump} failed: ${err.message}` };
140
+ }
141
+ },
142
+ commit({ branch, title }) {
143
+ try {
144
+ git('checkout', '-b', branch);
145
+ const files = ['package.json'];
146
+ if (fs.existsSync(path.join(root, 'package-lock.json'))) files.push('package-lock.json');
147
+ git('add', '--', ...files);
148
+ git('commit', '-m', title);
149
+ return { ok: true };
150
+ } catch (err) {
151
+ return { ok: false, detail: `commit failed: ${err.message}` };
152
+ }
153
+ },
154
+ ship({ branch, title, base }) {
155
+ return ship.ship({ branch, title, base }); // real defaultInvoke (git push + gh)
156
+ },
157
+ };
158
+ }
159
+
160
+ module.exports = { decideBump, releaseSpec, cutRelease, realDeps, BUMPS, DEFAULT_BASE };
package/lib/release.cjs CHANGED
@@ -60,4 +60,17 @@ function decidePublish({ typeRelease, versionOnNpm } = {}) {
60
60
  return Boolean(typeRelease) || !versionOnNpm;
61
61
  }
62
62
 
63
- module.exports = { shouldRelease, parseLog, decidePublish };
63
+ /**
64
+ * decideWarn({ typeRelease, versionOnNpm }) → boolean. The silent-skip detector (#103): warn when the
65
+ * merged commits warrant a type release AND package.json.version is already on npm. In that case
66
+ * decidePublish is true (typeRelease), so the run proceeds — but the publish step's idempotency guard
67
+ * skips the already-published version, so nothing reaches npm: the releasable commits silently never
68
+ * ship. The workflow turns this into a visible `::warning::` to run `wrxn release` (bump + publish).
69
+ * Pure — the npm probe stays in the workflow. False for a normal publish (versionOnNpm false) and for
70
+ * non-release merges (the idempotent no-op is expected, not a mistake).
71
+ */
72
+ function decideWarn({ typeRelease, versionOnNpm } = {}) {
73
+ return Boolean(typeRelease) && Boolean(versionOnNpm);
74
+ }
75
+
76
+ module.exports = { shouldRelease, parseLog, decidePublish, decideWarn };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gcunharodrigues/wrxn",
3
- "version": "0.20.2",
3
+ "version": "0.21.0",
4
4
  "description": "WRXN Kernel — installable AI operating system. Two profiles (project | workspace), pull-based updates, managed/seeded/state file classes.",
5
5
  "bin": {
6
6
  "wrxn": "bin/wrxn.cjs"