yadflow 3.15.4 → 3.16.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/CHANGELOG.md +22 -0
- package/README.md +5 -0
- package/bin/yad.mjs +11 -4
- package/cli/doctor.mjs +5 -1
- package/cli/manifest.mjs +11 -0
- package/cli/plan.mjs +85 -5
- package/cli/reconcile.mjs +40 -11
- package/cli/setup.mjs +18 -3
- package/package.json +1 -1
- package/skills/yad-checks/SKILL.md +5 -2
- package/skills/yad-checks/references/check-gates.md +12 -0
- package/skills/yad-checks/templates/checks/ledger-guard.sh +42 -3
- package/skills/yad-connect-repos/SKILL.md +5 -1
- package/skills/yad-connect-repos/references/hub-config.md +2 -1
- package/skills/yad-hub-bridge/SKILL.md +6 -3
- package/skills/yad-hub-bridge/references/bridge.md +32 -12
- package/skills/yad-hub-bridge/templates/github/yad-gate-sync.yml +81 -10
- package/skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml +45 -9
- package/skills/yad-pr-template/SKILL.md +12 -0
- package/skills/yad-pr-template/templates/checks/pr-template.sh +17 -0
- package/skills/yad-pr-template/templates/gitlab/merge_request_templates/Default.md +4 -0
- package/skills/yad-pr-template/templates/hub/gitlab/merge_request_templates/Default.md +12 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
1
|
+
# [3.16.0](https://github.com/abdelrahmannasr/yadflow/compare/v3.15.5...v3.16.0) (2026-08-12)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **pr-template:** name GitLab's 2700-character description truncation ([475e2b7](https://github.com/abdelrahmannasr/yadflow/commit/475e2b7db8f0d24909ce5c874122f18dc6508985)), closes [#164](https://github.com/abdelrahmannasr/yadflow/issues/164)
|
|
7
|
+
* **update:** reject an unusable provenance record instead of ignoring it ([5107381](https://github.com/abdelrahmannasr/yadflow/commit/5107381ae2c08b261fddda76433cfc2a3fe3ea46)), closes [#188](https://github.com/abdelrahmannasr/yadflow/issues/188) [#164](https://github.com/abdelrahmannasr/yadflow/issues/164)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
### Features
|
|
11
|
+
|
|
12
|
+
* **update:** never silently overwrite a locally modified managed file ([28d6ee4](https://github.com/abdelrahmannasr/yadflow/commit/28d6ee4c1af250415c3ad50d999a263ebe91cd83)), closes [#164](https://github.com/abdelrahmannasr/yadflow/issues/164)
|
|
13
|
+
|
|
14
|
+
## [3.15.5](https://github.com/abdelrahmannasr/yadflow/compare/v3.15.4...v3.15.5) (2026-08-12)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Bug Fixes
|
|
18
|
+
|
|
19
|
+
* **bridge:** resolve the wired gate-sync pin from the repo, not a floating major ([8489bf5](https://github.com/abdelrahmannasr/yadflow/commit/8489bf5298f529868dc65085068882b59e89bbb5)), closes [#163](https://github.com/abdelrahmannasr/yadflow/issues/163) [#163](https://github.com/abdelrahmannasr/yadflow/issues/163)
|
|
20
|
+
* **checks:** require a platform for the ledger-guard bridge gate ([297d13a](https://github.com/abdelrahmannasr/yadflow/commit/297d13a0ebf26bec995ce64e0aacfa022b228e18)), closes [#185](https://github.com/abdelrahmannasr/yadflow/issues/185) [#186](https://github.com/abdelrahmannasr/yadflow/issues/186)
|
|
21
|
+
* **checks:** scope the ledger-guard bridge read to root-level keys ([4fb83a9](https://github.com/abdelrahmannasr/yadflow/commit/4fb83a968d4537e89a9367d72418aede9cf305f0)), closes [#186](https://github.com/abdelrahmannasr/yadflow/issues/186) [#186](https://github.com/abdelrahmannasr/yadflow/issues/186)
|
|
22
|
+
|
|
1
23
|
## [3.15.4](https://github.com/abdelrahmannasr/yadflow/compare/v3.15.3...v3.15.4) (2026-08-12)
|
|
2
24
|
|
|
3
25
|
## [3.15.3](https://github.com/abdelrahmannasr/yadflow/compare/v3.15.2...v3.15.3) (2026-08-11)
|
package/README.md
CHANGED
|
@@ -65,6 +65,11 @@ Every step stops at a gate until a human approves. New here? **Walk it lesson-by
|
|
|
65
65
|
|
|
66
66
|
Running `yad` tells you when a new release is out — upgrade with `npm install yadflow -g`, then
|
|
67
67
|
`yad update` to re-sync this project's skills. See [staying up to date](docs/CLI.md#staying-up-to-date).
|
|
68
|
+
An update rewrites the files yad manages (gate scripts, CI, PR/MR templates) — but not one **you**
|
|
69
|
+
edited: yad records the sha of every file it writes, so an edit to one is reported as `modified` and
|
|
70
|
+
left alone. A file it has no record of (an install predating that record) is still replaced, but only
|
|
71
|
+
after saving a `.yad-orig` backup
|
|
72
|
+
([managed files](docs/CLI.md#managed-files-what-yad-owns-and-what-you-edited)).
|
|
68
73
|
|
|
69
74
|
## What `npx yadflow setup` installs
|
|
70
75
|
|
package/bin/yad.mjs
CHANGED
|
@@ -30,11 +30,15 @@ ${c.bold('Setup & maintenance')}
|
|
|
30
30
|
yad setup Guided first-run setup (profile interview, install, connect & wire repos)
|
|
31
31
|
profile flags: --solo | --team <n>, --greenfield | --brownfield,
|
|
32
32
|
--monorepo | --separate, --tools (configure design/testing/learning now)
|
|
33
|
-
yad check Report what is missing / drifted / stale / legacy (read-only)
|
|
33
|
+
yad check Report what is missing / drifted / modified / stale / legacy (read-only)
|
|
34
34
|
yad check --fix Reconcile: fill what is missing, update what changed
|
|
35
35
|
yad update Apply drift only (alias for: check --fix --scope=changed);
|
|
36
36
|
installs newly-added skills, updates changed skills + gate scripts,
|
|
37
|
-
and migrates pre-2.0 sdlc-* installs to the yad-* names
|
|
37
|
+
and migrates pre-2.0 sdlc-* installs to the yad-* names.
|
|
38
|
+
A managed file whose edit yad can prove (its recorded sha) is
|
|
39
|
+
reported 'modified' and left alone; --overwrite-local replaces
|
|
40
|
+
it. Anything else it cannot account for is replaced only after
|
|
41
|
+
a <file>.yad-orig backup
|
|
38
42
|
yad update --push Also commit each repo's applied changes and push them straight to the
|
|
39
43
|
default branch of the hub + every connected repo (a chore(yad-update)
|
|
40
44
|
commit; no PR — the push-on-main yad-update-guard runs verified-commits
|
|
@@ -159,6 +163,8 @@ ${c.bold('Options')}
|
|
|
159
163
|
--no-push gate ci: commit the ledger but do not push
|
|
160
164
|
--push check --fix / update: commit + push applied changes to the default branch
|
|
161
165
|
--allow-branch check --fix --push / update --push / repo refresh --push: allow committing on a non-default branch
|
|
166
|
+
--overwrite-local check --fix / update: replace managed files reported as 'modified'
|
|
167
|
+
(a <file>.yad-orig backup is written first)
|
|
162
168
|
-h, --help Show this help
|
|
163
169
|
-v, --version Print version
|
|
164
170
|
|
|
@@ -178,6 +184,7 @@ function parseArgs(argv) {
|
|
|
178
184
|
else if (a === '--no-push') o.noPush = true;
|
|
179
185
|
else if (a === '--push') o.push = true;
|
|
180
186
|
else if (a === '--allow-branch') o.allowBranch = true;
|
|
187
|
+
else if (a === '--overwrite-local') o.overwriteLocal = true;
|
|
181
188
|
else if (a === '--merged') o.merged = true;
|
|
182
189
|
else if (a === '--overview') o.overview = true;
|
|
183
190
|
// `--check` is a bare boolean for `docs sync --check`, but takes a value for
|
|
@@ -232,10 +239,10 @@ async function main() {
|
|
|
232
239
|
});
|
|
233
240
|
break;
|
|
234
241
|
case 'check':
|
|
235
|
-
await reconcile(o.dir, { fix: o.fix, scope: o.scope, force: o.force, push: o.push, allowBranch: o.allowBranch, today });
|
|
242
|
+
await reconcile(o.dir, { fix: o.fix, scope: o.scope, force: o.force, push: o.push, allowBranch: o.allowBranch, overwriteLocal: o.overwriteLocal, today });
|
|
236
243
|
break;
|
|
237
244
|
case 'update':
|
|
238
|
-
await reconcile(o.dir, { fix: true, scope: 'changed', force: o.force, push: o.push, allowBranch: o.allowBranch, today });
|
|
245
|
+
await reconcile(o.dir, { fix: true, scope: 'changed', force: o.force, push: o.push, allowBranch: o.allowBranch, overwriteLocal: o.overwriteLocal, today });
|
|
239
246
|
break;
|
|
240
247
|
case 'doctor':
|
|
241
248
|
await runDoctor(o.dir, { json: o.json });
|
package/cli/doctor.mjs
CHANGED
|
@@ -62,7 +62,11 @@ export function projectChecks(checks, root) {
|
|
|
62
62
|
// version stamp
|
|
63
63
|
const ver = readJSON(verPath, null);
|
|
64
64
|
if (!ver) check(checks, 'cli-version', 'project', 'warn', `${PROJECT_FILES.version} missing or unreadable`, 'run `yad check --fix`');
|
|
65
|
-
|
|
65
|
+
// The stamp is not only cosmetic: in bridge mode the wired gate-sync job resolves the yadflow it
|
|
66
|
+
// RUNS from it — unless hub.json pins `gate_sync_version`, a YAD_VERSION variable overrides, or the
|
|
67
|
+
// stamp is not an exact release of the current major (then the job skips it and floats). So a stale
|
|
68
|
+
// stamp can mean CI is running an old gate; say so, or the warning reads as bookkeeping.
|
|
69
|
+
else if (ver.version !== VERSION) check(checks, 'cli-version', 'project', 'warn', `project stamped v${ver.version}, CLI is v${VERSION} — this also drives the wired gate-sync pin`, 'run `yad update` to reconcile');
|
|
66
70
|
else check(checks, 'cli-version', 'project', 'ok', `version stamp matches (v${VERSION})`);
|
|
67
71
|
|
|
68
72
|
// hub.json: parse + shape
|
package/cli/manifest.mjs
CHANGED
|
@@ -230,6 +230,17 @@ export const REPO_WIRING = {
|
|
|
230
230
|
],
|
|
231
231
|
};
|
|
232
232
|
|
|
233
|
+
// Provenance of the wired files above, per repo root. Wiring files are OURS to rewrite, but "the
|
|
234
|
+
// on-disk copy differs from the shipped template" cannot tell a STALE copy (overwrite it) from one a
|
|
235
|
+
// team deliberately CUSTOMIZED (ask first) — so every write records the sha256 of what it wrote here.
|
|
236
|
+
// On the next update, on-disk == recorded proves the copy is untouched since we wrote it; anything
|
|
237
|
+
// else is a local edit, reported as `modified` and left alone (#164). Committed, so the record
|
|
238
|
+
// travels with the repo instead of living in one person's clone.
|
|
239
|
+
export const MANAGED_LEDGER = '.sdlc/managed.json';
|
|
240
|
+
// Suffix for the copy written beside a managed file before its content is replaced without that
|
|
241
|
+
// proof — the local edit is always recoverable from the working tree, not only from git history.
|
|
242
|
+
export const BACKUP_SUFFIX = '.yad-orig';
|
|
243
|
+
|
|
233
244
|
export const wiringFor = (platform) => [
|
|
234
245
|
...REPO_WIRING.common,
|
|
235
246
|
...(REPO_WIRING[platform] || []),
|
package/cli/plan.mjs
CHANGED
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
// setup (apply all), update (apply changed), and check (report; fix non-ok) share it.
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
+
import { err } from './errors.mjs';
|
|
6
7
|
import {
|
|
7
|
-
asset, exists, copyDir, copyFile, dirMatches, sameContent, readJSON,
|
|
8
|
+
asset, exists, copyDir, copyFile, dirMatches, sameContent, readJSON, readJSONStrict, writeJSON, fileSha,
|
|
8
9
|
} from './lib.mjs';
|
|
9
10
|
import {
|
|
10
|
-
SKILLS, IDE_TARGETS, IDE_OPENCODE_DIR, MODULE_FILES, wiringFor, HUB_WIRING, PROJECT_FILES,
|
|
11
|
-
LEGACY_SKILLS, REMOVED_SKILLS, LEGACY_MARKER, LEGACY_REPO_FILES, LEGACY_HUB_FILES,
|
|
11
|
+
VERSION, SKILLS, IDE_TARGETS, IDE_OPENCODE_DIR, MODULE_FILES, wiringFor, HUB_WIRING, PROJECT_FILES,
|
|
12
|
+
LEGACY_SKILLS, REMOVED_SKILLS, LEGACY_MARKER, LEGACY_REPO_FILES, LEGACY_HUB_FILES, MANAGED_LEDGER, BACKUP_SUFFIX,
|
|
12
13
|
} from './manifest.mjs';
|
|
13
14
|
|
|
14
15
|
// A git pathspec (forward slashes, relative to a repo root) for `dest` under `root`. Actions carry
|
|
@@ -36,6 +37,83 @@ const dirAction = (scope, item, src, dest, { root } = {}) => ({
|
|
|
36
37
|
apply: () => copyDir(src, dest),
|
|
37
38
|
});
|
|
38
39
|
|
|
40
|
+
// ---- managed-file provenance (#164) --------------------------------------------------------
|
|
41
|
+
// Read one repo root's ledger of "files yad wrote, and the sha it wrote". Strict, like every other
|
|
42
|
+
// ledger read: only an ABSENT ledger means "no record" ({}). One that exists but does not parse — or
|
|
43
|
+
// parses into something that is not a `files` map — must throw. Defaulting either to {} would
|
|
44
|
+
// silently downgrade every locally-modified file to an unrecorded one and re-open, one backup short,
|
|
45
|
+
// the silent clobber this record exists to prevent.
|
|
46
|
+
const isMap = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
47
|
+
export function readManagedLedger(root) {
|
|
48
|
+
const file = path.join(root, MANAGED_LEDGER);
|
|
49
|
+
const rec = readJSONStrict(file, null);
|
|
50
|
+
if (rec === null && !exists(file)) return {};
|
|
51
|
+
if (!isMap(rec) || !isMap(rec.files)) {
|
|
52
|
+
// Parses, but is not a record — YAD-STATE-002 (wrong shape), not -001 (does not parse).
|
|
53
|
+
throw err('YAD-STATE-002', `unreadable provenance record in ${file}: expected an object with a "files" map`,
|
|
54
|
+
'restore it from git — or delete it to start over, which costs the record (the next update then backs up every managed file it replaces)');
|
|
55
|
+
}
|
|
56
|
+
return rec.files;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Where the pre-overwrite copy of `dest` goes.
|
|
60
|
+
export const backupPathFor = (dest) => `${dest}${BACKUP_SUFFIX}`;
|
|
61
|
+
|
|
62
|
+
// A wired file (gate script, CI fragment, PR/MR template) — a fileAction plus provenance:
|
|
63
|
+
// 'ok' bytes are the shipped template
|
|
64
|
+
// 'missing' not installed
|
|
65
|
+
// 'outdated' differs, and the recorded sha proves WE wrote what is there (a stale copy) — or the
|
|
66
|
+
// file predates the ledger (no record at all), in which case nothing is proven and
|
|
67
|
+
// apply() saves a .yad-orig copy before replacing it
|
|
68
|
+
// 'modified' differs, and the recorded sha says someone edited our copy — never overwritten by a
|
|
69
|
+
// plain update; `--overwrite-local` replaces it (after a .yad-orig backup)
|
|
70
|
+
// apply() backs up whenever provenance is not proven, so no unproven content is ever discarded.
|
|
71
|
+
const wiredFileAction = (scope, item, src, dest, { root, exec = false, ledger = {} } = {}) => {
|
|
72
|
+
const base = fileAction(scope, item, src, dest, { root, exec });
|
|
73
|
+
const managed = { src, dest, root };
|
|
74
|
+
if (base.status !== 'outdated') return { ...base, managed };
|
|
75
|
+
const recorded = ledger[rel(root, dest)];
|
|
76
|
+
const ours = !!recorded && recorded === fileSha(dest);
|
|
77
|
+
const backup = ours ? null : backupPathFor(dest);
|
|
78
|
+
return {
|
|
79
|
+
...base,
|
|
80
|
+
// No record at all is a pre-ledger install, not evidence of an edit: keep the routine upgrade
|
|
81
|
+
// working (still 'outdated'), but never discard content we cannot prove we wrote — hence backup.
|
|
82
|
+
status: ours || !recorded ? 'outdated' : 'modified',
|
|
83
|
+
managed,
|
|
84
|
+
backup,
|
|
85
|
+
apply: () => {
|
|
86
|
+
if (backup) fs.copyFileSync(dest, backup);
|
|
87
|
+
copyFile(src, dest, { exec });
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// Persist the provenance of every managed file whose on-disk bytes ARE the shipped template — the
|
|
93
|
+
// ones just applied AND the ones already correct. Seeding the already-correct ones is what migrates
|
|
94
|
+
// an install made before this ledger existed: from then on, an edit to any of them is detectable.
|
|
95
|
+
// A file we skipped as `modified` is deliberately NOT recorded — it is the team's copy, not ours.
|
|
96
|
+
// Keys are sorted so two repos' updates produce mergeable, byte-stable ledgers.
|
|
97
|
+
// Returns the roots written, so the caller can stage them alongside what they describe.
|
|
98
|
+
export function recordManagedWrites(actions = []) {
|
|
99
|
+
const byRoot = new Map();
|
|
100
|
+
for (const a of actions) {
|
|
101
|
+
const m = a?.managed;
|
|
102
|
+
if (!m || !m.root) continue;
|
|
103
|
+
if (!sameContent(m.src, m.dest)) continue;
|
|
104
|
+
if (!byRoot.has(m.root)) byRoot.set(m.root, {});
|
|
105
|
+
byRoot.get(m.root)[rel(m.root, m.dest)] = fileSha(m.dest);
|
|
106
|
+
}
|
|
107
|
+
const roots = [];
|
|
108
|
+
for (const [root, written] of byRoot) {
|
|
109
|
+
const files = { ...readManagedLedger(root), ...written };
|
|
110
|
+
const sorted = Object.fromEntries(Object.keys(files).sort().map((k) => [k, files[k]]));
|
|
111
|
+
writeJSON(path.join(root, MANAGED_LEDGER), { version: VERSION, files: sorted });
|
|
112
|
+
roots.push(root);
|
|
113
|
+
}
|
|
114
|
+
return roots;
|
|
115
|
+
}
|
|
116
|
+
|
|
39
117
|
// Persisted state gets one deliberately narrow compatibility repair. Explicit setup/planner input
|
|
40
118
|
// does not: a caller typo is an error, while the known v3.11.1 `.cluade` stamp is safely migrated.
|
|
41
119
|
const PERSISTED_IDE_ALIASES = new Map([['.cluade', '.claude']]);
|
|
@@ -371,8 +449,9 @@ export function legacyHubActions(root) {
|
|
|
371
449
|
// Per-repo wiring (gate scripts, CI, PR template).
|
|
372
450
|
export function repoActions(root, repo) {
|
|
373
451
|
const repoRoot = path.resolve(root, repo.path);
|
|
452
|
+
const ledger = readManagedLedger(repoRoot);
|
|
374
453
|
return wiringFor(repo.platform).map((w) =>
|
|
375
|
-
|
|
454
|
+
wiredFileAction(repo.name, w.dest, asset(w.src), path.join(repoRoot, w.dest), { root: repoRoot, exec: !!w.exec, ledger }),
|
|
376
455
|
);
|
|
377
456
|
}
|
|
378
457
|
|
|
@@ -383,8 +462,9 @@ export function hubActions(root) {
|
|
|
383
462
|
// `bridge_enabled` is the canonical flag (the documented hub-config schema); older setup versions
|
|
384
463
|
// wrote `bridge` — accept an explicit true in either spelling, wire nothing otherwise.
|
|
385
464
|
if (!hub?.platform || !(hub.bridge_enabled === true || hub.bridge === true)) return [];
|
|
465
|
+
const ledger = readManagedLedger(root);
|
|
386
466
|
return [...HUB_WIRING.common, ...(HUB_WIRING[hub.platform] || [])].map((w) =>
|
|
387
|
-
|
|
467
|
+
wiredFileAction('hub', w.dest, asset(w.src), path.join(root, w.dest), { root, exec: !!w.exec, ledger }),
|
|
388
468
|
);
|
|
389
469
|
}
|
|
390
470
|
|
package/cli/reconcile.mjs
CHANGED
|
@@ -10,18 +10,18 @@ import {
|
|
|
10
10
|
const readFileSafe = (p) => { try { return fs.readFileSync(p, 'utf8'); } catch { return ''; } };
|
|
11
11
|
|
|
12
12
|
import { preflightGuardReadiness } from './hubcommit.mjs';
|
|
13
|
-
import { VERSION, PROJECT_FILES } from './manifest.mjs';
|
|
13
|
+
import { VERSION, PROJECT_FILES, MANAGED_LEDGER, BACKUP_SUFFIX } from './manifest.mjs';
|
|
14
14
|
import {
|
|
15
15
|
moduleActions, repoActions, hubActions, authorsActions,
|
|
16
16
|
legacyModuleActions, removedModuleActions, legacyRepoActions, legacyHubActions,
|
|
17
|
-
ideTargetStateFor,
|
|
17
|
+
ideTargetStateFor, recordManagedWrites,
|
|
18
18
|
} from './plan.mjs';
|
|
19
19
|
import { gitHead, packRepo } from './setup.mjs';
|
|
20
|
-
import { groupByRoot, commitUpdates } from './update-commit.mjs';
|
|
20
|
+
import { groupByRoot, commitUpdates, repoLabel } from './update-commit.mjs';
|
|
21
21
|
|
|
22
|
-
const MARK = { missing: c.red('missing'), new: c.cyan('new'), outdated: c.yellow('outdated'), stale: c.yellow('stale'), legacy: c.yellow('legacy'), removed: c.yellow('removed'), ok: c.green('ok') };
|
|
22
|
+
const MARK = { missing: c.red('missing'), new: c.cyan('new'), outdated: c.yellow('outdated'), modified: c.cyan('modified'), stale: c.yellow('stale'), legacy: c.yellow('legacy'), removed: c.yellow('removed'), ok: c.green('ok') };
|
|
23
23
|
|
|
24
|
-
export async function reconcile(root, { fix = false, scope = 'all', force = false, push = false, allowBranch = false } = {}) {
|
|
24
|
+
export async function reconcile(root, { fix = false, scope = 'all', force = false, push = false, allowBranch = false, overwriteLocal = false } = {}) {
|
|
25
25
|
log(c.bold(`\nSDLC reconcile ${c.dim('v' + VERSION)}`));
|
|
26
26
|
log(c.dim(`target: ${root}\n`));
|
|
27
27
|
|
|
@@ -91,7 +91,7 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
|
|
|
91
91
|
if (!byScope.has(a.scope)) byScope.set(a.scope, []);
|
|
92
92
|
byScope.get(a.scope).push(a);
|
|
93
93
|
}
|
|
94
|
-
const counts = { missing: 0, new: 0, outdated: 0, stale: 0, legacy: 0, removed: 0, ok: 0 };
|
|
94
|
+
const counts = { missing: 0, new: 0, outdated: 0, modified: 0, stale: 0, legacy: 0, removed: 0, ok: 0 };
|
|
95
95
|
for (const [scopeName, items] of byScope) {
|
|
96
96
|
const notOk = items.filter((i) => i.status !== 'ok');
|
|
97
97
|
items.forEach((i) => counts[i.status]++);
|
|
@@ -125,16 +125,29 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
|
|
|
125
125
|
warn('existing .cluade path was left untouched; review its contents and remove it manually');
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
// A managed file the team edited is NEVER rewritten by a plain update — that silent clobber is what
|
|
129
|
+
// #164 reported. It is reported on every run (honest drift) until either the edit is dropped or
|
|
130
|
+
// `--overwrite-local` replaces it, which still saves the previous content beside it.
|
|
131
|
+
const modified = actions.filter((a) => a.status === 'modified');
|
|
132
|
+
for (const m of modified) {
|
|
133
|
+
warn(`${m.scope}/${m.item} is locally modified — it matches neither the shipped template nor the copy yad wrote`);
|
|
134
|
+
}
|
|
135
|
+
if (modified.length && !overwriteLocal) {
|
|
136
|
+
hand(`keep the edits (reported as \`modified\` on every check), or replace them with \`yad update --overwrite-local\` — each previous version is saved beside the file as <file>${BACKUP_SUFFIX}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
128
139
|
const fixable = actions.filter((a) =>
|
|
129
|
-
a.status !== 'ok'
|
|
140
|
+
a.status !== 'ok'
|
|
141
|
+
&& (a.status !== 'modified' || overwriteLocal)
|
|
142
|
+
&& (scope === 'all' ? true : a.status !== 'missing'),
|
|
130
143
|
);
|
|
131
144
|
log('');
|
|
132
|
-
log(c.dim(`summary: ${counts.missing} missing, ${counts.new} new, ${counts.outdated} outdated, ${counts.stale} stale, ${counts.legacy} legacy, ${counts.removed} removed, ${counts.ok} ok`));
|
|
145
|
+
log(c.dim(`summary: ${counts.missing} missing, ${counts.new} new, ${counts.outdated} outdated, ${counts.modified} modified, ${counts.stale} stale, ${counts.legacy} legacy, ${counts.removed} removed, ${counts.ok} ok`));
|
|
133
146
|
|
|
134
147
|
if (!fix) {
|
|
135
148
|
if (push) warn('--push has no effect without --fix (there is nothing applied to commit).');
|
|
136
149
|
if (fixable.length || gaps.length) hand('run `yad check --fix` to reconcile (or `yad setup` for missing one-time setup).');
|
|
137
|
-
return { counts, gaps, applied: 0 };
|
|
150
|
+
return { counts, gaps, applied: 0, modified: modified.length };
|
|
138
151
|
}
|
|
139
152
|
|
|
140
153
|
// --- apply --- (collect the applied actions so --push can stage each repo's exact allowlist) ---
|
|
@@ -145,16 +158,32 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
|
|
|
145
158
|
a.apply();
|
|
146
159
|
applied++;
|
|
147
160
|
appliedActions.push(a);
|
|
148
|
-
|
|
161
|
+
// A backup means the replaced content was not provably ours (a pre-ledger install, or an edit
|
|
162
|
+
// --overwrite-local was told to discard). Never report that as an ordinary template adoption.
|
|
163
|
+
info(`${a.status} → fixed: ${a.scope}/${a.item}${a.backup ? ` ${c.yellow(`(previous content saved to ${path.basename(a.backup)})`)}` : ''}`);
|
|
149
164
|
}
|
|
150
165
|
if (force) {
|
|
166
|
+
// --force re-copies what is already correct; it deliberately does NOT reach a `modified` file —
|
|
167
|
+
// only --overwrite-local discards a local edit, and only after backing it up.
|
|
151
168
|
for (const a of actions.filter((a) => a.status === 'ok')) { a.apply(); appliedActions.push(a); }
|
|
152
169
|
}
|
|
153
170
|
// Refresh the version stamp and persist only the canonical targets used to build actions. This also
|
|
154
171
|
// completes legacy/corrupt target migration even when no skill content itself needed an update.
|
|
155
172
|
writeCanonicalStamp();
|
|
156
173
|
appliedActions.push({ scope: 'hub', item: PROJECT_FILES.version, status: 'stamp', root, paths: [PROJECT_FILES.version] });
|
|
174
|
+
// Record what we wrote (and what was already correct) so the NEXT update can tell a stale managed
|
|
175
|
+
// file from an edited one. Seeding the already-correct files is what migrates a pre-ledger install.
|
|
176
|
+
// A file left as `modified` records nothing — it differs from the template by definition.
|
|
177
|
+
for (const ledgerRoot of recordManagedWrites(actions)) {
|
|
178
|
+
appliedActions.push({
|
|
179
|
+
scope: repoLabel(root, ledgerRoot), item: MANAGED_LEDGER, status: 'stamp',
|
|
180
|
+
root: ledgerRoot, paths: [MANAGED_LEDGER],
|
|
181
|
+
});
|
|
182
|
+
}
|
|
157
183
|
applied ? ok(`reconciled ${applied} item(s)`) : info('nothing to fix');
|
|
184
|
+
if (modified.length && !overwriteLocal) {
|
|
185
|
+
warn(`${modified.length} locally modified file(s) left untouched — this update did not reach them`);
|
|
186
|
+
}
|
|
158
187
|
if (gaps.length) hand('one-time setup still missing — run `yad setup`.');
|
|
159
188
|
|
|
160
189
|
// --- publish: commit each repo's applied changes and push directly to its default branch ---
|
|
@@ -182,5 +211,5 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
|
|
|
182
211
|
},
|
|
183
212
|
});
|
|
184
213
|
}
|
|
185
|
-
return { counts, gaps, applied };
|
|
214
|
+
return { counts, gaps, applied, modified: modified.length };
|
|
186
215
|
}
|
package/cli/setup.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { VERSION, IDE_TARGETS, PROJECT_FILES, DESIGN_TOOLS, DESIGN_PRIMARY, TEST
|
|
|
10
10
|
import {
|
|
11
11
|
moduleActions, repoActions, hubActions, authorsActions,
|
|
12
12
|
legacyModuleActions, removedModuleActions, legacyRepoActions, legacyHubActions,
|
|
13
|
-
safeIdeTargetsFor, detectedIdeTargetStateFor,
|
|
13
|
+
safeIdeTargetsFor, detectedIdeTargetStateFor, recordManagedWrites,
|
|
14
14
|
} from './plan.mjs';
|
|
15
15
|
import { validateLogin, rolesForScope } from './platform.mjs';
|
|
16
16
|
|
|
@@ -348,10 +348,16 @@ export function registerLearning(root, { tool, kb = null, today = null } = {}) {
|
|
|
348
348
|
function applyActions(actions, { force = false } = {}) {
|
|
349
349
|
let changed = 0;
|
|
350
350
|
for (const a of actions) {
|
|
351
|
+
// A managed file the team edited is left alone here too — setup re-runs with force:true, so
|
|
352
|
+
// without this the wizard would be a second silent-clobber path for the same edits (#164).
|
|
353
|
+
if (a.status === 'modified') {
|
|
354
|
+
warn(`kept locally modified ${a.scope}/${a.item} — replace it with \`yad update --overwrite-local\``);
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
351
357
|
if (a.status === 'ok' && !force) continue;
|
|
352
358
|
a.apply();
|
|
353
359
|
changed++;
|
|
354
|
-
info(`${a.status === 'missing' ? 'installed' : 'updated'} ${a.scope}/${a.item}`);
|
|
360
|
+
info(`${a.status === 'missing' ? 'installed' : 'updated'} ${a.scope}/${a.item}${a.backup ? ` (previous content saved to ${path.basename(a.backup)})` : ''}`);
|
|
355
361
|
}
|
|
356
362
|
if (!changed) info('already up to date');
|
|
357
363
|
return changed;
|
|
@@ -708,9 +714,14 @@ export async function runSetup(root, opts = {}) {
|
|
|
708
714
|
S('Wire connected repos + the hub (CI gates, PR template, gate-sync)');
|
|
709
715
|
guide(['Installs the CI safety gates, PR/MR template, and gate-sync — automatic, no input needed.']);
|
|
710
716
|
if (registry.repos.length === 0) info('no repos to wire');
|
|
717
|
+
// Every managed file this step writes is recorded (sha per repo root) so a LATER `yad update` can
|
|
718
|
+
// tell a stale copy from one the team edited, instead of silently rewriting both (#164).
|
|
719
|
+
const wired = [];
|
|
711
720
|
for (const repo of registry.repos) {
|
|
712
721
|
log(` ${c.bold(repo.name)} ${c.dim(`(${repo.platform})`)}`);
|
|
713
|
-
|
|
722
|
+
const repoWiring = repoActions(root, repo);
|
|
723
|
+
applyActions(repoWiring, { force: true });
|
|
724
|
+
wired.push(...repoWiring);
|
|
714
725
|
// Migrate pre-2.0 wired CI (marker-owned sdlc-*.yml -> yad-*.yml); a user-authored
|
|
715
726
|
// same-named file is never touched.
|
|
716
727
|
applyActions(legacyRepoActions(root, repo), { force: true });
|
|
@@ -720,8 +731,12 @@ export async function runSetup(root, opts = {}) {
|
|
|
720
731
|
if (hubWiring.length) {
|
|
721
732
|
log(` ${c.bold('hub')} ${c.dim('(gate-sync + verified-commits CI)')}`);
|
|
722
733
|
applyActions(hubWiring, { force: true });
|
|
734
|
+
wired.push(...hubWiring);
|
|
723
735
|
}
|
|
724
736
|
applyActions(legacyHubActions(root), { force: true });
|
|
737
|
+
// After every write to a managed path has landed (including the legacy renames), so the recorded
|
|
738
|
+
// sha is the file's final state.
|
|
739
|
+
recordManagedWrites(wired);
|
|
725
740
|
// author allowlists for the verified-commits gate (hub + every repo), from the roster emails
|
|
726
741
|
applyActions(authorsActions(root, registry.repos), { force: true });
|
|
727
742
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yadflow",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.16.0",
|
|
4
4
|
"description": "Yadflow — the gated, team, multi-repo SDLC: author → review → build with a PR-driven review gate and a zero-dependency `yad` CLI (setup, gate, commit, open-pr, ship, repo, thread, reconcile). A BMAD module + 38 yad-* skills.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "AbdelRahman Nasr",
|
|
@@ -51,8 +51,11 @@ and GitLab CI. This step is **by hand** in Phase 3 — run the gates with the sk
|
|
|
51
51
|
- Canonical gate sources live in this skill's `templates/` (the source of truth that gets installed
|
|
52
52
|
into each code repo):
|
|
53
53
|
- `templates/checks/{spec-link,contract-check,build-test-lint,verified-commits}.sh`
|
|
54
|
-
- `templates/checks/ledger-guard.sh` → **hub-only** gate, active **only in bridge mode**
|
|
55
|
-
|
|
54
|
+
- `templates/checks/ledger-guard.sh` → **hub-only** gate, active **only in bridge mode** — hub.json
|
|
55
|
+
carries BOTH a `platform` and `bridge_enabled` (or the legacy `bridge`) true, the same predicate
|
|
56
|
+
`isBridge` (`cli/gate.mjs`) applies, so the gate and the CLI can never disagree about who owns the
|
|
57
|
+
ledger (#186). A no-op otherwise, when humans legitimately own it. On review PRs it FAILs any
|
|
58
|
+
commit that touches the
|
|
56
59
|
CI-owned gate ledger (`.sdlc/{state,approvals,comments,hub-prs}.json`, `reviews/*.md`) unless it
|
|
57
60
|
is a **verified gate-bot commit** — bot-authored AND platform-Verified, since author text alone is
|
|
58
61
|
spoofable. `.sdlc/contract-lock.json` is artifact-side and exempt. So is a **new epic's seed**:
|
|
@@ -203,6 +203,18 @@ catches a free-form description that bypassed it:
|
|
|
203
203
|
(`epics/**`, detected from the CI-supplied `--changed <file>` list) **FAILS** — artifact changes
|
|
204
204
|
must go through a `review/EP-*` PR.
|
|
205
205
|
|
|
206
|
+
**GitLab truncates the description this gate reads.** `$CI_MERGE_REQUEST_DESCRIPTION` stops at **2700
|
|
207
|
+
characters**, so a long but perfectly valid MR can lose a required section *before the gate sees it* —
|
|
208
|
+
the author then reads "does not use the template" while looking at a description that visibly contains
|
|
209
|
+
it (#164). GitHub is unaffected (`github.event.pull_request.body` is not truncated). Two mitigations,
|
|
210
|
+
both shipped:
|
|
211
|
+
|
|
212
|
+
- the GitLab MR templates (`yad-pr-template` `templates/gitlab/…` and `templates/hub/gitlab/…`) carry
|
|
213
|
+
the constraint as a comment and keep every required section early, so a truncated body still passes;
|
|
214
|
+
- when a required section is missing **and** the body it read is ≥ 2700 characters, the gate prints a
|
|
215
|
+
`NOTE` naming the truncation and the fix — reorder the required sections above the cutoff and push
|
|
216
|
+
the long narrative to the end. Never delete a section: reordering is always allowed.
|
|
217
|
+
|
|
206
218
|
## 8. Phase 6 — feature-thread gates (`lineage-check.sh`, `epic-open.sh`, `reconcile-debt-check.sh`)
|
|
207
219
|
|
|
208
220
|
After the contract locks and code ships, a change must not mutate a locked artifact — it becomes a new
|
|
@@ -20,16 +20,55 @@
|
|
|
20
20
|
# distinguishes CI-generated commits. A spoofed-author commit that is not Verified is treated as a
|
|
21
21
|
# human edit and rejected.
|
|
22
22
|
#
|
|
23
|
-
# Scope: enforced ONLY when the bridge is enabled
|
|
24
|
-
# (
|
|
23
|
+
# Scope: enforced ONLY when the bridge is enabled — hub.json carries BOTH a `platform` and
|
|
24
|
+
# `bridge_enabled` (or the legacy `bridge`) true, the same predicate `isBridge` (cli/gate.mjs) and
|
|
25
|
+
# `hubActions` (cli/plan.mjs) apply. Without the bridge (file-only / non-bridge, or a platform-less
|
|
26
|
+
# hub) humans legitimately write the ledger locally, so the gate is a no-op.
|
|
25
27
|
#
|
|
26
28
|
# Degradation: a base ref that cannot be resolved FAILs closed; no platform (cannot read the Verified
|
|
27
29
|
# badge) WARNs and waives the signature half — the same stance verified-commits takes.
|
|
28
30
|
set -euo pipefail
|
|
29
31
|
|
|
30
32
|
# ---- bridge gate: only CI-owned ledgers are guarded -------------------------------------------
|
|
33
|
+
# The predicate is BOTH a platform and the bridge flag, exactly as `isBridge` (cli/gate.mjs) and
|
|
34
|
+
# `hubActions` (cli/plan.mjs) define it. Requiring the flag alone put this gate out of step with every
|
|
35
|
+
# other bridge detector (issue #186): a hub carrying `bridge_enabled: true` with no `platform` would
|
|
36
|
+
# have its human ledger commits rejected here while the CLI, reading the same file, called it
|
|
37
|
+
# file-only and kept the LOCAL write path — no CI writer and no permitted human writer, so no gate
|
|
38
|
+
# could advance. Reachable through a stale install (platform set, script wired, platform later
|
|
39
|
+
# nulled), not through `yad setup`, which derives both from one value.
|
|
40
|
+
#
|
|
41
|
+
# `tr -d '\n'` first, like every other hub.json read in these gates: a key and its value may legally
|
|
42
|
+
# sit on separate lines, and a per-line match would MISS the flag and silently no-op a security gate
|
|
43
|
+
# (the fail-open direction of issue #161).
|
|
44
|
+
#
|
|
45
|
+
# Matched at the ROOT LEVEL only. The shared `default_branch` read below is depth-blind, and that is
|
|
46
|
+
# survivable there — a false match yields a bogus branch name and the gate fails loudly. Here it is
|
|
47
|
+
# not: a nested `"bridge": true` (say under `review`) would silently ENABLE this gate on a hub whose
|
|
48
|
+
# `isBridge` is false, recreating the exact no-writer deadlock #186 is about, from the other side. So
|
|
49
|
+
# the nesting is stripped rather than ignored: peel the outermost braces, then delete innermost
|
|
50
|
+
# objects/arrays until none remain, leaving only root-level pairs to match against. Not a JSON parser
|
|
51
|
+
# — a value containing a literal brace would confuse it — but hub.json is machine-written and the
|
|
52
|
+
# failure it prevents is the one that matters.
|
|
53
|
+
#
|
|
54
|
+
# Flattened ONCE into a variable and matched with here-strings, never `tr … | grep -q`: under the
|
|
55
|
+
# `pipefail` set above, `grep -q` exits at the first match and can SIGPIPE `tr`, which would make a
|
|
56
|
+
# MATCHING pipeline report failure. Reading from a here-string has no upstream process to kill.
|
|
31
57
|
HUB="${SDLC_HUB_CONFIG:-.sdlc/hub.json}"
|
|
32
|
-
|
|
58
|
+
HUB_FLAT="$(tr -d '\n' < "$HUB" 2>/dev/null || true)"
|
|
59
|
+
HUB_ROOT="${HUB_FLAT#*\{}"
|
|
60
|
+
HUB_ROOT="${HUB_ROOT%\}*}"
|
|
61
|
+
while :; do
|
|
62
|
+
_stripped="$(sed -E 's/\{[^{}]*\}//g; s/\[[^][]*\]//g' <<< "$HUB_ROOT")"
|
|
63
|
+
[ "$_stripped" = "$HUB_ROOT" ] && break
|
|
64
|
+
HUB_ROOT="$_stripped"
|
|
65
|
+
done
|
|
66
|
+
# One line in, so `sed` emits at most one line out — no `head` needed (which would re-introduce the
|
|
67
|
+
# SIGPIPE-under-pipefail problem this avoids).
|
|
68
|
+
hub_str() { sed -nE "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"([^\"]*)\".*/\1/p" <<< "$HUB_ROOT"; }
|
|
69
|
+
hub_true() { grep -Eq "\"$1\"[[:space:]]*:[[:space:]]*true" <<< "$HUB_ROOT"; }
|
|
70
|
+
|
|
71
|
+
if [ ! -f "$HUB" ] || [ -z "$(hub_str platform)" ] || { ! hub_true bridge_enabled && ! hub_true bridge; }; then
|
|
33
72
|
echo "PASS [ledger-guard]: bridge not enabled — the ledger is locally owned, nothing to guard."
|
|
34
73
|
exit 0
|
|
35
74
|
fi
|
|
@@ -148,7 +148,11 @@ write only `{project-root}/.sdlc/hub.json` (`config.yaml` `hub.config`) — neve
|
|
|
148
148
|
- **`detect-hub`** — detect the hub's own platform and upsert `.sdlc/hub.json`. Run
|
|
149
149
|
`git remote get-url origin` **on the hub** and read the host with the SAME logic Step 1 uses for code
|
|
150
150
|
repos: `github.com` → `github`, GitLab host → `gitlab`, no remote → `platform: null`. Record
|
|
151
|
-
`git_url`, `default_branch`, `detectedAt`, and `bridge_enabled
|
|
151
|
+
`git_url`, `default_branch`, `detectedAt`, and `bridge_enabled` — **`true` only when a platform was
|
|
152
|
+
detected, `false` alongside `platform: null`** (preserve an existing roster). The two travel
|
|
153
|
+
together: bridge mode is a platform AND the flag (`isBridge`, `cli/gate.mjs`), and `yad setup`
|
|
154
|
+
derives both from one value, so writing the flag onto a platform-less hub creates a state no CLI
|
|
155
|
+
path can produce and the gates read differently (#186).
|
|
152
156
|
Auth is the local user's own `gh`/`glab`/git; **store no tokens**. Idempotent — safe to re-run.
|
|
153
157
|
- **`roster`** — set one roster entry mapping a platform `login` → SDLC `name` + `email` + a per-scope
|
|
154
158
|
`roles` map (`roles: { hub: ["owner","reviewer"], <repo>: ["domain-owner", …] }`). Upsert by `login`;
|
|
@@ -18,7 +18,8 @@ login to an SDLC name + role. It is a single object for the hub itself — the s
|
|
|
18
18
|
"platform": "github", // github | gitlab (from the hub's own remote host); null when local-only
|
|
19
19
|
"git_url": "https://github.com/abdelrahmannasr/yadflow.git", // REQUIRED when platform is non-null (scopes auth + opens PRs); yad doctor warns YAD-CFG-005 if absent
|
|
20
20
|
"default_branch": "main",
|
|
21
|
-
"bridge_enabled": true, // open review PRs/MRs on the hub for front-half reviews
|
|
21
|
+
"bridge_enabled": true, // open review PRs/MRs on the hub for front-half reviews; travels WITH platform — bridge mode is both (isBridge), so never true beside platform: null (#186)
|
|
22
|
+
"gate_sync_version": "3.15.3", // OPTIONAL exact pin for the wired gate-sync job; an exact 3.x.y, prereleases included (3.16.0-rc.1) — anything else is skipped. Omitted => the .sdlc/cli-version.json stamp if that qualifies, else floating 3
|
|
22
23
|
"review": { "requireEngagement": false }, // Review Companion: false (soft) counts bare approves but nudges; true counts only verified-engagement approvals
|
|
23
24
|
"detectedAt": "2026-06-08", // last detect-hub run (YYYY-MM-DD)
|
|
24
25
|
"roster": [
|
|
@@ -103,9 +103,12 @@ default branch. (File-only mode keeps `yad gate sync` as the local writer.)
|
|
|
103
103
|
- GitLab → `.gitlab/ci/yad-gate-sync.yml` (from `templates/gitlab/yad-gate-sync.gitlab-ci.yml`)
|
|
104
104
|
- plus the hub-side **verified-commits** gate (`checks/verified-commits.sh` + its workflow/fragment,
|
|
105
105
|
owned by `yad-checks`) so review PRs accept only signed commits from roster-known authors
|
|
106
|
-
- the wired job runs
|
|
107
|
-
|
|
108
|
-
|
|
106
|
+
- the wired job runs an **exact** version, resolved at run time from the repo: the `YAD_VERSION`
|
|
107
|
+
variable, else `hub.json` `gate_sync_version`, else the `.sdlc/cli-version.json` stamp, else the
|
|
108
|
+
floating `3`. It stopped floating on the major because that let a release change a scheduled job's
|
|
109
|
+
behaviour unattended — how #163's churn reached hubs that never opted in. So upgrading is now a
|
|
110
|
+
deliberate act (`yad update`); if the resolved pin predates 3.15.3, upgrade or disable the
|
|
111
|
+
schedule. Never edit a version into the wired file — `yad check --fix` rewrites it byte-for-byte
|
|
109
112
|
from the template. See `references/bridge.md`.
|
|
110
113
|
2. **GitLab only — two one-time steps** (see the fragment's header for the exact recipes):
|
|
111
114
|
- add `include: - local: '.gitlab/ci/yad-gate-sync.yml'` to the root `.gitlab-ci.yml`, or write
|
|
@@ -111,6 +111,9 @@ login and requested too — otherwise an escalated step is structurally unsatisf
|
|
|
111
111
|
reorder commit per epic (the records are the same; only their order changes), then converges
|
|
112
112
|
permanently. On an older yadflow the workaround is to disable the pipeline schedule — merges still
|
|
113
113
|
advance gates via the push path; only the catch-up for squash merges and bare approvals is lost.
|
|
114
|
+
Since the wired job now runs an exact pin rather than floating on the major, that upgrade is a
|
|
115
|
+
deliberate act: `yad update` (which re-stamps `.sdlc/cli-version.json`), or a `gate_sync_version` in
|
|
116
|
+
`hub.json` — see the version table below.
|
|
114
117
|
|
|
115
118
|
## Contract re-lock invalidates prior platform approvals too
|
|
116
119
|
|
|
@@ -182,18 +185,32 @@ other way — up through its first review PR/MR; see "the seed of a new epic" be
|
|
|
182
185
|
**Which yadflow the wired job runs.** Both fragments resolve the version from a `YAD_VERSION` variable
|
|
183
186
|
and fall back to `3`:
|
|
184
187
|
|
|
185
|
-
|
|
|
188
|
+
| # | Source | Set it in |
|
|
186
189
|
|---|---|---|
|
|
187
|
-
|
|
|
188
|
-
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
version.
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
190
|
+
| 1 | `YAD_VERSION` — used **verbatim**, the operator's override | GitHub: Settings → Secrets and variables → Actions → **Variables**. GitLab: Settings → CI/CD → **Variables** (beside `SDLC_GATE_TOKEN`) |
|
|
191
|
+
| 2 | `.sdlc/hub.json` → `gate_sync_version` — this hub's committed pin | edit `hub.json`, commit it |
|
|
192
|
+
| 3 | `.sdlc/cli-version.json` → `version` — the yadflow that last wired the hub | `yad update` re-stamps it |
|
|
193
|
+
| 4 | `3` — floating major, only when nothing above resolves | — |
|
|
194
|
+
|
|
195
|
+
Sources 2 and 3 are **validated** before use: an exact `3.x.y` release token, prereleases included
|
|
196
|
+
(`3.16.0-rc.1` is a legitimate pin; `latest` and a bare `3` are not). A `.sdlc/cli-version.json`
|
|
197
|
+
written by a long-untouched project can still say something like `1.0.2`, a version with no `yad gate ci`
|
|
198
|
+
in it at all, and the value is interpolated into `npx -p "yadflow@$V"` on a runner holding a push token —
|
|
199
|
+
so anything that is not an exact release of this major is skipped, loudly, in favour of the next source.
|
|
200
|
+
`YAD_VERSION` is exempt: it is a human's deliberate act, and it is the only way to cross a major.
|
|
201
|
+
|
|
202
|
+
**Why this is no longer a floating major.** It used to be, on the argument that a published fix should
|
|
203
|
+
reach a scheduled job with nobody in the loop — this page's own issue #163 as the example. The same
|
|
204
|
+
mechanism is how #163's churn *arrived*: the CI fragment ran `yadflow@3`, so 3.13.1 rolled onto every
|
|
205
|
+
wired hub automatically and took the reporting one from 20 to 96 churn commits an hour, with nobody
|
|
206
|
+
deciding to upgrade. Issue #163's fourth suggested fix was to stop that. The trade-off is real and cuts
|
|
207
|
+
both ways — a hub is no longer carried onto a fix for free, so **if the resolved pin is older than
|
|
208
|
+
3.15.3, run `yad update` or disable the schedule** (`yad doctor` flags a stale stamp).
|
|
209
|
+
|
|
210
|
+
The pin is **never stamped into the wired file**: `yad` owns that file and `yad check --fix` rewrites it
|
|
211
|
+
byte-for-byte from the template, so a version edited into it would report `outdated` on every check and
|
|
212
|
+
be reverted on the next sync. That is exactly why the job resolves it from committed files at run time
|
|
213
|
+
instead — the fragment itself stays byte-identical to what ships.
|
|
197
214
|
|
|
198
215
|
**Why no pre-merge write fixes the gate.** Keeping CI off the PR head means an in-flight approval is
|
|
199
216
|
never dismissed by a CI commit, and the PR's required checks never strand on a `[skip ci]` CI commit.
|
|
@@ -204,7 +221,10 @@ commit — the advance plus the `draft → approved` status flip — lands on th
|
|
|
204
221
|
|
|
205
222
|
**The ledger is CI-owned (bridge mode only).** Humans never commit gate-state files: the `ledger-guard`
|
|
206
223
|
check (yad-checks) FAILs any commit on a review PR that touches `.sdlc/{state,approvals,comments,hub-prs}
|
|
207
|
-
.json` or `reviews/*.md` (`.sdlc/contract-lock.json` is artifact-side and allowed).
|
|
224
|
+
.json` or `reviews/*.md` (`.sdlc/contract-lock.json` is artifact-side and allowed). "Bridge mode" there
|
|
225
|
+
means the same thing it means everywhere else — a `platform` **and** the bridge flag, `isBridge`'s
|
|
226
|
+
predicate. The gate used to enable itself on the flag alone, which let a platform-less hub reject the
|
|
227
|
+
human's ledger write while the CLI still expected one (#186). Under Path B **no
|
|
208
228
|
CI commit lands in a review PR at all**, so the only ledger change the guard can see there is a human
|
|
209
229
|
edit — which it rejects, with one carve-out for a new epic's seed (below). (The `verified-commits`
|
|
210
230
|
gate still vets every commit's signature + author;
|
|
@@ -23,7 +23,9 @@
|
|
|
23
23
|
# and nothing is committed — re-visiting a merged review for a week costs one no-op read per pass. That
|
|
24
24
|
# last part is load-bearing: before the #163 fix the re-sync re-appended each step's approvals at the
|
|
25
25
|
# tail, so this job rotated approvals.json and committed the reorder every 15 minutes, forever (issue
|
|
26
|
-
# #163).
|
|
26
|
+
# #163). These jobs run an EXACT version resolved from the repo (see the resolver in each job), so a
|
|
27
|
+
# hub sitting on a pre-#163 yadflow keeps that behaviour until someone upgrades it — if the resolved
|
|
28
|
+
# pin is older than 3.15.3, run `yad update` or disable the schedule.
|
|
27
29
|
# On GitHub a scheduled workflow runs automatically once committed (no setup).
|
|
28
30
|
#
|
|
29
31
|
# CI never approves and never merges — the merge click is the human approval act.
|
|
@@ -45,13 +47,16 @@ permissions:
|
|
|
45
47
|
pull-requests: read # gh pr view + reviewThreads GraphQL
|
|
46
48
|
|
|
47
49
|
env:
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
|
|
50
|
+
# The manual override for which yadflow these jobs run: set a repository (or organization) Actions
|
|
51
|
+
# **variable** named YAD_VERSION to e.g. `3.15.2`, under Settings → Secrets and variables → Actions →
|
|
52
|
+
# Variables. It lives there and not in this file, which `yad` owns and rewrites byte-for-byte from
|
|
53
|
+
# its template on every sync — a version edited in here would be silently reverted.
|
|
54
|
+
#
|
|
55
|
+
# Left EMPTY the jobs resolve an exact pin from the repo itself instead of floating on the major (see
|
|
56
|
+
# the resolver step in each job). Floating meant a release could change what a scheduled job does with
|
|
57
|
+
# nobody deciding to upgrade — the amplification path in issue #163, where 3.13.1 took one hub from 20
|
|
58
|
+
# to 96 churn commits an hour.
|
|
59
|
+
YAD_VERSION: ${{ vars.YAD_VERSION || '' }}
|
|
55
60
|
|
|
56
61
|
jobs:
|
|
57
62
|
mergesync:
|
|
@@ -77,6 +82,39 @@ jobs:
|
|
|
77
82
|
- uses: actions/setup-node@v7
|
|
78
83
|
with:
|
|
79
84
|
node-version: "20"
|
|
85
|
+
- name: Resolve the yadflow pin
|
|
86
|
+
run: |
|
|
87
|
+
# >>> yad-pin
|
|
88
|
+
# Which yadflow this job runs, most explicit source first:
|
|
89
|
+
# 1. $YAD_VERSION — the platform variable, a human's deliberate override, verbatim
|
|
90
|
+
# 2. .sdlc/hub.json — "gate_sync_version", the committed pin for this hub
|
|
91
|
+
# 3. .sdlc/cli-version.json — the version that last wired it (`yad update` re-stamps this)
|
|
92
|
+
# 4. 3 — floating major, only when nothing above resolves
|
|
93
|
+
# Sources 2 and 3 are machine-written files a stale or hand-edited checkout can get wrong, so
|
|
94
|
+
# they are VALIDATED: an exact release token of the major this fragment was written for. That
|
|
95
|
+
# also keeps the value safe to interpolate into `npx -p "yadflow@$V"` — a repo-controlled
|
|
96
|
+
# string reaching a shell on a runner holding `contents: write` is the same class of hole the
|
|
97
|
+
# HEAD_REF fix closed. Anything unparseable is skipped loudly rather than failing the run.
|
|
98
|
+
# Pipes, not bash here-strings, so the block stays byte-identical to the GitLab fragment's
|
|
99
|
+
# (which must run under `sh` when an image has no bash). `sed` drains its input, so nothing
|
|
100
|
+
# SIGPIPEs `tr`.
|
|
101
|
+
yad_pin_read() { # <file> <key>
|
|
102
|
+
[ -f "$1" ] || return 0
|
|
103
|
+
tr -d '\n' < "$1" 2>/dev/null | sed -nE "s/.*\"$2\"[[:space:]]*:[[:space:]]*\"([^\"]*)\".*/\1/p"
|
|
104
|
+
}
|
|
105
|
+
yad_pin_valid() { printf '%s' "${1:-}" | grep -Eq '^3\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$'; }
|
|
106
|
+
YAD_PKG="${YAD_VERSION:-}"
|
|
107
|
+
if [ -z "$YAD_PKG" ]; then
|
|
108
|
+
for _cand in "$(yad_pin_read .sdlc/hub.json gate_sync_version)" "$(yad_pin_read .sdlc/cli-version.json version)"; do
|
|
109
|
+
[ -n "$_cand" ] || continue
|
|
110
|
+
if yad_pin_valid "$_cand"; then YAD_PKG="$_cand"; break; fi
|
|
111
|
+
echo "yad-gate-sync: ignoring pin '${_cand}' — not an exact 3.x release." >&2
|
|
112
|
+
done
|
|
113
|
+
fi
|
|
114
|
+
YAD_PKG="${YAD_PKG:-3}"
|
|
115
|
+
echo "yad-gate-sync: running yadflow@${YAD_PKG}"
|
|
116
|
+
# <<< yad-pin
|
|
117
|
+
echo "YAD_PKG=${YAD_PKG}" >> "$GITHUB_ENV"
|
|
80
118
|
- name: Advance the gate on merge
|
|
81
119
|
# The head ref reaches the shell through `env:`, never through `${{ }}` inside `run:`. A
|
|
82
120
|
# branch name may legally contain `$`, backticks and parentheses, so a PR opened from
|
|
@@ -88,7 +126,7 @@ jobs:
|
|
|
88
126
|
run: |
|
|
89
127
|
git config user.name "yad-gate-sync[bot]"
|
|
90
128
|
git config user.email "yad-gate-sync[bot]@users.noreply.github.com"
|
|
91
|
-
npx -y -p "yadflow@${
|
|
129
|
+
npx -y -p "yadflow@${YAD_PKG}" yad gate ci \
|
|
92
130
|
--branch "$HEAD_REF" \
|
|
93
131
|
--pr "$PR_NUMBER" \
|
|
94
132
|
--merged
|
|
@@ -112,6 +150,39 @@ jobs:
|
|
|
112
150
|
- uses: actions/setup-node@v7
|
|
113
151
|
with:
|
|
114
152
|
node-version: "20"
|
|
153
|
+
- name: Resolve the yadflow pin
|
|
154
|
+
run: |
|
|
155
|
+
# >>> yad-pin
|
|
156
|
+
# Which yadflow this job runs, most explicit source first:
|
|
157
|
+
# 1. $YAD_VERSION — the platform variable, a human's deliberate override, verbatim
|
|
158
|
+
# 2. .sdlc/hub.json — "gate_sync_version", the committed pin for this hub
|
|
159
|
+
# 3. .sdlc/cli-version.json — the version that last wired it (`yad update` re-stamps this)
|
|
160
|
+
# 4. 3 — floating major, only when nothing above resolves
|
|
161
|
+
# Sources 2 and 3 are machine-written files a stale or hand-edited checkout can get wrong, so
|
|
162
|
+
# they are VALIDATED: an exact release token of the major this fragment was written for. That
|
|
163
|
+
# also keeps the value safe to interpolate into `npx -p "yadflow@$V"` — a repo-controlled
|
|
164
|
+
# string reaching a shell on a runner holding `contents: write` is the same class of hole the
|
|
165
|
+
# HEAD_REF fix closed. Anything unparseable is skipped loudly rather than failing the run.
|
|
166
|
+
# Pipes, not bash here-strings, so the block stays byte-identical to the GitLab fragment's
|
|
167
|
+
# (which must run under `sh` when an image has no bash). `sed` drains its input, so nothing
|
|
168
|
+
# SIGPIPEs `tr`.
|
|
169
|
+
yad_pin_read() { # <file> <key>
|
|
170
|
+
[ -f "$1" ] || return 0
|
|
171
|
+
tr -d '\n' < "$1" 2>/dev/null | sed -nE "s/.*\"$2\"[[:space:]]*:[[:space:]]*\"([^\"]*)\".*/\1/p"
|
|
172
|
+
}
|
|
173
|
+
yad_pin_valid() { printf '%s' "${1:-}" | grep -Eq '^3\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$'; }
|
|
174
|
+
YAD_PKG="${YAD_VERSION:-}"
|
|
175
|
+
if [ -z "$YAD_PKG" ]; then
|
|
176
|
+
for _cand in "$(yad_pin_read .sdlc/hub.json gate_sync_version)" "$(yad_pin_read .sdlc/cli-version.json version)"; do
|
|
177
|
+
[ -n "$_cand" ] || continue
|
|
178
|
+
if yad_pin_valid "$_cand"; then YAD_PKG="$_cand"; break; fi
|
|
179
|
+
echo "yad-gate-sync: ignoring pin '${_cand}' — not an exact 3.x release." >&2
|
|
180
|
+
done
|
|
181
|
+
fi
|
|
182
|
+
YAD_PKG="${YAD_PKG:-3}"
|
|
183
|
+
echo "yad-gate-sync: running yadflow@${YAD_PKG}"
|
|
184
|
+
# <<< yad-pin
|
|
185
|
+
echo "YAD_PKG=${YAD_PKG}" >> "$GITHUB_ENV"
|
|
115
186
|
- name: Reconcile recently-merged review PRs
|
|
116
187
|
run: |
|
|
117
188
|
git config user.name "yad-gate-sync[bot]"
|
|
@@ -130,7 +201,7 @@ jobs:
|
|
|
130
201
|
[ -n "$N" ] || continue
|
|
131
202
|
REF="$(gh pr view "$N" --json headRefName --jq '.headRefName' 2>/dev/null)" || rc=1
|
|
132
203
|
case "$REF" in
|
|
133
|
-
review/EP-*) npx -y -p "yadflow@${
|
|
204
|
+
review/EP-*) npx -y -p "yadflow@${YAD_PKG}" yad gate ci --branch "$REF" --pr "$N" --merged || rc=1 ;;
|
|
134
205
|
esac
|
|
135
206
|
done < /tmp/yad-merged-prs
|
|
136
207
|
exit $rc
|
|
@@ -36,12 +36,16 @@
|
|
|
36
36
|
# pipeline or run `yad gate ci --branch <review-branch> --pr <iid> --merged` locally on the default
|
|
37
37
|
# branch (advisory `yad gate sync` is read-only in bridge mode and cannot recover a stuck gate).
|
|
38
38
|
#
|
|
39
|
-
# Which yadflow this job runs:
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
#
|
|
39
|
+
# Which yadflow this job runs: an EXACT version resolved from the repo at run time — see the `yad-pin`
|
|
40
|
+
# block in the script below for the precedence. It no longer floats on the major, because floating let a
|
|
41
|
+
# release change what a scheduled job does with nobody deciding to upgrade (issue #163, where 3.13.1
|
|
42
|
+
# took one hub from 20 to 96 churn commits an hour). The trade-off is deliberate and cuts both ways: a
|
|
43
|
+
# hub is no longer carried onto a fix automatically, so if the resolved pin is older than 3.15.3, run
|
|
44
|
+
# `yad update` or disable this schedule.
|
|
45
|
+
#
|
|
46
|
+
# To override it, set a CI/CD variable YAD_VERSION (e.g. `3.15.2`) in project Settings → CI/CD →
|
|
47
|
+
# Variables — the same place SDLC_GATE_TOKEN lives, and deliberately NOT in this file, which `yad` owns
|
|
48
|
+
# and rewrites byte-for-byte from its template on every sync.
|
|
45
49
|
variables:
|
|
46
50
|
GIT_DEPTH: "0" # full history: gate ci pushes the advance to the default branch
|
|
47
51
|
|
|
@@ -82,6 +86,37 @@ yad-gate-sync:
|
|
|
82
86
|
- export GITLAB_TOKEN="$SDLC_GATE_TOKEN" GITLAB_HOST="$CI_SERVER_URL"
|
|
83
87
|
- git fetch origin "$CI_DEFAULT_BRANCH"
|
|
84
88
|
- git checkout -B "$CI_DEFAULT_BRANCH" "origin/$CI_DEFAULT_BRANCH"
|
|
89
|
+
- |
|
|
90
|
+
# >>> yad-pin
|
|
91
|
+
# Which yadflow this job runs, most explicit source first:
|
|
92
|
+
# 1. $YAD_VERSION — the CI/CD variable, a human's deliberate override, verbatim
|
|
93
|
+
# 2. .sdlc/hub.json — "gate_sync_version", the committed pin for this hub
|
|
94
|
+
# 3. .sdlc/cli-version.json — the version that last wired it (`yad update` re-stamps this)
|
|
95
|
+
# 4. 3 — floating major, only when nothing above resolves
|
|
96
|
+
# Sources 2 and 3 are machine-written files a stale or hand-edited checkout can get wrong, so
|
|
97
|
+
# they are VALIDATED: an exact release token of the major this fragment was written for. That
|
|
98
|
+
# also keeps the value safe to interpolate into `npx -p "yadflow@$V"` — a repo-controlled
|
|
99
|
+
# string reaching a shell on a runner holding a push token. Anything unparseable is skipped
|
|
100
|
+
# loudly rather than failing the run. Resolved once here; GitLab runs every `script` line in
|
|
101
|
+
# the same shell, so YAD_PKG carries into the block below.
|
|
102
|
+
# Pipes, not bash here-strings: a GitLab runner falls back to `sh` when the image has no bash,
|
|
103
|
+
# and `<<<` is a bashism. `sed` drains its input (no early exit, so nothing SIGPIPEs `tr`).
|
|
104
|
+
yad_pin_read() { # <file> <key>
|
|
105
|
+
[ -f "$1" ] || return 0
|
|
106
|
+
tr -d '\n' < "$1" 2>/dev/null | sed -nE "s/.*\"$2\"[[:space:]]*:[[:space:]]*\"([^\"]*)\".*/\1/p"
|
|
107
|
+
}
|
|
108
|
+
yad_pin_valid() { printf '%s' "${1:-}" | grep -Eq '^3\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$'; }
|
|
109
|
+
YAD_PKG="${YAD_VERSION:-}"
|
|
110
|
+
if [ -z "$YAD_PKG" ]; then
|
|
111
|
+
for _cand in "$(yad_pin_read .sdlc/hub.json gate_sync_version)" "$(yad_pin_read .sdlc/cli-version.json version)"; do
|
|
112
|
+
[ -n "$_cand" ] || continue
|
|
113
|
+
if yad_pin_valid "$_cand"; then YAD_PKG="$_cand"; break; fi
|
|
114
|
+
echo "yad-gate-sync: ignoring pin '${_cand}' — not an exact 3.x release." >&2
|
|
115
|
+
done
|
|
116
|
+
fi
|
|
117
|
+
YAD_PKG="${YAD_PKG:-3}"
|
|
118
|
+
echo "yad-gate-sync: running yadflow@${YAD_PKG}"
|
|
119
|
+
# <<< yad-pin
|
|
85
120
|
- |
|
|
86
121
|
rc=0
|
|
87
122
|
if [ "$CI_PIPELINE_SOURCE" = "schedule" ]; then
|
|
@@ -91,7 +126,8 @@ yad-gate-sync:
|
|
|
91
126
|
# unchanged approval record produces an unchanged file and nothing is committed. That last part
|
|
92
127
|
# is load-bearing — before the #163 fix the re-sync re-appended each step's approvals at the
|
|
93
128
|
# tail, so this loop rotated approvals.json and committed the reorder on every pass, forever
|
|
94
|
-
# (issue #163).
|
|
129
|
+
# (issue #163). This job runs an exact pin resolved from the repo, so a hub still sitting on a
|
|
130
|
+
# pre-3.15.3 yadflow keeps that behaviour — upgrade it or disable this schedule.
|
|
95
131
|
# A stuck review MR (a squash merge whose commit dropped the branch name, or a failed merge
|
|
96
132
|
# push) is always RECENT, so sweep a generous recent window and PAGINATE it fully (--paginate)
|
|
97
133
|
# — this bounds cost without the old hard 50-row cap that could permanently strand older MRs.
|
|
@@ -105,7 +141,7 @@ yad-gate-sync:
|
|
|
105
141
|
while read -r IID REF; do
|
|
106
142
|
[ -n "$IID" ] || continue
|
|
107
143
|
git checkout -q -B "$CI_DEFAULT_BRANCH" "origin/$CI_DEFAULT_BRANCH"
|
|
108
|
-
npx -y -p "yadflow@${
|
|
144
|
+
npx -y -p "yadflow@${YAD_PKG}" yad gate ci --branch "$REF" --pr "$IID" --merged || rc=1
|
|
109
145
|
done < /tmp/yad-merged-mrs
|
|
110
146
|
else
|
|
111
147
|
# MERGE push to the default branch whose commit names a review branch: resolve the merged MR's
|
|
@@ -117,7 +153,7 @@ yad-gate-sync:
|
|
|
117
153
|
IID="$(glab api "projects/:id/merge_requests?source_branch=${REVIEW_BRANCH}&state=merged" 2>/dev/null | jq -r '.[0].iid // empty' || true)"
|
|
118
154
|
if [ -n "$IID" ]; then
|
|
119
155
|
# Pass --pr + IID as two distinct args (avoid a fragile, shell-dependent ${IID:+...} split).
|
|
120
|
-
npx -y -p "yadflow@${
|
|
156
|
+
npx -y -p "yadflow@${YAD_PKG}" yad gate ci --branch "$REVIEW_BRANCH" --pr "$IID" --merged || rc=1
|
|
121
157
|
else
|
|
122
158
|
# Without the IID, gate ci cannot re-read approvals — fail visibly (the scheduled sweep
|
|
123
159
|
# retries) rather than running a green no-op that silently leaves the gate unadvanced.
|
|
@@ -26,6 +26,18 @@ touched domain). This step **never auto-advances**; it sets up the template and
|
|
|
26
26
|
`templates/hub/gitlab/merge_request_templates/Default.md` →
|
|
27
27
|
`{project-root}/.gitlab/merge_request_templates/Default.md`. The hub body carries no `Task:` trailer
|
|
28
28
|
(hub PRs change artifacts, not code); its routing helper is `yad-hub-bridge`'s `hub-route.sh`.
|
|
29
|
+
- **GitLab reads a truncated description.** The `pr-template` gate is fed
|
|
30
|
+
`$CI_MERGE_REQUEST_DESCRIPTION`, which GitLab cuts at **2700 characters** — a required section below
|
|
31
|
+
that cutoff is invisible to the gate even though the MR shows it, and the failure reads "does not use
|
|
32
|
+
the template" (#164). Both GitLab templates say so in a comment and keep `## Summary` /
|
|
33
|
+
`## Impact & Risk` / `## Checklist` (hub: `## Artifact under review` / `## Impact & Risk (front-half)`
|
|
34
|
+
/ `## Checklist`) early, so a truncated body still passes. Long narrative goes **after** them.
|
|
35
|
+
Sections may be reordered freely; deleting one fails the gate. GitHub is unaffected.
|
|
36
|
+
- **Installed templates are yad-managed.** `yad update` rewrites them on upgrade. An edit yad can
|
|
37
|
+
prove — the file's sha differs from the one it recorded when it wrote the template — is reported as
|
|
38
|
+
`modified` and left alone; a copy it has no record of is replaced after a `.yad-orig` backup (see
|
|
39
|
+
`docs/CLI.md` → *Managed files*). Either way, put knowledge that must survive an upgrade in an ADR
|
|
40
|
+
under `docs/`, not in the template.
|
|
29
41
|
- The Impact & Risk block reuses the conventions of earlier steps: the `Task: <story>-<task>` trailer
|
|
30
42
|
(`yad-implement`), the contract surface (`yad-architecture` / contract-check), and the
|
|
31
43
|
domain-owner escalation (`yad-review-gate`).
|
|
@@ -45,6 +45,18 @@ if [ -z "$BODY" ] || [ ! -f "$BODY" ]; then
|
|
|
45
45
|
exit 1
|
|
46
46
|
fi
|
|
47
47
|
|
|
48
|
+
# GitLab TRUNCATES $CI_MERGE_REQUEST_DESCRIPTION at 2700 characters, so a long-but-valid description
|
|
49
|
+
# can lose a required section before this gate ever reads it — the author then sees "does not use the
|
|
50
|
+
# template" while looking at an MR that visibly contains it (#164). Measure the RAW body now (the
|
|
51
|
+
# trailer strip below shortens it) so a failure at that boundary can say so. GitHub bodies are not
|
|
52
|
+
# truncated, so the note is advisory and only ever printed alongside a real failure.
|
|
53
|
+
GITLAB_DESC_LIMIT=2700
|
|
54
|
+
# CHARACTERS, not bytes — GitLab counts characters, and a description full of multibyte punctuation
|
|
55
|
+
# (an em-dash costs 3 bytes) would hit 2700 bytes long before it could ever be truncated. `wc -m`
|
|
56
|
+
# would need a UTF-8 locale we cannot assume across CI images, so count UTF-8 code points the
|
|
57
|
+
# locale-independent way: every byte that is NOT a continuation byte (0x80-0xBF) starts one.
|
|
58
|
+
RAW_CHARS="$(LC_ALL=C tr -d '\200-\277' < "$BODY" | wc -c | tr -d '[:space:]')"
|
|
59
|
+
|
|
48
60
|
# The Review Companion injects a `<!-- yad:trailer --> … <!-- /yad:trailer -->` briefing block (and
|
|
49
61
|
# `<!-- yad:noblock -->` notes) into the description. Strip those before the template check so the
|
|
50
62
|
# AI-generated prose can never hide a required section heading or be mistaken for the `Risk level:`
|
|
@@ -110,5 +122,10 @@ else
|
|
|
110
122
|
check_code_body
|
|
111
123
|
fi
|
|
112
124
|
|
|
125
|
+
if [ "$rc" != 0 ] && [ "$RAW_CHARS" -ge "$GITLAB_DESC_LIMIT" ]; then
|
|
126
|
+
echo "NOTE [pr-template]: the description this gate read is ${RAW_CHARS} characters. On GitLab the gate reads \$CI_MERGE_REQUEST_DESCRIPTION, which is TRUNCATED at ${GITLAB_DESC_LIMIT} — a section below that cutoff is invisible here even though the MR shows it."
|
|
127
|
+
echo "NOTE [pr-template]: if the missing section IS in your description, move the required sections above the cutoff (reorder, never delete) and push the long narrative to the end."
|
|
128
|
+
fi
|
|
129
|
+
|
|
113
130
|
[ "$rc" = 0 ] && echo "PASS [pr-template]: body uses the ${KIND} template (required sections present)."
|
|
114
131
|
exit "$rc"
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
<!-- SDLC MR template (Phase 3 build plan §D). One atomic task per MR. -->
|
|
2
|
+
<!-- GITLAB 2700-CHARACTER LIMIT: the yad-pr-template gate reads $CI_MERGE_REQUEST_DESCRIPTION, which
|
|
3
|
+
GitLab truncates at 2700 characters — a heading past the cutoff reads as missing even though you
|
|
4
|
+
can see it here. Keep Summary, Impact & Risk (with its filled risk-level line) and Checklist
|
|
5
|
+
within the first 2700 characters; put long narrative below them. Reorder, never delete. -->
|
|
2
6
|
|
|
3
7
|
## Summary
|
|
4
8
|
<!-- What this MR does, in one or two sentences. -->
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
<!-- This MR is a REVIEW VEHICLE on the product hub, not a code merge. The file gate (yad-review-gate)
|
|
3
3
|
advances the step; do NOT rely on merging this MR to advance. Reviewers approve/comment here, then a
|
|
4
4
|
`yad-review-gate action: sync` pulls that into the file ledger. -->
|
|
5
|
+
<!-- GITLAB 2700-CHARACTER LIMIT: the yad-pr-template gate reads $CI_MERGE_REQUEST_DESCRIPTION, which
|
|
6
|
+
GitLab truncates at 2700 characters — a heading past the cutoff reads as missing. The required
|
|
7
|
+
sections come first for that reason; keep long narrative last. Reorder, never delete. -->
|
|
5
8
|
|
|
6
9
|
## Artifact under review
|
|
7
10
|
- Epic: `EP-<slug>`
|
|
@@ -9,14 +12,20 @@
|
|
|
9
12
|
- Gate step: `<epic-review | architecture-review | ui-design-review | stories-review>`
|
|
10
13
|
- Owner: `<epic.md owner>`
|
|
11
14
|
|
|
12
|
-
## What changed
|
|
13
|
-
<!-- One or two sentences on what this artifact says / what changed since the last review round. -->
|
|
14
|
-
|
|
15
15
|
## Impact & Risk (front-half)
|
|
16
16
|
- **Domains / repos touched:** <epic.repos, e.g. backend, mobile>
|
|
17
17
|
- **Risk tags:** <none | contract | auth | payments> <!-- contract/auth/payments => escalates to domain owners -->
|
|
18
18
|
- **Contract surface:** <n/a | locked @ sha256:…> <!-- architecture only; a re-lock invalidates prior approvals -->
|
|
19
19
|
|
|
20
|
+
## Checklist
|
|
21
|
+
- [ ] `owner` set in the artifact frontmatter (inherited from `epic.md`)
|
|
22
|
+
- [ ] Contract re-locked (`.sdlc/contract-lock.json`) if the surface changed (architecture only)
|
|
23
|
+
- [ ] Risk tags reflect the real surface touched (contract/auth/payments escalate)
|
|
24
|
+
- [ ] No secrets or tokens in the artifact or this description
|
|
25
|
+
|
|
26
|
+
## What changed
|
|
27
|
+
<!-- One or two sentences on what this artifact says / what changed since the last review round. -->
|
|
28
|
+
|
|
20
29
|
## Required approvals (yad-review-gate rule)
|
|
21
30
|
- Base: **owner + 1 reviewer**.
|
|
22
31
|
- Escalated (risk tag set, or a stories MR): **plus one domain-owner per touched repo** — see the
|
|
@@ -28,10 +37,4 @@
|
|
|
28
37
|
- **Comment** to record review comments (synced into `reviews/<artifact>--<date>--comments.md`).
|
|
29
38
|
- **Do NOT merge to advance** — `yad-review-gate action: sync` + `action: advance` move the step.
|
|
30
39
|
|
|
31
|
-
## Checklist
|
|
32
|
-
- [ ] `owner` set in the artifact frontmatter (inherited from `epic.md`)
|
|
33
|
-
- [ ] Contract re-locked (`.sdlc/contract-lock.json`) if the surface changed (architecture only)
|
|
34
|
-
- [ ] Risk tags reflect the real surface touched (contract/auth/payments escalate)
|
|
35
|
-
- [ ] No secrets or tokens in the artifact or this description
|
|
36
|
-
|
|
37
40
|
/assign me
|