agent-nuvira 3.3.1 → 3.3.3
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/README.md +5 -8
- package/dist/agents/agents/runner.d.ts +9 -0
- package/dist/agents/agents/runner.d.ts.map +1 -1
- package/dist/agents/agents/runner.js +34 -12
- package/dist/agents/agents/runner.js.map +1 -1
- package/dist/agents/agents/writer-tool-calling.d.ts.map +1 -1
- package/dist/agents/agents/writer-tool-calling.js +7 -2
- package/dist/agents/agents/writer-tool-calling.js.map +1 -1
- package/dist/agents/agents/writer.d.ts.map +1 -1
- package/dist/agents/agents/writer.js +7 -1
- package/dist/agents/agents/writer.js.map +1 -1
- package/dist/agents/artifact-verification.d.ts +102 -0
- package/dist/agents/artifact-verification.d.ts.map +1 -0
- package/dist/agents/artifact-verification.js +216 -0
- package/dist/agents/artifact-verification.js.map +1 -0
- package/dist/agents/checkpoint-store.d.ts +73 -0
- package/dist/agents/checkpoint-store.d.ts.map +1 -1
- package/dist/agents/checkpoint-store.js +161 -0
- package/dist/agents/checkpoint-store.js.map +1 -1
- package/dist/agents/credential-store.d.ts +120 -0
- package/dist/agents/credential-store.d.ts.map +1 -1
- package/dist/agents/credential-store.js +358 -19
- package/dist/agents/credential-store.js.map +1 -1
- package/dist/agents/orchestrator.d.ts +10 -1
- package/dist/agents/orchestrator.d.ts.map +1 -1
- package/dist/agents/orchestrator.js +234 -75
- package/dist/agents/orchestrator.js.map +1 -1
- package/dist/agents/phase-engine.d.ts +61 -0
- package/dist/agents/phase-engine.d.ts.map +1 -1
- package/dist/agents/phase-engine.js +68 -2
- package/dist/agents/phase-engine.js.map +1 -1
- package/dist/agents/prompt-assembly.d.ts +11 -0
- package/dist/agents/prompt-assembly.d.ts.map +1 -1
- package/dist/agents/prompt-assembly.js +17 -0
- package/dist/agents/prompt-assembly.js.map +1 -1
- package/dist/agents/release-preflight.d.ts +121 -0
- package/dist/agents/release-preflight.d.ts.map +1 -0
- package/dist/agents/release-preflight.js +259 -0
- package/dist/agents/release-preflight.js.map +1 -0
- package/dist/agents/release-runner.d.ts +157 -0
- package/dist/agents/release-runner.d.ts.map +1 -0
- package/dist/agents/release-runner.js +719 -0
- package/dist/agents/release-runner.js.map +1 -0
- package/dist/agents/step-handoff.d.ts +190 -0
- package/dist/agents/step-handoff.d.ts.map +1 -0
- package/dist/agents/step-handoff.js +443 -0
- package/dist/agents/step-handoff.js.map +1 -0
- package/dist/cli/benchmark.d.ts.map +1 -1
- package/dist/cli/benchmark.js +11 -4
- package/dist/cli/benchmark.js.map +1 -1
- package/dist/cli/chat.d.ts.map +1 -1
- package/dist/cli/chat.js +25 -4
- package/dist/cli/chat.js.map +1 -1
- package/dist/cli/cli-program.d.ts.map +1 -1
- package/dist/cli/cli-program.js +3 -0
- package/dist/cli/cli-program.js.map +1 -1
- package/dist/cli/credentials.d.ts +28 -0
- package/dist/cli/credentials.d.ts.map +1 -0
- package/dist/cli/credentials.js +213 -0
- package/dist/cli/credentials.js.map +1 -0
- package/dist/cli/failover-runner.d.ts.map +1 -1
- package/dist/cli/failover-runner.js +9 -3
- package/dist/cli/failover-runner.js.map +1 -1
- package/dist/cli/loop-executor.d.ts.map +1 -1
- package/dist/cli/loop-executor.js +74 -7
- package/dist/cli/loop-executor.js.map +1 -1
- package/dist/cli/nlu.d.ts.map +1 -1
- package/dist/cli/nlu.js +8 -2
- package/dist/cli/nlu.js.map +1 -1
- package/dist/cli/publish.d.ts +22 -0
- package/dist/cli/publish.d.ts.map +1 -1
- package/dist/cli/publish.js +109 -13
- package/dist/cli/publish.js.map +1 -1
- package/dist/config/live-credentials.d.ts +57 -0
- package/dist/config/live-credentials.d.ts.map +1 -0
- package/dist/config/live-credentials.js +126 -0
- package/dist/config/live-credentials.js.map +1 -0
- package/dist/config/paths.d.ts +18 -0
- package/dist/config/paths.d.ts.map +1 -1
- package/dist/config/paths.js +25 -0
- package/dist/config/paths.js.map +1 -1
- package/dist/federation/a2a-types.js +1 -1
- package/dist/federation/a2a-types.js.map +1 -1
- package/dist/inference/model-validator.d.ts +6 -1
- package/dist/inference/model-validator.d.ts.map +1 -1
- package/dist/inference/model-validator.js +7 -2
- package/dist/inference/model-validator.js.map +1 -1
- package/dist/inference/route-resolver.d.ts +116 -0
- package/dist/inference/route-resolver.d.ts.map +1 -0
- package/dist/inference/route-resolver.js +159 -0
- package/dist/inference/route-resolver.js.map +1 -0
- package/dist/learning/autonomy-policy.d.ts +18 -0
- package/dist/learning/autonomy-policy.d.ts.map +1 -1
- package/dist/learning/autonomy-policy.js +35 -0
- package/dist/learning/autonomy-policy.js.map +1 -1
- package/dist/learning/reasoning-trace.d.ts +1 -1
- package/dist/learning/reasoning-trace.d.ts.map +1 -1
- package/dist/learning/reasoning-trace.js +25 -1
- package/dist/learning/reasoning-trace.js.map +1 -1
- package/dist/learning/resilient-call.d.ts.map +1 -1
- package/dist/learning/resilient-call.js +29 -17
- package/dist/learning/resilient-call.js.map +1 -1
- package/dist/learning/skill-store.d.ts.map +1 -1
- package/dist/learning/skill-store.js +30 -9
- package/dist/learning/skill-store.js.map +1 -1
- package/dist/mcp/catalog.js +2 -3
- package/dist/mcp/catalog.js.map +1 -1
- package/dist/mcp/manager.d.ts.map +1 -1
- package/dist/mcp/manager.js +5 -3
- package/dist/mcp/manager.js.map +1 -1
- package/dist/skills/secret-capture.d.ts.map +1 -1
- package/dist/skills/secret-capture.js +9 -4
- package/dist/skills/secret-capture.js.map +1 -1
- package/dist/tools/credentials-tool.d.ts +24 -0
- package/dist/tools/credentials-tool.d.ts.map +1 -0
- package/dist/tools/credentials-tool.js +125 -0
- package/dist/tools/credentials-tool.js.map +1 -0
- package/dist/tools/git-tool.d.ts +25 -7
- package/dist/tools/git-tool.d.ts.map +1 -1
- package/dist/tools/git-tool.js +155 -15
- package/dist/tools/git-tool.js.map +1 -1
- package/dist/tools/loop-project-context.d.ts.map +1 -1
- package/dist/tools/loop-project-context.js +16 -0
- package/dist/tools/loop-project-context.js.map +1 -1
- package/dist/tools/loop-route-feed.d.ts +57 -0
- package/dist/tools/loop-route-feed.d.ts.map +1 -0
- package/dist/tools/loop-route-feed.js +101 -0
- package/dist/tools/loop-route-feed.js.map +1 -0
- package/dist/tools/pipeline-tool.d.ts.map +1 -1
- package/dist/tools/pipeline-tool.js +8 -2
- package/dist/tools/pipeline-tool.js.map +1 -1
- package/dist/tools/publish-tool.d.ts.map +1 -1
- package/dist/tools/publish-tool.js +76 -10
- package/dist/tools/publish-tool.js.map +1 -1
- package/dist/tools/registry.d.ts +21 -1
- package/dist/tools/registry.d.ts.map +1 -1
- package/dist/tools/registry.js +44 -6
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/run-terminal.d.ts +9 -0
- package/dist/tools/run-terminal.d.ts.map +1 -1
- package/dist/tools/run-terminal.js +80 -2
- package/dist/tools/run-terminal.js.map +1 -1
- package/dist/tools/tool-loop.d.ts +12 -5
- package/dist/tools/tool-loop.d.ts.map +1 -1
- package/dist/tools/tool-loop.js +148 -0
- package/dist/tools/tool-loop.js.map +1 -1
- package/dist/tools/toolsets.js +2 -2
- package/dist/tools/toolsets.js.map +1 -1
- package/dist/workflow/registry.js +1 -1
- package/dist/workflow/registry.js.map +1 -1
- package/package.json +5 -4
- package/src/web-dashboard/public/assets/{index-CjvoBhlz.js → index-CxDj7p6i.js} +2 -2
- package/src/web-dashboard/public/assets/{index-CjvoBhlz.js.map → index-CxDj7p6i.js.map} +1 -1
- package/src/web-dashboard/public/index.html +1 -1
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Release runners — the DETERMINISTIC half of the publish pipeline.
|
|
3
|
+
*
|
|
4
|
+
* `nuvira publish` defines its phases as goals ("Bump version (patch), update
|
|
5
|
+
* CHANGELOG.md with release notes") and used to hand every one of them to the
|
|
6
|
+
* LLM orchestrator. That made a mechanical release depend on plan quality and
|
|
7
|
+
* provider health: a live 3.3.2 run planned an unrelated job (add
|
|
8
|
+
* `standard-version`, write a new `scripts/release.ts`) instead of bumping the
|
|
9
|
+
* version, and the write steps then failed under a rate-limited provider. A
|
|
10
|
+
* release is not a creative task — every step here is a command with a known
|
|
11
|
+
* correct outcome — so each phase now carries a RUNNER, and the orchestrator is
|
|
12
|
+
* only used for phases that have none (e.g. the free-text `evaluate` phase of
|
|
13
|
+
* `nuvira phase add`).
|
|
14
|
+
*
|
|
15
|
+
* Properties that matter:
|
|
16
|
+
* - A runner reads the world at RUN time (version, changelog, branch, remote)
|
|
17
|
+
* rather than closing over state, so a phase resumed later in a new process
|
|
18
|
+
* sees the same facts the filesystem does.
|
|
19
|
+
* - Nothing is invented: the changelog entry is built from `git log`, and the
|
|
20
|
+
* commit headline is the changelog heading when one exists.
|
|
21
|
+
* - An author-written `## vX.Y.Z` section in CHANGELOG.md is LEFT ALONE. The
|
|
22
|
+
* generated entry is a fallback for the case where nobody wrote one, never an
|
|
23
|
+
* overwrite of prose a human wrote.
|
|
24
|
+
* - Failures carry the command's own output as the error, so the summary line
|
|
25
|
+
* the user reads says what npm/git actually said.
|
|
26
|
+
* - A phase refuses the work it must not do, rather than doing it and reporting
|
|
27
|
+
* afterwards: the git phase will not tag a release that changes the dependency
|
|
28
|
+
* set, and the version phase will not commit a version whose pinned artifacts
|
|
29
|
+
* are stale.
|
|
30
|
+
*/
|
|
31
|
+
import { execSync } from 'node:child_process';
|
|
32
|
+
import { existsSync, mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
33
|
+
import { tmpdir } from 'node:os';
|
|
34
|
+
import { join } from 'node:path';
|
|
35
|
+
import { TEST_UNSAFE_ENV_KEYS, stripTestUnsafeEnv } from '../config/live-credentials.js';
|
|
36
|
+
import { logger } from '../utils/logger.js';
|
|
37
|
+
/** Phase ids produced by `buildPublishPhases` — the runner table's keys. */
|
|
38
|
+
export const RELEASE_PHASE_IDS = {
|
|
39
|
+
tests: 'phase-1-tests',
|
|
40
|
+
version: 'phase-2-version',
|
|
41
|
+
git: 'phase-3-git',
|
|
42
|
+
npm: 'phase-4-npm',
|
|
43
|
+
github: 'phase-5-github',
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Run a command and never throw — the caller decides what a non-zero exit
|
|
47
|
+
* means. `2>&1` keeps stderr in the captured text because that is where npm,
|
|
48
|
+
* git and gh put the sentence that explains a failure.
|
|
49
|
+
*/
|
|
50
|
+
function run(command, timeoutMs = 600_000, cwd, env) {
|
|
51
|
+
try {
|
|
52
|
+
const output = execSync(`${command} 2>&1`, {
|
|
53
|
+
cwd: cwd || process.cwd(),
|
|
54
|
+
timeout: timeoutMs,
|
|
55
|
+
encoding: 'utf-8',
|
|
56
|
+
stdio: 'pipe',
|
|
57
|
+
// Undefined keeps the inherited environment; a caller passes `env` to
|
|
58
|
+
// spawn WITHOUT something this process holds (the test phase passes a
|
|
59
|
+
// credential-stripped copy — see `src/config/live-credentials.ts`).
|
|
60
|
+
...(env ? { env } : {}),
|
|
61
|
+
});
|
|
62
|
+
return { ok: true, output: (output || '').trim() };
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
const error = err;
|
|
66
|
+
return { ok: false, output: (error.stdout || error.stderr || error.message || '').trim() };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function tail(text, max = 300) {
|
|
70
|
+
const trimmed = text.trim();
|
|
71
|
+
return trimmed.length > max ? `…${trimmed.slice(-max)}` : trimmed;
|
|
72
|
+
}
|
|
73
|
+
/** Read what failed out of a vitest run, in a form a caller can act on. */
|
|
74
|
+
export function testFailureShape(output) {
|
|
75
|
+
// vitest colours the `FAIL` marker, so escape sequences sit BETWEEN the marker
|
|
76
|
+
// and the path and a plain match finds nothing. Measured: the first version of
|
|
77
|
+
// this digest named the failure kind and no file at all.
|
|
78
|
+
const clean = output.replace(/\u001B\[[0-9;]*[A-Za-z]/g, '');
|
|
79
|
+
const files = [
|
|
80
|
+
...new Set([...clean.matchAll(/(?:FAIL\s+|❯\s+)(tests\/[^\s(]+)/g)].map((m) => m[1])),
|
|
81
|
+
];
|
|
82
|
+
const kinds = [
|
|
83
|
+
...new Set([
|
|
84
|
+
...clean.matchAll(/Test timed out in \d+ ?ms/g),
|
|
85
|
+
...clean.matchAll(/Timed out waiting for [^\n]+/g),
|
|
86
|
+
...clean.matchAll(/AssertionError/g),
|
|
87
|
+
...clean.matchAll(/Unhandled [Ee]rrors?/g),
|
|
88
|
+
].map((m) => m[0])),
|
|
89
|
+
];
|
|
90
|
+
const notTimeouts = kinds.filter((kind) => !/timed out|Timed out/.test(kind));
|
|
91
|
+
return { files, kinds, timeoutsOnly: kinds.length > 0 && notTimeouts.length === 0 };
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The digest a failed Test Verification reports: which files, what kind, and
|
|
95
|
+
* whether the shape is the one that means "re-run before blaming the code".
|
|
96
|
+
*/
|
|
97
|
+
export function testFailureDigest(output, context) {
|
|
98
|
+
const { files, kinds, timeoutsOnly } = testFailureShape(output);
|
|
99
|
+
const lines = [];
|
|
100
|
+
if (files.length > 0) {
|
|
101
|
+
const shown = files.slice(0, 10).join(', ');
|
|
102
|
+
lines.push(`${files.length} failing file(s): ${shown}${files.length > 10 ? `, +${files.length - 10} more` : ''}`);
|
|
103
|
+
}
|
|
104
|
+
if (kinds.length > 0)
|
|
105
|
+
lines.push(`failure kinds: ${kinds.slice(0, 6).join('; ')}`);
|
|
106
|
+
if (timeoutsOnly) {
|
|
107
|
+
lines.push('every failure is a TIMEOUT: a timeout is about scheduling or a live network path, not an ' +
|
|
108
|
+
'assertion — re-run before treating this as a code regression');
|
|
109
|
+
}
|
|
110
|
+
if (context)
|
|
111
|
+
lines.push(context);
|
|
112
|
+
return lines.join('\n');
|
|
113
|
+
}
|
|
114
|
+
export function readManifest(cwd = process.cwd()) {
|
|
115
|
+
const path = join(cwd, 'package.json');
|
|
116
|
+
try {
|
|
117
|
+
if (!existsSync(path))
|
|
118
|
+
return null;
|
|
119
|
+
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
export function readVersion(cwd = process.cwd()) {
|
|
126
|
+
return readManifest(cwd)?.version || '';
|
|
127
|
+
}
|
|
128
|
+
/** Pure version arithmetic — exported so it can be pinned by test. */
|
|
129
|
+
export function bumpVersionString(current, bumpType) {
|
|
130
|
+
const parts = current.split('.').map((p) => Number.parseInt(p, 10) || 0);
|
|
131
|
+
while (parts.length < 3)
|
|
132
|
+
parts.push(0);
|
|
133
|
+
if (bumpType === 'major')
|
|
134
|
+
return `${parts[0] + 1}.0.0`;
|
|
135
|
+
if (bumpType === 'minor')
|
|
136
|
+
return `${parts[0]}.${parts[1] + 1}.0`;
|
|
137
|
+
return `${parts[0]}.${parts[1]}.${parts[2] + 1}`;
|
|
138
|
+
}
|
|
139
|
+
/** The tag this release is measured from — version tags only (`v3.3.1`). */
|
|
140
|
+
export function lastReleaseTag() {
|
|
141
|
+
const tagged = run('git describe --tags --abbrev=0 --match "v[0-9]*"', 15_000);
|
|
142
|
+
if (tagged.ok && tagged.output)
|
|
143
|
+
return tagged.output.split('\n')[0].trim();
|
|
144
|
+
// No version tag yet: measure from the root commit so the entry is not empty.
|
|
145
|
+
const root = run('git rev-list --max-parents=0 HEAD', 15_000);
|
|
146
|
+
return root.ok ? root.output.split('\n')[0].trim() : '';
|
|
147
|
+
}
|
|
148
|
+
export function commitsSince(base) {
|
|
149
|
+
if (!base)
|
|
150
|
+
return [];
|
|
151
|
+
const log = run(`git log --oneline --no-decorate ${base}..HEAD`, 15_000);
|
|
152
|
+
if (!log.ok)
|
|
153
|
+
return [];
|
|
154
|
+
return log.output.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
155
|
+
}
|
|
156
|
+
/** Does CHANGELOG.md already have a section for this version? Section = `## vX.Y.Z`. */
|
|
157
|
+
export function hasChangelogSection(version, changelog) {
|
|
158
|
+
if (!changelog)
|
|
159
|
+
return false;
|
|
160
|
+
const escaped = version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
161
|
+
// `\b` is not enough: '3.3' followed by '.1' IS a word boundary, so '3.3'
|
|
162
|
+
// would match the '## v3.3.1' heading and the release would keep a section
|
|
163
|
+
// belonging to a different version.
|
|
164
|
+
return new RegExp(`^##\\s*v?${escaped}(?![0-9.])`, 'm').test(changelog);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* The headline of `## vX.Y.Z — headline`, used as the commit subject.
|
|
168
|
+
* Returns '' when the section carries no headline.
|
|
169
|
+
*/
|
|
170
|
+
export function changelogHeadline(version, changelog) {
|
|
171
|
+
const escaped = version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
172
|
+
const match = changelog.match(new RegExp(`^##\\s*v?${escaped}\\s*[—-]\\s*(.+)$`, 'm'));
|
|
173
|
+
return match ? match[1].trim() : '';
|
|
174
|
+
}
|
|
175
|
+
/** The full text of the `## vX.Y.Z` section, for GitHub release notes. */
|
|
176
|
+
export function changelogSection(version, changelog) {
|
|
177
|
+
const escaped = version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
178
|
+
const heading = new RegExp(`^##\\s*v?${escaped}(?![0-9.]).*$`, 'm');
|
|
179
|
+
const start = changelog.search(heading);
|
|
180
|
+
if (start === -1)
|
|
181
|
+
return '';
|
|
182
|
+
const rest = changelog.slice(start);
|
|
183
|
+
const nextSection = rest.slice(1).search(/^##\s/m);
|
|
184
|
+
return (nextSection === -1 ? rest : rest.slice(0, nextSection + 1)).trim();
|
|
185
|
+
}
|
|
186
|
+
function readChangelog(cwd = process.cwd()) {
|
|
187
|
+
const path = join(cwd, 'CHANGELOG.md');
|
|
188
|
+
try {
|
|
189
|
+
return existsSync(path) ? readFileSync(path, 'utf-8') : '';
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
return '';
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// ─── Phase 1: tests ─────────────────────────────────────────────────────────
|
|
196
|
+
export async function runTestsPhase() {
|
|
197
|
+
const manifest = readManifest();
|
|
198
|
+
if (!manifest?.scripts?.test) {
|
|
199
|
+
return { success: true, summary: 'No test script in package.json — nothing to run' };
|
|
200
|
+
}
|
|
201
|
+
// Generous: a full suite on a large project runs for minutes, and a timeout
|
|
202
|
+
// here would read as "tests failed" for a suite that was still passing.
|
|
203
|
+
//
|
|
204
|
+
// The suite is spawned WITHOUT the credentials this process holds. The release
|
|
205
|
+
// legitimately loads `~/.nuvira/.env` to push and publish, and it used to hand
|
|
206
|
+
// that same environment to `npm test` — so tests conditioned on a provider key
|
|
207
|
+
// woke up and took real network paths, and Phase 1 failed on the operator's
|
|
208
|
+
// shell rather than on the code. See `src/config/live-credentials.ts`.
|
|
209
|
+
// What the suite is NOT being told, reported on failure so the operator can
|
|
210
|
+
// rule the environment in or out instead of re-deriving it.
|
|
211
|
+
const withheld = TEST_UNSAFE_ENV_KEYS.filter((key) => process.env[key] !== undefined);
|
|
212
|
+
const withheldNote = withheld.length
|
|
213
|
+
? `note: ${withheld.length} credential(s) were withheld from the suite (${withheld.slice(0, 5).join(', ')}${withheld.length > 5 ? ', …' : ''}), so a live-credential cause is already ruled out`
|
|
214
|
+
: undefined;
|
|
215
|
+
const startedAt = Date.now();
|
|
216
|
+
const result = run('npm test', 1_800_000, undefined, stripTestUnsafeEnv());
|
|
217
|
+
if (!result.ok) {
|
|
218
|
+
const shape = testFailureShape(result.output);
|
|
219
|
+
const firstRunMs = Date.now() - startedAt;
|
|
220
|
+
// RETRY ONCE, and only for the signature that means "reproduce before you
|
|
221
|
+
// conclude": every failure a timeout, on a run short enough that a second
|
|
222
|
+
// one stays cheap (10 minutes). This is the judgement a human makes by hand
|
|
223
|
+
// after a timing-shaped failure — re-run it — and a pipeline that grades a
|
|
224
|
+
// release without it reports flakes as regressions. A timeout-only failure
|
|
225
|
+
// that REPEATS is reported as real, with both attempts named.
|
|
226
|
+
if (shape.timeoutsOnly && shape.files.length > 0 && firstRunMs < 600_000) {
|
|
227
|
+
const retry = run('npm test', 1_800_000, undefined, stripTestUnsafeEnv());
|
|
228
|
+
if (retry.ok) {
|
|
229
|
+
const passed = retry.output.match(/Tests\s+.*?(\d+)\s+passed/);
|
|
230
|
+
return {
|
|
231
|
+
success: true,
|
|
232
|
+
summary: `Tests passed on retry (${passed ? passed[1] : 'all'} passing) — the first run had ` +
|
|
233
|
+
`${shape.files.length} timeout-only failure(s), which did not reproduce`,
|
|
234
|
+
details: tail(retry.output, 800),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
success: false,
|
|
239
|
+
summary: 'Test suite failed twice, timeouts only',
|
|
240
|
+
error: [
|
|
241
|
+
testFailureDigest(result.output, withheldNote),
|
|
242
|
+
'the retry failed the same way, so treat it as real',
|
|
243
|
+
tail(retry.output, 400),
|
|
244
|
+
]
|
|
245
|
+
.filter(Boolean)
|
|
246
|
+
.join('\n'),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
const files = shape.files.length;
|
|
250
|
+
return {
|
|
251
|
+
success: false,
|
|
252
|
+
summary: files ? `Test suite failed (${files} file(s))` : 'Test suite failed',
|
|
253
|
+
error: [testFailureDigest(result.output, withheldNote), tail(result.output, 400)]
|
|
254
|
+
.filter(Boolean)
|
|
255
|
+
.join('\n'),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
const passed = result.output.match(/Tests\s+.*?(\d+)\s+passed/);
|
|
259
|
+
return {
|
|
260
|
+
success: true,
|
|
261
|
+
summary: passed ? `Tests passed (${passed[1]} passing)` : 'Tests passed',
|
|
262
|
+
details: tail(result.output, 800),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
// ─── Phase 2: version bump + changelog ──────────────────────────────────────
|
|
266
|
+
export async function runVersionBumpPhase(bumpType = 'patch') {
|
|
267
|
+
const cwd = process.cwd();
|
|
268
|
+
const pkgPath = join(cwd, 'package.json');
|
|
269
|
+
const manifest = readManifest(cwd);
|
|
270
|
+
if (!manifest) {
|
|
271
|
+
return { success: false, summary: 'No package.json found', error: `Expected ${pkgPath}` };
|
|
272
|
+
}
|
|
273
|
+
const current = manifest.version || '0.0.0';
|
|
274
|
+
const next = bumpVersionString(current, bumpType);
|
|
275
|
+
if (next === current) {
|
|
276
|
+
return { success: false, summary: `Version did not change (${current})`, error: 'Bump produced the same version' };
|
|
277
|
+
}
|
|
278
|
+
manifest.version = next;
|
|
279
|
+
writeFileSync(pkgPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
|
|
280
|
+
// The lockfile's OWN version fields are the ones npm compares; leaving them
|
|
281
|
+
// stale makes `npm ci` in a fresh checkout disagree with package.json.
|
|
282
|
+
const lockPath = join(cwd, 'package-lock.json');
|
|
283
|
+
let lockSynced = false;
|
|
284
|
+
try {
|
|
285
|
+
if (existsSync(lockPath)) {
|
|
286
|
+
const lock = JSON.parse(readFileSync(lockPath, 'utf-8'));
|
|
287
|
+
lock.version = next;
|
|
288
|
+
if (lock.packages?.[''])
|
|
289
|
+
lock.packages[''].version = next;
|
|
290
|
+
writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n', 'utf-8');
|
|
291
|
+
lockSynced = true;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
catch (err) {
|
|
295
|
+
logger.warn(` ⚠️ Could not sync package-lock.json: ${err}`);
|
|
296
|
+
}
|
|
297
|
+
// Changelog: an author-written section wins. Only write a generated one when
|
|
298
|
+
// the version has no section at all.
|
|
299
|
+
const changelog = readChangelog(cwd);
|
|
300
|
+
let changelogNote = 'kept the existing entry';
|
|
301
|
+
if (!hasChangelogSection(next, changelog)) {
|
|
302
|
+
const commits = commitsSince(lastReleaseTag());
|
|
303
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
304
|
+
const bullets = commits.length
|
|
305
|
+
? commits.map((c) => `- ${c}`).join('\n')
|
|
306
|
+
: '- No commits recorded since the previous release.';
|
|
307
|
+
const entry = `## v${next} — released ${date}\n\n${bullets}\n\n`;
|
|
308
|
+
const header = '# Changelog\n';
|
|
309
|
+
const body = changelog.startsWith(header)
|
|
310
|
+
? header + '\n' + entry + changelog.slice(header.length).replace(/^\n+/, '')
|
|
311
|
+
: entry + changelog;
|
|
312
|
+
writeFileSync(join(cwd, 'CHANGELOG.md'), body, 'utf-8');
|
|
313
|
+
changelogNote = `added an entry from ${commits.length} commit(s)`;
|
|
314
|
+
}
|
|
315
|
+
// Version-pinned artifacts — the recorder the release docs pin to, the
|
|
316
|
+
// dashboard bundle that inlines the version — must move in the SAME commit as
|
|
317
|
+
// the version. They did not, and the 3.3.2 release is what found it: the
|
|
318
|
+
// pipeline bumped, committed and pushed, and then `prepublishOnly`'s test run
|
|
319
|
+
// failed on the staleness guard ("cast records v3.3.1 but package.json is
|
|
320
|
+
// v3.3.2"), so the publish could not complete. The v3.3.1 release had fixed
|
|
321
|
+
// this by hand, which is exactly the sign it belongs in the pipeline.
|
|
322
|
+
const artifacts = await regenerateVersionPinnedArtifacts();
|
|
323
|
+
if (!artifacts.success) {
|
|
324
|
+
return {
|
|
325
|
+
success: false,
|
|
326
|
+
summary: `Bumped to ${next}, but its version-pinned artifacts could not be regenerated`,
|
|
327
|
+
error: artifacts.error,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
const parts = [
|
|
331
|
+
`Bumped ${current} -> ${next} (${bumpType})`,
|
|
332
|
+
`changelog ${changelogNote}`,
|
|
333
|
+
lockSynced ? 'lockfile synced' : null,
|
|
334
|
+
artifacts.summary,
|
|
335
|
+
].filter(Boolean);
|
|
336
|
+
return { success: true, summary: parts.join('; ') };
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Which dependencies differ between HEAD and the working tree.
|
|
340
|
+
*
|
|
341
|
+
* Compares every dependency section as a SET, so a pure reformat is not a
|
|
342
|
+
* finding while an added, removed or re-ranged package is — and reports the
|
|
343
|
+
* packages by name so the message is actionable rather than "package.json
|
|
344
|
+
* changed".
|
|
345
|
+
*/
|
|
346
|
+
const DEP_SECTIONS = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'];
|
|
347
|
+
/** The dependency sections of a parsed manifest, as one `section::name` set. */
|
|
348
|
+
function dependencySet(manifest) {
|
|
349
|
+
const out = {};
|
|
350
|
+
for (const section of DEP_SECTIONS) {
|
|
351
|
+
const bag = manifest[section];
|
|
352
|
+
if (!bag || typeof bag !== 'object')
|
|
353
|
+
continue;
|
|
354
|
+
for (const [name, range] of Object.entries(bag)) {
|
|
355
|
+
out[`${section}::${name}`] = String(range);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return out;
|
|
359
|
+
}
|
|
360
|
+
export function dependencyDelta(cwd = process.cwd()) {
|
|
361
|
+
const sections = DEP_SECTIONS;
|
|
362
|
+
const local = readManifest(cwd);
|
|
363
|
+
if (!local)
|
|
364
|
+
return [];
|
|
365
|
+
const headRaw = run('git show HEAD:package.json', 15_000, cwd);
|
|
366
|
+
if (!headRaw.ok || !headRaw.output.trim())
|
|
367
|
+
return []; // no HEAD yet — nothing to compare
|
|
368
|
+
let head;
|
|
369
|
+
try {
|
|
370
|
+
head = JSON.parse(headRaw.output);
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
return [];
|
|
374
|
+
}
|
|
375
|
+
const changes = [];
|
|
376
|
+
for (const section of sections) {
|
|
377
|
+
const before = (head[section] ?? {});
|
|
378
|
+
const after = (local[section] ?? {});
|
|
379
|
+
for (const name of new Set([...Object.keys(before), ...Object.keys(after)])) {
|
|
380
|
+
if (before[name] === after[name])
|
|
381
|
+
continue;
|
|
382
|
+
if (!(name in before))
|
|
383
|
+
changes.push(`+${name} (${section})`);
|
|
384
|
+
else if (!(name in after))
|
|
385
|
+
changes.push(`-${name} (${section})`);
|
|
386
|
+
else
|
|
387
|
+
changes.push(`~${name} ${before[name]} → ${after[name]} (${section})`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return changes.sort();
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Which dependencies moved in a NESTED manifest — staged content vs HEAD's.
|
|
394
|
+
*
|
|
395
|
+
* `null` means NO CLAIM CAN BE MADE: the staged content cannot be read, the file
|
|
396
|
+
* is new (there is no HEAD side), or it is not JSON. A caller must treat `null`
|
|
397
|
+
* as unverifiable rather than as "nothing moved" — this guard exists so a
|
|
398
|
+
* dependency cannot enter a tagged release unseen, and "I could not read it" is
|
|
399
|
+
* not "it is fine".
|
|
400
|
+
*/
|
|
401
|
+
export function nestedDependencyDelta(path, cwd = process.cwd()) {
|
|
402
|
+
const staged = run(`git show :"${path}"`, 20_000, cwd);
|
|
403
|
+
const head = run(`git show "HEAD:${path}"`, 20_000, cwd);
|
|
404
|
+
if (!staged.ok || !head.ok)
|
|
405
|
+
return null;
|
|
406
|
+
let before;
|
|
407
|
+
let after;
|
|
408
|
+
try {
|
|
409
|
+
before = JSON.parse(head.output);
|
|
410
|
+
after = JSON.parse(staged.output);
|
|
411
|
+
}
|
|
412
|
+
catch {
|
|
413
|
+
return null;
|
|
414
|
+
}
|
|
415
|
+
if (!before || !after || typeof before !== 'object' || typeof after !== 'object')
|
|
416
|
+
return null;
|
|
417
|
+
const b = dependencySet(before);
|
|
418
|
+
const a = dependencySet(after);
|
|
419
|
+
const changes = [];
|
|
420
|
+
for (const key of new Set([...Object.keys(b), ...Object.keys(a)])) {
|
|
421
|
+
if (b[key] === a[key])
|
|
422
|
+
continue;
|
|
423
|
+
const [section, name] = key.split('::');
|
|
424
|
+
if (!(key in b))
|
|
425
|
+
changes.push(`+${name} (${section})`);
|
|
426
|
+
else if (!(key in a))
|
|
427
|
+
changes.push(`-${name} (${section})`);
|
|
428
|
+
else
|
|
429
|
+
changes.push(`~${name} ${b[key]} → ${a[key]} (${section})`);
|
|
430
|
+
}
|
|
431
|
+
return changes.sort();
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Decide what a release commit should do about the nested manifests it carries.
|
|
435
|
+
*
|
|
436
|
+
* WHY IT IS NOT "refuse every nested manifest edit". That was the first version,
|
|
437
|
+
* and it produced a FALSE POSITIVE the first time it ran for real: three
|
|
438
|
+
* sub-package manifests (`packages/sdk`, `src/agent-sdk`, `vscode-extension`)
|
|
439
|
+
* whose entire diff was a `repository` / `homepage` URL pointing at the new docs
|
|
440
|
+
* repo. No dependencies moved, yet the release could not be completed without
|
|
441
|
+
* `NUVIRA_ALLOW_DEP_CHANGES=1` — and an override that gets used on a false alarm
|
|
442
|
+
* is an override that stops meaning anything. A URL edit and a dependency edit
|
|
443
|
+
* are distinguishable, so the guard distinguishes them and keeps its teeth for
|
|
444
|
+
* the case it was built for.
|
|
445
|
+
*
|
|
446
|
+
* Lockfiles stay unverifiable on purpose: `yarn.lock`, `pnpm-lock.yaml` and
|
|
447
|
+
* `bun.lock` are not JSON, and a nested lockfile only moves when its sibling's
|
|
448
|
+
* dependencies do.
|
|
449
|
+
*/
|
|
450
|
+
export function evaluateNestedManifests(paths, deltaOf) {
|
|
451
|
+
const moved = [];
|
|
452
|
+
const unverifiable = [];
|
|
453
|
+
for (const path of paths) {
|
|
454
|
+
if (!path.endsWith('package.json')) {
|
|
455
|
+
unverifiable.push(path);
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
const delta = deltaOf(path);
|
|
459
|
+
if (delta === null) {
|
|
460
|
+
unverifiable.push(path);
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
moved.push(...delta.map((entry) => `${path}: ${entry}`));
|
|
464
|
+
}
|
|
465
|
+
return { moved, unverifiable };
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Run the project's own hook for artifacts that embed the version.
|
|
469
|
+
*
|
|
470
|
+
* The hook is an explicit npm script rather than a guess at which directories
|
|
471
|
+
* are generated: a project that needs one declares `release:artifacts`, and a
|
|
472
|
+
* project without it skips this cleanly. Guessing by path ("rebuild if `public/`
|
|
473
|
+
* is tracked") would be wrong in both directions, and wrong silently.
|
|
474
|
+
*/
|
|
475
|
+
export async function regenerateVersionPinnedArtifacts() {
|
|
476
|
+
const manifest = readManifest();
|
|
477
|
+
const hook = manifest?.scripts?.['release:artifacts'];
|
|
478
|
+
if (!hook) {
|
|
479
|
+
return { success: true, summary: 'no release:artifacts hook to run' };
|
|
480
|
+
}
|
|
481
|
+
const result = run('npm run release:artifacts', 1_800_000);
|
|
482
|
+
if (!result.ok) {
|
|
483
|
+
return {
|
|
484
|
+
success: false,
|
|
485
|
+
summary: 'release:artifacts failed',
|
|
486
|
+
error: tail(result.output, 400),
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
return { success: true, summary: 'version-pinned artifacts regenerated' };
|
|
490
|
+
}
|
|
491
|
+
// ─── Phase 3: git commit, tag, push ─────────────────────────────────────────
|
|
492
|
+
export async function runGitPhase() {
|
|
493
|
+
const version = readVersion();
|
|
494
|
+
if (!version) {
|
|
495
|
+
return { success: false, summary: 'Cannot tag without a version', error: 'package.json has no version' };
|
|
496
|
+
}
|
|
497
|
+
const tag = `v${version}`;
|
|
498
|
+
// ── A release commit changes the VERSION, not the dependency set ──────────
|
|
499
|
+
// Checked against HEAD, so it is independent of when the change appeared. The
|
|
500
|
+
// incident: two dependencies were written into `package.json` mid-release by
|
|
501
|
+
// something outside the pipeline, and this phase's `git add -A` was one step
|
|
502
|
+
// from committing them into a tagged, published release whose code never
|
|
503
|
+
// referenced either. A dependency change is a decision with its own commit,
|
|
504
|
+
// never a side effect of cutting a release.
|
|
505
|
+
const deps = dependencyDelta();
|
|
506
|
+
if (deps.length > 0 && process.env.NUVIRA_ALLOW_DEP_CHANGES !== '1') {
|
|
507
|
+
return {
|
|
508
|
+
success: false,
|
|
509
|
+
summary: 'Refusing to tag a release that changes dependencies',
|
|
510
|
+
error: `${deps.join(', ')} differ from HEAD. A release commit must not introduce or remove ` +
|
|
511
|
+
'dependencies — commit that change on its own (and say why), or set NUVIRA_ALLOW_DEP_CHANGES=1 ' +
|
|
512
|
+
'if the dependency change is genuinely part of this release.',
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
const branchResult = run('git rev-parse --abbrev-ref HEAD', 15_000);
|
|
516
|
+
const branch = branchResult.ok ? branchResult.output.trim() : 'main';
|
|
517
|
+
const remotes = run('git remote', 15_000);
|
|
518
|
+
if (!remotes.ok || !remotes.output.trim()) {
|
|
519
|
+
return { success: false, summary: 'No git remote configured', error: 'Add a remote: git remote add origin <url>' };
|
|
520
|
+
}
|
|
521
|
+
const remote = remotes.output.split('\n')[0].trim();
|
|
522
|
+
// Stage everything the release touched (version bump, changelog, lockfile).
|
|
523
|
+
const staged = run('git add -A', 30_000);
|
|
524
|
+
if (!staged.ok) {
|
|
525
|
+
return { success: false, summary: 'git add failed', error: tail(staged.output) };
|
|
526
|
+
}
|
|
527
|
+
// ── What the commit will actually CONTAIN ──────────────────────────────
|
|
528
|
+
// `git add -A` stages the whole working tree, which is right for a release
|
|
529
|
+
// (it ships the work), but it means the commit's contents have to be READ
|
|
530
|
+
// rather than assumed. The dependency guard above only reads the ROOT
|
|
531
|
+
// manifest, so a NESTED manifest — a sub-package's package.json or lockfile —
|
|
532
|
+
// could still carry a dependency change into a tagged, published release
|
|
533
|
+
// unseen. That is the same supply-chain event one level down, so it gets the
|
|
534
|
+
// same rule: refuse it unless the operator says the change is intended.
|
|
535
|
+
const stagedList = run('git diff --cached --name-only', 30_000);
|
|
536
|
+
const stagedPaths = stagedList.ok
|
|
537
|
+
? stagedList.output.split('\n').map((p) => p.trim()).filter(Boolean)
|
|
538
|
+
: [];
|
|
539
|
+
const ROOT_MANIFESTS = new Set([
|
|
540
|
+
'package.json', 'package-lock.json', 'npm-shrinkwrap.json',
|
|
541
|
+
'yarn.lock', 'pnpm-lock.yaml', 'bun.lock',
|
|
542
|
+
]);
|
|
543
|
+
const NESTED_MANIFEST_RE = /(^|\/)(package\.json|package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lock)$/;
|
|
544
|
+
const nestedManifests = stagedPaths.filter((p) => !ROOT_MANIFESTS.has(p) && NESTED_MANIFEST_RE.test(p));
|
|
545
|
+
const nestedVerdict = nestedManifests.length > 0
|
|
546
|
+
? evaluateNestedManifests(nestedManifests, (path) => nestedDependencyDelta(path))
|
|
547
|
+
: { moved: [], unverifiable: [] };
|
|
548
|
+
if ((nestedVerdict.moved.length > 0 || nestedVerdict.unverifiable.length > 0) &&
|
|
549
|
+
process.env.NUVIRA_ALLOW_DEP_CHANGES !== '1') {
|
|
550
|
+
const findings = [];
|
|
551
|
+
if (nestedVerdict.moved.length > 0) {
|
|
552
|
+
findings.push(`dependency edits: ${nestedVerdict.moved.join(', ')}`);
|
|
553
|
+
}
|
|
554
|
+
if (nestedVerdict.unverifiable.length > 0) {
|
|
555
|
+
findings.push(`cannot verify (new, unreadable, or a lockfile this pipeline cannot compare): ${nestedVerdict.unverifiable.join(', ')}`);
|
|
556
|
+
}
|
|
557
|
+
return {
|
|
558
|
+
success: false,
|
|
559
|
+
summary: 'Refusing to tag a release that changes a nested dependency manifest',
|
|
560
|
+
error: `${nestedManifests.join(', ')} would enter this release commit — ${findings.join('; ')}. ` +
|
|
561
|
+
'A release commit must not carry a dependency change it cannot name: commit it on its ' +
|
|
562
|
+
'own (and say why), or set NUVIRA_ALLOW_DEP_CHANGES=1 if it is genuinely part of this release.',
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
const status = run('git status --short', 30_000);
|
|
566
|
+
const changedCount = status.ok ? status.output.split('\n').filter(Boolean).length : 0;
|
|
567
|
+
// Names, not just a count: the live incident was one `git add -A` from
|
|
568
|
+
// committing two packages nobody had declared, and "21 files changed" said
|
|
569
|
+
// nothing about them. The list is bounded because a release commit can
|
|
570
|
+
// legitimately be large, and it is reported on the success path too — the
|
|
571
|
+
// point is that the operator can SEE what shipped.
|
|
572
|
+
const stagedNote = stagedPaths.length
|
|
573
|
+
? `staging ${stagedPaths.length} path(s): ${stagedPaths.slice(0, 40).join(', ')}` +
|
|
574
|
+
(stagedPaths.length > 40 ? ` …and ${stagedPaths.length - 40} more` : '')
|
|
575
|
+
: 'nothing staged';
|
|
576
|
+
// Reported, not assumed: "these nested manifests were checked and carry no
|
|
577
|
+
// dependency edit" is a fact the operator can verify, unlike silence.
|
|
578
|
+
const nestedNote = nestedManifests.length > 0
|
|
579
|
+
? `; ${nestedManifests.length} nested manifest(s) verified dependency-free: ${nestedManifests.join(', ')}`
|
|
580
|
+
: '';
|
|
581
|
+
let committed = false;
|
|
582
|
+
if (changedCount > 0) {
|
|
583
|
+
const headline = changelogHeadline(version, readChangelog());
|
|
584
|
+
const message = headline ? `Release ${tag}: ${headline}` : `Release ${tag}`;
|
|
585
|
+
const dir = mkdtempSync(join(tmpdir(), 'nuvira-release-'));
|
|
586
|
+
const msgFile = join(dir, 'commit-msg.txt');
|
|
587
|
+
writeFileSync(msgFile, message + '\n', 'utf-8');
|
|
588
|
+
const commit = run(`git commit -F "${msgFile}"`, 60_000);
|
|
589
|
+
try {
|
|
590
|
+
unlinkSync(msgFile);
|
|
591
|
+
}
|
|
592
|
+
catch { /* best-effort */ }
|
|
593
|
+
if (!commit.ok) {
|
|
594
|
+
return { success: false, summary: 'git commit failed', error: tail(commit.output) };
|
|
595
|
+
}
|
|
596
|
+
committed = true;
|
|
597
|
+
}
|
|
598
|
+
const existingTag = run(`git tag -l "${tag}"`, 15_000);
|
|
599
|
+
let createdTag = false;
|
|
600
|
+
if (!existingTag.ok || existingTag.output.trim() !== tag) {
|
|
601
|
+
const tagResult = run(`git tag -a "${tag}" -m "Release ${tag}"`, 30_000);
|
|
602
|
+
if (!tagResult.ok) {
|
|
603
|
+
return { success: false, summary: `Failed to create tag ${tag}`, error: tail(tagResult.output) };
|
|
604
|
+
}
|
|
605
|
+
createdTag = true;
|
|
606
|
+
}
|
|
607
|
+
const pushBranch = run(`git push -u "${remote}" "${branch}"`, 180_000);
|
|
608
|
+
if (!pushBranch.ok) {
|
|
609
|
+
return {
|
|
610
|
+
success: false,
|
|
611
|
+
summary: `Push to ${remote}/${branch} failed`,
|
|
612
|
+
error: tail(pushBranch.output, 400),
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
const pushTag = run(`git push "${remote}" "${tag}"`, 120_000);
|
|
616
|
+
if (!pushTag.ok) {
|
|
617
|
+
return {
|
|
618
|
+
success: false,
|
|
619
|
+
summary: `Committed and pushed ${branch}, but pushing tag ${tag} failed`,
|
|
620
|
+
error: tail(pushTag.output, 400),
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
const parts = [committed ? `committed ${changedCount} file(s)` : 'nothing to commit',
|
|
624
|
+
createdTag ? `tagged ${tag}` : `${tag} already existed`,
|
|
625
|
+
`pushed to ${remote}/${branch}`,
|
|
626
|
+
`${stagedNote}${nestedNote}`,
|
|
627
|
+
];
|
|
628
|
+
return { success: true, summary: parts.join('; ') };
|
|
629
|
+
}
|
|
630
|
+
// ─── Phase 4: npm build + publish ───────────────────────────────────────────
|
|
631
|
+
/** Turn npm's exit text into the sentence the user needs. */
|
|
632
|
+
export function describeNpmError(output) {
|
|
633
|
+
if (output.includes('ENEEDAUTH'))
|
|
634
|
+
return 'Authentication required — set NPM_TOKEN (nuvira credentials set NPM_TOKEN)';
|
|
635
|
+
if (output.includes('E403') || /\b403\b/.test(output) || output.includes('not_logged_in') || output.includes('unauthorized')) {
|
|
636
|
+
return 'Not authorized to publish — check the npm token and its permissions';
|
|
637
|
+
}
|
|
638
|
+
if (output.includes('cannot publish over previously published version')) {
|
|
639
|
+
return 'Version already published — bump to a new version';
|
|
640
|
+
}
|
|
641
|
+
if (output.includes('E402') || output.includes('unpaid'))
|
|
642
|
+
return 'Payment required — check the npm account';
|
|
643
|
+
if (output.includes('E404'))
|
|
644
|
+
return 'Package not found — check the name and registry';
|
|
645
|
+
return tail(output, 400);
|
|
646
|
+
}
|
|
647
|
+
export async function runNpmPhase() {
|
|
648
|
+
const manifest = readManifest();
|
|
649
|
+
if (!manifest) {
|
|
650
|
+
return { success: false, summary: 'No package.json found', error: 'Cannot publish without a manifest' };
|
|
651
|
+
}
|
|
652
|
+
const version = manifest.version || '';
|
|
653
|
+
if (manifest.scripts?.build) {
|
|
654
|
+
const build = run('npm run build', 1_200_000);
|
|
655
|
+
if (!build.ok) {
|
|
656
|
+
return { success: false, summary: 'Build failed — publish aborted', error: tail(build.output, 400) };
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
// `npm publish` runs the project's own `prepublishOnly` — here that is
|
|
660
|
+
// `npm run build && npm test`, which takes ~10 minutes on this repo. Timing
|
|
661
|
+
// out mid-publish would leave a release half-shipped, so this is deliberately
|
|
662
|
+
// long rather than tidy.
|
|
663
|
+
const publish = run('npm publish', 2_700_000);
|
|
664
|
+
if (!publish.ok) {
|
|
665
|
+
return { success: false, summary: `npm publish failed for ${version}`, error: describeNpmError(publish.output) };
|
|
666
|
+
}
|
|
667
|
+
return { success: true, summary: `Published agent-nuvira@${version} to the npm registry`, details: tail(publish.output, 400) };
|
|
668
|
+
}
|
|
669
|
+
// ─── Phase 5: GitHub release ────────────────────────────────────────────────
|
|
670
|
+
export async function runGitHubReleasePhase() {
|
|
671
|
+
const gh = run('gh --version', 15_000);
|
|
672
|
+
if (!gh.ok) {
|
|
673
|
+
return {
|
|
674
|
+
success: false,
|
|
675
|
+
summary: 'GitHub CLI not available',
|
|
676
|
+
error: 'Install the gh CLI (https://cli.github.com) or create the release from the pushed tag',
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
const version = readVersion();
|
|
680
|
+
if (!version) {
|
|
681
|
+
return { success: false, summary: 'Cannot name a release without a version', error: 'package.json has no version' };
|
|
682
|
+
}
|
|
683
|
+
const tag = `v${version}`;
|
|
684
|
+
const existing = run(`gh release view "${tag}"`, 30_000);
|
|
685
|
+
if (existing.ok) {
|
|
686
|
+
return { success: true, summary: `GitHub release ${tag} already exists` };
|
|
687
|
+
}
|
|
688
|
+
// Notes come from the changelog section for this version; when nobody wrote
|
|
689
|
+
// one, fall back to the commit list rather than shipping an empty release.
|
|
690
|
+
const section = changelogSection(version, readChangelog());
|
|
691
|
+
const notes = section || commitsSince(lastReleaseTag()).map((c) => `- ${c}`).join('\n') || `Release ${tag}`;
|
|
692
|
+
const dir = mkdtempSync(join(tmpdir(), 'nuvira-release-notes-'));
|
|
693
|
+
const notesFile = join(dir, 'notes.md');
|
|
694
|
+
writeFileSync(notesFile, notes + '\n', 'utf-8');
|
|
695
|
+
const create = run(`gh release create "${tag}" --title "${tag}" --notes-file "${notesFile}"`, 120_000);
|
|
696
|
+
try {
|
|
697
|
+
unlinkSync(notesFile);
|
|
698
|
+
}
|
|
699
|
+
catch { /* best-effort */ }
|
|
700
|
+
if (!create.ok) {
|
|
701
|
+
return { success: false, summary: `Could not create GitHub release ${tag}`, error: tail(create.output, 400) };
|
|
702
|
+
}
|
|
703
|
+
return { success: true, summary: `Created GitHub release ${tag}`, details: tail(create.output, 200) };
|
|
704
|
+
}
|
|
705
|
+
// ─── Runner table ───────────────────────────────────────────────────────────
|
|
706
|
+
/**
|
|
707
|
+
* The deterministic implementation for every phase `buildPublishPhases`
|
|
708
|
+
* produces. A phase whose id is absent here keeps the orchestrator.
|
|
709
|
+
*/
|
|
710
|
+
export function createReleaseRunners(bumpType) {
|
|
711
|
+
return {
|
|
712
|
+
[RELEASE_PHASE_IDS.tests]: runTestsPhase,
|
|
713
|
+
[RELEASE_PHASE_IDS.version]: () => runVersionBumpPhase(bumpType),
|
|
714
|
+
[RELEASE_PHASE_IDS.git]: runGitPhase,
|
|
715
|
+
[RELEASE_PHASE_IDS.npm]: runNpmPhase,
|
|
716
|
+
[RELEASE_PHASE_IDS.github]: runGitHubReleasePhase,
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
//# sourceMappingURL=release-runner.js.map
|