@sylad/cadence 1.34.1 → 1.36.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/NOTICE +28 -0
- package/README.md +1 -1
- package/agents/qa-reviewer.md +5 -4
- package/agents/spec-writer.md +2 -0
- package/agents/ux-reviewer.md +28 -0
- package/dist/cli.js +13 -29
- package/dist/config.js +8 -21
- package/dist/corrections-sans-code.js +12 -0
- package/dist/deliver-ci.js +105 -0
- package/dist/deliver.js +61 -79
- package/dist/gate-config.js +47 -0
- package/dist/orchestrate/accept.js +5 -2
- package/dist/orchestrate/ajout.js +186 -0
- package/dist/orchestrate/baseline.js +57 -0
- package/dist/orchestrate/briefs.js +4 -1
- package/dist/orchestrate/command.js +118 -140
- package/dist/orchestrate/cycle.js +69 -55
- package/dist/orchestrate/depot.js +33 -0
- package/dist/orchestrate/gate-notes.js +27 -0
- package/dist/orchestrate/gate-parts.js +135 -0
- package/dist/orchestrate/gate-place.js +66 -0
- package/dist/orchestrate/lot-budget.js +50 -0
- package/dist/orchestrate/pool.js +1 -1
- package/dist/orchestrate/retrait.js +37 -0
- package/dist/orchestrate/snapshot.js +13 -0
- package/dist/orchestrate/state.js +48 -10
- package/dist/orchestrate/tiers.js +9 -0
- package/dist/orchestrate/workers.js +31 -4
- package/dist/plan-commit.js +4 -2
- package/dist/plan-uncommitted.js +26 -0
- package/dist/release.js +9 -7
- package/dist/remote-gate.js +278 -0
- package/dist/state.js +4 -0
- package/dist/verify-gate-remote.js +55 -0
- package/dist/verify-gate.js +37 -28
- package/docs/corrections.md +4 -0
- package/docs/deliver.md +22 -2
- package/docs/lead.md +3 -1
- package/docs/news.md +2 -4
- package/docs/orchestrate.md +22 -0
- package/docs/overview.md +1 -0
- package/docs/raf.md +3 -3
- package/docs/releasing.md +17 -8
- package/docs/remote-gate.md +89 -0
- package/docs/skills.md +5 -2
- package/docs/whats-new.md +6 -0
- package/package.json +3 -1
- package/skills/lead/SKILL.md +1 -1
- package/templates/orchestrate/spec.md +3 -1
package/dist/release.js
CHANGED
|
@@ -200,20 +200,22 @@ function whatsNewIssues(text, version) {
|
|
|
200
200
|
* types que Claude Code génère dans plugins/cadence-hud et que seul un clone ne porte pas). `git clone` du dépôt courant ne prend que ce qui est commité,
|
|
201
201
|
* `--no-hardlinks` pour ne rien partager avec lui ; le clone est retiré, vert ou rouge.
|
|
202
202
|
*/
|
|
203
|
-
function cloneStep(scripts) {
|
|
203
|
+
function cloneStep(scripts, untag) {
|
|
204
204
|
const run = scripts.map((n) => (n === 'test' ? 'npm test' : `npm run ${n}`)).join(' && ');
|
|
205
|
-
return `d=$(mktemp -d) && git clone -q --no-hardlinks . "$d/c" && (cd "$d/c" && npm ci && ${run}); r=$?; rm -rf "$d"; test $r -eq 0 (la suite dans un git clone neuf, comme publish.yml ; rouge =
|
|
205
|
+
return `d=$(mktemp -d) && git clone -q --no-hardlinks . "$d/c" && (cd "$d/c" && npm ci && ${run}); r=$?; rm -rf "$d"; test $r -eq 0 || ${untag} (la suite dans un git clone neuf, qui reçoit le tag provisoire, comme publish.yml ; rouge = tag retiré)`;
|
|
206
206
|
}
|
|
207
207
|
/** Les étapes que le lead joue, dans l'ordre : la commande n'en fait aucune. */
|
|
208
208
|
function steps(p, files, branch) {
|
|
209
209
|
const tag = `v${p.version}`;
|
|
210
|
+
// Rouge à la porte, au clone ou à l'éval : le tag provisoire est retiré, la release n'a pas eu lieu.
|
|
211
|
+
const untag = `{ git tag -d ${tag}; false; }`;
|
|
210
212
|
const out = [
|
|
211
213
|
`git add ${files.join(' ')} && git commit -m "release: ${p.version}"`,
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
...(p.
|
|
215
|
-
`
|
|
216
|
-
`git push origin ${branch} ${tag} (le tag lance le workflow de publication)`,
|
|
214
|
+
`git tag ${tag} (tag local provisoire sur le commit de version : les tests qui lisent le dernier tag, comme test/changelog.test.ts, voient la version publiée ; retiré si une des trois étapes suivantes est rouge, jamais poussé avant)`,
|
|
215
|
+
`cadence verify --gate --sha HEAD || ${untag} (la suite et le build rejoués sur le commit de version, où le CHANGELOG n'a plus de [Unreleased] plein ; rouge = tag retiré, pas d'éval)`,
|
|
216
|
+
...(p.cloneScripts.length ? [cloneStep(p.cloneScripts, untag)] : []),
|
|
217
|
+
...(p.hasEvalScript ? [`npm run eval:plugin -- --trust-plugin || ${untag} (facturé ; un cas sous le seuil arrête la release et retire le tag ; si la lecture du rapport le juge faux, reposer le tag de l’étape 2 avant le push)`] : []),
|
|
218
|
+
`git push origin ${branch} ${tag} (le tag provisoire devient le tag de la release ; il lance le workflow de publication)`,
|
|
217
219
|
'gh run watch (jusqu\'à la fin du run de publication)',
|
|
218
220
|
...(p.isPrivate || p.packageName === '' ? [] : [`npm view ${p.packageName} version (doit répondre ${p.version})`]),
|
|
219
221
|
...(p.pluginName ? [
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// Porte déportée (L403) : build et tests joués par ssh sur l'hôte `orchestrate.gate.host`, au sha poussé dans un dépôt nu, avec un cache de dépendances par empreinte des lockfiles.
|
|
2
|
+
// Ce module ne connaît ni nom de machine ni nom de dépôt : l'hôte vient de la config, le dossier de travail distant est dérivé du dossier du projet (`.cadence-gate/<projet>`).
|
|
3
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
4
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
5
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { basename, join, resolve } from 'node:path';
|
|
7
|
+
import { onTermination } from './proc.js';
|
|
8
|
+
import { git, gitFailure, ignoredDeps, isCopiedDep } from './orchestrate/worktree.js';
|
|
9
|
+
/** Les lockfiles que l'empreinte couvre, à tout niveau du dépôt (R18). */
|
|
10
|
+
const LOCKFILES = ['package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml', 'yarn.lock', 'poetry.lock', 'uv.lock', 'Pipfile.lock', 'Cargo.lock', 'go.sum'];
|
|
11
|
+
/** Au-delà de cet âge, un worktree ou un cache temporaire laissé sur l'hôte est un orphelin (aucune commande de la porte ne dure autant) : le prochain appel le retire (R17, R19). */
|
|
12
|
+
const ORPHAN_MIN = 360;
|
|
13
|
+
/** Délai de chaque commande de la porte : celui du rejeu de la ligne de base de la vague. */
|
|
14
|
+
export const GATE_TIMEOUT_MS = 30 * 60_000;
|
|
15
|
+
/** Délai de connexion de la sonde (R4) puis des autres appels ssh. */
|
|
16
|
+
const PROBE_CONNECT_S = 5;
|
|
17
|
+
const CONNECT_S = 10;
|
|
18
|
+
/** Un nom de commit sûr à insérer dans une commande : hexadécimal de 4 à 64 caractères (R18). */
|
|
19
|
+
export const isSha = (s) => /^[0-9a-f]{4,64}$/i.test(s);
|
|
20
|
+
/** Une valeur entre apostrophes pour `sh`, apostrophes internes échappées (R18) : le sha, l'hôte et les chemins insérés dans une commande ssh passent par là. */
|
|
21
|
+
export const shq = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
22
|
+
/** Empreinte des lockfiles du sha (R3, R18) ; null quand le sha n'en porte aucun (pas de cache). `files` : leurs chemins dans le dépôt, triés. */
|
|
23
|
+
export function lockfileFingerprint(repo, sha) {
|
|
24
|
+
const files = git(repo, 'ls-tree', '-r', '--name-only', '-z', sha)
|
|
25
|
+
.split('\0')
|
|
26
|
+
.filter((f) => f && LOCKFILES.includes(basename(f)) && !f.split('/').includes('node_modules'))
|
|
27
|
+
.sort();
|
|
28
|
+
if (!files.length)
|
|
29
|
+
return null;
|
|
30
|
+
const h = createHash('sha256');
|
|
31
|
+
for (const f of files) {
|
|
32
|
+
h.update(`${f}\0`);
|
|
33
|
+
h.update(spawnSync('git', ['show', `${sha}:${f}`], { cwd: repo, maxBuffer: 1 << 28 }).stdout);
|
|
34
|
+
h.update('\0');
|
|
35
|
+
}
|
|
36
|
+
return { fp: h.digest('hex'), files };
|
|
37
|
+
}
|
|
38
|
+
/** Empreinte des mêmes lockfiles dans le dossier `dir` du disque (R16) ; null si l'un manque. */
|
|
39
|
+
function diskFingerprint(dir, files) {
|
|
40
|
+
const h = createHash('sha256');
|
|
41
|
+
for (const f of files) {
|
|
42
|
+
if (!existsSync(join(dir, f)))
|
|
43
|
+
return null;
|
|
44
|
+
h.update(`${f}\0`);
|
|
45
|
+
h.update(readFileSync(join(dir, f)));
|
|
46
|
+
h.update('\0');
|
|
47
|
+
}
|
|
48
|
+
return h.digest('hex');
|
|
49
|
+
}
|
|
50
|
+
const archOf = (m) => ({ x86_64: 'x64', amd64: 'x64', aarch64: 'arm64', arm64: 'arm64' })[m] ?? m;
|
|
51
|
+
/** Système et architecture de cette machine, écrits comme ceux de l'hôte (relevés par la sonde) : `linux-x64`, `darwin-arm64` (R16). */
|
|
52
|
+
const localPlatform = () => `${process.platform}-${process.arch}`;
|
|
53
|
+
/** ssh a perdu la connexion pendant une commande de la porte déportée (code 255, R6) : la porte est rejouée en local. */
|
|
54
|
+
export class ConnectionLost extends Error {
|
|
55
|
+
}
|
|
56
|
+
const sshArgs = (c) => ['-o', 'BatchMode=yes', '-o', `ConnectTimeout=${c.connectS ?? CONNECT_S}`, ...(c.tty ? ['-tt'] : []), '--', c.host, c.script];
|
|
57
|
+
function killGroup(pgid) {
|
|
58
|
+
try {
|
|
59
|
+
process.kill(-pgid, 'SIGKILL');
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// groupe déjà vide
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Un appel ssh non interactif (jamais de mot de passe : BatchMode). Le délai tue le ssh local et son groupe ; 255 est une connexion perdue (`lost`), 124 un délai. */
|
|
66
|
+
function ssh(c) {
|
|
67
|
+
return new Promise((resolveRan) => {
|
|
68
|
+
const child = spawn('ssh', sshArgs(c), { env: c.env, stdio: [c.stdin ? 'pipe' : 'ignore', 'pipe', 'pipe'], detached: true });
|
|
69
|
+
child.once('error', (e) => resolveRan({ code: 127, output: e.message }));
|
|
70
|
+
const pgid = child.pid;
|
|
71
|
+
if (pgid === undefined)
|
|
72
|
+
return;
|
|
73
|
+
c.stdin?.pipe(child.stdin).on('error', () => { });
|
|
74
|
+
let output = '';
|
|
75
|
+
for (const s of [child.stdout, child.stderr])
|
|
76
|
+
s.on('data', (d) => (output += d.toString()));
|
|
77
|
+
// Un terminal écrit « \r\n » : la sortie de la porte est celle d'une commande locale.
|
|
78
|
+
const text = () => output.replace(/\r\n/g, '\n');
|
|
79
|
+
// Au délai le ssh local est tué et la porte n'attend pas la fermeture de ses tubes : ce qui tourne encore sur l'hôte est arrêté par le nettoyage (R19).
|
|
80
|
+
const timer = c.timeoutMs
|
|
81
|
+
? setTimeout(() => {
|
|
82
|
+
killGroup(pgid);
|
|
83
|
+
resolveRan({ code: 124, output: `${text()}\ndélai dépassé (${Math.round((c.timeoutMs ?? 0) / 60_000)} min)` });
|
|
84
|
+
}, c.timeoutMs)
|
|
85
|
+
: undefined;
|
|
86
|
+
child.once('close', (code) => {
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
killGroup(pgid);
|
|
89
|
+
resolveRan({ code: code ?? 1, output: text(), lost: code === 255 });
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
/** La dernière ligne utile d'une sortie d'échec : la cause dite à l'humain. */
|
|
94
|
+
const causeOf = (r) => r.output.trim().split('\n').filter(Boolean).slice(-1)[0] ?? `code ${r.code}`;
|
|
95
|
+
/**
|
|
96
|
+
* Sonde l'hôte, y pousse le sha, prépare le cache de dépendances et le worktree (R3-R5, R15-R18). Un échec avant la première commande de la porte rend `ok: false`
|
|
97
|
+
* avec son étape : l'appelant se replie sur la porte locale, jamais un rouge (R4, R5). Le sha doit être hexadécimal (R18) : refusé avant toute commande ssh.
|
|
98
|
+
*/
|
|
99
|
+
export async function openRemoteGate(o) {
|
|
100
|
+
if (!isSha(o.sha))
|
|
101
|
+
throw new Error(`sha invalide pour la porte déportée : ${o.sha}`);
|
|
102
|
+
// Dès la préparation (gate.install dure jusqu'à 30 min) et jusqu'à `close`, Ctrl-C ou un signal arrête ce qui tourne sur l'hôte et en retire le worktree (R10, R19).
|
|
103
|
+
let forget = () => { };
|
|
104
|
+
try {
|
|
105
|
+
const opened = await prepare(o, (w) => {
|
|
106
|
+
forget = onTermination(() => {
|
|
107
|
+
cleanupHost(o, w);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
if (!opened.ok)
|
|
111
|
+
forget();
|
|
112
|
+
else {
|
|
113
|
+
const close = opened.gate.close;
|
|
114
|
+
opened.gate.close = () => {
|
|
115
|
+
forget();
|
|
116
|
+
return close();
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return opened;
|
|
120
|
+
}
|
|
121
|
+
catch (e) {
|
|
122
|
+
forget();
|
|
123
|
+
// Une erreur locale (sha absent du dépôt, git illisible) n'est pas un rouge : la porte se replie en local, avec la cause (R5).
|
|
124
|
+
return { ok: false, stage: 'host', cause: e instanceof Error ? e.message.split('\n')[0] : String(e) };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** Sonde l'hôte (R4) : sa plateforme et ses cœurs, ou la cause de son silence. */
|
|
128
|
+
async function probeHost(o) {
|
|
129
|
+
const probe = await ssh({ host: o.host, env: o.env, connectS: PROBE_CONNECT_S, timeoutMs: 30_000, script: 'uname -s; uname -m; nproc 2>/dev/null || getconf _NPROCESSORS_ONLN' });
|
|
130
|
+
if (probe.code !== 0)
|
|
131
|
+
return causeOf(probe);
|
|
132
|
+
// Une bannière de connexion (motd, .bashrc bavard) précède la sortie : les trois dernières lignes sont celles de la sonde.
|
|
133
|
+
const [os = '', arch = '', cores = ''] = probe.output.trim().split('\n').map((l) => l.trim()).slice(-3);
|
|
134
|
+
const nproc = Number.parseInt(cores, 10);
|
|
135
|
+
return { platform: `${os.toLowerCase()}-${archOf(arch)}`, cores: Number.isFinite(nproc) && nproc >= 1 ? nproc : 1 };
|
|
136
|
+
}
|
|
137
|
+
/** Crée le dossier de travail de l'hôte (dépôt nu créé à part puis renommé : deux portes simultanées ne s'y disputent pas, orphelins retirés même si le renommage échoue, R17, R19) et y pousse le sha (R5) ; rend la cause d'un échec. */
|
|
138
|
+
async function pushSha(o, w) {
|
|
139
|
+
const { host, env } = o;
|
|
140
|
+
const { head, bare, root } = w;
|
|
141
|
+
const init = await ssh({
|
|
142
|
+
host,
|
|
143
|
+
env,
|
|
144
|
+
script: `${head}set -e; mkdir -p "$R/deps" "$R/wt"; [ -d ${bare} ] || { git init --bare -q ${bare}.init-$$ && mv ${bare}.init-$$ ${bare} 2>/dev/null || true; }; rm -rf ${bare}.init-$$ ${bare}/repo.git.init-$$; [ -d ${bare} ]; ` +
|
|
145
|
+
`find "$R/wt" -mindepth 1 -maxdepth 1 -mmin +${ORPHAN_MIN} -exec rm -rf {} + 2>/dev/null || true; ` +
|
|
146
|
+
`find "$R/deps" -mindepth 1 -maxdepth 1 -name '.tmp-*' -mmin +${ORPHAN_MIN} -exec rm -rf {} + 2>/dev/null || true; git -C ${bare} worktree prune 2>/dev/null || true`,
|
|
147
|
+
});
|
|
148
|
+
if (init.code !== 0)
|
|
149
|
+
return `dépôt de travail impossible : ${causeOf(init)}`;
|
|
150
|
+
const push = await new Promise((res) => {
|
|
151
|
+
const child = spawn('git', ['push', '-q', '--no-verify', `${host}:${root}/repo.git`, `${o.sha}:refs/cadence-gate/${o.sha}`], { cwd: o.repo, env: { ...env, GIT_SSH_COMMAND: `ssh -o BatchMode=yes -o ConnectTimeout=${CONNECT_S}`, GIT_TERMINAL_PROMPT: '0' }, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
152
|
+
let output = '';
|
|
153
|
+
for (const s of [child.stdout, child.stderr])
|
|
154
|
+
s.on('data', (d) => (output += d.toString()));
|
|
155
|
+
child.once('error', (e) => res({ code: 127, output: e.message }));
|
|
156
|
+
child.once('close', (code) => res({ code: code ?? 1, output }));
|
|
157
|
+
});
|
|
158
|
+
if (push.code === 0)
|
|
159
|
+
return null;
|
|
160
|
+
// Deux pushes simultanés du même sha (vague en parallèle, verify --gate) se disputent la même référence : le perdant échoue alors que le sha est arrivé.
|
|
161
|
+
const there = await ssh({ host, env, script: `${head}git -C ${bare} cat-file -e ${shq(`${o.sha}^{commit}`)}` });
|
|
162
|
+
return there.code === 0 ? null : `push du sha impossible : ${causeOf(push)}`;
|
|
163
|
+
}
|
|
164
|
+
/** Arrête ce qui tourne encore de cette porte sur l'hôte et en retire les worktrees et le cache temporaire (R10, R19) ; rend la cause si l'hôte n'a pas pu être nettoyé. */
|
|
165
|
+
function cleanupHost(o, w) {
|
|
166
|
+
const { head, bare, id } = w;
|
|
167
|
+
const script = `${head}for i in ${id} ${id}-i; do if [ -f "$R/wt/$i.pid" ]; then p=$(cat "$R/wt/$i.pid"); kill -s KILL -- "-$p" 2>/dev/null || true; fi; ` +
|
|
168
|
+
`git -C ${bare} worktree remove --force "$R/wt/$i" 2>/dev/null || rm -rf "$R/wt/$i"; rm -f "$R/wt/$i.pid"; done; rm -rf "$R/deps/.tmp-${id}"; git -C ${bare} worktree prune 2>/dev/null || true`;
|
|
169
|
+
const r = spawnSync('ssh', sshArgs({ host: o.host, env: o.env, script }), { env: o.env, encoding: 'utf8', timeout: 30_000 });
|
|
170
|
+
return r.status === 0 ? null : `${(r.stderr || r.stdout || `code ${r.status}`).trim().split('\n').slice(-1)[0]}`;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Le cache de dépendances du sha dans le worktree de l'hôte (R3) : copié s'il existe, sinon rempli (R15, R16) puis copié ; rend la ligne qui dit son état, ou `{ cause }`.
|
|
174
|
+
* Sans lockfile au sha, il n'y a pas de cache.
|
|
175
|
+
*/
|
|
176
|
+
async function prepareCache(o, w, hostPlatform) {
|
|
177
|
+
const prints = lockfileFingerprint(o.repo, o.sha);
|
|
178
|
+
if (!prints)
|
|
179
|
+
return 'sans cache (aucun lockfile au sha)';
|
|
180
|
+
const { head, wt } = w;
|
|
181
|
+
const short = prints.fp.slice(0, 8);
|
|
182
|
+
const keyInstall = `${prints.fp}-${hostPlatform}`;
|
|
183
|
+
const copyDeps = () => ssh({
|
|
184
|
+
host: o.host,
|
|
185
|
+
env: o.env,
|
|
186
|
+
script: `${head}set -e; for k in ${shq(keyInstall)} ${shq(prints.fp)}; do if [ -d "$R/deps/$k" ]; then ` +
|
|
187
|
+
`while IFS= read -r p; do mkdir -p ${wt}/"$(dirname "$p")"; cp -R "$R/deps/$k/$p" ${wt}/"$p"; done < "$R/deps/$k/.paths"; echo "HIT $k"; exit 0; fi; done; echo MISS`,
|
|
188
|
+
});
|
|
189
|
+
let r = await copyDeps();
|
|
190
|
+
if (r.code !== 0)
|
|
191
|
+
return { cause: `lecture du cache impossible : ${causeOf(r)}` };
|
|
192
|
+
if (!r.output.includes('MISS'))
|
|
193
|
+
return `cache utilisé (empreinte ${short})`;
|
|
194
|
+
const filled = o.install ? await fillByInstall(o, keyInstall, w.id, head, w.bare) : await fillByCopy(o, prints, hostPlatform, w.id, head);
|
|
195
|
+
if (typeof filled === 'string')
|
|
196
|
+
return { cause: filled };
|
|
197
|
+
r = await copyDeps();
|
|
198
|
+
if (r.code !== 0 || !r.output.includes('HIT'))
|
|
199
|
+
return { cause: `cache introuvable après remplissage : ${causeOf(r)}` };
|
|
200
|
+
return `cache rempli ${o.install ? 'par gate.install' : 'par copie des dépendances locales'} (empreinte ${short})`;
|
|
201
|
+
}
|
|
202
|
+
async function prepare(o, onWork) {
|
|
203
|
+
const { host, env } = o;
|
|
204
|
+
const probe = await probeHost(o);
|
|
205
|
+
if (typeof probe === 'string')
|
|
206
|
+
return { ok: false, stage: 'host', cause: probe };
|
|
207
|
+
const id = `${Date.now().toString(36)}-${randomBytes(4).toString('hex')}`;
|
|
208
|
+
const root = `.cadence-gate/${basename(resolve(o.repo))}`;
|
|
209
|
+
const w = { id, root, head: `R="$PWD"/${shq(root)}; `, bare: '"$R/repo.git"', wt: `"$R/wt/${id}"` };
|
|
210
|
+
onWork(w);
|
|
211
|
+
const pushed = await pushSha(o, w);
|
|
212
|
+
if (pushed)
|
|
213
|
+
return { ok: false, stage: 'host', cause: pushed };
|
|
214
|
+
const fail = (cause, stage) => {
|
|
215
|
+
cleanupHost(o, w);
|
|
216
|
+
return { ok: false, stage, cause };
|
|
217
|
+
};
|
|
218
|
+
const add = await ssh({ host, env, script: `${w.head}set -e; git -C ${w.bare} worktree add -q --detach ${w.wt} ${shq(o.sha)}` });
|
|
219
|
+
if (add.code !== 0)
|
|
220
|
+
return fail(`worktree impossible : ${causeOf(add)}`, 'host');
|
|
221
|
+
const cache = await prepareCache(o, w, probe.platform);
|
|
222
|
+
if (typeof cache !== 'string')
|
|
223
|
+
return fail(cache.cause, 'cache');
|
|
224
|
+
const workers = o.workers(probe.cores);
|
|
225
|
+
const gate = {
|
|
226
|
+
host,
|
|
227
|
+
cache,
|
|
228
|
+
run: (command, timeoutMs) => ssh({ host, env, tty: true, timeoutMs, script: `${w.head}echo $$ > "$R/wt/${id}.pid"; cd ${w.wt} && export CADENCE_TEST_WORKERS=${workers} && exec sh -c ${shq(command)}` }),
|
|
229
|
+
close: () => cleanupHost(o, w),
|
|
230
|
+
};
|
|
231
|
+
return { ok: true, gate };
|
|
232
|
+
}
|
|
233
|
+
/** Remplit le cache `key` en jouant `gate.install` sur l'hôte, dans un worktree au sha (R15) ; rend la cause d'un échec. */
|
|
234
|
+
async function fillByInstall(o, key, id, head, bare) {
|
|
235
|
+
const iw = `"$R/wt/${id}-i"`;
|
|
236
|
+
const tmp = `"$R/deps/.tmp-${id}"`;
|
|
237
|
+
const dirs = o.worktreeDeps
|
|
238
|
+
? `for p in ${o.worktreeDeps.map(shq).join(' ')}; do [ -e "$p" ] && echo "$p"; done`
|
|
239
|
+
: `find . -name .git -prune -o \\( -name node_modules -o -name .venv \\) -type d -print -prune`;
|
|
240
|
+
const script = `${head}echo $$ > "$R/wt/${id}-i.pid"; set -e; git -C ${bare} worktree add -q --detach ${iw} ${shq(o.sha)}; cd ${iw}; sh -c ${shq(o.install)}; ` +
|
|
241
|
+
`mkdir -p ${tmp}; : > ${tmp}/.paths; (${dirs}) | while IFS= read -r p; do p="\${p#./}"; mkdir -p ${tmp}/"$(dirname "$p")"; cp -R "$p" ${tmp}/"$p"; echo "$p" >> ${tmp}/.paths; done; ` +
|
|
242
|
+
`cd "$R"; git -C ${bare} worktree remove --force ${iw} || true; mv ${tmp} "$R/deps/${key}" 2>/dev/null || true; rm -rf "$R/deps/${key}/.tmp-${id}" ${tmp}; [ -d "$R/deps/${key}" ]`;
|
|
243
|
+
const r = await ssh({ host: o.host, env: o.env, tty: true, timeoutMs: o.timeoutMs, script });
|
|
244
|
+
return r.code === 0 ? null : `gate.install en échec sur ${o.host} (code ${r.code}) : ${causeOf(r)}`;
|
|
245
|
+
}
|
|
246
|
+
/** Remplit le cache par copie des dépendances locales, seulement si elles méritent l'empreinte du sha et viennent de la même plateforme que l'hôte (R16) ; rend la cause d'un refus ou d'un échec. */
|
|
247
|
+
async function fillByCopy(o, prints, hostPlatform, id, head) {
|
|
248
|
+
if (localPlatform() !== hostPlatform)
|
|
249
|
+
return `gate.install absent et plateforme locale ${localPlatform()} différente de celle de l'hôte ${hostPlatform} : dépendances locales non copiées`;
|
|
250
|
+
if (diskFingerprint(o.depsFrom, prints.files) !== prints.fp)
|
|
251
|
+
return 'gate.install absent et lockfile local différent de celui du sha : dépendances locales non copiées';
|
|
252
|
+
let paths;
|
|
253
|
+
try {
|
|
254
|
+
paths = o.worktreeDeps ? o.worktreeDeps.filter((n) => existsSync(join(o.depsFrom, n))) : ignoredDeps(o.depsFrom).filter(isCopiedDep);
|
|
255
|
+
}
|
|
256
|
+
catch (e) {
|
|
257
|
+
return `dépendances locales illisibles (${gitFailure(e)})`;
|
|
258
|
+
}
|
|
259
|
+
if (!paths.length)
|
|
260
|
+
return 'gate.install absent et aucune dépendance locale à copier';
|
|
261
|
+
const tmp = `"$R/deps/.tmp-${id}"`;
|
|
262
|
+
const tar = spawn('tar', ['-cf', '-', '-C', o.depsFrom, ...paths], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
263
|
+
const tarDone = new Promise((res) => {
|
|
264
|
+
tar.once('error', () => res(127));
|
|
265
|
+
tar.once('close', (c) => res(c ?? 1));
|
|
266
|
+
});
|
|
267
|
+
const up = await ssh({ host: o.host, env: o.env, timeoutMs: o.timeoutMs, stdin: tar.stdout, script: `${head}set -e; mkdir -p ${tmp}; tar -xf - -C ${tmp}` });
|
|
268
|
+
const tarCode = await tarDone;
|
|
269
|
+
if (up.code !== 0 || tarCode !== 0)
|
|
270
|
+
return `copie des dépendances locales impossible : ${tarCode !== 0 ? `tar code ${tarCode}` : causeOf(up)}`;
|
|
271
|
+
const key = prints.fp;
|
|
272
|
+
const publish = await ssh({
|
|
273
|
+
host: o.host,
|
|
274
|
+
env: o.env,
|
|
275
|
+
script: `${head}set -e; printf '%s\\n' ${paths.map(shq).join(' ')} > ${tmp}/.paths; mv ${tmp} "$R/deps/${key}" 2>/dev/null || true; rm -rf "$R/deps/${key}/.tmp-${id}" ${tmp}; [ -d "$R/deps/${key}" ]`,
|
|
276
|
+
});
|
|
277
|
+
return publish.code === 0 ? null : `publication du cache impossible : ${causeOf(publish)}`;
|
|
278
|
+
}
|
package/dist/state.js
CHANGED
|
@@ -156,6 +156,10 @@ export function pidAlive(pid) {
|
|
|
156
156
|
export function appendDelivery(dir, day, sha) {
|
|
157
157
|
appendFileSync(join(dir, 'deliveries.log'), `${day} ${sha}\n`);
|
|
158
158
|
}
|
|
159
|
+
/** Ajoute une étape à `deliveries-steps.log` (ajout seul, une ligne JSON par étape) ; `deliveries.log` n'est pas touché. */
|
|
160
|
+
export function appendStep(dir, step) {
|
|
161
|
+
appendFileSync(join(dir, 'deliveries-steps.log'), `${JSON.stringify(step)}\n`);
|
|
162
|
+
}
|
|
159
163
|
/** Classe d'une livraison rouge : où elle a cassé. `refus` : le script a refusé avant d'agir (code 2). */
|
|
160
164
|
export const FAIL_CLASSES = ['refus', 'ci', 'deploiement', 'verification'];
|
|
161
165
|
const TAIL_SEP = ' ⏎ ';
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { configuredCap } from './orchestrate/command.js';
|
|
2
|
+
import { testWorkersFrom } from './orchestrate/workers.js';
|
|
3
|
+
import { ConnectionLost, GATE_TIMEOUT_MS, isSha, openRemoteGate } from './remote-gate.js';
|
|
4
|
+
/**
|
|
5
|
+
* La porte de `cadence verify --gate` sur l'hôte `orchestrate.gate.host` (L403) : sha poussé, cache de dépendances, worktree jetable distant ; les mêmes étapes que la porte locale (`play` est son `runSteps`), le même verdict.
|
|
6
|
+
* Rend le code de la porte, ou null quand elle doit être jouée en local : pas d'hôte, `verify.inPlace: true` qui impose l'arbre principal (R9), hôte injoignable, push ou cache impossible (R4, R5, jamais un rouge),
|
|
7
|
+
* ou connexion perdue pendant une commande (R6, la porte entière est rejouée en local). Un autre code non nul d'une commande reste son verdict (R8). Le worktree distant est retiré dans tous les cas, au signal compris (R10, R19).
|
|
8
|
+
*/
|
|
9
|
+
export async function tryRemote(ctx, short, replayReason, play) {
|
|
10
|
+
const { repo, sha, config, out } = ctx;
|
|
11
|
+
const host = config.gate?.host;
|
|
12
|
+
if (!host)
|
|
13
|
+
return null;
|
|
14
|
+
if (ctx.verify?.inPlace === true) {
|
|
15
|
+
out(`verify --gate : verify.inPlace demande l'arbre principal, l'hôte ${host} est ignoré`);
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
if (!isSha(sha)) {
|
|
19
|
+
out('verify --gate : sha invalide pour la porte déportée (hexadécimal attendu)');
|
|
20
|
+
return 2;
|
|
21
|
+
}
|
|
22
|
+
const base = ctx.env ?? process.env;
|
|
23
|
+
const timeout = ctx.timeoutMs ?? GATE_TIMEOUT_MS;
|
|
24
|
+
const opened = await openRemoteGate({ host, install: config.gate?.install, repo, sha, depsFrom: repo, worktreeDeps: config.worktreeDeps, env: base, timeoutMs: timeout, workers: (cores) => testWorkersFrom(base, cores, configuredCap(base)) });
|
|
25
|
+
if (!opened.ok) {
|
|
26
|
+
out(opened.stage === 'host' ? `verify --gate : porte déportée indisponible sur ${host} (${opened.cause}) — repli sur la porte locale` : `verify --gate : cache de dépendances impossible sur ${host} (${opened.cause}) — repli sur la porte locale`);
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const gate = opened.gate;
|
|
30
|
+
out(`verify --gate ${short} : porte jouée sur l'hôte ${host} (sha ${short}, ${gate.cache})`);
|
|
31
|
+
// Les lignes des étapes sont tenues jusqu'au bout : une connexion perdue en cours de porte la fait rejouer en local, les ✓ de l'hôte ne seraient pas ceux du verdict.
|
|
32
|
+
const lines = [replayReason];
|
|
33
|
+
try {
|
|
34
|
+
const { code, summary } = await play(async (cmd) => {
|
|
35
|
+
const r = await gate.run(cmd, timeout);
|
|
36
|
+
if (r.lost)
|
|
37
|
+
throw new ConnectionLost();
|
|
38
|
+
return r;
|
|
39
|
+
}, (l) => lines.push(l));
|
|
40
|
+
for (const l of [...lines, summary])
|
|
41
|
+
out(l);
|
|
42
|
+
return code;
|
|
43
|
+
}
|
|
44
|
+
catch (e) {
|
|
45
|
+
if (!(e instanceof ConnectionLost))
|
|
46
|
+
throw e;
|
|
47
|
+
out(`verify --gate : connexion perdue avec ${host} pendant la porte — rejouée en local`);
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
const orphan = gate.close();
|
|
52
|
+
if (orphan)
|
|
53
|
+
out(`verify --gate : nettoyage de l'hôte ${host} en échec (${orphan}) — le worktree orphelin y sera retiré au prochain appel`);
|
|
54
|
+
}
|
|
55
|
+
}
|
package/dist/verify-gate.js
CHANGED
|
@@ -9,10 +9,11 @@ import { sharedStateDir } from './state.js';
|
|
|
9
9
|
import { activeLock, REPO_LOCK } from './orchestrate/lock.js';
|
|
10
10
|
import { failedTests, hasUnnamedFailure, unloadedSuites } from './orchestrate/baseline.js';
|
|
11
11
|
import { configuredCap } from './orchestrate/command.js';
|
|
12
|
-
import {
|
|
12
|
+
import { TEST_WORKERS_ENV, testWorkersFrom } from './orchestrate/workers.js';
|
|
13
|
+
import { gateLabel, gateReplayable, gateUnnamed, replayGate, runGate } from './orchestrate/gate-parts.js';
|
|
13
14
|
import { copyDeps, git, gitFailure, ignoredDeps, isCopiedDep } from './orchestrate/worktree.js';
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
import { tryRemote } from './verify-gate-remote.js';
|
|
16
|
+
import { GATE_TIMEOUT_MS } from './remote-gate.js';
|
|
16
17
|
/** Lignes de la fin de la sortie montrées quand aucun test n'est identifiable dans une sortie rouge. */
|
|
17
18
|
const TAIL_LINES = 10;
|
|
18
19
|
function killGroup(pgid) {
|
|
@@ -112,11 +113,24 @@ export async function verifyGate(ctx) {
|
|
|
112
113
|
}
|
|
113
114
|
return code;
|
|
114
115
|
}
|
|
116
|
+
/** La porte déjà jouée par une vague au même arbre (L397) : son verdict est repris, rien n'est rejoué. */
|
|
117
|
+
function reuseRecorded(f, steps, sha, out) {
|
|
118
|
+
const short = sha.slice(0, 7);
|
|
119
|
+
const where = f.checked === sha ? 'au même sha' : `au sha ${f.checked.slice(0, 7)}, sous les seuls commits de plan de ${short}`;
|
|
120
|
+
out(`verify --gate ${short} : verdict repris de la porte de la vague ${f.wave} (lot ${f.lot} : contrôles du programme ${where}, arbre ${f.tree.slice(0, 7)} identique) — rien rejoué`);
|
|
121
|
+
for (const step of steps) {
|
|
122
|
+
const run = f.runs.find((r) => r.label === (step.name === 'build' ? 'build' : 'tests') && r.command === step.cmd);
|
|
123
|
+
out(`✓ ${step.name} (${step.cmd}) — enregistré par la vague${run.instables?.length ? `, ${run.instables.length} test(s) instable(s) sous charge, verts rejoués seuls` : ''}`);
|
|
124
|
+
}
|
|
125
|
+
out(`verify --gate ${short} : vert`);
|
|
126
|
+
return 0;
|
|
127
|
+
}
|
|
115
128
|
async function playGate(ctx) {
|
|
116
129
|
const { repo, sha, config, out } = ctx;
|
|
130
|
+
// `cmd` est le libellé (celui du CheckRun d'une vague, pour reprendre son verdict) ; `gate` ce qui est joué : pour des parties (L512), chacune dans son dossier.
|
|
117
131
|
const steps = [
|
|
118
|
-
...(config.build ? [{ name: 'build', cmd: config.build }] : []),
|
|
119
|
-
...(config.test ? [{ name: 'tests', cmd: config.test }] : []),
|
|
132
|
+
...(config.build ? [{ name: 'build', cmd: config.build, gate: config.build }] : []),
|
|
133
|
+
...(config.test ? [{ name: 'tests', cmd: gateLabel(config.test), gate: config.test }] : []),
|
|
120
134
|
];
|
|
121
135
|
const short = sha.slice(0, 7);
|
|
122
136
|
const base = ctx.env ?? process.env;
|
|
@@ -128,25 +142,19 @@ async function playGate(ctx) {
|
|
|
128
142
|
let recorded = { reason: 'aucune porte de vague enregistrée pour ce sha' };
|
|
129
143
|
try {
|
|
130
144
|
// Un test qui lit le plan (orchestrate.gate.readsPlan, L450) voit un autre contenu après un `raf done` : les commits de plan seuls ne sont pas traversés.
|
|
131
|
-
recorded = recordedGate(ctx.launchDirs ?? [], repo, sha, steps, config.gate ? undefined : ctx.planFile);
|
|
145
|
+
recorded = recordedGate(ctx.launchDirs ?? [], repo, sha, steps, config.gate?.readsPlan ? undefined : ctx.planFile);
|
|
132
146
|
}
|
|
133
147
|
catch (e) {
|
|
134
148
|
recorded = { reason: `état des vagues illisible (${gitFailure(e)})` };
|
|
135
149
|
}
|
|
136
|
-
if ('found' in recorded)
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
out(`verify --gate ${short} : verdict repris de la porte de la vague ${f.wave} (lot ${f.lot} : contrôles du programme ${where}, arbre ${f.tree.slice(0, 7)} identique) — rien rejoué`);
|
|
140
|
-
for (const step of steps) {
|
|
141
|
-
const run = f.runs.find((r) => r.label === (step.name === 'build' ? 'build' : 'tests') && r.command === step.cmd);
|
|
142
|
-
out(`✓ ${step.name} (${step.cmd}) — enregistré par la vague${run.instables?.length ? `, ${run.instables.length} test(s) instable(s) sous charge, verts rejoués seuls` : ''}`);
|
|
143
|
-
}
|
|
144
|
-
out(`verify --gate ${short} : vert`);
|
|
145
|
-
return 0;
|
|
146
|
-
}
|
|
147
|
-
const replayReason = `verdict rejoué : ${recorded.reason}${config.gate && ctx.planFile ? ' (orchestrate.gate.readsPlan : un test lit le plan, les commits de plan seuls ne reprennent pas le verdict d\'un sha dessous)' : ''}`;
|
|
150
|
+
if ('found' in recorded)
|
|
151
|
+
return reuseRecorded(recorded.found, steps, sha, out);
|
|
152
|
+
const replayReason = `verdict rejoué : ${recorded.reason}${config.gate?.readsPlan && ctx.planFile ? ' (orchestrate.gate.readsPlan : un test lit le plan, les commits de plan seuls ne reprennent pas le verdict d\'un sha dessous)' : ''}`;
|
|
148
153
|
const obstacle = mainTreeObstacle(repo, sha);
|
|
149
154
|
const mode = ctx.verify?.inPlace;
|
|
155
|
+
const remote = await tryRemote(ctx, short, replayReason, (exec, emit) => runSteps(steps, exec, emit, config, short));
|
|
156
|
+
if (remote !== null)
|
|
157
|
+
return remote;
|
|
150
158
|
if (mode === true && obstacle) {
|
|
151
159
|
out(`verify --gate : verify.inPlace demande l'arbre principal, mais ${obstacle} — rien n'est joué`);
|
|
152
160
|
return 2;
|
|
@@ -169,7 +177,7 @@ async function playGate(ctx) {
|
|
|
169
177
|
killGroup(group.pgid);
|
|
170
178
|
});
|
|
171
179
|
try {
|
|
172
|
-
const { code, summary } = await runSteps(steps, repo, ctx, group, env, short);
|
|
180
|
+
const { code, summary } = await runSteps(steps, (cmd) => sh(cmd, repo, ctx.timeoutMs ?? GATE_TIMEOUT_MS, group, env), ctx.out, config, short);
|
|
173
181
|
// Une vague intègre ses lots par fast-forward dans cet arbre et y commite son plan : s'il a bougé pendant la porte, les commandes ont lu un mélange de commits.
|
|
174
182
|
const moved = mainTreeMoved(repo, sha);
|
|
175
183
|
if (moved === null) {
|
|
@@ -227,7 +235,7 @@ async function playGate(ctx) {
|
|
|
227
235
|
out(replayReason);
|
|
228
236
|
if (missing.length)
|
|
229
237
|
out(`dépendances déclarées absentes de l'arbre principal, non copiées : ${missing.join(', ')}`);
|
|
230
|
-
const done = await runSteps(steps, dir, ctx, group, env, short);
|
|
238
|
+
const done = await runSteps(steps, (cmd) => sh(cmd, dir, ctx.timeoutMs ?? GATE_TIMEOUT_MS, group, env), out, config, short);
|
|
231
239
|
out(done.summary);
|
|
232
240
|
return done.code;
|
|
233
241
|
}
|
|
@@ -236,12 +244,10 @@ async function playGate(ctx) {
|
|
|
236
244
|
cleanup();
|
|
237
245
|
}
|
|
238
246
|
}
|
|
239
|
-
|
|
240
|
-
async function runSteps(steps, cwd, ctx, group, env, short) {
|
|
241
|
-
const { config, out } = ctx;
|
|
247
|
+
async function runSteps(steps, exec, out, config, short) {
|
|
242
248
|
let red = 0;
|
|
243
249
|
for (const step of steps) {
|
|
244
|
-
const r = await
|
|
250
|
+
const r = await runGate(exec, step.gate);
|
|
245
251
|
if (r.code === 0) {
|
|
246
252
|
out(`✓ ${step.name} (${step.cmd})`);
|
|
247
253
|
continue;
|
|
@@ -251,14 +257,15 @@ async function runSteps(steps, cwd, ctx, group, env, short) {
|
|
|
251
257
|
const unloaded = tests ? unloadedSuites(r.output) : [];
|
|
252
258
|
// `orchestrate.testReplay` comme la porte d'une vague (L354, L387) : un rouge nommé est rejoué seul avant d'être compté ; vert au rejeu, il est instable sous charge.
|
|
253
259
|
// Un échec que la sortie ne rattache à aucun test, ou un fichier qui ne se charge pas, n'est jamais rejoué.
|
|
254
|
-
|
|
260
|
+
// Porte en parties (L512) : une partie rouge sans test nommé, ou à échec sans nom, bloque tout rejeu (`gateUnnamed`) : l'instable d'une autre partie n'excuse jamais la porte.
|
|
261
|
+
const replayable = tests && gateReplayable(step.gate, config.testReplay) && names.length > 0 && unloaded.length === 0 && !gateUnnamed(r);
|
|
255
262
|
const replays = [];
|
|
256
263
|
const { red: still, unstable } = replayable
|
|
257
|
-
? await
|
|
258
|
-
const p = await
|
|
264
|
+
? await replayGate(async (c) => {
|
|
265
|
+
const p = await exec(c);
|
|
259
266
|
replays.push(p.code);
|
|
260
267
|
return p;
|
|
261
|
-
}, config.testReplay, names, r
|
|
268
|
+
}, config.testReplay, names, r)
|
|
262
269
|
: { red: names, unstable: [] };
|
|
263
270
|
if (replayable && still.length === 0) {
|
|
264
271
|
out(`✓ ${step.name} (${step.cmd}) — ${unstable.length} test(s) rouge(s) sous charge, verts rejoués seuls (instables sous charge) :`);
|
|
@@ -268,6 +275,8 @@ async function runSteps(steps, cwd, ctx, group, env, short) {
|
|
|
268
275
|
}
|
|
269
276
|
red++;
|
|
270
277
|
out(`✗ ${step.name} (${step.cmd}) — code ${r.code}`);
|
|
278
|
+
if (r.parts)
|
|
279
|
+
out(` parties rouges : ${r.parts.filter((p) => p.code !== 0).map((p) => `${p.part.dir} (code ${p.code})`).join(', ')}`);
|
|
271
280
|
for (const n of still)
|
|
272
281
|
out(` - ${n}`);
|
|
273
282
|
for (const n of unloaded)
|
package/docs/corrections.md
CHANGED
|
@@ -33,3 +33,7 @@ The cause `bug-code` alone is 69 % of the classified passes (74 of 107, 24→30-
|
|
|
33
33
|
- The report gains the section « Bug de code par sous-cause » (sub-cause, passes, share of the sub-classified passes, link) and a line « Maillon dominant ». `--json` gains the key `bugCode` — `{ classees, sansSousCause, parSousCause, maillon: { nom, raison } }`. With no `bug-code` pass in the window nothing is added: the text report and the JSON keys are those of before.
|
|
34
34
|
|
|
35
35
|
**The link.** Each sub-cause belongs to one link of the chain: `cas-limite` and `test-vide` to the `implement` brief, `affirmation-fausse` and `appelant-oublie` to the self-check before handing back, `test-casse` to the local gate, `contrat-mal-lu` to the sheet, `autre` to none. The **dominant link** is the one whose sub-causes weigh the most passes; it is named only when at least 10 passes are sub-classified and its passes are at least 40 % of them. With fewer, with a link under 40 %, or with two links tied at the top, the line says « aucun » and why. The two thresholds and the table are constants of `src/corrections.ts` (hypotheses: the link is recomputed at each read, nothing stores it). `cadence perf` prints the same count under « causes dominantes » for the current 7-day window — `bug de code (N passes sous-classées sur M) : 1. cas limite … · maillon dominant : …` — reading the cache without writing it: an absent file gives `sous-causes n/d · à classer : cadence corrections --classify`, an unreadable one a warning on stderr (`perf : <file> illisible, sous-causes n/d`) and no line, exit code 0 in both cases (`corrections.causes.current.bugCode` in `--json`). Over 7 days the 10-pass threshold is often not reached: « aucun » is then the usual answer, not an error.
|
|
36
|
+
|
|
37
|
+
## Passes launched on tests that ended without a code change (L510)
|
|
38
|
+
|
|
39
|
+
A pass with no review finding to read is a pass the gate launched on **red tests** right after a write session (`sans revue (tests rouges)` above). When such a pass ends with **no commit**, the session found nothing to change: the red was a false red (a timeout under load, a test that was already red) or the test needed no fix. `cadence corrections` prints, after the report, `N passes lancées sur des tests, M terminées sans changement de code (P %)` (nothing when there is no pass on tests) and adds `testsSansCode: { passes, sansCode }` to `--json`. The line is outside the report of the 10-10 baseline, which `test/corrections-mesure.test.ts` replays word for word. Two windows are measured with `--since` and `--until` (a window of creation dates of the waves, see above): the figures of the last two 7-day windows are in [mesures/tests-sans-code-2026-11-01.md](mesures/tests-sans-code-2026-11-01.md).
|
package/docs/deliver.md
CHANGED
|
@@ -133,6 +133,24 @@ cadence deliver # 0 delivered and verified · 1 a step failed · 2
|
|
|
133
133
|
5 minutes is a failure (you probably pushed another commit than the one you
|
|
134
134
|
deliver); every run must end `success`, `skipped` or `neutral`. `gh` errors
|
|
135
135
|
are retried, and reported with their cause after 5 minutes.
|
|
136
|
+
- **CI not waited for on a plan-only sha** (L516): with `ci: github` or a CI command (no `deliver.script`, which
|
|
137
|
+
carries its own CI), when the previous delivery (`deliveries.log`) is an ancestor of the sha and **every** commit of
|
|
138
|
+
`previous..sha` only touches the plan (the same test as `raf check`, `isPlanOnly`), nothing is waited for: no
|
|
139
|
+
`gh run list`, no CI command. The output says `→ CI : non attendue (aucun fichier hors plan depuis <previous sha>)`
|
|
140
|
+
and the deploy and the checks run as usual. One commit that touches anything else (source, tests, docs,
|
|
141
|
+
`cadence.yaml` outside its `plan` key) is enough to wait for the CI as before; so is any case where this cannot be
|
|
142
|
+
proved: no previous delivery, a previous delivery outside the history of the sha or equal to it, no plan loaded, an
|
|
143
|
+
unreadable or empty range. `--dry-run` announces it with the same words on its CI line (`CI : non attendue (…)`).
|
|
144
|
+
- **Timestamps and step journal** (L516): every step line of the output (`→ script`, `→ CI`, `→ déploiement i/n`,
|
|
145
|
+
`→ vérification i/n`, `✓ livré`) starts with the UTC time `HH:MM:SS`, and `✓ livré` ends with `en N s`, the time
|
|
146
|
+
since the lock was taken. The failure messages, the exit codes, `deliveries.log` (`day sha`) and
|
|
147
|
+
`deliveries-failed.log` are unchanged. Each step played (the CI wait, each deploy command, each check) also appends
|
|
148
|
+
one JSON line to `<git common dir>/cadence/deliveries-steps.log`, append-only, with the keys `lots` (lots cited since
|
|
149
|
+
the previous delivery, `[]` if none), `sha`, `projet`, `etape` (`ci`, `deploiement i/n`, `verification i/n`), `debut`
|
|
150
|
+
and `fin` (ISO 8601 UTC), `duree_s` (seconds, rounded) and `resultat` (`ok` or `echec`). A failing step writes its
|
|
151
|
+
`echec` line before `deliver` exits 1; steps not played write nothing (no `ci` line when the CI is not waited for or
|
|
152
|
+
`ci: none`). The project script (`deliver.script`) is not a step of this journal. `--dry-run` writes nothing. A write
|
|
153
|
+
error only prints a warning (`deliver : journal des étapes non écrit : <cause>`): the delivery and its code stand.
|
|
136
154
|
- Commands get `CADENCE_SHA`, `CADENCE_SHORT` (7 characters) and `CADENCE_BRANCH` (a `deliver.script` also gets `CADENCE_DEPLOYED_FILE`, see below);
|
|
137
155
|
`${SHA}` and `${SHORT}` are replaced in `url` and `contains`.
|
|
138
156
|
- On success the lots cited by the commits since the previous delivery are
|
|
@@ -143,7 +161,7 @@ cadence deliver # 0 delivered and verified · 1 a step failed · 2
|
|
|
143
161
|
commits (see "Plan upkeep needs no lot") are skipped in that list: they cite
|
|
144
162
|
lots without delivering anything.
|
|
145
163
|
- With [`docs.articles`](news.md#an-article-elsewhere-that-follows-the-delivery), a green delivery of lots with a
|
|
146
|
-
public title also opens the lot `Article <project> à rafraîchir` in the plan (committed, with the files `plan.onWrite` produces,
|
|
164
|
+
public title also opens the lot `Article <project> à rafraîchir` in the plan (committed, with the files `plan.onWrite` produces, during a wave as well as outside one, L388, L420).
|
|
147
165
|
|
|
148
166
|
## A project with its own delivery script
|
|
149
167
|
|
|
@@ -317,6 +335,8 @@ verify --gate 6b0d9aa : vert
|
|
|
317
335
|
|
|
318
336
|
Nothing runs: no worktree, no copy of the dependencies, exit 0. Otherwise the gate is **replayed** exactly as described above, and the line right after the one telling where it ran gives the reason: `verdict rejoué : aucune porte de vague enregistrée pour ce sha` (no wave state, another sha such as the head after an integration that rebased, a commit that also touches another file (a plan-only commit is crossed, L444), another repository), `arbre différent de celui contrôlé`, `arbre non enregistré` (state written before L397), or `l'enregistrement (vague …, lot …) ne couvre pas en vert test (…)` (a red, missing or changed step). A recorded red is never repeated as a verdict: it is replayed. The verdict read is the one of the wave's gate (with `orchestrate.testReplay` applied), not a new run: to force a replay, ask for a sha the wave did not check.
|
|
319
337
|
|
|
338
|
+
**On another machine (L403).** With `orchestrate.gate.host` in `cadence.yaml` the gate is played by ssh on that host, at the sha pushed there, with a dependency cache; it falls back to the local gate when the host does not answer, and replays locally a command whose connection was lost: [docs/remote-gate.md](remote-gate.md).
|
|
339
|
+
|
|
320
340
|
**A test that reads the plan (L450).** Crossing the plan-only commits assumes that no test reads the plan. On 30-10 avatar-pandora's `plan-page.e2e`, which reads `.cadence/raf.yaml`, went red after the `raf done` of L8 while the head gate, taking over the wave's verdict under that commit, saw nothing. A project whose gate has such a test declares it: `orchestrate.gate: { readsPlan: true }` (or `readsPlan: [e2e/plan-page.e2e.ts]`, the paths of the tests that read it; a non-empty list of relative paths without `..`, anything else is refused when `cadence.yaml` is read). The verdict of a sha *below* plan-only commits is then never taken over: the gate is replayed at the sha asked, and the reason line ends with `(orchestrate.gate.readsPlan : un test lit le plan, …)`. A verdict recorded at the very sha asked is still taken over. Absent or `false`: unchanged. The pre-push hook keeps crossing plan-only commits (its rule, L432, is not changed here).
|
|
321
341
|
|
|
322
342
|
```yaml
|
|
@@ -342,7 +362,7 @@ verify --gate 6b0d9aa : rouge (1 étape sur 2)
|
|
|
342
362
|
|
|
343
363
|
Both commands always run, even when the first is red, so the report is whole. A red step lists the failing
|
|
344
364
|
tests the output names (the same reading as the wave's baseline) and the test files that do not load (`- test/a.test.ts (ne se charge pas)`, the typical sign of a missing dependency); with none identifiable (or when an unnamed failure is mixed with them), the last 10 lines of the output, each prefixed ` | `: a red that names no test, seen three times under load on 27-10 (L380, B28.17), is no longer a bare exit code.
|
|
345
|
-
With `orchestrate.testReplay` declared (`docs/orchestrate.md`, « A red test is replayed alone »), the gate does what a wave does: a red test the output names is **replayed alone, twice at most**, in the same throwaway worktree. Green at a replay it is *unstable under load*: the step prints `✓ tests (…) — N test(s) rouge(s) sous charge, verts rejoués seuls (instables sous charge) :` with the names and does not make the gate red; the tests still red stay listed and the unstable ones follow, under a line of their own. A failure the output ties to no test, or a file that does not load, is never replayed and stays red. When a replay took place and the step stays red, one line says so (`rejeu seul (orchestrate.testReplay) : N rejeu(x), dernier code C — k test(s) confirmé(s) vert(s) sur n`): a replay that fails or runs no test confirms nothing, and the line tells a real red from a replay that could not run. Without `testReplay` nothing is replayed.
|
|
365
|
+
With `orchestrate.testReplay` declared (`docs/orchestrate.md`, « A red test is replayed alone »), the gate does what a wave does: a red test the output names is **replayed alone, twice at most**, in the same throwaway worktree. Green at a replay it is *unstable under load*: the step prints `✓ tests (…) — N test(s) rouge(s) sous charge, verts rejoués seuls (instables sous charge) :` with the names and does not make the gate red; the tests still red stay listed and the unstable ones follow, under a line of their own. A failure the output ties to no test, or a file that does not load, is never replayed and stays red. With `orchestrate.test` declared as a list of parts (L512, `docs/orchestrate.md`, « A gate declared in parts »), the `tests` step is labelled `dir : run ; dir : run`, every part is played in its folder, a red step also prints `parties rouges : <dir> (code N), …`, and each red test is replayed alone by the `replay` of the part that printed it, in that folder (a name printed by several parts must be green in each of them). When a replay took place and the step stays red, one line says so (`rejeu seul (orchestrate.testReplay) : N rejeu(x), dernier code C — k test(s) confirmé(s) vert(s) sur n`): a replay that fails or runs no test confirms nothing, and the line tells a real red from a replay that could not run. Without `testReplay` nothing is replayed.
|
|
346
366
|
Each command has 30 minutes (then its whole process group is killed, code 124). Ctrl-C, SIGTERM or a hangup received by cadence kills the running command and removes the worktree before cadence dies.
|
|
347
367
|
`--sha` is required (the moving head of a wave is not a sha to re-verify) and `--retry` does not apply. Exit code:
|
|
348
368
|
**0** green · **1** red · **2** nothing to replay (neither `orchestrate.build` nor `orchestrate.test`), unknown sha, a worktree that cannot be made, or `verify.inPlace: true` with a main tree not clean at the sha.
|
package/docs/lead.md
CHANGED
|
@@ -58,7 +58,9 @@ picture, not its source.
|
|
|
58
58
|
soon as the folder keeps several unfinished waves). While the wave is still running the same command (or
|
|
59
59
|
`cadence orchestrate --answer <project>:<lot> "…"`) queues the answer and the live wave replays the lot at
|
|
60
60
|
once, whatever the other lots do (L407). The lead never answers in the
|
|
61
|
-
human's place. `--drop` and `--stop-after-current` steer a wave that is running
|
|
61
|
+
human's place. `--drop` and `--stop-after-current` steer a wave that is running, and
|
|
62
|
+
`cadence orchestrate --add <project>:<lot> [--wave <wave>]` adds a lot to it instead of launching a second
|
|
63
|
+
wave, which is refused in a repository the first one holds; the refusal names this command (L421).
|
|
62
64
|
4. **Review.** The final table of the wave lists the lots ready, handed back, and the line
|
|
63
65
|
« livrable jusqu'à <sha> » for a ready lot stacked under a handed-back one. Minor findings come back in
|
|
64
66
|
the table: one becomes a lot of its own only if it describes an observable bug.
|
package/docs/news.md
CHANGED
|
@@ -201,10 +201,8 @@ else all the `##` headings. The lot is then played like any other, in an ordinar
|
|
|
201
201
|
implementer rewrites the article in the neighbouring repository, the review checks it against the note). If a lot
|
|
202
202
|
of that title is still open, it receives a note instead of a duplicate. Two entries with the same name (the same
|
|
203
203
|
project told in two neighbouring repositories) share one lot, which declares both repositories in `repos:`. Internal lots (no public title) open nothing;
|
|
204
|
-
a red delivery opens nothing; `cadence deliver --dry-run` announces the articles.
|
|
205
|
-
|
|
206
|
-
using the `plan.commit` template (`plan: L9 article à rafraîchir ouvert (orchestrate <wave>)` by default), so no plan change is left in the tree a session
|
|
207
|
-
of the wave works in; if the plan or one of those files already had uncommitted changes (the commit would carry them away), or the commit is refused (a `commit-msg` hook), nothing is committed, it stays written and the line says so. With a plan kept by another
|
|
204
|
+
a red delivery opens nothing; `cadence deliver --dry-run` announces the articles. The plan is committed with the files that `plan.onWrite` produces from it,
|
|
205
|
+
using the `plan.commit` template (`plan: L9 article à rafraîchir ouvert (orchestrate <wave>)` by default, `plan: L9 article à rafraîchir ouvert` outside a wave), so no plan change is left in the tree, during a wave (L388) or when the deliverer is run by hand (L420); if the plan or one of those files already had uncommitted changes (the commit would carry them away), or the commit is refused (a `commit-msg` hook), nothing is committed, it stays written and the line says so. With a plan kept by another
|
|
208
206
|
tool (read-only), the same line carries the instruction to open the lot with that tool. This replaces the manual
|
|
209
207
|
`sync-site-docs` skill.
|
|
210
208
|
|