@gcunharodrigues/wrxn 0.20.1 → 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 +34 -0
- package/lib/release-cut.cjs +160 -0
- package/lib/release.cjs +14 -1
- package/package.json +1 -1
- package/payload/.claude/agents/devops.md +5 -0
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
|
-
|
|
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.
|
|
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"
|
|
@@ -25,6 +25,11 @@ are global slash-skills with no local file; the promote is yours). Follow these
|
|
|
25
25
|
opens a PR, and arms auto-merge (`gh pr merge --auto --squash`) — no settings file, no env flag, no
|
|
26
26
|
GitHub clicks. `--branch` defaults to the current branch; pass `--base` if the trunk is not `main`.
|
|
27
27
|
Run `wrxn ship --dry-run` first if you want to preview the exact promote plan before it runs.
|
|
28
|
+
- **Multi-issue PR bodies — one closing keyword PER issue.** When the PR resolves more than one
|
|
29
|
+
issue, write a closing keyword before EACH number (`Closes #104, closes #105`, or one `Closes #N`
|
|
30
|
+
per line). GitHub auto-closes only the issue directly after a keyword, so a comma/`+` list after a
|
|
31
|
+
single keyword (`Closes #104, #105`) closes ONLY the first — trailing slices leak open (real: PR
|
|
32
|
+
#106 left #105 open). Applies on squash-merge too (GitHub reads the PR body + the squash message).
|
|
28
33
|
3. **Confirm auto-merge is armed** (e.g. `gh pr view --json autoMergeRequest` shows it enabled). The
|
|
29
34
|
server-enforced CI ruleset is now the authority: GitHub merges to the trunk the instant CI is green.
|
|
30
35
|
You never merge by hand and you never push directly to the trunk.
|