yadflow 3.15.2 → 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 CHANGED
@@ -1,3 +1,12 @@
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
+
1
10
  ## [3.15.2](https://github.com/abdelrahmannasr/yadflow/compare/v3.15.1...v3.15.2) (2026-08-11)
2
11
 
3
12
 
@@ -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
  }
@@ -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
- return kept;
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
- kept.push({ artifact, step: stepId, commenter: byName(login), role: roleOf(login), round, count, date: today });
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
- const targets = resolved.targets;
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
- if (existing?.number != null && number !== existing.number) {
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) { advancedEpics.add(job.epic); await syncStatuses(root, { epic: job.epic }); }
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
- // Pre-merge is read-only (Path B): the gate was evaluated with a dry sync that persists nothing.
558
- // The one working-tree write is the hub-prs.json seed above (so the dry sync could find the PR);
559
- // restore exactly that file per epic so the checkout stays clean — never touching anything else,
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 (everything lands on the default branch):
572
- // - advanced the whole epic (ledger advance + the status flip syncStatuses wrote into the .md).
573
- // - merged but not advanced (merged before the rule passed) → the ledger (.sdlc) + the generated
574
- // reviews/ summaries only; the artifact is the owner's, left untouched.
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
- if (advancedEpics.has(e)) git('add', '-A', '--', path.join('epics', e));
577
- else { git('add', '-A', '--', path.join('epics', e, '.sdlc')); git('add', '-A', '--', path.join('epics', e, 'reviews')); }
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.2",
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",
@@ -103,6 +103,10 @@ 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 `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`.
106
110
  2. **GitLab only — two one-time steps** (see the fragment's header for the exact recipes):
107
111
  - add `include: - local: '.gitlab/ci/yad-gate-sync.yml'` to the root `.gitlab-ci.yml`, or write
108
112
  `templates/gitlab/gitlab-ci.include-root.yml` as the root when none exists;
@@ -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
@@ -165,6 +179,22 @@ other way — up through its first review PR/MR; see "the seed of a new epic" be
165
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** |
166
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) |
167
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
+
168
198
  **Why no pre-merge write fixes the gate.** Keeping CI off the PR head means an in-flight approval is
169
199
  never dismissed by a CI commit, and the PR's required checks never strand on a `[skip ci]` CI commit.
170
200
  Correctness is unaffected: at merge CI re-reads the PR/MR approvals from the platform and re-checks
@@ -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: a step that is
21
- # already `done` is never re-advanced (the chain is one-way), and re-syncing it writes nothing unless
22
- # its approvals genuinely changed so re-visiting a merged review for a week costs one no-op read per
23
- # pass, not a commit. On GitHub a scheduled workflow runs automatically once committed (no setup).
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@v4
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@v4
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@3 yad gate ci \
72
- --branch "${{ github.event.pull_request.head.ref }}" \
73
- --pr "${{ github.event.pull_request.number }}" \
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@v4
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@v4
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@3 yad gate ci --branch "$REF" --pr "$N" --merged || rc=1 ;;
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 is never
83
- # re-advanced, and re-syncing it writes nothing unless its approvals changed.
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@3 yad gate ci --branch "$REF" --pr "$IID" --merged || rc=1
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@3 yad gate ci --branch "$REVIEW_BRANCH" --pr "$IID" --merged || rc=1
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.