@vodmal/vdx-cli 0.29.0 → 0.31.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/README.md +14 -0
- package/package.json +1 -1
- package/src/gate.ts +183 -0
- package/src/hooks.ts +19 -1
- package/src/hosting.ts +121 -9
- package/src/index.ts +64 -1
- package/src/publish.ts +45 -7
package/README.md
CHANGED
|
@@ -45,6 +45,9 @@ vdx init [project-path] [--baseline github.com/org/repo@vX.Y] [--stack <id>] [-
|
|
|
45
45
|
# Publish a library (Node MVP; PHP/Python coming in Y.3)
|
|
46
46
|
vdx publish <patch|minor|major> [--dry-run] [--force] [--no-push]
|
|
47
47
|
|
|
48
|
+
# Push the branch; one that requires checks on GitHub takes the commit once they pass
|
|
49
|
+
vdx push [project-path] [--dry-run] # default: cwd
|
|
50
|
+
|
|
48
51
|
# Environment self-check (Node / git / mise / npm auth / docker / Claude Code plugin; git hooks of the project)
|
|
49
52
|
vdx doctor [project-path] [--format=ansi|markdown|json] # default: cwd
|
|
50
53
|
vdx doctor --check [project-path] # one line per hook declared but not running here — the plugin's SessionStart hook tells the agent
|
|
@@ -88,6 +91,17 @@ vdx ai
|
|
|
88
91
|
vdx ai@m3
|
|
89
92
|
```
|
|
90
93
|
|
|
94
|
+
`vdx push` pushes the current branch to its upstream. When the upstream is on
|
|
95
|
+
GitHub and requires checks (branch protection or a ruleset), the commit goes to
|
|
96
|
+
`vdx-gate/<branch>` first; CI checks it there — its workflow has to run on
|
|
97
|
+
`vdx-gate/**` — and vdx waits for the checks, then pushes the same commit to the
|
|
98
|
+
branch with its annotated tags. A failed check leaves the branch as it was and
|
|
99
|
+
names the check. Without required checks, or off GitHub, it is a plain
|
|
100
|
+
`git push`. Its pushes through the checks carry `VDX_GATE_PUSH=1`, so a pre-push
|
|
101
|
+
hook that runs what CI runs can step aside. `vdx publish` sends its release
|
|
102
|
+
commit the same way, after `npm publish`, and then refuses local commits not
|
|
103
|
+
pushed yet: npm would get them before CI.
|
|
104
|
+
|
|
91
105
|
`vdx ai` reads a personal profile, never a bundled one: `$VDX_ENVIRONMENT`, else
|
|
92
106
|
`~/.vdx-environment.yaml`, else a plain `claude`/`codex` with no flags. Format:
|
|
93
107
|
[docs/specs/environment-format.md](../docs/specs/environment-format.md).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vodmal/vdx-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.0",
|
|
4
4
|
"description": "vdx — unified lifecycle interface (up/down/build/test/check/fix) + versioned maturity audit with drift detection. Pairs with the vdx Claude Code plugin.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"vdx",
|
package/src/gate.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `vdx push`: a commit reaches a branch that requires checks only once they pass
|
|
3
|
+
* (DL #8–#10 of docs/tasks/hosting-extension). It goes to a gate branch of its own,
|
|
4
|
+
* CI checks it there, and the branch takes the same commit — the same SHA, a linear
|
|
5
|
+
* history. A branch that requires nothing, a remote not on GitHub, a hosting that
|
|
6
|
+
* does not answer: a plain `git push`, as before.
|
|
7
|
+
*/
|
|
8
|
+
import { execFileSync } from 'node:child_process';
|
|
9
|
+
import { commitChecks, githubRepo, machineIo, requiredChecks, type CheckState, type HostingIo } from './hosting.ts';
|
|
10
|
+
import { errorMessage } from './narrow.ts';
|
|
11
|
+
import type { Unknown } from './predicates.ts';
|
|
12
|
+
|
|
13
|
+
/** The branch CI checks a commit on before `<branch>` takes it: `vdx-gate/main`. */
|
|
14
|
+
export const GATE_PREFIX = 'vdx-gate/';
|
|
15
|
+
/** Set for the pushes `vdx push` makes: a pre-push hook that runs what CI runs may step aside. */
|
|
16
|
+
const GATE_ENV = 'VDX_GATE_PUSH';
|
|
17
|
+
|
|
18
|
+
/** Where a push goes, and through what. */
|
|
19
|
+
export interface GateTarget {
|
|
20
|
+
remote: string;
|
|
21
|
+
branch: string;
|
|
22
|
+
repo: string;
|
|
23
|
+
token: string | null;
|
|
24
|
+
/** What the branch requires — GitHub's names. */
|
|
25
|
+
checks: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type GatePlan =
|
|
29
|
+
| { kind: 'gate'; target: GateTarget }
|
|
30
|
+
/** Nothing to wait for: `git push` as it is. */
|
|
31
|
+
| { kind: 'plain'; why: string }
|
|
32
|
+
| { kind: 'refuse'; message: string };
|
|
33
|
+
|
|
34
|
+
function isUnknown(v: object): v is Unknown {
|
|
35
|
+
return 'unknown' in v;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The current branch, its upstream and what the upstream requires — nothing is fetched. */
|
|
39
|
+
export function planGate(io: HostingIo): GatePlan {
|
|
40
|
+
const local = io.git(['symbolic-ref', '--quiet', '--short', 'HEAD']);
|
|
41
|
+
if (!local) return { kind: 'refuse', message: 'HEAD is not on a branch — nothing to push it to' };
|
|
42
|
+
const remote = io.git(['config', '--get', `branch.${local}.remote`]);
|
|
43
|
+
const merge = io.git(['config', '--get', `branch.${local}.merge`]);
|
|
44
|
+
if (!remote || !merge?.startsWith('refs/heads/')) {
|
|
45
|
+
return { kind: 'refuse', message: `${local} has no upstream — git push -u <remote> ${local}` };
|
|
46
|
+
}
|
|
47
|
+
const branch = merge.slice('refs/heads/'.length);
|
|
48
|
+
const gh = githubRepo(io, remote);
|
|
49
|
+
if (isUnknown(gh)) return { kind: 'plain', why: gh.unknown };
|
|
50
|
+
let checks: string[] | Unknown;
|
|
51
|
+
try {
|
|
52
|
+
checks = requiredChecks(io, gh.repo, branch, gh.token);
|
|
53
|
+
} catch (e) {
|
|
54
|
+
return { kind: 'plain', why: `GitHub did not answer: ${errorMessage(e)}` };
|
|
55
|
+
}
|
|
56
|
+
if (isUnknown(checks)) return { kind: 'plain', why: checks.unknown };
|
|
57
|
+
if (checks.length === 0) return { kind: 'plain', why: `${remote}/${branch} requires no checks` };
|
|
58
|
+
const behind = parseInt(io.git(['rev-list', '--count', 'HEAD..@{u}']) ?? '0', 10) || 0;
|
|
59
|
+
if (behind > 0) {
|
|
60
|
+
return {
|
|
61
|
+
kind: 'refuse',
|
|
62
|
+
message: `${remote}/${branch} has ${behind} commit(s) this branch lacks — pull first; CI would check a commit ${branch} cannot take`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return { kind: 'gate', target: { remote, branch, repo: gh.repo, token: gh.token, checks } };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** What a gated push does outside — the machine's own below, a fake in the tests. */
|
|
69
|
+
export interface GateDeps {
|
|
70
|
+
io: HostingIo;
|
|
71
|
+
/** `git <args>` with `VDX_GATE_PUSH=1`, its output on this terminal; throws on a non-zero exit. */
|
|
72
|
+
push: (args: string[]) => void;
|
|
73
|
+
log: (line: string) => void;
|
|
74
|
+
/** Rewrites one status line in place (a terminal); without it the wait logs a line now and then. */
|
|
75
|
+
progress?: (text: string) => void;
|
|
76
|
+
sleep: (ms: number) => void;
|
|
77
|
+
now: () => number;
|
|
78
|
+
/** How often GitHub is asked: its limit is 60 an hour without a token. */
|
|
79
|
+
pollMs: number;
|
|
80
|
+
timeoutMs: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function sleepSync(ms: number): void {
|
|
84
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function defaultGateDeps(root: string): GateDeps {
|
|
88
|
+
return {
|
|
89
|
+
io: machineIo(root),
|
|
90
|
+
push: (args) => {
|
|
91
|
+
execFileSync('git', ['-C', root, ...args], { stdio: 'inherit', env: { ...process.env, [GATE_ENV]: '1' } });
|
|
92
|
+
},
|
|
93
|
+
log: (line) => process.stderr.write(line + '\n'),
|
|
94
|
+
progress: process.stderr.isTTY ? (text) => process.stderr.write(`\r${text}\x1b[K`) : undefined,
|
|
95
|
+
sleep: sleepSync,
|
|
96
|
+
now: Date.now,
|
|
97
|
+
pollMs: 15_000,
|
|
98
|
+
timeoutMs: 20 * 60_000,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const passed = (states: CheckState[]) => states.every((s) => s.state === 'passed');
|
|
103
|
+
const brief = (states: CheckState[]) => states.map((s) => `${s.name}: ${s.detail.replace(/_/g, ' ')}`).join(', ');
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* HEAD to the target branch through its checks: the gate branch, the wait, then the
|
|
107
|
+
* branch itself, with the annotated tags on the way (`--follow-tags` — a release's
|
|
108
|
+
* tag). Throws when the branch did not take the commit; the reason names what next.
|
|
109
|
+
*/
|
|
110
|
+
export function pushThroughGate(t: GateTarget, d: GateDeps): void {
|
|
111
|
+
const sha = d.io.git(['rev-parse', 'HEAD']);
|
|
112
|
+
if (!sha) throw new Error('git rev-parse HEAD failed');
|
|
113
|
+
const short = sha.slice(0, 7);
|
|
114
|
+
const where = `${t.remote}/${t.branch}`;
|
|
115
|
+
if (d.io.git(['rev-parse', '@{u}']) === sha) {
|
|
116
|
+
d.log(`✓ ${where} is at ${short} already — nothing to push`);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const names = t.checks.join(', ');
|
|
120
|
+
const link = `https://github.com/${t.repo}/commit/${sha}/checks`;
|
|
121
|
+
const again = `once they pass: vdx push (${link})`;
|
|
122
|
+
const ask = () => {
|
|
123
|
+
try {
|
|
124
|
+
return commitChecks(d.io, t.repo, sha, t.checks, t.token);
|
|
125
|
+
} catch (e) {
|
|
126
|
+
return { unknown: `GitHub did not answer: ${errorMessage(e)}` };
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
const toBranch = () => {
|
|
130
|
+
d.log(`→ git push --follow-tags ${t.remote} ${short}:${t.branch}`);
|
|
131
|
+
d.push(['push', '--follow-tags', t.remote, `${sha}:refs/heads/${t.branch}`]);
|
|
132
|
+
d.log(`✓ ${where} is at ${short}`);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// Pushed once and checked: the branch takes it now.
|
|
136
|
+
const before = ask();
|
|
137
|
+
if (!isUnknown(before) && passed(before)) {
|
|
138
|
+
d.log(`✓ ${short} has passed ${names}`);
|
|
139
|
+
toBranch();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const gate = GATE_PREFIX + t.branch;
|
|
144
|
+
d.log(`→ git push --force ${t.remote} ${short}:${gate} — ${where} requires ${names}; CI checks the commit there`);
|
|
145
|
+
d.push(['push', '--force', t.remote, `${sha}:refs/heads/${gate}`]);
|
|
146
|
+
d.log(`→ waiting for ${names} on ${short} (asking every ${Math.round(d.pollMs / 1000)} s): ${link}`);
|
|
147
|
+
|
|
148
|
+
const started = d.now();
|
|
149
|
+
const seconds = () => Math.round((d.now() - started) / 1000);
|
|
150
|
+
let lastLogged = 0;
|
|
151
|
+
let shown = '';
|
|
152
|
+
for (;;) {
|
|
153
|
+
// Between the asks, count the seconds on one line: a silent wait looks hung.
|
|
154
|
+
const next = d.now() + d.pollMs;
|
|
155
|
+
while (d.now() < next) {
|
|
156
|
+
if (d.progress) d.progress(` … ${seconds()} s${shown}`);
|
|
157
|
+
else if (seconds() - lastLogged >= 30) {
|
|
158
|
+
lastLogged = seconds();
|
|
159
|
+
d.log(` … ${lastLogged} s${shown}`);
|
|
160
|
+
}
|
|
161
|
+
d.sleep(1_000);
|
|
162
|
+
}
|
|
163
|
+
const states = ask();
|
|
164
|
+
d.progress?.('');
|
|
165
|
+
if (isUnknown(states)) throw new Error(`${states.unknown} — ${t.branch} did not take ${short}; ${again}`);
|
|
166
|
+
const failed = states.filter((s) => s.state === 'failed');
|
|
167
|
+
if (failed.length > 0) {
|
|
168
|
+
const which = failed.map((s) => `${s.name} — ${s.detail}${s.url ? ` (${s.url})` : ''}`).join('; ');
|
|
169
|
+
throw new Error(`${which}: ${t.branch} did not take ${short}`);
|
|
170
|
+
}
|
|
171
|
+
if (passed(states)) {
|
|
172
|
+
d.log(`✓ ${names} passed (${seconds()} s)`);
|
|
173
|
+
toBranch();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (d.now() - started >= d.timeoutMs) {
|
|
177
|
+
throw new Error(
|
|
178
|
+
`${brief(states)} after ${Math.round(d.timeoutMs / 60_000)} min — ${t.branch} did not take ${short}; ${again}`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
shown = ` — ${brief(states)}`;
|
|
182
|
+
}
|
|
183
|
+
}
|
package/src/hooks.ts
CHANGED
|
@@ -428,12 +428,30 @@ export function unsetGatingVars(commands: string[], env: NodeJS.ProcessEnv = pro
|
|
|
428
428
|
for (const body of commands) {
|
|
429
429
|
for (const m of body.matchAll(GATING_VAR_RE)) {
|
|
430
430
|
const name = m[1] ?? m[2];
|
|
431
|
-
if (name && !env[name]) out.add(name);
|
|
431
|
+
if (name && !env[name] && !opensAnEscape(body.slice(m.index + m[0].length))) out.add(name);
|
|
432
432
|
}
|
|
433
433
|
}
|
|
434
434
|
return [...out].sort();
|
|
435
435
|
}
|
|
436
436
|
|
|
437
|
+
/**
|
|
438
|
+
* `[ -n "$VAR" ] && exit 0`, `if [ -n "$VAR" ]; then echo …; exit 0; fi`: the variable
|
|
439
|
+
* lets the hook step aside (`VDX_GATE_PUSH` of `vdx push`), and unset, the hook runs in
|
|
440
|
+
* full — no gate is a no-op. A block doing anything but saying so and exiting 0 is a gate.
|
|
441
|
+
*/
|
|
442
|
+
function opensAnEscape(afterTest: string): boolean {
|
|
443
|
+
const m = afterTest.match(/^\s*\](?:\s*;?\s*then\b([\s\S]*?)\bfi\b|\s*&&([^\n]*))/);
|
|
444
|
+
const block = m?.[1] ?? m?.[2];
|
|
445
|
+
if (block === undefined) return false;
|
|
446
|
+
const statements = block
|
|
447
|
+
.split(/\n|;/)
|
|
448
|
+
.map((st) => st.trim())
|
|
449
|
+
.filter((st) => st !== '');
|
|
450
|
+
return (
|
|
451
|
+
statements.at(-1) === 'exit 0' && statements.every((st) => st === 'exit 0' || /^(?:echo|printf|:)(?:\s|$)/.test(st))
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
437
455
|
/**
|
|
438
456
|
* A program a command runs from this machine, outside the repository:
|
|
439
457
|
* `$HOME/…`, `~/…`, `/Users/…` — quoted or bare, at the head of a command.
|
package/src/hosting.ts
CHANGED
|
@@ -60,7 +60,7 @@ function get(apiPath: string, token: string | null): Answer {
|
|
|
60
60
|
return { status, body };
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
function machineIo(root: string): HostingIo {
|
|
63
|
+
export function machineIo(root: string): HostingIo {
|
|
64
64
|
const run = (cmd: string, args: string[]): string | null => {
|
|
65
65
|
try {
|
|
66
66
|
return (
|
|
@@ -152,17 +152,129 @@ export function branchRequiresChecks(ctx: Ctx, io: HostingIo = machineIo(ctx.pro
|
|
|
152
152
|
}
|
|
153
153
|
|
|
154
154
|
function ask(io: HostingIo): Verdict {
|
|
155
|
+
const gh = githubRepo(io, 'origin');
|
|
156
|
+
if (isUnknownRepo(gh)) return gh;
|
|
157
|
+
try {
|
|
158
|
+
return judge(io, gh.repo, gh.token);
|
|
159
|
+
} catch (e) {
|
|
160
|
+
return { unknown: `GitHub did not answer: ${errorMessage(e)}` };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** A remote's repository on GitHub and the token to ask with; unknown, with the reason, when vdx cannot ask. */
|
|
165
|
+
export function githubRepo(io: HostingIo, remote: string): { repo: string; token: string | null } | Unknown {
|
|
155
166
|
if (io.env.VDX_OFFLINE) return { unknown: 'VDX_OFFLINE is set — the hosting was not asked' };
|
|
156
|
-
const url = io.git(['remote', 'get-url',
|
|
157
|
-
if (!url) return { unknown:
|
|
167
|
+
const url = io.git(['remote', 'get-url', remote]);
|
|
168
|
+
if (!url) return { unknown: `no ${remote} remote — the hosting is not known` };
|
|
158
169
|
const { host, path: repo } = remoteParts(url);
|
|
159
170
|
// The host only: a URL can carry a token.
|
|
160
171
|
if (host !== 'github.com')
|
|
161
|
-
return { unknown:
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
172
|
+
return { unknown: `${remote} is on ${host || 'a host vdx cannot tell'} — vdx reads GitHub only` };
|
|
173
|
+
return { repo, token: io.env.GH_TOKEN || io.env.GITHUB_TOKEN || io.ghToken() };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function isUnknownRepo(v: { repo: string } | Unknown): v is Unknown {
|
|
177
|
+
return 'unknown' in v;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The checks a branch requires before it takes a commit — of classic protection and of
|
|
182
|
+
* rulesets together, whoever they bind: what GitHub will ask of the commit.
|
|
183
|
+
*/
|
|
184
|
+
export function requiredChecks(io: HostingIo, repo: string, branch: string, token: string | null): string[] | Unknown {
|
|
185
|
+
const ref = encodeURIComponent(branch);
|
|
186
|
+
const names = new Set<string>();
|
|
187
|
+
const b = io.get(`/repos/${repo}/branches/${ref}`, token);
|
|
188
|
+
const refusedBranch = refusal(b, repo, token);
|
|
189
|
+
if (refusedBranch) return refusedBranch;
|
|
190
|
+
const classic = at(b.body, 'protection', 'required_status_checks');
|
|
191
|
+
if (at(classic, 'enforcement_level') !== 'off') {
|
|
192
|
+
for (const c of list(at(classic, 'checks'))) add(names, at(c, 'context'));
|
|
193
|
+
for (const c of list(at(classic, 'contexts'))) add(names, c);
|
|
194
|
+
}
|
|
195
|
+
const rules = io.get(`/repos/${repo}/rules/branches/${ref}`, token);
|
|
196
|
+
const refusedRules = refusal(rules, repo, token);
|
|
197
|
+
if (refusedRules) return refusedRules;
|
|
198
|
+
for (const rule of list(rules.body)) {
|
|
199
|
+
if (at(rule, 'type') !== 'required_status_checks') continue;
|
|
200
|
+
for (const c of list(at(rule, 'parameters', 'required_status_checks'))) add(names, at(c, 'context'));
|
|
201
|
+
}
|
|
202
|
+
return [...names];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function add(names: Set<string>, name: unknown): void {
|
|
206
|
+
const n = str(name);
|
|
207
|
+
if (n) names.add(n);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Where one required check of a commit stands. */
|
|
211
|
+
export interface CheckState {
|
|
212
|
+
name: string;
|
|
213
|
+
state: 'pending' | 'passed' | 'failed';
|
|
214
|
+
/** GitHub's word for it: `in_progress`, `success`, `failure`, `not started`. */
|
|
215
|
+
detail: string;
|
|
216
|
+
url?: string;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The named checks of a commit: its check runs (GitHub Actions), else its commit
|
|
221
|
+
* statuses (an outside CI). A check that has not reported yet is pending.
|
|
222
|
+
*/
|
|
223
|
+
export function commitChecks(
|
|
224
|
+
io: HostingIo,
|
|
225
|
+
repo: string,
|
|
226
|
+
sha: string,
|
|
227
|
+
names: string[],
|
|
228
|
+
token: string | null,
|
|
229
|
+
): CheckState[] | Unknown {
|
|
230
|
+
const runs = io.get(`/repos/${repo}/commits/${sha}/check-runs?per_page=100`, token);
|
|
231
|
+
const refusedRuns = refusal(runs, repo, token);
|
|
232
|
+
if (refusedRuns) return refusedRuns;
|
|
233
|
+
// A re-run is a new check run under the same name: the newest one counts.
|
|
234
|
+
const latest = new Map<string, unknown>();
|
|
235
|
+
for (const run of list(at(runs.body, 'check_runs'))) {
|
|
236
|
+
const name = str(at(run, 'name'));
|
|
237
|
+
const id = at(run, 'id');
|
|
238
|
+
if (!name || typeof id !== 'number') continue;
|
|
239
|
+
const seen = at(latest.get(name), 'id');
|
|
240
|
+
if (typeof seen !== 'number' || id > seen) latest.set(name, run);
|
|
241
|
+
}
|
|
242
|
+
let statuses: Map<string, unknown> | null = null;
|
|
243
|
+
const states: CheckState[] = [];
|
|
244
|
+
for (const name of names) {
|
|
245
|
+
const run = latest.get(name);
|
|
246
|
+
if (run !== undefined) {
|
|
247
|
+
const status = str(at(run, 'status')) ?? 'queued';
|
|
248
|
+
const conclusion = str(at(run, 'conclusion')) ?? '';
|
|
249
|
+
const url = str(at(run, 'html_url'));
|
|
250
|
+
const state =
|
|
251
|
+
status !== 'completed'
|
|
252
|
+
? 'pending'
|
|
253
|
+
: ['success', 'neutral', 'skipped'].includes(conclusion)
|
|
254
|
+
? 'passed'
|
|
255
|
+
: 'failed';
|
|
256
|
+
states.push({ name, state, detail: status === 'completed' ? conclusion : status, ...(url ? { url } : {}) });
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (statuses === null) {
|
|
260
|
+
const combined = io.get(`/repos/${repo}/commits/${sha}/status`, token);
|
|
261
|
+
const refused = refusal(combined, repo, token);
|
|
262
|
+
if (refused) return refused;
|
|
263
|
+
statuses = new Map();
|
|
264
|
+
for (const s of list(at(combined.body, 'statuses'))) {
|
|
265
|
+
const context = str(at(s, 'context'));
|
|
266
|
+
if (context && !statuses.has(context)) statuses.set(context, s);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const status = statuses.get(name);
|
|
270
|
+
const word = str(at(status, 'state'));
|
|
271
|
+
const url = str(at(status, 'target_url'));
|
|
272
|
+
states.push({
|
|
273
|
+
name,
|
|
274
|
+
state: word === 'success' ? 'passed' : word === 'failure' || word === 'error' ? 'failed' : 'pending',
|
|
275
|
+
detail: word ?? 'not started',
|
|
276
|
+
...(url ? { url } : {}),
|
|
277
|
+
});
|
|
167
278
|
}
|
|
279
|
+
return states;
|
|
168
280
|
}
|
package/src/index.ts
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
type PublishOptions,
|
|
35
35
|
} from './publish.ts';
|
|
36
36
|
import { resolveDefaultRubric } from './defaults.ts';
|
|
37
|
+
import { defaultGateDeps, GATE_PREFIX, planGate, pushThroughGate } from './gate.ts';
|
|
37
38
|
import { runAi, runAiCheck, runAiRemote, defaultDeps, hostLabel, REMOTE_HOST_RE } from './ai.ts';
|
|
38
39
|
import { isConversationId } from './conversations.ts';
|
|
39
40
|
|
|
@@ -44,6 +45,7 @@ const USAGE = `Usage:
|
|
|
44
45
|
vdx audit [project_path] [--rubric <path>] [--stack <stack>] [--format=ansi|markdown|json] [--json] (default: cwd)
|
|
45
46
|
vdx init [project_path] [--stack <id>] [--baseline <ref>] [--dry-run] [--force] (default: cwd)
|
|
46
47
|
vdx publish <patch|minor|major> [--dry-run] [--force] [--no-push]
|
|
48
|
+
vdx push [project_path] [--dry-run] (default: cwd; through the branch's required checks)
|
|
47
49
|
vdx doctor [project_path] [--check | --fix] [--format=ansi|markdown|json] [--json] (default: cwd)
|
|
48
50
|
vdx ai[@host] [project_path] [--new | --conversation <id>] [--restart] [--detach] [--dry-run] (default: cwd; @host: over ssh, in tmux there)
|
|
49
51
|
vdx <command> --help what the command does; an unknown option is refused (exit 2)
|
|
@@ -151,7 +153,9 @@ mise.toml is overwritten only with --force.
|
|
|
151
153
|
the version bump in package.json and package-lock.json;
|
|
152
154
|
npm publish (npm asks for an OTP or a confirmation in the browser; Ctrl-C
|
|
153
155
|
there puts the version back);
|
|
154
|
-
git commit and tag, then \`git push --follow-tags\` to the branch's upstream
|
|
156
|
+
git commit and tag, then \`git push --follow-tags\` to the branch's upstream —
|
|
157
|
+
through its checks when it requires some, as \`vdx push\` does: the pre-flight
|
|
158
|
+
then refuses local commits not pushed yet, npm would get them before CI;
|
|
155
159
|
a wait until the registry shows the new version (up to 3 min).
|
|
156
160
|
--dry-run runs the pre-flight and prints the plan; --force goes on past a failed
|
|
157
161
|
pre-flight; --no-push leaves the commit and the tag local.
|
|
@@ -159,6 +163,31 @@ pre-flight; --no-push leaves the commit and the tag local.
|
|
|
159
163
|
bools: ['dry-run', 'force', 'no-push'],
|
|
160
164
|
positionals: 1,
|
|
161
165
|
},
|
|
166
|
+
push: {
|
|
167
|
+
usage: 'vdx push [project_path] [--dry-run]',
|
|
168
|
+
help: `Pushes the current branch to its upstream. When the upstream is on GitHub and
|
|
169
|
+
requires checks (branch protection or a ruleset), the commit goes to
|
|
170
|
+
${GATE_PREFIX}<branch> first: CI checks it there, vdx waits for the checks and then
|
|
171
|
+
pushes the same commit to the branch, with its annotated tags (--follow-tags).
|
|
172
|
+
A failed check leaves the branch as it was and names the check. A branch that
|
|
173
|
+
requires no checks, a remote not on GitHub, a GitHub that does not answer: a
|
|
174
|
+
plain \`git push\`, hooks and all.
|
|
175
|
+
|
|
176
|
+
The pushes through the checks carry VDX_GATE_PUSH=1: a pre-push hook that runs
|
|
177
|
+
what CI runs may step aside. GitHub is asked with a token from $GH_TOKEN,
|
|
178
|
+
$GITHUB_TOKEN or gh when there is one — without one it answers 60 requests an
|
|
179
|
+
hour, and a push takes about ten; VDX_OFFLINE=1 does not ask.
|
|
180
|
+
|
|
181
|
+
project_path the repository (default: cwd)
|
|
182
|
+
--dry-run say which way the push goes; push nothing
|
|
183
|
+
|
|
184
|
+
Exit codes: 0 pushed; 2 nowhere to push it (no branch, no upstream, behind it);
|
|
185
|
+
3 the branch did not take the commit — a check failed, did not finish in 20 min,
|
|
186
|
+
or GitHub stopped answering; a plain git push exits with git's own code.
|
|
187
|
+
`,
|
|
188
|
+
bools: ['dry-run'],
|
|
189
|
+
positionals: 1,
|
|
190
|
+
},
|
|
162
191
|
doctor: {
|
|
163
192
|
usage: 'vdx doctor [project_path] [--check | --fix] [--format=ansi|markdown|json] [--json]',
|
|
164
193
|
help: `Checks this machine for what vdx needs (Node, git, mise, npm auth, docker, the
|
|
@@ -435,6 +464,39 @@ function cmdPublish(opts: ParsedArgs): void {
|
|
|
435
464
|
}
|
|
436
465
|
}
|
|
437
466
|
|
|
467
|
+
function cmdPush(opts: ParsedArgs): void {
|
|
468
|
+
const root = path.resolve(opts.positionals[0] ?? '.');
|
|
469
|
+
const deps = defaultGateDeps(root);
|
|
470
|
+
const plan = planGate(deps.io);
|
|
471
|
+
if (plan.kind === 'refuse') {
|
|
472
|
+
process.stderr.write(`vdx push: ${plan.message}\n`);
|
|
473
|
+
process.exit(2);
|
|
474
|
+
}
|
|
475
|
+
if (plan.kind === 'plain') {
|
|
476
|
+
process.stderr.write(`→ git push — ${plan.why}\n`);
|
|
477
|
+
if (opts.flags['dry-run']) return;
|
|
478
|
+
try {
|
|
479
|
+
execFileSync('git', ['-C', root, 'push'], { stdio: 'inherit' });
|
|
480
|
+
} catch (e) {
|
|
481
|
+
const status = at(e, 'status');
|
|
482
|
+
process.exit(typeof status === 'number' ? status : 1);
|
|
483
|
+
}
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
const t = plan.target;
|
|
487
|
+
process.stderr.write(
|
|
488
|
+
`→ ${t.remote}/${t.branch} requires ${t.checks.join(', ')}: through ${GATE_PREFIX}${t.branch}, ` +
|
|
489
|
+
`then ${t.branch} once they pass\n`,
|
|
490
|
+
);
|
|
491
|
+
if (opts.flags['dry-run']) return;
|
|
492
|
+
try {
|
|
493
|
+
pushThroughGate(t, deps);
|
|
494
|
+
} catch (e) {
|
|
495
|
+
process.stderr.write(`\nerror: ${errorMessage(e)}\n`);
|
|
496
|
+
process.exit(3);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
438
500
|
function cmdRun(verb: LifecycleVerb): void {
|
|
439
501
|
const projectRoot = process.cwd();
|
|
440
502
|
const res = resolveLifecycleVerb(projectRoot, verb);
|
|
@@ -551,6 +613,7 @@ else if (['--help', '-h', 'help'].includes(parsed.cmd)) process.stdout.write(USA
|
|
|
551
613
|
else if (parsed.cmd === 'audit') cmdAudit(checkArgs('audit', parsed));
|
|
552
614
|
else if (parsed.cmd === 'init') cmdInit(checkArgs('init', parsed));
|
|
553
615
|
else if (parsed.cmd === 'publish') cmdPublish(checkArgs('publish', parsed));
|
|
616
|
+
else if (parsed.cmd === 'push') cmdPush(checkArgs('push', parsed));
|
|
554
617
|
else if (parsed.cmd === 'doctor') cmdDoctor(checkArgs('doctor', parsed));
|
|
555
618
|
else if (parsed.cmd === 'ai' || parsed.cmd.startsWith('ai@')) cmdAi(checkArgs('ai', parsed));
|
|
556
619
|
else if ((LIFECYCLE_VERBS as readonly string[]).includes(parsed.cmd)) {
|
package/src/publish.ts
CHANGED
|
@@ -3,6 +3,8 @@ import * as path from 'node:path';
|
|
|
3
3
|
import { execFileSync } from 'node:child_process';
|
|
4
4
|
import type { AuditResult } from './audit.ts';
|
|
5
5
|
import { stackForDir, type Ctx } from './facts.ts';
|
|
6
|
+
import { defaultGateDeps, GATE_PREFIX, planGate, pushThroughGate, type GatePlan, type GateTarget } from './gate.ts';
|
|
7
|
+
import { machineIo } from './hosting.ts';
|
|
6
8
|
import { at, dict, errorMessage, stderrOf, str } from './narrow.ts';
|
|
7
9
|
|
|
8
10
|
export type BumpKind = 'patch' | 'minor' | 'major';
|
|
@@ -34,6 +36,8 @@ export interface PushPlan {
|
|
|
34
36
|
ahead: number;
|
|
35
37
|
/** Upstream commits not here: the push would be refused. */
|
|
36
38
|
behind: number;
|
|
39
|
+
/** The upstream requires checks: the release commit goes through them (`vdx push`). */
|
|
40
|
+
gate?: GateTarget;
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
export interface PublishPlan {
|
|
@@ -226,6 +230,7 @@ export function planPublish(
|
|
|
226
230
|
ctx: Ctx,
|
|
227
231
|
npm: NpmRunner = runNpm,
|
|
228
232
|
git: GitRunner = runGit,
|
|
233
|
+
gate: (projectRoot: string) => GatePlan = (root) => planGate(machineIo(root)),
|
|
229
234
|
): PublishPlan {
|
|
230
235
|
if (checkMiseOverride(opts.projectRoot)) {
|
|
231
236
|
return {
|
|
@@ -339,6 +344,10 @@ export function planPublish(
|
|
|
339
344
|
}
|
|
340
345
|
|
|
341
346
|
const push = planPush(opts.projectRoot, opts.push ?? true, git);
|
|
347
|
+
if (push.enabled && push.upstream) {
|
|
348
|
+
const g = gate(opts.projectRoot);
|
|
349
|
+
if (g.kind === 'gate') push.gate = g.target;
|
|
350
|
+
}
|
|
342
351
|
if (push.enabled && push.upstream && push.behind > 0) {
|
|
343
352
|
preflight.push({
|
|
344
353
|
name: 'push-target',
|
|
@@ -347,13 +356,25 @@ export function planPublish(
|
|
|
347
356
|
`${push.upstream} has ${push.behind} commit(s) this branch lacks — the push after npm publish ` +
|
|
348
357
|
`would be refused. Pull first (or --no-push)`,
|
|
349
358
|
});
|
|
359
|
+
} else if (push.gate && push.ahead > 0) {
|
|
360
|
+
// npm publish comes before the push (DL #9 of docs/tasks/hosting-extension): what
|
|
361
|
+
// CI has not seen would reach npm first.
|
|
362
|
+
preflight.push({
|
|
363
|
+
name: 'push-target',
|
|
364
|
+
ok: false,
|
|
365
|
+
message:
|
|
366
|
+
`${push.ahead} local commit(s) are not on ${push.upstream}, which requires ${push.gate.checks.join(', ')}: ` +
|
|
367
|
+
`npm would get them before CI checks them. Push them first: vdx push`,
|
|
368
|
+
});
|
|
350
369
|
} else if (push.enabled) {
|
|
351
370
|
preflight.push({
|
|
352
371
|
name: 'push-target',
|
|
353
372
|
ok: true,
|
|
354
|
-
message: push.
|
|
355
|
-
? `OK (${push.upstream}
|
|
356
|
-
:
|
|
373
|
+
message: push.gate
|
|
374
|
+
? `OK (${push.upstream}, through ${push.gate.checks.join(', ')})`
|
|
375
|
+
: push.upstream
|
|
376
|
+
? `OK (${push.upstream}${push.ahead ? `, ${push.ahead} local commit(s) go with the release` : ''})`
|
|
377
|
+
: 'no upstream for this branch — the release commit and tag stay local',
|
|
357
378
|
});
|
|
358
379
|
}
|
|
359
380
|
|
|
@@ -394,6 +415,8 @@ export interface PublishDeps {
|
|
|
394
415
|
registryWaitMs: number;
|
|
395
416
|
/** Rewrites one status line in place (a terminal); without it the wait logs a line now and then. */
|
|
396
417
|
progress?: (text: string) => void;
|
|
418
|
+
/** HEAD through the upstream's checks, with the tag (`vdx push`); throws when the upstream did not take it. */
|
|
419
|
+
gatePush: (target: GateTarget, projectRoot: string) => void;
|
|
397
420
|
}
|
|
398
421
|
|
|
399
422
|
function sleepSync(ms: number): void {
|
|
@@ -410,6 +433,7 @@ export function defaultPublishDeps(): PublishDeps {
|
|
|
410
433
|
sleep: sleepSync,
|
|
411
434
|
registryWaitMs: 180_000,
|
|
412
435
|
progress: process.stderr.isTTY ? (text) => process.stderr.write(`\r${text}\x1b[K`) : undefined,
|
|
436
|
+
gatePush: (target, projectRoot) => pushThroughGate(target, defaultGateDeps(projectRoot)),
|
|
413
437
|
};
|
|
414
438
|
}
|
|
415
439
|
|
|
@@ -480,6 +504,7 @@ export function waitForRegistry(name: string, version: string, deps: PublishDeps
|
|
|
480
504
|
* is recoverable manually).
|
|
481
505
|
* 4. `git push --follow-tags` to the branch's upstream, unless `--no-push`
|
|
482
506
|
* or there is none. The pre-flight refused a branch behind its upstream.
|
|
507
|
+
* An upstream that requires checks takes the commit through them (`vdx push`).
|
|
483
508
|
* 5. Wait until the registry shows the version.
|
|
484
509
|
*
|
|
485
510
|
* On npm publish failure or interruption (Ctrl-C at its prompt) → restore the original files.
|
|
@@ -541,7 +566,18 @@ export function executePublish(plan: PublishPlan, deps: PublishDeps = defaultPub
|
|
|
541
566
|
|
|
542
567
|
// 4. push
|
|
543
568
|
const push = plan.push;
|
|
544
|
-
if (push?.enabled && push.
|
|
569
|
+
if (push?.enabled && push.gate) {
|
|
570
|
+
try {
|
|
571
|
+
deps.gatePush(push.gate, plan.projectRoot);
|
|
572
|
+
} catch (e) {
|
|
573
|
+
deps.log(
|
|
574
|
+
`✗ ${errorMessage(e)} — ${plan.packageName}@${plan.newVersion} is published and tagged here; ` +
|
|
575
|
+
`once the checks pass: vdx push`,
|
|
576
|
+
);
|
|
577
|
+
throw new Error(`${push.upstream} did not take the release commit`, { cause: e });
|
|
578
|
+
}
|
|
579
|
+
deps.log(`✓ pushed to ${push.upstream} with tag v${plan.newVersion}, through ${push.gate.checks.join(', ')}`);
|
|
580
|
+
} else if (push?.enabled && push.upstream) {
|
|
545
581
|
deps.log(`→ git push --follow-tags (${push.upstream})...`);
|
|
546
582
|
try {
|
|
547
583
|
deps.run('git', ['push', '--follow-tags'], plan.projectRoot);
|
|
@@ -616,9 +652,11 @@ export function renderPublishPlan(plan: PublishPlan): string {
|
|
|
616
652
|
lines.push(
|
|
617
653
|
!push?.enabled
|
|
618
654
|
? '5. Not pushed (`--no-push`): `git push --follow-tags` is yours'
|
|
619
|
-
: push.
|
|
620
|
-
? `5. \`
|
|
621
|
-
:
|
|
655
|
+
: push.gate
|
|
656
|
+
? `5. Through CI (\`vdx push\`): \`${GATE_PREFIX}${push.gate.branch}\`, then ${push.upstream} with the tag once ${push.gate.checks.join(', ')} pass`
|
|
657
|
+
: push.upstream
|
|
658
|
+
? `5. \`git push --follow-tags\` → ${push.upstream}${push.ahead ? ` (with ${push.ahead} local commit(s) not pushed yet)` : ''}`
|
|
659
|
+
: '5. Not pushed: the branch has no upstream',
|
|
622
660
|
);
|
|
623
661
|
lines.push(`6. Wait until the registry shows ${plan.newVersion} (up to 3 min)`);
|
|
624
662
|
} else {
|