yadflow 3.15.1 → 3.15.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/cli/artifact-status.mjs +9 -3
- package/cli/epic-state.mjs +37 -1
- package/cli/gate.mjs +88 -19
- package/cli/lib.mjs +7 -0
- package/package.json +1 -1
- package/skills/yad-analysis/SKILL.md +4 -0
- package/skills/yad-change/SKILL.md +6 -0
- package/skills/yad-checks/SKILL.md +5 -1
- package/skills/yad-checks/templates/checks/ledger-guard.sh +78 -3
- package/skills/yad-checks/templates/github/yad-hub-checks.yml +3 -1
- package/skills/yad-checks/templates/gitlab/yad-hub-checks.gitlab-ci.yml +3 -1
- package/skills/yad-discovery/SKILL.md +4 -0
- package/skills/yad-epic/SKILL.md +4 -0
- package/skills/yad-epic/references/state-schema.md +10 -0
- package/skills/yad-hub-bridge/SKILL.md +13 -3
- package/skills/yad-hub-bridge/references/bridge.md +48 -3
- package/skills/yad-hub-bridge/templates/github/yad-gate-sync.yml +32 -12
- package/skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml +15 -4
- package/skills/yad-review-gate/SKILL.md +3 -1
- package/skills/yad-stub/SKILL.md +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
## [3.15.3](https://github.com/abdelrahmannasr/yadflow/compare/v3.15.2...v3.15.3) (2026-08-11)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **bridge:** pass the PR head ref through env, not into the run script ([306f49f](https://github.com/abdelrahmannasr/yadflow/commit/306f49f569c5398bbfb36ce9aa3fb38d8341336c))
|
|
7
|
+
* **gate:** make the reconcile sweep converge instead of committing forever ([6bcb8fd](https://github.com/abdelrahmannasr/yadflow/commit/6bcb8fd8cd296b669bcf11226013fcd2e107f650)), closes [#163](https://github.com/abdelrahmannasr/yadflow/issues/163)
|
|
8
|
+
* **gate:** stage the merge commit from an allowlist, not the whole epic dir ([8142ddc](https://github.com/abdelrahmannasr/yadflow/commit/8142ddcce0f889505a29558c6664e42548d9d671))
|
|
9
|
+
|
|
10
|
+
## [3.15.2](https://github.com/abdelrahmannasr/yadflow/compare/v3.15.1...v3.15.2) (2026-08-11)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* **checks:** exempt a new epic's ledger seed from ledger-guard ([ba923c2](https://github.com/abdelrahmannasr/yadflow/commit/ba923c2a3823e8bf17b2fc59b41f0160a3a11a19)), closes [#162](https://github.com/abdelrahmannasr/yadflow/issues/162)
|
|
16
|
+
|
|
1
17
|
## [3.15.1](https://github.com/abdelrahmannasr/yadflow/compare/v3.15.0...v3.15.1) (2026-08-11)
|
|
2
18
|
|
|
3
19
|
|
package/cli/artifact-status.mjs
CHANGED
|
@@ -59,9 +59,15 @@ export async function syncStatuses(root, { epic, dryRun = false } = {}) {
|
|
|
59
59
|
const epics = epic
|
|
60
60
|
? [epic]
|
|
61
61
|
: (fs.existsSync(epicsDir) ? fs.readdirSync(epicsDir).filter((e) => fs.statSync(path.join(epicsDir, e)).isDirectory()).sort() : []);
|
|
62
|
-
if (!epics.length) { info('no epics found — nothing to sync'); return { changed: 0 }; }
|
|
62
|
+
if (!epics.length) { info('no epics found — nothing to sync'); return { changed: 0, files: [] }; }
|
|
63
63
|
|
|
64
64
|
let changed = 0;
|
|
65
|
+
// The root-relative paths actually rewritten. `gate ci` stages its merge commit from an explicit
|
|
66
|
+
// allowlist — the ledger, the generated reviews, and exactly these artifacts — so a run on a dirty
|
|
67
|
+
// checkout (the documented manual recovery) can never sweep an unrelated file onto the default
|
|
68
|
+
// branch. Reporting the paths is what lets the caller be that specific. Named `written`, not
|
|
69
|
+
// `files`: the per-epic loop below already binds a local `files` (its candidate list).
|
|
70
|
+
const written = [];
|
|
65
71
|
for (const e of epics) {
|
|
66
72
|
const dir = epicRoot(root, e);
|
|
67
73
|
const state = readJSONStrict(epicFiles(dir).state, null);
|
|
@@ -98,10 +104,10 @@ export async function syncStatuses(root, { epic, dryRun = false } = {}) {
|
|
|
98
104
|
continue;
|
|
99
105
|
}
|
|
100
106
|
const prev = setFrontmatterStatus(file, want);
|
|
101
|
-
if (prev) { ok(`${path.relative(root, file)}: ${prev} → ${want}`); changed++; }
|
|
107
|
+
if (prev) { ok(`${path.relative(root, file)}: ${prev} → ${want}`); changed++; written.push(path.relative(root, file)); }
|
|
102
108
|
}
|
|
103
109
|
}
|
|
104
110
|
if (!changed) info(dryRun ? 'no status changes needed' : 'all artifact statuses already in sync');
|
|
105
111
|
else if (!dryRun) ok(`updated ${changed} artifact status(es)`);
|
|
106
|
-
return { changed };
|
|
112
|
+
return { changed, files: written };
|
|
107
113
|
}
|
package/cli/epic-state.mjs
CHANGED
|
@@ -52,9 +52,45 @@ export function artifactPaths(base) {
|
|
|
52
52
|
return [`${base}.md`];
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
// ---- canonical ledger order ---------------------------------------------------------------------
|
|
56
|
+
// Every epic-ledger upsert in this codebase is drop-and-re-append: the records being refreshed are
|
|
57
|
+
// filtered out of the array and pushed back at the TAIL. That makes the file's bytes depend on WHICH
|
|
58
|
+
// step was synced last, not on what the ledger holds — and the wired sweep drives one `gate ci` per
|
|
59
|
+
// merged PR/MR, so a pass over N merged reviews ROTATES the array:
|
|
60
|
+
//
|
|
61
|
+
// [A,B,C,D,E] -sync A-> [B,C,D,E,A] -sync B-> [C,D,E,A,B] -> … -sync E-> [A,B,C,D,E]
|
|
62
|
+
//
|
|
63
|
+
// Every hop is a non-empty diff, so every hop commits and pushes, and the pass lands back where it
|
|
64
|
+
// started — an unbounded commit loop with zero semantic change (issue #163). Sorting the array on
|
|
65
|
+
// write makes the bytes a pure function of the record SET, so an unchanged re-sync is byte-identical
|
|
66
|
+
// and `gate ci`'s existing "nothing staged -> nothing to commit" guard finally holds.
|
|
67
|
+
//
|
|
68
|
+
// The per-record JSON.stringify tiebreak is what makes the order TOTAL. `Array#sort` is stable, so
|
|
69
|
+
// records tying on the tuple key would keep their (rotating) insertion order — and a manual,
|
|
70
|
+
// skill-written approval can tie with a bridge one on (step, approver, role, domain). The tiebreak is
|
|
71
|
+
// position-independent, so ties resolve by content instead.
|
|
72
|
+
//
|
|
73
|
+
// Compared by CODE UNIT (`<`), not localeCompare: the ledger is written by CI and read/re-written by
|
|
74
|
+
// every teammate's machine, and localeCompare's order depends on the host locale and ICU build. Two
|
|
75
|
+
// machines disagreeing on where one record sorts would reintroduce exactly the churn this prevents.
|
|
76
|
+
const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
77
|
+
const canonical = (list, keyOf) => [...list].sort(
|
|
78
|
+
(x, y) => cmp(keyOf(x), keyOf(y)) || cmp(JSON.stringify(x), JSON.stringify(y)),
|
|
79
|
+
);
|
|
80
|
+
const field = (v) => (v == null ? '' : String(v));
|
|
81
|
+
|
|
82
|
+
export const canonicalApprovals = (approvals = []) => canonical(approvals, (a) =>
|
|
83
|
+
[a.step, a.artifact, a.role, a.domain, a.approver, a.source, a.approvedAt, a.date].map(field).join('|'));
|
|
84
|
+
|
|
85
|
+
export const canonicalComments = (comments = []) => canonical(comments, (cm) =>
|
|
86
|
+
[cm.step, String(cm.round ?? '').padStart(6, '0'), cm.commenter, cm.role, cm.date].map(field).join('|'));
|
|
87
|
+
|
|
88
|
+
export const canonicalHubPrs = (hubPrs = []) => canonical(hubPrs, (p) =>
|
|
89
|
+
[p.artifact, p.step].map(field).join('|'));
|
|
90
|
+
|
|
55
91
|
// Replace-not-append upsert into hub-prs.json, keyed by artifact (one live review PR per artifact).
|
|
56
92
|
export function upsertHubPr(hubPrs = [], rec) {
|
|
57
|
-
return [...hubPrs.filter((p) => p.artifact !== rec.artifact), rec];
|
|
93
|
+
return canonicalHubPrs([...hubPrs.filter((p) => p.artifact !== rec.artifact), rec]);
|
|
58
94
|
}
|
|
59
95
|
|
|
60
96
|
// SHA-256 of the contract surface block (architecture only). Byte-for-byte identical to the recipe
|
package/cli/gate.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
epicRoot, loadLedger, findReviewStep, artifactBase, artifactHash, gatePredicate,
|
|
13
13
|
advanceState, markInReview, isEscalated, parseReviewBranch, artifactFromBase,
|
|
14
14
|
upsertHubPr, stateInvariants, repairState, DISCOVERY_FILES,
|
|
15
|
+
canonicalApprovals, canonicalComments, canonicalHubPrs,
|
|
15
16
|
} from './epic-state.mjs';
|
|
16
17
|
import { hubGit, preflightGuardReadiness, resolveDefaultBranch, guardDefaultBranch } from './hubcommit.mjs';
|
|
17
18
|
import {
|
|
@@ -179,7 +180,11 @@ function upsertBridge(approvals, recs, { stepId, artifact, curHash, today, prNum
|
|
|
179
180
|
...(r.unverified ? { unverified: true } : {}),
|
|
180
181
|
});
|
|
181
182
|
}
|
|
182
|
-
|
|
183
|
+
// Canonical order, not insertion order: this function re-appends at the tail, so without it the
|
|
184
|
+
// bytes depend on which step was synced last and the sweep rotates the file forever (issue #163 —
|
|
185
|
+
// see canonicalApprovals). Sorting here also makes the in-memory before/after comparison below
|
|
186
|
+
// (`approvalsBefore`) mean what it says, so an unchanged re-sync no longer re-stamps lastSyncedAt.
|
|
187
|
+
return canonicalApprovals(kept);
|
|
183
188
|
}
|
|
184
189
|
|
|
185
190
|
// Mutates in place, returns how many it stamped. Backfill `pr` on this step's bridge approvals that
|
|
@@ -216,14 +221,28 @@ function recordComments(comments, { artifact, stepId, today, roster, blocking })
|
|
|
216
221
|
if (!blocking.length) return comments;
|
|
217
222
|
const byName = (login) => (roster.find((r) => r.login === login)?.name) || login || 'reviewer';
|
|
218
223
|
const roleOf = (login) => (roster.find((r) => r.login === login)?.role) || 'reviewer';
|
|
219
|
-
const round = (comments.filter((cm) => cm.step === stepId).reduce((m, cm) => Math.max(m, cm.round || 0), 0)) + 1;
|
|
220
224
|
const counts = new Map();
|
|
221
225
|
for (const t of blocking) counts.set(t.login, (counts.get(t.login) || 0) + 1);
|
|
226
|
+
// A round is a CHANGE in the thread state, not a sync. Allocating max+1 on every call made an
|
|
227
|
+
// unchanged re-read append a whole new record set each pass — and the sweep re-reads a merged review
|
|
228
|
+
// every 15 minutes for a week. A step whose gate never passes (one unresolved thread, or a missing
|
|
229
|
+
// approval) is never `alreadyDone`, so it kept reaching here: ~96 ledger commits a day per stuck
|
|
230
|
+
// review, the same unbounded loop as #163 from the other side. So when the latest recorded round
|
|
231
|
+
// already describes exactly these commenters and counts, REWRITE it in place instead.
|
|
232
|
+
const rounds = comments.filter((cm) => cm.step === stepId);
|
|
233
|
+
const latest = rounds.reduce((m, cm) => Math.max(m, cm.round || 0), 0);
|
|
234
|
+
const prior = rounds.filter((cm) => cm.round === latest);
|
|
235
|
+
const now = new Map([...counts].map(([login, count]) => [byName(login), count]));
|
|
236
|
+
const same = latest > 0 && prior.length === now.size && prior.every((cm) => now.get(cm.commenter) === cm.count);
|
|
237
|
+
const round = same ? latest : latest + 1;
|
|
222
238
|
const kept = comments.filter((cm) => !(cm.step === stepId && cm.round === round));
|
|
223
239
|
for (const [login, count] of counts) {
|
|
224
|
-
|
|
240
|
+
// An unchanged round keeps its original date, so re-syncing it is byte-identical rather than a
|
|
241
|
+
// daily one-line churn (the same rule upsertBridge applies to an unchanged approval).
|
|
242
|
+
const was = prior.find((cm) => cm.commenter === byName(login));
|
|
243
|
+
kept.push({ artifact, step: stepId, commenter: byName(login), role: roleOf(login), round, count, date: (same && was?.date) || today });
|
|
225
244
|
}
|
|
226
|
-
return kept;
|
|
245
|
+
return canonicalComments(kept); // same drop-and-re-append churn as approvals — see canonicalApprovals
|
|
227
246
|
}
|
|
228
247
|
|
|
229
248
|
// ---- actions ------------------------------------------------------------------------------------
|
|
@@ -306,7 +325,19 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, f
|
|
|
306
325
|
if (s) stampLegacyPr(approvals, s.id, p.number);
|
|
307
326
|
}
|
|
308
327
|
const resolved = resolveTargets(hubPrs, { epic, artifact, state, platform, number, finder, branchOf, cwd: root });
|
|
309
|
-
|
|
328
|
+
// Advance in CHAIN order, never in ledger order. `advanceState` opens the step that FOLLOWS the one
|
|
329
|
+
// it closes, so syncing two passing gates out of chain order rewinds the epic: closing
|
|
330
|
+
// architecture-review first (next: ui-design) and epic-review second (next: architecture) reopens the
|
|
331
|
+
// already-done `architecture` author step and points currentStep backward — the YAD-STATE-005 chain
|
|
332
|
+
// inconsistency `yad gate repair` exists to undo. This used to hold only by accident, because
|
|
333
|
+
// hub-prs.json happened to be in insertion order; now that the file is written sorted by artifact
|
|
334
|
+
// (see canonicalHubPrs) the accident is gone, so make the ordering explicit. `gate ci` is unaffected
|
|
335
|
+
// either way — it always names a single artifact.
|
|
336
|
+
const stepIndex = (p) => {
|
|
337
|
+
const s = findReviewStep(state, p.artifact);
|
|
338
|
+
return s ? state.steps.indexOf(s) : Number.MAX_SAFE_INTEGER;
|
|
339
|
+
};
|
|
340
|
+
const targets = [...resolved.targets].sort((a, b) => stepIndex(a) - stepIndex(b));
|
|
310
341
|
if (!targets.length) {
|
|
311
342
|
warn(`no review PR recorded for ${epic}${artifact ? ` / ${artifact}` : ''}${resolved.reason ? ` — ${resolved.reason}` : ''}`);
|
|
312
343
|
hand(`run \`yad gate open ${epic} ${artifact || '<artifact>'}\`, or name the PR: \`yad gate sync ${epic} ${artifact || '<artifact>'} --pr <n>\``);
|
|
@@ -417,11 +448,17 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, f
|
|
|
417
448
|
info('bridge mode: advisory view — CI owns the ledger, nothing written locally');
|
|
418
449
|
return { synced, advanced };
|
|
419
450
|
}
|
|
451
|
+
// Belt-and-braces: the upserts above already return canonical order, but a ledger this run only
|
|
452
|
+
// READ (no matching target, or a pre-canonical file written by an older release) still gets sorted
|
|
453
|
+
// here, so the first sweep after the upgrade converges the file once and never churns it again.
|
|
454
|
+
approvals = canonicalApprovals(approvals);
|
|
455
|
+
comments = canonicalComments(comments);
|
|
456
|
+
hubPrs = canonicalHubPrs(hubPrs);
|
|
420
457
|
writeJSON(ledger.files.approvals, approvals);
|
|
421
458
|
writeJSON(ledger.files.comments, comments);
|
|
422
459
|
writeJSON(ledger.files.hubPrs, hubPrs);
|
|
423
460
|
writeJSON(ledger.files.state, state);
|
|
424
|
-
refreshRoster(epicDir, open, approvals, today);
|
|
461
|
+
refreshRoster(epicDir, open, approvals, today); // the dated side file lists them in the same order
|
|
425
462
|
return { synced, advanced };
|
|
426
463
|
}
|
|
427
464
|
|
|
@@ -482,6 +519,7 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
|
|
|
482
519
|
let synced = 0;
|
|
483
520
|
const touched = new Set();
|
|
484
521
|
const advancedEpics = new Set(); // epics whose step actually passed this run (merge OR a swept merge)
|
|
522
|
+
const statusFiles = new Map(); // epic -> the artifact files syncStatuses rewrote (staging allowlist)
|
|
485
523
|
for (const job of jobs) {
|
|
486
524
|
const epicDir = epicRoot(root, job.epic);
|
|
487
525
|
// Event mode (--branch) targets a single epic: fail loudly. Sweep mode skips the bad epic.
|
|
@@ -508,10 +546,18 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
|
|
|
508
546
|
// Same migration as gateSync, at the one point CI knows the OLD pointer: stamp the approvals it
|
|
509
547
|
// recorded before replacing it, or a re-review on the replacement PR can never be told from a
|
|
510
548
|
// re-read of the old one and stays permanently stale.
|
|
511
|
-
|
|
549
|
+
//
|
|
550
|
+
// MERGE PHASE ONLY. This is the one approvals.json write gateCi itself performs, and pre-merge the
|
|
551
|
+
// run persists nothing and commits nothing — so a stamp there would be a working-tree edit with no
|
|
552
|
+
// purpose, left behind for `ledger-guard` to reject (and a `git checkout` broad enough to undo it
|
|
553
|
+
// would also revert approvals this run never wrote, e.g. a human's uncommitted manual record). The
|
|
554
|
+
// stamp loses nothing by waiting: pre-merge writes nothing, so the OLD pointer is still on disk when
|
|
555
|
+
// the merge event arrives and re-runs this. In sweep mode `job.pr` comes from the ledger itself, so
|
|
556
|
+
// `number === existing.number` and the condition is false regardless.
|
|
557
|
+
if (merged && existing?.number != null && number !== existing.number) {
|
|
512
558
|
const stamped = stampLegacyPr(ledger.approvals, step.id, existing.number);
|
|
513
559
|
if (stamped) {
|
|
514
|
-
writeJSON(ledger.files.approvals, ledger.approvals);
|
|
560
|
+
writeJSON(ledger.files.approvals, canonicalApprovals(ledger.approvals));
|
|
515
561
|
info(`${job.epic}: recorded PR #${existing.number} on ${stamped} approval(s) that predate PR provenance`);
|
|
516
562
|
}
|
|
517
563
|
}
|
|
@@ -535,7 +581,13 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
|
|
|
535
581
|
// reflect it in the artifact frontmatter (draft → approved). Keyed off the advance, not the
|
|
536
582
|
// --merged flag, so the GitLab scheduled sweep also flips status on a merge it catches. Never
|
|
537
583
|
// on a held step: CI must not touch the artifact while the owner is editing it pre-merge.
|
|
538
|
-
if (r.advanced > 0) {
|
|
584
|
+
if (r.advanced > 0) {
|
|
585
|
+
advancedEpics.add(job.epic);
|
|
586
|
+
const st = await syncStatuses(root, { epic: job.epic });
|
|
587
|
+
// Remember exactly which artifacts were rewritten — that, and nothing else, is what the
|
|
588
|
+
// commit below may stage outside the ledger (see the staging allowlist).
|
|
589
|
+
statusFiles.set(job.epic, [...(statusFiles.get(job.epic) || []), ...(st.files || [])]);
|
|
590
|
+
}
|
|
539
591
|
} catch (err) {
|
|
540
592
|
if (branch) throw err; // event mode: one epic — surface the failure
|
|
541
593
|
warn(`${job.epic}: sync failed — ${err.message} — skipping this epic`);
|
|
@@ -554,10 +606,18 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
|
|
|
554
606
|
// unaffected: the merge phase re-reads approvals fresh from the platform (readPr).
|
|
555
607
|
const advancedAny = advancedEpics.size > 0;
|
|
556
608
|
if (!merged && !advancedAny) {
|
|
557
|
-
//
|
|
558
|
-
//
|
|
559
|
-
//
|
|
560
|
-
// so a local `yad gate ci --branch` cannot disturb unrelated files.
|
|
609
|
+
// EVENT mode (--branch) pre-merge is read-only (Path B): the gate was evaluated with a dry sync
|
|
610
|
+
// that persists nothing, so the one working-tree write is the hub-prs.json seed above (which let
|
|
611
|
+
// the dry sync find the PR). Restore exactly that file per epic so the checkout stays clean —
|
|
612
|
+
// never touching anything else, so a local `yad gate ci --branch` cannot disturb unrelated files.
|
|
613
|
+
// The stampLegacyPr backfill is gated on `merged` above precisely so there is no second file to
|
|
614
|
+
// undo: a restore wide enough to cover approvals.json would also revert records this run never
|
|
615
|
+
// wrote — a human's uncommitted manual approval, or an untracked ledger seed `git clean` deletes.
|
|
616
|
+
//
|
|
617
|
+
// SWEEP mode (no --branch) reaches here too, and its sync was NOT dry, so state.json/comments.json
|
|
618
|
+
// and reviews/*.md may be modified and are deliberately left alone: reverting a sync that genuinely
|
|
619
|
+
// ran would discard platform state the run just recorded. A bare `yad gate ci` that advances
|
|
620
|
+
// nothing therefore leaves those files dirty for the operator to inspect and commit (or discard).
|
|
561
621
|
for (const e of touched) {
|
|
562
622
|
const hp = path.join('epics', e, '.sdlc', 'hub-prs.json');
|
|
563
623
|
git('checkout', '-q', '--', hp); // restore it if it was tracked
|
|
@@ -568,13 +628,22 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
|
|
|
568
628
|
}
|
|
569
629
|
const target = defaultBranch; // CI only ever commits the ledger to the default branch
|
|
570
630
|
|
|
571
|
-
// Stage what this merge-phase run owns, per epic
|
|
572
|
-
//
|
|
573
|
-
// -
|
|
574
|
-
//
|
|
631
|
+
// Stage what this merge-phase run owns, per epic, by an EXPLICIT ALLOWLIST — never `git add -A`
|
|
632
|
+
// over the whole epic directory:
|
|
633
|
+
// - always → the ledger (.sdlc) + the generated reviews/ summaries.
|
|
634
|
+
// - advanced → plus exactly the artifact files syncStatuses rewrote (the draft → approved flip).
|
|
635
|
+
// The owner's artifact is otherwise theirs, and is left untouched.
|
|
636
|
+
//
|
|
637
|
+
// `git add -A -- epics/<e>` would also sweep up anything else sitting in that directory. In CI the
|
|
638
|
+
// checkout is fresh, so it is invisible there — but `yad gate ci … --merged` on the default branch
|
|
639
|
+
// is the DOCUMENTED manual recovery for a stuck gate, and a human's checkout is rarely pristine.
|
|
640
|
+
// Half-finished edits to another artifact, or a stray untracked file, would be committed and pushed
|
|
641
|
+
// straight to the default branch under a `chore(gate)` subject with [skip ci] — unreviewed, and
|
|
642
|
+
// contradicting the "CI commits only the ledger" contract every doc in this repo states.
|
|
575
643
|
for (const e of touched) {
|
|
576
|
-
|
|
577
|
-
|
|
644
|
+
git('add', '-A', '--', path.join('epics', e, '.sdlc'));
|
|
645
|
+
git('add', '-A', '--', path.join('epics', e, 'reviews'));
|
|
646
|
+
for (const f of statusFiles.get(e) || []) git('add', '--', f);
|
|
578
647
|
}
|
|
579
648
|
if (git('diff', '--cached', '--quiet').ok) { info('ledger unchanged — nothing to commit'); return { synced }; }
|
|
580
649
|
// [skip ci]: the advance lands on the default branch (no PR trigger) but keeps the marker to guard
|
package/cli/lib.mjs
CHANGED
|
@@ -123,6 +123,13 @@ export function readJSONStrict(p, def = null) {
|
|
|
123
123
|
// file, and a failed rename never leaves a stray .tmp for `git add -A` to pick up.
|
|
124
124
|
export function writeJSON(p, obj) {
|
|
125
125
|
const data = JSON.stringify(obj, null, 2) + '\n';
|
|
126
|
+
// Byte-identical content is not a write. The ledger writers are unconditional — they re-serialize
|
|
127
|
+
// whether or not anything changed — so this keeps an unchanged sync from touching the file at all
|
|
128
|
+
// (no mtime churn, nothing for a watcher or a `git add -A` to notice). A backstop, not the fix: the
|
|
129
|
+
// load-bearing guarantee is that the serialized bytes are canonically ordered (see #163).
|
|
130
|
+
try {
|
|
131
|
+
if (fs.readFileSync(p, 'utf8') === data) return;
|
|
132
|
+
} catch { /* missing or unreadable — fall through and write it */ }
|
|
126
133
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
127
134
|
const tmp = `${p}.${process.pid}.tmp`;
|
|
128
135
|
fs.writeFileSync(tmp, data);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yadflow",
|
|
3
|
-
"version": "3.15.
|
|
3
|
+
"version": "3.15.3",
|
|
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",
|
|
@@ -138,6 +138,10 @@ Notes:
|
|
|
138
138
|
- Also create an empty approvals ledger `{project-root}/epics/EP-<slug>/.sdlc/approvals.json`
|
|
139
139
|
and an empty comments ledger `{project-root}/epics/EP-<slug>/.sdlc/comments.json`, each containing
|
|
140
140
|
`[]`, and the `reviews/` directory.
|
|
141
|
+
- Commit the seed on the `analysis/EP-<slug>` branch, and cut `review/EP-<slug>/analysis` from it so the
|
|
142
|
+
epic's **first** review PR/MR carries the ledger to the default branch. In bridge mode `ledger-guard`
|
|
143
|
+
exempts a new epic's ledger (creation, not mutation, #162); every later change to it is CI's. See
|
|
144
|
+
`../yad-epic/references/state-schema.md`, "Authoring branches".
|
|
141
145
|
|
|
142
146
|
### Step 7 — Stop at the gate (do NOT advance)
|
|
143
147
|
Report: epic ID, the path to `analysis.md`, and that the next action is **review** via
|
|
@@ -134,6 +134,12 @@ Seed `.sdlc/approvals.json` with one **provenance** record per inherited gate (N
|
|
|
134
134
|
`{ "artifact": "<art>", "step": "<…-review>", "status": "inherited", "from": "<epic>", "boundHash": "<hash>", "date": "<today>" }`.
|
|
135
135
|
Seed `.sdlc/comments.json` = `[]` and create `reviews/`.
|
|
136
136
|
|
|
137
|
+
Commit the seed on the `change/EP-<slug>` branch. It reaches the hub's default branch through this
|
|
138
|
+
change-epic's **first** review PR/MR — cut the `review/EP-<slug>/<artifact>` branch from `change/…` so
|
|
139
|
+
it carries the seed. In bridge mode `ledger-guard` exempts a new epic's ledger (creation, not mutation,
|
|
140
|
+
#162), so no direct push to a protected default branch is needed; every later change to that ledger is
|
|
141
|
+
CI's. See `../yad-epic/references/state-schema.md`, "Authoring branches".
|
|
142
|
+
|
|
137
143
|
When `architecture` is **inherited**, materialize the **pointer-lock** `.sdlc/contract-lock.json`:
|
|
138
144
|
`{ "artifact": "contract.md", "hash": "<parent surface hash, verbatim>", "lockedAt": "<today>", "inheritedFrom": "<epic>", "ref": "../../<epic>/.sdlc/contract-lock.json" }`.
|
|
139
145
|
There is no `contract.md` in the change-epic, so the surface cannot drift, and `contract-check` passes
|
|
@@ -55,7 +55,11 @@ and GitLab CI. This step is **by hand** in Phase 3 — run the gates with the sk
|
|
|
55
55
|
when humans legitimately own the ledger). On review PRs it FAILs any commit that touches the
|
|
56
56
|
CI-owned gate ledger (`.sdlc/{state,approvals,comments,hub-prs}.json`, `reviews/*.md`) unless it
|
|
57
57
|
is a **verified gate-bot commit** — bot-authored AND platform-Verified, since author text alone is
|
|
58
|
-
spoofable. `.sdlc/contract-lock.json` is artifact-side and exempt.
|
|
58
|
+
spoofable. `.sdlc/contract-lock.json` is artifact-side and exempt. So is a **new epic's seed**:
|
|
59
|
+
no CI path can create a ledger (`gate ci` only *advances* an existing chain, at merge, on the
|
|
60
|
+
default branch), so an epic whose `.sdlc/state.json` is absent from the base ref may be created by
|
|
61
|
+
a human on its first review PR/MR — **creation, not mutation** (#162). Once the ledger is on the
|
|
62
|
+
default branch the guard is absolute again. Runs in `yad-hub-checks`
|
|
59
63
|
alongside `verified-commits` (which waives the allowlist for the bot but still requires its
|
|
60
64
|
signature). See `yad-hub-bridge`.
|
|
61
65
|
- `templates/github/yad-verified-commits.yml` + `templates/gitlab/yad-verified-commits.gitlab-ci.yml`
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
# NOT protected:
|
|
12
12
|
# epics/*/.sdlc/contract-lock.json — artifact-side: the architect locks the contract surface in
|
|
13
13
|
# `gate open`, so a human legitimately commits it alongside the architecture artifact.
|
|
14
|
+
# A brand-new epic's ledger — CREATION is not mutation (#162). No CI path can seed one, so the
|
|
15
|
+
# seed rides the first review PR/MR; see the carve-out below. Mutation stays bot-only.
|
|
14
16
|
#
|
|
15
17
|
# A "bot commit" must be BOTH authored by the gate bot (name/email contains yad-gate-sync) AND
|
|
16
18
|
# platform-VERIFIED — author/committer text alone is user-controlled and spoofable, so the platform
|
|
@@ -114,19 +116,81 @@ trusted_bot() {
|
|
|
114
116
|
signature_verified "$1"
|
|
115
117
|
}
|
|
116
118
|
|
|
119
|
+
# ---- seeding carve-out: creation is not mutation (#162) ---------------------------------------
|
|
120
|
+
# A brand-new epic's ledger has no CI author. `gate ci` only ADVANCES an existing chain — it bails on
|
|
121
|
+
# a missing state.json ("the review branch is cut from the default branch, so it should carry it") and
|
|
122
|
+
# writes only at merge, on the default branch — and the engine itself reads a missing state.json as
|
|
123
|
+
# "not seeded yet". `gate open` writes nothing in bridge mode, and `checkpoint` stages back-half
|
|
124
|
+
# ledgers only. So the seed the authoring skills write (yad-epic / yad-change / yad-analysis /
|
|
125
|
+
# yad-discovery / yad-stub) can reach the trunk ONLY through the first review PR/MR — the one place
|
|
126
|
+
# this gate runs. Guarding it there makes the documented flow unshippable on a protected trunk, so an
|
|
127
|
+
# epic whose ledger is absent from the BASE ref is exempt: that ledger is human-authored by
|
|
128
|
+
# construction and the reviewer sees the whole of it in the diff. The moment its state.json is on the
|
|
129
|
+
# base ref the guard is absolute again.
|
|
130
|
+
#
|
|
131
|
+
# Anchored on state.json — the ledger root, the same "is this epic seeded?" question the engine asks —
|
|
132
|
+
# NOT on the individual file: adding hub-prs.json to an epic that IS on the base ref still FAILs.
|
|
133
|
+
# Anchored on the BASE ref, not the parent commit, so delete-then-re-add cannot reset the exemption
|
|
134
|
+
# (the deletion is itself a guarded change).
|
|
135
|
+
#
|
|
136
|
+
# The slug match is CASE-FOLDED on purpose. Git paths are byte-exact, but macOS/Windows checkouts are
|
|
137
|
+
# not: seeding `epics/ep-x/.sdlc/state.json` beside an on-base `epics/EP-X/` would probe as a brand-new
|
|
138
|
+
# epic, pass, and then land ON TOP of the real ledger in every case-insensitive clone — a mutation
|
|
139
|
+
# laundered as a creation. So the base's seeded slugs are read once (one ls-tree, not one probe per
|
|
140
|
+
# path) and compared folded.
|
|
141
|
+
#
|
|
142
|
+
# Slugs are held in ARRAYS read from NUL-delimited git output, never in a space- or newline-delimited
|
|
143
|
+
# string: git permits a newline inside a path, and a `EP-<newline>x` slug split across two records
|
|
144
|
+
# would drop the real epic out of the on-base list and let a mutation through as a "creation". Both
|
|
145
|
+
# arrays carry one empty sentinel element so `"${a[@]}"` is safe under `set -u` on bash 3.2 (macOS),
|
|
146
|
+
# which has no associative arrays; a slug is never empty, so the sentinel can never match.
|
|
147
|
+
base_slugs=("") # every epic with a ledger on BASE, lowercased
|
|
148
|
+
base_slugs_loaded=0
|
|
149
|
+
noted_slugs=("") # slugs already announced, so the note prints once each
|
|
150
|
+
fold() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; }
|
|
151
|
+
in_list() { # $1 = needle, $2… = haystack
|
|
152
|
+
_needle="$1"; shift
|
|
153
|
+
for _item in "$@"; do [ "$_item" = "$_needle" ] && return 0; done
|
|
154
|
+
return 1
|
|
155
|
+
}
|
|
156
|
+
is_seeding() { # $1 = epic slug; 0 when that epic has no ledger on BASE
|
|
157
|
+
if [ "$base_slugs_loaded" = 0 ]; then
|
|
158
|
+
while IFS= read -r -d '' _p; do
|
|
159
|
+
case "$_p" in
|
|
160
|
+
epics/*/.sdlc/state.json)
|
|
161
|
+
_s="${_p#epics/}"; _s="${_s%%/*}"
|
|
162
|
+
base_slugs[${#base_slugs[@]}]="$(fold "$_s")"
|
|
163
|
+
;;
|
|
164
|
+
esac
|
|
165
|
+
done < <(git -c core.quotePath=false ls-tree -r --name-only -z "${BASE}" -- epics 2>/dev/null || true)
|
|
166
|
+
base_slugs_loaded=1
|
|
167
|
+
fi
|
|
168
|
+
_f="$(fold "$1")"
|
|
169
|
+
in_list "$_f" "${base_slugs[@]}" && return 1
|
|
170
|
+
in_list "$_f" "${noted_slugs[@]}" && return 0
|
|
171
|
+
noted_slugs[${#noted_slugs[@]}]="$_f"
|
|
172
|
+
echo "note [ledger-guard]: epics/$1 has no ledger on ${BASE} — new epic, its seed is exempt (creation, not mutation)."
|
|
173
|
+
return 0
|
|
174
|
+
}
|
|
175
|
+
|
|
117
176
|
violations=0
|
|
118
177
|
for sha in $commits; do
|
|
119
178
|
touches_ledger=0
|
|
120
|
-
|
|
179
|
+
# quotePath=false + -z on both git reads: a path git chose to escape ("epics/EP-caf\303\251/…") and a
|
|
180
|
+
# path holding a newline both match no arm below, so the gate would fail OPEN on exactly the paths it
|
|
181
|
+
# is meant to guard. NUL is the only byte a git path cannot contain.
|
|
182
|
+
while IFS= read -r -d '' f; do
|
|
121
183
|
[ -n "$f" ] || continue
|
|
122
184
|
case "$f" in
|
|
123
185
|
epics/*/.sdlc/contract-lock.json) ;; # artifact-side — allowed
|
|
124
186
|
epics/*/.sdlc/state.json|epics/*/.sdlc/approvals.json|epics/*/.sdlc/comments.json|epics/*/.sdlc/hub-prs.json|epics/*/reviews/*.md)
|
|
187
|
+
_slug="${f#epics/}"; _slug="${_slug%%/*}"
|
|
188
|
+
is_seeding "$_slug" && continue # a new epic's seed — not a mutation of a CI-owned ledger
|
|
125
189
|
touches_ledger=1
|
|
126
190
|
echo " ${sha} (author $(git show -s --format='%an' "$sha")) → $f"
|
|
127
191
|
;;
|
|
128
192
|
esac
|
|
129
|
-
done < <(git diff-tree --no-commit-id --name-only -r "$sha")
|
|
193
|
+
done < <(git -c core.quotePath=false diff-tree --no-commit-id --name-only -r -z "$sha")
|
|
130
194
|
if [ "$touches_ledger" = 1 ] && ! trusted_bot "$sha"; then
|
|
131
195
|
violations=$((violations + 1))
|
|
132
196
|
fi
|
|
@@ -134,7 +198,18 @@ done
|
|
|
134
198
|
|
|
135
199
|
if [ "$violations" -gt 0 ]; then
|
|
136
200
|
echo "FAIL [ledger-guard]: ${violations} commit(s) change CI-owned gate files without a verified gate-bot signature. The ledger is CI-owned — let CI sync the gate; do not commit .sdlc/*.json or reviews/*.md yourself."
|
|
201
|
+
# An epic's seed is exempt only while its ledger is off the base ref. Once the first review PR merges
|
|
202
|
+
# (squashed or rebased, so the SHAs differ), those same seed commits still sitting on a sibling
|
|
203
|
+
# authoring branch read as mutations — the author did nothing wrong and the remedy is a rebase, so
|
|
204
|
+
# name it rather than leaving them with "do not commit the ledger yourself".
|
|
205
|
+
echo "hint [ledger-guard]: if these are seed commits from an already-merged review PR, rebase this branch onto the updated ${BASE} — the seed carve-out applies only until the ledger is on the base ref."
|
|
137
206
|
exit 1
|
|
138
207
|
fi
|
|
139
|
-
|
|
208
|
+
# Say WHICH rule passed the range: claiming "every change is a bot commit" would be false on a range
|
|
209
|
+
# whose only ledger change was a human seed the carve-out let through.
|
|
210
|
+
if [ "${#noted_slugs[@]}" -gt 1 ]; then # >1: the sentinel element is always there
|
|
211
|
+
echo "PASS [ledger-guard]: every CI-owned gate change in ${RANGE} is a verified gate-bot commit or a new epic's seed."
|
|
212
|
+
else
|
|
213
|
+
echo "PASS [ledger-guard]: every CI-owned gate change in ${RANGE} is a verified gate-bot commit."
|
|
214
|
+
fi
|
|
140
215
|
exit 0
|
|
@@ -55,7 +55,9 @@ jobs:
|
|
|
55
55
|
body="$(mktemp)"; printf '%s' "$PR_BODY" > "$body"
|
|
56
56
|
bash checks/pr-template.sh --profile hub --head "$PR_HEAD" --changed "$changed" "$body"
|
|
57
57
|
|
|
58
|
-
# The gate ledger is CI-owned: reject non-bot commits to .sdlc
|
|
58
|
+
# The gate ledger is CI-owned: reject non-bot commits to .sdlc/{state,approvals,comments,hub-prs}.json
|
|
59
|
+
# or reviews/*.md (.sdlc/contract-lock.json is artifact-side and allowed). The one other exception is a
|
|
60
|
+
# brand-new epic's seed, which no CI path can write, so it rides this first review PR (#162).
|
|
59
61
|
ledger-guard:
|
|
60
62
|
runs-on: ubuntu-latest
|
|
61
63
|
if: github.event.action != 'edited'
|
|
@@ -44,7 +44,9 @@ yad-hub-pr-template:
|
|
|
44
44
|
- body="$(mktemp)"; printf '%s' "$CI_MERGE_REQUEST_DESCRIPTION" > "$body"
|
|
45
45
|
- bash checks/pr-template.sh --profile hub --head "$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME" --changed "$changed" "$body"
|
|
46
46
|
|
|
47
|
-
# The gate ledger is CI-owned: reject non-bot commits to .sdlc
|
|
47
|
+
# The gate ledger is CI-owned: reject non-bot commits to .sdlc/{state,approvals,comments,hub-prs}.json
|
|
48
|
+
# or reviews/*.md (.sdlc/contract-lock.json is artifact-side and allowed). The one other exception is a
|
|
49
|
+
# brand-new epic's seed, which no CI path can write, so it rides this first review MR (#162).
|
|
48
50
|
yad-hub-ledger-guard:
|
|
49
51
|
extends: .yad_hub_mr_only
|
|
50
52
|
needs: []
|
|
@@ -112,6 +112,10 @@ Notes:
|
|
|
112
112
|
never escalates to domain owners (no contract surface is touched yet).
|
|
113
113
|
- Also create an empty approvals ledger `.sdlc/approvals.json` and comments ledger
|
|
114
114
|
`.sdlc/comments.json`, each containing `[]`, and the `reviews/` directory.
|
|
115
|
+
- Commit the seed on the `discovery/EP-discovery` branch, and cut `review/EP-discovery/discovery` from
|
|
116
|
+
it so the **first** review PR/MR carries the ledger to the default branch. In bridge mode
|
|
117
|
+
`ledger-guard` exempts a new epic's ledger (creation, not mutation, #162); every later change to it
|
|
118
|
+
is CI's. See `../yad-epic/references/state-schema.md`, "Authoring branches".
|
|
115
119
|
|
|
116
120
|
### Step 6 — Stop at the gate (do NOT advance)
|
|
117
121
|
Report: the path to the discovery set, and that the next action is **review** via `yad-review-gate`
|
package/skills/yad-epic/SKILL.md
CHANGED
|
@@ -152,6 +152,10 @@ Notes:
|
|
|
152
152
|
- `test-cases` / `test-cases-review` are a **parallel, non-blocking track**: they seed `blocked` and open
|
|
153
153
|
when `stories-review` passes — at which point the epic is already `ready-for-build`, so the build half
|
|
154
154
|
runs alongside the tester. They never gate `ready-for-build` (see `references/state-schema.md`).
|
|
155
|
+
- Commit the seed on this step's authoring branch. It reaches the hub's default branch through the
|
|
156
|
+
epic's **first** review PR/MR — cut `review/EP-<slug>/epic` from the authoring branch so it carries
|
|
157
|
+
the seed. In bridge mode `ledger-guard` exempts a new epic's ledger (creation, not mutation, #162);
|
|
158
|
+
every later change to it is CI's. See `references/state-schema.md`, "Authoring branches".
|
|
155
159
|
- Also create an empty approvals ledger `{project-root}/epics/EP-<slug>/.sdlc/approvals.json`
|
|
156
160
|
and an empty comments ledger `{project-root}/epics/EP-<slug>/.sdlc/comments.json`, each containing
|
|
157
161
|
`[]`, and the `reviews/` directory. (`comments.json` is the machine-readable counterpart to the
|
|
@@ -94,6 +94,16 @@ The shared procedure (run once the `EP-<slug>` is known):
|
|
|
94
94
|
hub's default branch (`git checkout -b <step>/EP-<slug>`).
|
|
95
95
|
3. Author and commit the step's artifact(s) on that branch. The bridge's `review/…` branch is created
|
|
96
96
|
separately at review time and is untouched by this step.
|
|
97
|
+
|
|
98
|
+
**How the seed reaches the default branch.** The `.sdlc/` ledger is seeded once, by hand, on the
|
|
99
|
+
**entry** step's authoring branch (`analysis/…`, `epic/…`, `change/…`, `discovery/…`) — no CLI or CI
|
|
100
|
+
path creates one (`yad gate ci` only *advances* an existing chain, at merge, on the default branch).
|
|
101
|
+
So for the **first** gate of an epic, cut `review/EP-<slug>/<artifact-base>` from that authoring
|
|
102
|
+
branch: the review PR/MR then carries the seed alongside the artifact, and the ledger lands on the
|
|
103
|
+
default branch when it merges. In bridge mode `ledger-guard` exempts exactly this case — **creation,
|
|
104
|
+
not mutation** (#162) — so no direct push to a protected default branch is needed. For every **later**
|
|
105
|
+
gate the ledger is already on the default branch: cut the review branch from there, commit the
|
|
106
|
+
artifact only, and leave `.sdlc/{state,approvals,comments,hub-prs}.json` and `reviews/*.md` to CI.
|
|
97
107
|
| `type` | `author` \| `review+approve` | Authoring step or a team review gate. |
|
|
98
108
|
| `artifact` | filename or folder | The file/folder this step produces or gates. |
|
|
99
109
|
| `assistance` | `none` \| `review` \| `heavy` | Dial 1 — how much AI helps (build plan §2). |
|
|
@@ -52,7 +52,10 @@ each required domain-owner to a platform `login` via the roster (a roster `name`
|
|
|
52
52
|
### Step 2 — `open` (create the review PR/MR)
|
|
53
53
|
1. From the hub default branch, create `review/EP-<slug>/<artifact-base>` and ensure the artifact file
|
|
54
54
|
(and, for architecture, `contract.md` + `.sdlc/contract-lock.json`) is committed on it. Push as the
|
|
55
|
-
local user.
|
|
55
|
+
local user. **First gate of a new epic:** cut the review branch from the **authoring** branch
|
|
56
|
+
(`epic/…`, `change/…`, `analysis/…`, `discovery/…`) instead, so it carries the `.sdlc/` **seed** —
|
|
57
|
+
that PR/MR is the only way a new epic's ledger reaches the default branch, and `ledger-guard`
|
|
58
|
+
exempts it (see step 4).
|
|
56
59
|
2. Open the PR/MR with `gh`/`glab` using the hub body template (`yad-pr-template` `templates/hub/…`),
|
|
57
60
|
filled with the epic, artifact, gate step, owner, `epic.repos`, and the step's risk tags.
|
|
58
61
|
3. **Request the required reviewers** (their logins) and add a `domain:<repo>` label per touched repo so
|
|
@@ -68,7 +71,9 @@ each required domain-owner to a platform `login` via the roster (a roster `name`
|
|
|
68
71
|
A human commit touching the gate-state files (`.sdlc/{state,approvals,comments,hub-prs}.json` or
|
|
69
72
|
`reviews/*.md`; `.sdlc/contract-lock.json` is artifact-side and allowed) on a review PR is rejected
|
|
70
73
|
by the `ledger-guard` check. (The `yad gate open` CLI behaves the same: in bridge mode it opens the
|
|
71
|
-
PR only and writes no ledger.)
|
|
74
|
+
PR only and writes no ledger.) The **one** exception is a brand-new epic's **seed** — no CI path can
|
|
75
|
+
create a ledger, so an epic whose `.sdlc/state.json` is absent from the base ref may be created by a
|
|
76
|
+
human on this first PR/MR (#162). Every later change to it is CI's alone.
|
|
72
77
|
5. Report the PR/MR URL and the required reviewers. **Do not** record approvals or advance — reviewers
|
|
73
78
|
act on the platform; CI (`yad gate ci`) reconciles it onto the default branch at merge.
|
|
74
79
|
|
|
@@ -98,6 +103,10 @@ default branch. (File-only mode keeps `yad gate sync` as the local writer.)
|
|
|
98
103
|
- GitLab → `.gitlab/ci/yad-gate-sync.yml` (from `templates/gitlab/yad-gate-sync.gitlab-ci.yml`)
|
|
99
104
|
- plus the hub-side **verified-commits** gate (`checks/verified-commits.sh` + its workflow/fragment,
|
|
100
105
|
owned by `yad-checks`) so review PRs accept only signed commits from roster-known authors
|
|
106
|
+
- the wired job runs `yadflow@${YAD_VERSION}`, defaulting to the floating major `3` so a published
|
|
107
|
+
fix reaches the schedule on its own. To pin an exact version, set a `YAD_VERSION` CI/CD (GitLab) or
|
|
108
|
+
Actions (GitHub) **variable** — never edit it into the wired file, which `yad check --fix` rewrites
|
|
109
|
+
from the template. See `references/bridge.md`.
|
|
101
110
|
2. **GitLab only — two one-time steps** (see the fragment's header for the exact recipes):
|
|
102
111
|
- add `include: - local: '.gitlab/ci/yad-gate-sync.yml'` to the root `.gitlab-ci.yml`, or write
|
|
103
112
|
`templates/gitlab/gitlab-ci.include-root.yml` as the root when none exists;
|
|
@@ -143,7 +152,8 @@ default branch. (File-only mode keeps `yad gate sync` as the local writer.)
|
|
|
143
152
|
- **Protect the hub default branch.** Require that `epics/**` artifacts change only through a review
|
|
144
153
|
PR/MR (branch protection). This keeps revoke-on-change sound: it removes the only window where a
|
|
145
154
|
delayed reconcile could advance on an out-of-band post-merge artifact change (see `references/bridge.md`,
|
|
146
|
-
"Known limitation").
|
|
155
|
+
"Known limitation"). Safe to require: a new epic's `.sdlc/` seed rides its first review PR/MR, so
|
|
156
|
+
nobody needs a direct push to the default branch to start an epic (#162).
|
|
147
157
|
- **Degrade gracefully.** No platform / disabled bridge / no CLI → the gate runs file-only with no error.
|
|
148
158
|
|
|
149
159
|
## Reference
|
|
@@ -95,9 +95,23 @@ login and requested too — otherwise an escalated step is structurally unsatisf
|
|
|
95
95
|
- Update the step's `hub-prs.json` `lastSyncedAt` when the sync **learned something** — every sync on
|
|
96
96
|
an open step, and on a closed one only when the approval record actually changed (a re-opened review
|
|
97
97
|
that was re-approved). An identical re-sync leaves it alone, so the ledger does not churn.
|
|
98
|
+
- **Write the ledgers in a canonical order.** `approvals.json`, `comments.json` and `hub-prs.json` are
|
|
99
|
+
sorted on write, so the bytes are a function of the record *set* and never of which step was synced
|
|
100
|
+
last. Without this the upsert above — which drops the records it refreshes and re-appends them at the
|
|
101
|
+
tail — makes the file depend on sync order, and the wired sweep (one `gate ci --branch <ref>
|
|
102
|
+
--merged` per merged PR/MR, every 15 minutes) walks a rotation:
|
|
103
|
+
`[A,B,C] → sync A → [B,C,A] → sync B → [C,A,B] → sync C → [A,B,C]`. Every hop is a non-empty diff, so
|
|
104
|
+
every hop commits and pushes, and the pass ends where it began — an unbounded commit loop with zero
|
|
105
|
+
semantic change. That is issue #163: ~1,800 bot commits/day on the hub that reported it. Sorting is
|
|
106
|
+
what makes the "nothing staged → nothing to commit" guard in `gate ci` actually hold.
|
|
98
107
|
- Running `sync` twice with no platform change is a no-op on the ledger — byte-identical, including
|
|
99
108
|
`comments.json` and the dated `reviews/*.md` side files.
|
|
100
109
|
|
|
110
|
+
**Upgrading past #163.** The first sweep on a yadflow carrying the fix writes one canonicalizing
|
|
111
|
+
reorder commit per epic (the records are the same; only their order changes), then converges
|
|
112
|
+
permanently. On an older yadflow the workaround is to disable the pipeline schedule — merges still
|
|
113
|
+
advance gates via the push path; only the catch-up for squash merges and bare approvals is lost.
|
|
114
|
+
|
|
101
115
|
## Contract re-lock invalidates prior platform approvals too
|
|
102
116
|
|
|
103
117
|
For the **architecture+contract** review, the gate already drops approvals when the contract-surface hash
|
|
@@ -155,7 +169,9 @@ During review CI writes nothing: the platform PR/MR is the source of truth (nati
|
|
|
155
169
|
threads). The CLI is self-sufficient at merge: it derives the epic + artifact from the
|
|
156
170
|
`review/EP-<slug>/<artifact-base>` head branch, takes the PR/MR number from the event (GitHub) or
|
|
157
171
|
resolves it from the platform (GitLab), upserts the `hub-prs.json` entry itself, and **re-reads
|
|
158
|
-
approvals fresh from the platform** — so no ledger needs to be pre-seeded on the branch.
|
|
172
|
+
approvals fresh from the platform** — so no ledger needs to be pre-seeded on the branch. (It only
|
|
173
|
+
*advances* a chain, though: it cannot **create** one. A brand-new epic's seed therefore travels the
|
|
174
|
+
other way — up through its first review PR/MR; see "the seed of a new epic" below.)
|
|
159
175
|
|
|
160
176
|
| Platform event | Phase | CI action |
|
|
161
177
|
|---|---|---|
|
|
@@ -163,6 +179,22 @@ approvals fresh from the platform** — so no ledger needs to be pre-seeded on t
|
|
|
163
179
|
| PR/MR closed **and merged** (the human act) | merge | `gate ci --branch <head> --pr <n> --merged` → re-read approvals, advance the step + flip the artifact `status:` **on the default branch** |
|
|
164
180
|
| Schedule (`*/15`) | reconcile | Safety net: enumerate recently-**merged** `review/EP-*` PRs/MRs via the API and advance any not yet `done` (idempotent). Recovers a merge whose merge-time run failed transiently, and on GitLab also picks up a squash merge whose commit dropped the branch name (and a bare approval — GitLab fires no pipeline on one). **GitHub:** a scheduled workflow, automatic once committed. **GitLab:** a pipeline schedule with `SDLC_GATE_SYNC=true` (one-time setup) |
|
|
165
181
|
|
|
182
|
+
**Which yadflow the wired job runs.** Both fragments resolve the version from a `YAD_VERSION` variable
|
|
183
|
+
and fall back to `3`:
|
|
184
|
+
|
|
185
|
+
| Platform | Where the job reads it | Where you set the pin |
|
|
186
|
+
|---|---|---|
|
|
187
|
+
| GitHub | workflow `env: YAD_VERSION: ${{ vars.YAD_VERSION \|\| '3' }}` | Settings → Secrets and variables → Actions → **Variables** |
|
|
188
|
+
| GitLab | `npx -y -p "yadflow@${YAD_VERSION:-3}"` | Settings → CI/CD → **Variables** (beside `SDLC_GATE_TOKEN`) |
|
|
189
|
+
|
|
190
|
+
The default `3` floats on the major, so a published fix reaches a scheduled job on its next pass with
|
|
191
|
+
nobody in the loop — which is how you want a correctness or gate-churn fix to arrive (this page's own
|
|
192
|
+
issue #163 is the example). To adopt releases deliberately instead, set `YAD_VERSION` to an exact
|
|
193
|
+
version.
|
|
194
|
+
The pin lives in **platform config, not in the wired file**: `yad` owns that file and `yad check --fix`
|
|
195
|
+
rewrites it byte-for-byte from the template, so a version edited into it would be silently reverted on
|
|
196
|
+
the next sync. A hub that pins then owns its own upgrade decision — including for fixes.
|
|
197
|
+
|
|
166
198
|
**Why no pre-merge write fixes the gate.** Keeping CI off the PR head means an in-flight approval is
|
|
167
199
|
never dismissed by a CI commit, and the PR's required checks never strand on a `[skip ci]` CI commit.
|
|
168
200
|
Correctness is unaffected: at merge CI re-reads the PR/MR approvals from the platform and re-checks
|
|
@@ -174,13 +206,26 @@ commit — the advance plus the `draft → approved` status flip — lands on th
|
|
|
174
206
|
check (yad-checks) FAILs any commit on a review PR that touches `.sdlc/{state,approvals,comments,hub-prs}
|
|
175
207
|
.json` or `reviews/*.md` (`.sdlc/contract-lock.json` is artifact-side and allowed). Under Path B **no
|
|
176
208
|
CI commit lands in a review PR at all**, so the only ledger change the guard can see there is a human
|
|
177
|
-
edit — which it rejects
|
|
209
|
+
edit — which it rejects, with one carve-out for a new epic's seed (below). (The `verified-commits`
|
|
210
|
+
gate still vets every commit's signature + author;
|
|
178
211
|
its gate-bot exemption is now vestigial in-PR because CI no longer commits there.) `yad gate open`
|
|
179
212
|
opens the PR only; local `yad gate sync` is advisory in bridge mode (writes nothing). After a merge,
|
|
180
213
|
everyone `git checkout <default> && git pull`. (Without the bridge, humans own the ledger locally and
|
|
181
214
|
these guards are no-ops.)
|
|
182
215
|
|
|
183
|
-
**The one sanctioned human ledger write
|
|
216
|
+
**The one sanctioned human ledger write *in a review PR*: the seed of a new epic.** `gate ci` only
|
|
217
|
+
**advances** an existing chain — it bails on a missing `state.json`, and the engine reads that absence
|
|
218
|
+
as "not seeded yet" — so no CI path can ever create a ledger. The seed the authoring skills write
|
|
219
|
+
(`yad-epic`, `yad-change`, `yad-analysis`, `yad-discovery`, `yad-stub`) can reach the default branch
|
|
220
|
+
only through the epic's **first** review PR/MR, which is exactly where `ledger-guard` runs. So the gate
|
|
221
|
+
exempts **creation**: an epic whose `.sdlc/state.json` is absent from the PR's base ref may have its
|
|
222
|
+
ledger written by a human there (#162). It stays a narrow carve-out — the probe is against the base
|
|
223
|
+
ref, not the parent commit, so deleting an on-trunk `state.json` to "re-seed" it is itself a rejected
|
|
224
|
+
mutation, and the moment the ledger is on the default branch every further change is CI's alone. Cut
|
|
225
|
+
that first review branch from the authoring branch (`epic/…`, `change/…`) so it carries the seed; no
|
|
226
|
+
direct push to a protected default branch is needed.
|
|
227
|
+
|
|
228
|
+
**The one sanctioned human ledger write *on the default branch*: `yad gate repair`.** It heals a `YAD-STATE-005` chain (an
|
|
184
229
|
authoring step stranded behind a review gate that already advanced) by writing `state.json` alone. This
|
|
185
230
|
is not a `ledger-guard` gap: the repair commits to the **default branch**, where `ledger-guard` — which
|
|
186
231
|
only inspects review PRs — never runs, and where the `yad-update-guard` (platform-Verified signature +
|
|
@@ -17,10 +17,14 @@
|
|
|
17
17
|
# RECONCILE (schedule, every 15 min): the safety net. The merge job's `closed` event fires once and
|
|
18
18
|
# never repeats, so a transient API/GraphQL/push failure (or a fail-closed degraded approval read)
|
|
19
19
|
# would otherwise strand a merged review until someone reran it by hand. This periodic job discovers
|
|
20
|
-
# recently-merged review PRs from the API and advances any not yet done. Idempotent
|
|
21
|
-
# already `done` is never re-advanced (the chain is one-way), and re-syncing it
|
|
22
|
-
#
|
|
23
|
-
#
|
|
20
|
+
# recently-merged review PRs from the API and advances any not yet done. Idempotent DOWN TO THE
|
|
21
|
+
# BYTES: a step that is already `done` is never re-advanced (the chain is one-way), and re-syncing it
|
|
22
|
+
# rewrites the ledger in a canonical order, so an unchanged approval record produces an unchanged file
|
|
23
|
+
# and nothing is committed — re-visiting a merged review for a week costs one no-op read per pass. That
|
|
24
|
+
# last part is load-bearing: before the #163 fix the re-sync re-appended each step's approvals at the
|
|
25
|
+
# tail, so this job rotated approvals.json and committed the reorder every 15 minutes, forever (issue
|
|
26
|
+
# #163). If you pin YAD_VERSION (below) to something older, disable the schedule.
|
|
27
|
+
# On GitHub a scheduled workflow runs automatically once committed (no setup).
|
|
24
28
|
#
|
|
25
29
|
# CI never approves and never merges — the merge click is the human approval act.
|
|
26
30
|
#
|
|
@@ -40,6 +44,15 @@ permissions:
|
|
|
40
44
|
contents: write # push the merge-advance ledger commit to the default branch
|
|
41
45
|
pull-requests: read # gh pr view + reviewThreads GraphQL
|
|
42
46
|
|
|
47
|
+
env:
|
|
48
|
+
# Which yadflow these jobs run. The default `3` floats on the major, so a published fix reaches the
|
|
49
|
+
# schedule on its next pass with nobody in the loop — which is how you want a correctness or
|
|
50
|
+
# gate-churn fix to arrive. To adopt releases deliberately instead, pin an exact version by setting a
|
|
51
|
+
# repository (or organization) Actions **variable** named YAD_VERSION to e.g. `3.15.2`:
|
|
52
|
+
# Settings → Secrets and variables → Actions → Variables. It lives there, deliberately NOT in this
|
|
53
|
+
# file, which `yad` owns and rewrites on every sync. A pinned hub then owns its own upgrade decision.
|
|
54
|
+
YAD_VERSION: ${{ vars.YAD_VERSION || '3' }}
|
|
55
|
+
|
|
43
56
|
jobs:
|
|
44
57
|
mergesync:
|
|
45
58
|
# The human merge of a review branch: advance the step + flip the artifact status on the default
|
|
@@ -57,20 +70,27 @@ jobs:
|
|
|
57
70
|
GH_TOKEN: ${{ github.token }}
|
|
58
71
|
steps:
|
|
59
72
|
# Check out the default branch — the merge already landed the artifact here.
|
|
60
|
-
- uses: actions/checkout@
|
|
73
|
+
- uses: actions/checkout@v7
|
|
61
74
|
with:
|
|
62
75
|
ref: ${{ github.event.pull_request.base.ref }}
|
|
63
76
|
fetch-depth: 0
|
|
64
|
-
- uses: actions/setup-node@
|
|
77
|
+
- uses: actions/setup-node@v7
|
|
65
78
|
with:
|
|
66
79
|
node-version: "20"
|
|
67
80
|
- name: Advance the gate on merge
|
|
81
|
+
# The head ref reaches the shell through `env:`, never through `${{ }}` inside `run:`. A
|
|
82
|
+
# branch name may legally contain `$`, backticks and parentheses, so a PR opened from
|
|
83
|
+
# `review/EP-x$(curl …)` would otherwise execute on a runner holding `contents: write` — and
|
|
84
|
+
# the `if:` guard above does not stop it, since that name still starts with `review/EP-`.
|
|
85
|
+
env:
|
|
86
|
+
HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
|
87
|
+
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
68
88
|
run: |
|
|
69
89
|
git config user.name "yad-gate-sync[bot]"
|
|
70
90
|
git config user.email "yad-gate-sync[bot]@users.noreply.github.com"
|
|
71
|
-
npx -y -p yadflow
|
|
72
|
-
--branch "$
|
|
73
|
-
--pr "$
|
|
91
|
+
npx -y -p "yadflow@${YAD_VERSION}" yad gate ci \
|
|
92
|
+
--branch "$HEAD_REF" \
|
|
93
|
+
--pr "$PR_NUMBER" \
|
|
74
94
|
--merged
|
|
75
95
|
|
|
76
96
|
reconcile:
|
|
@@ -85,11 +105,11 @@ jobs:
|
|
|
85
105
|
env:
|
|
86
106
|
GH_TOKEN: ${{ github.token }}
|
|
87
107
|
steps:
|
|
88
|
-
- uses: actions/checkout@
|
|
108
|
+
- uses: actions/checkout@v7
|
|
89
109
|
with:
|
|
90
110
|
ref: ${{ github.event.repository.default_branch }}
|
|
91
111
|
fetch-depth: 0
|
|
92
|
-
- uses: actions/setup-node@
|
|
112
|
+
- uses: actions/setup-node@v7
|
|
93
113
|
with:
|
|
94
114
|
node-version: "20"
|
|
95
115
|
- name: Reconcile recently-merged review PRs
|
|
@@ -110,7 +130,7 @@ jobs:
|
|
|
110
130
|
[ -n "$N" ] || continue
|
|
111
131
|
REF="$(gh pr view "$N" --json headRefName --jq '.headRefName' 2>/dev/null)" || rc=1
|
|
112
132
|
case "$REF" in
|
|
113
|
-
review/EP-*) npx -y -p yadflow
|
|
133
|
+
review/EP-*) npx -y -p "yadflow@${YAD_VERSION}" yad gate ci --branch "$REF" --pr "$N" --merged || rc=1 ;;
|
|
114
134
|
esac
|
|
115
135
|
done < /tmp/yad-merged-prs
|
|
116
136
|
exit $rc
|
|
@@ -35,6 +35,13 @@
|
|
|
35
35
|
# SDLC_GATE_TOKEN. Without it the job fails visibly; recover by setting the token, then re-run the
|
|
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
|
+
#
|
|
39
|
+
# Which yadflow this job runs: `npx -y -p "yadflow@${YAD_VERSION:-3}"`. The default `3` floats on the
|
|
40
|
+
# major, so a published fix reaches this job on its next run with nobody in the loop — which is how you
|
|
41
|
+
# want a correctness or gate-churn fix to arrive. To adopt releases deliberately instead, pin an exact
|
|
42
|
+
# version by setting a CI/CD variable YAD_VERSION (e.g. `3.15.2`) in project Settings → CI/CD →
|
|
43
|
+
# Variables — the same place SDLC_GATE_TOKEN lives, and deliberately NOT in this file, which `yad`
|
|
44
|
+
# owns and rewrites on every sync. A pinned hub then owns its own upgrade decision.
|
|
38
45
|
variables:
|
|
39
46
|
GIT_DEPTH: "0" # full history: gate ci pushes the advance to the default branch
|
|
40
47
|
|
|
@@ -79,8 +86,12 @@ yad-gate-sync:
|
|
|
79
86
|
rc=0
|
|
80
87
|
if [ "$CI_PIPELINE_SOURCE" = "schedule" ]; then
|
|
81
88
|
# SCHEDULED SWEEP — discover merged review MRs from the platform (Path B keeps no per-branch
|
|
82
|
-
# ledger), then advance each. `gate ci` is idempotent: an already-`done` step
|
|
83
|
-
# re-advanced, and re-syncing it
|
|
89
|
+
# ledger), then advance each. `gate ci` is idempotent DOWN TO THE BYTES: an already-`done` step
|
|
90
|
+
# is never re-advanced, and re-syncing it rewrites the ledger in a canonical order, so an
|
|
91
|
+
# unchanged approval record produces an unchanged file and nothing is committed. That last part
|
|
92
|
+
# is load-bearing — before the #163 fix the re-sync re-appended each step's approvals at the
|
|
93
|
+
# tail, so this loop rotated approvals.json and committed the reorder on every pass, forever
|
|
94
|
+
# (issue #163). If you are on an older yadflow, disable this schedule.
|
|
84
95
|
# A stuck review MR (a squash merge whose commit dropped the branch name, or a failed merge
|
|
85
96
|
# push) is always RECENT, so sweep a generous recent window and PAGINATE it fully (--paginate)
|
|
86
97
|
# — this bounds cost without the old hard 50-row cap that could permanently strand older MRs.
|
|
@@ -94,7 +105,7 @@ yad-gate-sync:
|
|
|
94
105
|
while read -r IID REF; do
|
|
95
106
|
[ -n "$IID" ] || continue
|
|
96
107
|
git checkout -q -B "$CI_DEFAULT_BRANCH" "origin/$CI_DEFAULT_BRANCH"
|
|
97
|
-
npx -y -p yadflow
|
|
108
|
+
npx -y -p "yadflow@${YAD_VERSION:-3}" yad gate ci --branch "$REF" --pr "$IID" --merged || rc=1
|
|
98
109
|
done < /tmp/yad-merged-mrs
|
|
99
110
|
else
|
|
100
111
|
# MERGE push to the default branch whose commit names a review branch: resolve the merged MR's
|
|
@@ -106,7 +117,7 @@ yad-gate-sync:
|
|
|
106
117
|
IID="$(glab api "projects/:id/merge_requests?source_branch=${REVIEW_BRANCH}&state=merged" 2>/dev/null | jq -r '.[0].iid // empty' || true)"
|
|
107
118
|
if [ -n "$IID" ]; then
|
|
108
119
|
# Pass --pr + IID as two distinct args (avoid a fragile, shell-dependent ${IID:+...} split).
|
|
109
|
-
npx -y -p yadflow
|
|
120
|
+
npx -y -p "yadflow@${YAD_VERSION:-3}" yad gate ci --branch "$REVIEW_BRANCH" --pr "$IID" --merged || rc=1
|
|
110
121
|
else
|
|
111
122
|
# Without the IID, gate ci cannot re-read approvals — fail visibly (the scheduled sweep
|
|
112
123
|
# retries) rather than running a green no-op that silently leaves the gate unadvanced.
|
|
@@ -176,7 +176,9 @@ PR only — against the `review/<epic>/<artifact>` branch, which must already ex
|
|
|
176
176
|
records this skill describes. The skill's
|
|
177
177
|
job is the human half: presenting the artifact, helping the owner address comments, and narrating the
|
|
178
178
|
gate. Local `yad gate sync` is advisory in bridge mode (reads the platform, prints status, writes
|
|
179
|
-
nothing); a human must never commit gate-state files (the `ledger-guard` check rejects it).
|
|
179
|
+
nothing); a human must never commit gate-state files (the `ledger-guard` check rejects it). The single
|
|
180
|
+
exception is an epic's **seed** — no CI path can create a ledger, so a brand-new epic's `.sdlc/` rides
|
|
181
|
+
its **first** review PR/MR, cut from the authoring branch (creation, not mutation, #162).
|
|
180
182
|
|
|
181
183
|
Under that CLI the gate **advances on merge**: a review PR/MR whose reviewer rule is satisfied, whose
|
|
182
184
|
comment threads are **all resolved**, and which has been **merged** auto-marks the step `done` and
|
package/skills/yad-stub/SKILL.md
CHANGED
|
@@ -115,6 +115,11 @@ into normal authoring with zero re-seeding.
|
|
|
115
115
|
Also create the empty ledgers `{.sdlc/approvals.json}` and `{.sdlc/comments.json}` (each `[]`) and the
|
|
116
116
|
`reviews/` directory. **Do NOT** write a `contract-lock.json` — a stub has no locked surface yet.
|
|
117
117
|
|
|
118
|
+
Commit the seed on this step's authoring branch; it reaches the hub's default branch through the
|
|
119
|
+
epic's **first** review PR/MR (or, for a stub, the PR that carries the stub itself). In bridge mode
|
|
120
|
+
`ledger-guard` exempts a new epic's ledger — creation, not mutation (#162) — while every later change
|
|
121
|
+
to it stays CI's. See `../yad-epic/references/state-schema.md`, "Authoring branches".
|
|
122
|
+
|
|
118
123
|
### Step 6 — Stop; hand off (NO auto-advance)
|
|
119
124
|
Report the new `EP-<slug>`, that it is a **stub (backfill pending)**, and the two next moves:
|
|
120
125
|
- **File bugs now:** `yad-change` (`--parent EP-<slug>`, `kind: defect|change`) — the defect threads off
|