yadflow 2.14.0 → 2.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2 -2
- package/bin/yad.mjs +19 -5
- package/cli/artifact-status.mjs +102 -0
- package/cli/doctor.mjs +14 -1
- package/cli/epic-state.mjs +1 -1
- package/cli/gate.mjs +121 -58
- package/cli/manifest.mjs +2 -0
- package/cli/platform.mjs +44 -6
- package/package.json +1 -1
- package/skills/yad-checks/SKILL.md +7 -0
- package/skills/yad-checks/references/check-gates.md +25 -7
- package/skills/yad-checks/templates/checks/ledger-guard.sh +117 -0
- package/skills/yad-checks/templates/checks/verified-commits.sh +9 -1
- package/skills/yad-checks/templates/github/yad-hub-checks.yml +28 -6
- package/skills/yad-checks/templates/gitlab/yad-hub-checks.gitlab-ci.yml +18 -5
- package/skills/yad-hub-bridge/SKILL.md +41 -14
- package/skills/yad-hub-bridge/references/bridge.md +93 -51
- package/skills/yad-hub-bridge/templates/github/yad-gate-sync.yml +85 -35
- package/skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml +63 -32
- package/skills/yad-pr-template/templates/checks/pr-template.sh +53 -10
- package/skills/yad-pr-template/templates/checks/pr-title.sh +55 -18
- package/skills/yad-review-gate/SKILL.md +12 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
# [2.
|
|
1
|
+
# [2.16.0](https://github.com/abdelrahmannasr/yadflow/compare/v2.15.0...v2.16.0) (2026-06-25)
|
|
2
2
|
|
|
3
3
|
|
|
4
4
|
### Features
|
|
5
5
|
|
|
6
|
-
*
|
|
6
|
+
* make hub pr-title/pr-template gates branch-aware so tooling PRs pass ([#79](https://github.com/abdelrahmannasr/yadflow/issues/79)) ([68050e0](https://github.com/abdelrahmannasr/yadflow/commit/68050e000010b4d98f304a7e5e1f6a39bc0c229c))
|
|
7
7
|
|
|
8
8
|
# [2.2.0](https://github.com/abdelrahmannasr/yadflow/compare/v2.1.0...v2.2.0) (2026-06-14)
|
|
9
9
|
|
package/bin/yad.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import { runRoster } from '../cli/roster.mjs';
|
|
|
14
14
|
import { runDocs } from '../cli/docs.mjs';
|
|
15
15
|
import { runDoctor } from '../cli/doctor.mjs';
|
|
16
16
|
import { runNext } from '../cli/next.mjs';
|
|
17
|
+
import { syncStatuses } from '../cli/artifact-status.mjs';
|
|
17
18
|
|
|
18
19
|
const HELP = `${c.bold('yad')} — setup, review-gate & build helpers for the SDLC Workflow module ${c.dim('v' + VERSION)}
|
|
19
20
|
|
|
@@ -27,6 +28,8 @@ ${c.bold('Setup & maintenance')}
|
|
|
27
28
|
also migrates pre-2.0 sdlc-* installs to the yad-* names
|
|
28
29
|
yad doctor [--json] Environment + state health: tools/auth, config files,
|
|
29
30
|
repo paths, epic ledgers (exit 1 on any failure)
|
|
31
|
+
yad sync-status [epic] Update artifact frontmatter status (draft/in-review/approved)
|
|
32
|
+
from .sdlc/state.json — all epics if omitted (--dry-run to preview)
|
|
30
33
|
|
|
31
34
|
${c.bold('Reviewer roster')}
|
|
32
35
|
yad roster list Show every member + their roles per scope (hub + each repo)
|
|
@@ -47,9 +50,9 @@ ${c.bold('Review gate (front half)')}
|
|
|
47
50
|
yad gate sync <epic> [artifact] Pull PR state -> ledger; advance on approved+resolved+merged
|
|
48
51
|
yad gate comments <epic> [artifact] Fetch unresolved review comments to address
|
|
49
52
|
yad gate status <epic> Show each review step + approvals
|
|
50
|
-
yad gate ci [--branch <head>] [--pr <n>]
|
|
51
|
-
CI entry (hub workflow):
|
|
52
|
-
|
|
53
|
+
yad gate ci [--branch <head>] [--pr <n>] [--merged]
|
|
54
|
+
CI entry (hub workflow): pre-merge is read-only (nothing pushed);
|
|
55
|
+
--merged advances the step + flips artifact status on the default branch
|
|
53
56
|
|
|
54
57
|
${c.bold('Build helpers')}
|
|
55
58
|
yad commit --type <t> -m <subject> Commit by convention (trailers, atomic guard)
|
|
@@ -80,6 +83,7 @@ ${c.bold('Options')}
|
|
|
80
83
|
--force commit: bypass the atomic-file guard / re-copy unchanged files
|
|
81
84
|
--branch <head> gate ci: the review PR/MR head branch (review/EP-<slug>/<artifact>)
|
|
82
85
|
--pr <n> gate ci: the PR/MR number from the CI event
|
|
86
|
+
--merged gate ci: merge phase — advance the step on the default branch
|
|
83
87
|
--no-push gate ci: commit the ledger but do not push
|
|
84
88
|
-h, --help Show this help
|
|
85
89
|
-v, --version Print version`;
|
|
@@ -94,6 +98,7 @@ function parseArgs(argv) {
|
|
|
94
98
|
else if (a === '--force') o.force = true;
|
|
95
99
|
else if (a === '--contract-change') o.contractChange = true;
|
|
96
100
|
else if (a === '--no-push') o.noPush = true;
|
|
101
|
+
else if (a === '--merged') o.merged = true;
|
|
97
102
|
else if (a === '--overview') o.overview = true;
|
|
98
103
|
// `--check` is a bare boolean for `docs sync --check`, but takes a value for
|
|
99
104
|
// `next <epic> --check <step>`. Only the `next` command consumes the following token as a value —
|
|
@@ -153,6 +158,12 @@ async function main() {
|
|
|
153
158
|
case 'doctor':
|
|
154
159
|
await runDoctor(o.dir, { json: o.json });
|
|
155
160
|
break;
|
|
161
|
+
case 'sync-status': {
|
|
162
|
+
const [, epic] = o._;
|
|
163
|
+
if (epic && !isValidEpicId(epic)) { log(c.red(`invalid epic id: ${epic} (expected EP-<slug>, [a-z0-9-] only)`)); process.exitCode = 1; break; }
|
|
164
|
+
await syncStatuses(o.dir, { epic, dryRun: o.dryRun });
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
156
167
|
case 'next': {
|
|
157
168
|
const [, epic] = o._;
|
|
158
169
|
// `--check` with no step is a malformed guard call — fail loudly rather than silently print.
|
|
@@ -163,12 +174,15 @@ async function main() {
|
|
|
163
174
|
case 'gate': {
|
|
164
175
|
const [, action, epic, artifact] = o._;
|
|
165
176
|
// `gate ci` takes no positionals — epic/artifact come from --branch (or a sweep of all PRs).
|
|
166
|
-
if (action === 'ci') { await gateCi(o.dir, { branch: o.branch, pr: o.pr, push: !o.noPush, today }); break; }
|
|
177
|
+
if (action === 'ci') { await gateCi(o.dir, { branch: o.branch, pr: o.pr, merged: o.merged, push: !o.noPush, today }); break; }
|
|
167
178
|
if (!epic) { log(c.red('usage: yad gate <open|sync|comments|status|ci> <epic> [artifact]')); process.exitCode = 1; break; }
|
|
168
179
|
// The epic id becomes a path segment under epics/ — reject anything but EP-<slug> outright.
|
|
169
180
|
if (!isValidEpicId(epic)) { log(c.red(`invalid epic id: ${epic} (expected EP-<slug>, [a-z0-9-] only)`)); process.exitCode = 1; break; }
|
|
181
|
+
// In bridge mode CI is the sole ledger writer: `open` only opens the PR, and local `sync` is
|
|
182
|
+
// advisory (reads the platform, prints status, writes nothing). The artifact status flip is
|
|
183
|
+
// CI's job at merge — never wired into the local gate. File-only mode keeps local writes.
|
|
170
184
|
if (action === 'open') await gateOpen(o.dir, { epic, artifact, today });
|
|
171
|
-
else if (action === 'sync') await gateSync(o.dir, { epic, artifact, today });
|
|
185
|
+
else if (action === 'sync') await gateSync(o.dir, { epic, artifact, today, local: true });
|
|
172
186
|
else if (action === 'comments') await gateComments(o.dir, { epic, artifact, today });
|
|
173
187
|
else if (action === 'status') await gateStatus(o.dir, { epic });
|
|
174
188
|
else { log(c.red(`unknown gate action: ${action} (open|sync|comments|status|ci)`)); process.exitCode = 1; }
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// `yad sync-status [epic]` — reconcile each artifact's frontmatter `status:` with the real
|
|
2
|
+
// source of truth, the per-epic state machine in .sdlc/state.json. The authoring skills hard-code
|
|
3
|
+
// `status: draft` at creation and never update it, so artifacts read as "draft" long after their
|
|
4
|
+
// gate has passed. This derives draft / in-review / approved from the step statuses and rewrites
|
|
5
|
+
// only the `status:` line — advance-only, and never touching build-owned values.
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { c, log, ok, info, readJSONStrict } from './lib.mjs';
|
|
9
|
+
import { epicRoot, artifactBase, artifactFromBase, findReviewStep } from './epic-state.mjs';
|
|
10
|
+
import { epicFiles } from './manifest.mjs';
|
|
11
|
+
|
|
12
|
+
// The front-gate lifecycle this command manages. Forward-only: a status is only ever moved UP this
|
|
13
|
+
// ladder, so a re-run never regresses anything.
|
|
14
|
+
const RANK = { draft: 0, 'in-review': 1, approved: 2 };
|
|
15
|
+
|
|
16
|
+
// Values owned by other parts of the workflow — left untouched. `locked` is the contract surface;
|
|
17
|
+
// `in-build` / `shipped` are set by the build half (engineer-review) per story; `ready-for-build`,
|
|
18
|
+
// `done`, `blocked` are roll-ups/states we must not overwrite from the front-gate view.
|
|
19
|
+
const PRESERVE = new Set(['locked', 'in-build', 'shipped', 'ready-for-build', 'done', 'blocked']);
|
|
20
|
+
|
|
21
|
+
// The per-epic artifact files this command considers (bases). Story files are handled separately
|
|
22
|
+
// because they live under stories/ and all map to the single stories / stories-review step pair.
|
|
23
|
+
const ARTIFACT_FILES = ['analysis.md', 'epic.md', 'architecture.md', 'contract.md', 'ui-design.md', 'test-cases.md'];
|
|
24
|
+
|
|
25
|
+
// The desired front-gate status for an artifact base, derived purely from state.json. Returns null
|
|
26
|
+
// when the chain has no steps for this base (nothing to manage) — e.g. contract has no own step.
|
|
27
|
+
export function desiredStatus(state, base) {
|
|
28
|
+
if (!state?.steps) return null;
|
|
29
|
+
const review = findReviewStep(state, artifactFromBase(base));
|
|
30
|
+
const author = state.steps.find((s) => s.type === 'author' && artifactBase(s.artifact) === base);
|
|
31
|
+
if (!review && !author) return null;
|
|
32
|
+
if (review?.status === 'done') return 'approved';
|
|
33
|
+
if (review?.status === 'in_review' || author?.status === 'done') return 'in-review';
|
|
34
|
+
return 'draft';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Rewrite ONLY the `status:` line inside the first `---\n...\n---` frontmatter block, preserving
|
|
38
|
+
// everything else. Returns the prior status when a change was written, or null for a no-op (no
|
|
39
|
+
// frontmatter, no status line, preserved value, or not an advance). Mirrors the frontmatter regex
|
|
40
|
+
// in gate.mjs.
|
|
41
|
+
export function setFrontmatterStatus(file, status) {
|
|
42
|
+
if (!fs.existsSync(file)) return null;
|
|
43
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
44
|
+
const fm = text.match(/^---\n([\s\S]*?)\n---/);
|
|
45
|
+
if (!fm) return null;
|
|
46
|
+
const cur = (fm[1].match(/^status:\s*(.*)$/m) || [])[1]?.trim();
|
|
47
|
+
if (cur === undefined) return null;
|
|
48
|
+
// Advance-only within the managed ladder; anything else (build-owned, roll-ups) is left as-is.
|
|
49
|
+
if (PRESERVE.has(cur)) return null;
|
|
50
|
+
if (!(cur in RANK) || !(status in RANK) || RANK[status] <= RANK[cur]) return null;
|
|
51
|
+
const block = fm[1].replace(/^status:\s*.*$/m, `status: ${status}`);
|
|
52
|
+
fs.writeFileSync(file, text.replace(fm[1], block));
|
|
53
|
+
return cur;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Sweep one epic (or every epic under epics/) and reconcile artifact frontmatter with state.json.
|
|
57
|
+
export async function syncStatuses(root, { epic, dryRun = false } = {}) {
|
|
58
|
+
const epicsDir = path.join(root, 'epics');
|
|
59
|
+
const epics = epic
|
|
60
|
+
? [epic]
|
|
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 }; }
|
|
63
|
+
|
|
64
|
+
let changed = 0;
|
|
65
|
+
for (const e of epics) {
|
|
66
|
+
const dir = epicRoot(root, e);
|
|
67
|
+
const state = readJSONStrict(epicFiles(dir).state, null);
|
|
68
|
+
if (!state?.steps) { info(`${e}: no state.json — skipping`); continue; }
|
|
69
|
+
|
|
70
|
+
// Single-file artifacts + each story file (all keyed to the stories step pair).
|
|
71
|
+
const files = ARTIFACT_FILES.map((f) => ({ base: artifactBase(f), file: path.join(dir, f) }));
|
|
72
|
+
const storiesDir = path.join(dir, 'stories');
|
|
73
|
+
if (fs.existsSync(storiesDir)) {
|
|
74
|
+
for (const f of fs.readdirSync(storiesDir).filter((x) => x.endsWith('.md'))) {
|
|
75
|
+
files.push({ base: 'stories', file: path.join(storiesDir, f) });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const { base, file } of files) {
|
|
80
|
+
if (!fs.existsSync(file)) continue;
|
|
81
|
+
const want = desiredStatus(state, base);
|
|
82
|
+
if (!want) continue;
|
|
83
|
+
if (dryRun) {
|
|
84
|
+
// Peek without writing so --dry-run reports exactly what would change. Scope the match to the
|
|
85
|
+
// frontmatter block so a `status:` line in the Markdown body can't be mistaken for the value.
|
|
86
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
87
|
+
const fm = text.match(/^---\n([\s\S]*?)\n---/);
|
|
88
|
+
const cur = (fm?.[1].match(/^status:\s*(.*)$/m) || [])[1]?.trim();
|
|
89
|
+
if (cur && !PRESERVE.has(cur) && cur in RANK && RANK[want] > RANK[cur]) {
|
|
90
|
+
log(` ${c.dim('• would update')} ${path.relative(root, file)}: ${cur} → ${want}`);
|
|
91
|
+
changed++;
|
|
92
|
+
}
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const prev = setFrontmatterStatus(file, want);
|
|
96
|
+
if (prev) { ok(`${path.relative(root, file)}: ${prev} → ${want}`); changed++; }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (!changed) info(dryRun ? 'no status changes needed' : 'all artifact statuses already in sync');
|
|
100
|
+
else if (!dryRun) ok(`updated ${changed} artifact status(es)`);
|
|
101
|
+
return { changed };
|
|
102
|
+
}
|
package/cli/doctor.mjs
CHANGED
|
@@ -244,7 +244,20 @@ export function epicChecks(checks, root) {
|
|
|
244
244
|
try {
|
|
245
245
|
const ledger = loadLedger(epicRoot(root, e));
|
|
246
246
|
if (!ledger.state) check(checks, `epic:${e}`, 'epics', 'warn', `${e}: no state.json — epic not seeded`, 'author it via yad-epic, or remove the directory');
|
|
247
|
-
else
|
|
247
|
+
else {
|
|
248
|
+
check(checks, `epic:${e}`, 'epics', 'ok', `${e}: currentStep ${ledger.state.currentStep}`);
|
|
249
|
+
// Migration guard (pre-3.0 model): under the current model CI records the ledger on the
|
|
250
|
+
// default branch only at merge (when the step is already done), and writes nothing during
|
|
251
|
+
// review — so an OPEN (non-done) review PR recorded here means it was opened under an older
|
|
252
|
+
// model. Merge/close it under the version that opened it before relying on the CI flow.
|
|
253
|
+
const openPr = (ledger.hubPrs || []).find((p) => {
|
|
254
|
+
const st = (ledger.state.steps.find((s) => s.id === p.step) || {}).status;
|
|
255
|
+
return st && st !== 'done';
|
|
256
|
+
});
|
|
257
|
+
if (openPr) check(checks, `epic:${e}:migration`, 'epics', 'warn',
|
|
258
|
+
`${e}: an open review PR (${openPr.artifact}${openPr.number ? ` #${openPr.number}` : ''}) is recorded on the default branch`,
|
|
259
|
+
'opened under a pre-3.0 yadflow? merge/close it before continuing — CI now records the gate ledger on the default branch only at merge');
|
|
260
|
+
}
|
|
248
261
|
} catch (err) {
|
|
249
262
|
check(checks, `epic:${e}`, 'epics', 'fail', `${e}: ${err.message} [${err.code || 'YAD-STATE-001'}]`, err.hint || 'fix the file or restore it from git');
|
|
250
263
|
}
|
package/cli/epic-state.mjs
CHANGED
|
@@ -42,7 +42,7 @@ export function artifactFromBase(base) {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
// The files (relative to the epic dir) a review of this artifact covers — what `gate open` commits
|
|
45
|
-
// on the review branch, and what
|
|
45
|
+
// on the review branch (the owner's artifact), and what CI re-reads to bind the approval at merge.
|
|
46
46
|
// Architecture mirrors artifactHash(): the approval is bound to the locked contract surface too.
|
|
47
47
|
export function artifactPaths(base) {
|
|
48
48
|
if (base === 'architecture') return ['architecture.md', 'contract.md', '.sdlc/contract-lock.json'];
|
package/cli/gate.mjs
CHANGED
|
@@ -11,9 +11,10 @@ import { PROJECT_FILES } from './manifest.mjs';
|
|
|
11
11
|
import {
|
|
12
12
|
epicRoot, loadLedger, findReviewStep, artifactBase, artifactHash, gatePredicate,
|
|
13
13
|
advanceState, markInReview, isEscalated, parseReviewBranch, artifactFromBase,
|
|
14
|
-
|
|
14
|
+
upsertHubPr,
|
|
15
15
|
} from './epic-state.mjs';
|
|
16
16
|
import { readPr, mapApprovers, createPr, reviewersForScopes, resolveCommitterLogin } from './platform.mjs';
|
|
17
|
+
import { syncStatuses } from './artifact-status.mjs';
|
|
17
18
|
import { err } from './errors.mjs';
|
|
18
19
|
|
|
19
20
|
// ---- tiny frontmatter reader (key: value, and `repos: [a, b]`) ----------------------------------
|
|
@@ -90,6 +91,12 @@ function loadHub(root) {
|
|
|
90
91
|
// merge advances the step). Recorded per-project in hub.json by `yad setup`.
|
|
91
92
|
const isSolo = (hub) => !!(hub && (hub.solo === true || hub.review_gate?.solo === true));
|
|
92
93
|
|
|
94
|
+
// Bridge mode: a platform AND the gate-sync CI explicitly enabled (the canonical `bridge_enabled`,
|
|
95
|
+
// or the older `bridge`). ONLY then is CI the sole ledger writer — so `gate open`/`sync` stay
|
|
96
|
+
// hands-off. A platform without the bridge (no gate-sync CI installed) keeps the local write path,
|
|
97
|
+
// or reviews could never advance. Mirrors plan.mjs hubActions.
|
|
98
|
+
const isBridge = (hub) => !!(hub?.platform && (hub.bridge_enabled === true || hub.bridge === true));
|
|
99
|
+
|
|
93
100
|
// Re-add this step's bridge approvals from the current platform state (drop+re-add => dismissals and
|
|
94
101
|
// revocations vanish idempotently; manual approvals are never touched). Preserve the artifactHash a
|
|
95
102
|
// reviewer first approved against unless their review is newer (a genuine re-approval) — that is what
|
|
@@ -158,13 +165,19 @@ function recordComments(comments, { artifact, stepId, today, roster, blocking })
|
|
|
158
165
|
|
|
159
166
|
// ---- actions ------------------------------------------------------------------------------------
|
|
160
167
|
|
|
161
|
-
export async function gateSync(root, { epic, artifact, today, reader = readPr } = {}) {
|
|
168
|
+
export async function gateSync(root, { epic, artifact, today, reader = readPr, local = false, dryRun = false } = {}) {
|
|
162
169
|
const { hub, repos } = loadHub(root);
|
|
163
170
|
if (!hub?.platform) { warn('no hub platform configured (.sdlc/hub.json) — file-only gate, nothing to sync'); return { synced: 0 }; }
|
|
164
171
|
const platform = hub.platform;
|
|
165
172
|
const roster = hub.roster || [];
|
|
166
173
|
const defaultReviewers = 1;
|
|
167
174
|
const solo = isSolo(hub);
|
|
175
|
+
// Local invocation in bridge mode is ADVISORY: CI is the sole ledger writer, so a human run reads
|
|
176
|
+
// the platform and prints the predicate but writes nothing. CI calls gateSync with local=false.
|
|
177
|
+
// Without the bridge (platform but no gate-sync CI) the local command stays the writer.
|
|
178
|
+
// dryRun forces the same read-only behavior regardless of bridge — used for the Path B pre-merge
|
|
179
|
+
// evaluation, which must persist nothing (gateCi passes dryRun for a held branch event).
|
|
180
|
+
const readOnly = (local && isBridge(hub)) || dryRun;
|
|
168
181
|
const epicDir = epicRoot(root, epic);
|
|
169
182
|
const ledger = loadLedger(epicDir);
|
|
170
183
|
if (!ledger.state) { fail(`no epic state at ${epicDir}/.sdlc/state.json`); process.exitCode = 1; return { synced: 0 }; }
|
|
@@ -174,6 +187,7 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr }
|
|
|
174
187
|
if (!targets.length) { warn(`no open review PR recorded for ${epic}${artifact ? ` / ${artifact}` : ''} (run \`yad gate open\` first)`); return { synced: 0 }; }
|
|
175
188
|
|
|
176
189
|
let synced = 0;
|
|
190
|
+
let advanced = 0;
|
|
177
191
|
for (const pr of targets) {
|
|
178
192
|
const step = findReviewStep(state, pr.artifact);
|
|
179
193
|
if (!step) { warn(`no review step for ${pr.artifact}`); continue; }
|
|
@@ -182,11 +196,13 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr }
|
|
|
182
196
|
if (step.status === 'done') { info(`${pr.artifact}: ${step.id} already done — skipping`); continue; }
|
|
183
197
|
const domains = touchedDomains(epicDir, step);
|
|
184
198
|
const pull = reader(platform, pr.number, { cwd: root });
|
|
185
|
-
|
|
199
|
+
// A failed platform read must not pass as a green no-op: flag the run non-zero so CI surfaces it
|
|
200
|
+
// (the wired workflow's reconcile/sweep aggregates this exit) instead of silently not advancing.
|
|
201
|
+
if (!pull.ok) { warn(`${pr.artifact}: ${pull.reason} — skipping (file-only)`); process.exitCode = 1; continue; }
|
|
186
202
|
|
|
187
203
|
const curHash = artifactHash(epicDir, pr.artifact);
|
|
188
204
|
warnUnlockedContract(epicDir, pr.artifact);
|
|
189
|
-
const recs = mapApprovers(pull.reviews, { roster, repos, touchedDomains: domains });
|
|
205
|
+
const recs = mapApprovers(pull.reviews, { roster, repos, touchedDomains: domains, headOid: pull.headOid });
|
|
190
206
|
approvals = upsertBridge(approvals, recs, { stepId: step.id, artifact: pr.artifact, curHash, today });
|
|
191
207
|
|
|
192
208
|
const changeRequested = pull.reviews.filter((r) => r.state === 'CHANGES_REQUESTED');
|
|
@@ -196,7 +212,8 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr }
|
|
|
196
212
|
...changeRequested.map((r) => ({ login: r.login, changesRequested: true })),
|
|
197
213
|
...unresolved,
|
|
198
214
|
];
|
|
199
|
-
|
|
215
|
+
// Advisory (read-only) sync must not touch the working tree — defer the reviews/*.md write.
|
|
216
|
+
if (!readOnly) writeComments(epicDir, base(pr.artifact), today, blocking);
|
|
200
217
|
comments = recordComments(comments, { artifact: pr.artifact, stepId: step.id, today, roster, repos, blocking });
|
|
201
218
|
|
|
202
219
|
const pred = gatePredicate({
|
|
@@ -207,6 +224,7 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr }
|
|
|
207
224
|
log(` ${c.bold(pr.artifact)} ${c.dim(`(PR #${pr.number}, rule: ${pred.rule})`)}`);
|
|
208
225
|
if (pred.passed) {
|
|
209
226
|
state = advanceState(state, step);
|
|
227
|
+
advanced++;
|
|
210
228
|
ok(`gate PASSED — ${step.id} → done; next: ${state.currentStep}`);
|
|
211
229
|
} else {
|
|
212
230
|
state = markInReview(state, step);
|
|
@@ -216,29 +234,42 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr }
|
|
|
216
234
|
synced++;
|
|
217
235
|
}
|
|
218
236
|
|
|
237
|
+
if (readOnly) {
|
|
238
|
+
info('bridge mode: advisory view — CI owns the ledger, nothing written locally');
|
|
239
|
+
return { synced, advanced };
|
|
240
|
+
}
|
|
219
241
|
writeJSON(ledger.files.approvals, approvals);
|
|
220
242
|
writeJSON(ledger.files.comments, comments);
|
|
221
243
|
writeJSON(ledger.files.hubPrs, hubPrs);
|
|
222
244
|
writeJSON(ledger.files.state, state);
|
|
223
245
|
refreshRoster(epicDir, targets, approvals, today);
|
|
224
|
-
return { synced };
|
|
246
|
+
return { synced, advanced };
|
|
225
247
|
}
|
|
226
248
|
|
|
227
|
-
// `yad gate ci` — the self-sufficient entry point hub CI calls on platform events
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
|
|
249
|
+
// `yad gate ci` — the self-sufficient entry point hub CI calls on platform events. Path B: CI
|
|
250
|
+
// never writes the ledger to the review branch — during review the platform PR/MR is the source of
|
|
251
|
+
// truth, and the ledger is reconciled onto the default branch at merge.
|
|
252
|
+
//
|
|
253
|
+
// PRE-MERGE (a held step, no --merged and nothing advanced): READ-ONLY. The predicate is
|
|
254
|
+
// evaluated for visibility, but nothing is committed or pushed — so an in-flight approval is never
|
|
255
|
+
// dismissed and the PR's required checks never strand on a CI commit.
|
|
256
|
+
//
|
|
257
|
+
// MERGE (--merged, PR/MR closed+merged): the artifact reached the default branch via the human
|
|
258
|
+
// merge; the workflow checks out the default branch. CI runs the sync — the PR reads merged=true,
|
|
259
|
+
// so the predicate ADVANCES the step, flips the artifact `status:` to approved (syncStatuses), and
|
|
260
|
+
// commits the advance to the default branch. CI re-reads approvals fresh from the platform, so it
|
|
261
|
+
// needs no ledger pre-seeded on the branch.
|
|
262
|
+
//
|
|
263
|
+
// CI is the SOLE writer of the ledger and only ever commits to the default branch; humans never
|
|
264
|
+
// commit gate-state files (enforced by the ledger-guard check). Sweep mode (no --branch) advances
|
|
265
|
+
// merged-but-stuck reviews found in the locally checked-out default-branch ledgers.
|
|
266
|
+
export async function gateCi(root, { branch, pr, merged = false, today, push = true, reader = readPr } = {}) {
|
|
234
267
|
const { hub } = loadHub(root);
|
|
235
268
|
if (!hub?.platform) { warn('no hub platform configured (.sdlc/hub.json) — nothing to sync'); return { synced: 0 }; }
|
|
236
269
|
const git = (...args) => run('git', args, { cwd: root });
|
|
237
|
-
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
const head = git('rev-parse', '--abbrev-ref', 'HEAD').stdout;
|
|
241
|
-
const target = hub.default_branch || (head && head !== 'HEAD' ? head : 'main');
|
|
270
|
+
const defaultBranch = hub.default_branch || (() => { const h = git('rev-parse', '--abbrev-ref', 'HEAD').stdout; return h && h !== 'HEAD' ? h : 'main'; })();
|
|
271
|
+
// Push is decided AFTER the sync, once we know whether any step advanced: a held step (no advance,
|
|
272
|
+
// not merged) is read-only and pushes nothing; an advance lands on the default branch (see below).
|
|
242
273
|
|
|
243
274
|
// Build the work list: one job per (epic, artifact) — from the event branch, or a full sweep.
|
|
244
275
|
const jobs = [];
|
|
@@ -271,6 +302,7 @@ export async function gateCi(root, { branch, pr, today, push = true, reader = re
|
|
|
271
302
|
|
|
272
303
|
let synced = 0;
|
|
273
304
|
const touched = new Set();
|
|
305
|
+
const advancedEpics = new Set(); // epics whose step actually passed this run (merge OR a swept merge)
|
|
274
306
|
for (const job of jobs) {
|
|
275
307
|
const epicDir = epicRoot(root, job.epic);
|
|
276
308
|
// Event mode (--branch) targets a single epic: fail loudly. Sweep mode skips the bad epic.
|
|
@@ -284,14 +316,14 @@ export async function gateCi(root, { branch, pr, today, push = true, reader = re
|
|
|
284
316
|
continue;
|
|
285
317
|
}
|
|
286
318
|
if (!ledger.state) {
|
|
287
|
-
warn(`${job.epic}: no epic state on
|
|
319
|
+
warn(`${job.epic}: no epic state on the checked-out branch — the review branch is cut from the default branch, so it should carry it`);
|
|
288
320
|
continue;
|
|
289
321
|
}
|
|
290
322
|
const step = findReviewStep(ledger.state, job.artifact);
|
|
291
323
|
if (!step) { warn(`${job.epic}: no review step for ${job.artifact} — skipping`); continue; }
|
|
292
324
|
|
|
293
|
-
// The event may fire before
|
|
294
|
-
// event itself
|
|
325
|
+
// The merge event may fire before any hub-prs record exists (Path B never wrote one pre-merge) —
|
|
326
|
+
// build the entry from the event itself so the advance commit carries it onto the default branch.
|
|
295
327
|
const existing = (ledger.hubPrs || []).find((x) => x.artifact === job.artifact);
|
|
296
328
|
const number = Number(job.pr) || existing?.number || null;
|
|
297
329
|
if (!existing || existing.number !== number || existing.branch !== job.branch) {
|
|
@@ -302,60 +334,78 @@ export async function gateCi(root, { branch, pr, today, push = true, reader = re
|
|
|
302
334
|
writeJSON(ledger.files.hubPrs, ledger.hubPrs);
|
|
303
335
|
}
|
|
304
336
|
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
// fetch (branch deleted after merge) is fine — the artifact already landed via the merge.
|
|
308
|
-
const overlay = artifactPaths(job.base).map((p) => path.join('epics', job.epic, p));
|
|
309
|
-
const fetched = job.branch ? git('fetch', 'origin', job.branch).ok : false;
|
|
310
|
-
if (fetched) for (const p of overlay) git('checkout', 'FETCH_HEAD', '--', p);
|
|
311
|
-
|
|
337
|
+
// No overlay: at merge the artifact is on the default branch CI checked out, so artifactHash
|
|
338
|
+
// binds to the reviewed content directly when CI re-reads the platform.
|
|
312
339
|
let failed = false;
|
|
313
340
|
try {
|
|
314
|
-
|
|
341
|
+
// A branch event that is not a merge can never advance (the predicate requires merged), so it
|
|
342
|
+
// is read-only under Path B — run it as a dry sync that persists nothing to the working tree.
|
|
343
|
+
const r = await gateSync(root, { epic: job.epic, artifact: job.artifact, today, reader, dryRun: !!branch && !merged });
|
|
315
344
|
synced += r.synced;
|
|
345
|
+
// When the step actually ADVANCED (the merge phase, or a swept merge the schedule observed),
|
|
346
|
+
// reflect it in the artifact frontmatter (draft → approved). Keyed off the advance, not the
|
|
347
|
+
// --merged flag, so the GitLab scheduled sweep also flips status on a merge it catches. Never
|
|
348
|
+
// on a held step: CI must not touch the artifact while the owner is editing it pre-merge.
|
|
349
|
+
if (r.advanced > 0) { advancedEpics.add(job.epic); await syncStatuses(root, { epic: job.epic }); }
|
|
316
350
|
} catch (err) {
|
|
317
351
|
if (branch) throw err; // event mode: one epic — surface the failure
|
|
318
352
|
warn(`${job.epic}: sync failed — ${err.message} — skipping this epic`);
|
|
319
353
|
process.exitCode = 1;
|
|
320
354
|
failed = true;
|
|
321
|
-
} finally {
|
|
322
|
-
// Drop the overlay — even when sync throws: only the ledger may reach the default branch via CI.
|
|
323
|
-
if (fetched) {
|
|
324
|
-
for (const p of overlay) {
|
|
325
|
-
git('checkout', 'HEAD', '--', p); // tracked files back to HEAD (no-op fail if not in HEAD)
|
|
326
|
-
git('clean', '-fd', '--', p); // files new on the branch: remove
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
355
|
}
|
|
330
356
|
if (failed) continue; // a failed epic's partial state must not be committed by this run
|
|
331
357
|
touched.add(job.epic);
|
|
332
358
|
}
|
|
333
359
|
if (!touched.size) return { synced };
|
|
334
360
|
|
|
335
|
-
//
|
|
336
|
-
//
|
|
361
|
+
// Path B: CI never writes the ledger to the review branch. A held step that did not advance is
|
|
362
|
+
// read-only here — during review the platform PR/MR is the source of truth (native approvals/
|
|
363
|
+
// threads); the ledger is reconciled onto the default branch at merge. Keeping CI off the PR head
|
|
364
|
+
// is what stops an approval from being dismissed and required checks from stranding. Correctness is
|
|
365
|
+
// unaffected: the merge phase re-reads approvals fresh from the platform (readPr).
|
|
366
|
+
const advancedAny = advancedEpics.size > 0;
|
|
367
|
+
if (!merged && !advancedAny) {
|
|
368
|
+
// Pre-merge is read-only (Path B): the gate was evaluated with a dry sync that persists nothing.
|
|
369
|
+
// The one working-tree write is the hub-prs.json seed above (so the dry sync could find the PR);
|
|
370
|
+
// restore exactly that file per epic so the checkout stays clean — never touching anything else,
|
|
371
|
+
// so a local `yad gate ci --branch` cannot disturb unrelated files.
|
|
372
|
+
for (const e of touched) {
|
|
373
|
+
const hp = path.join('epics', e, '.sdlc', 'hub-prs.json');
|
|
374
|
+
git('checkout', '-q', '--', hp); // restore it if it was tracked
|
|
375
|
+
git('clean', '-fq', '--', hp); // remove it if the event first-seeded it (untracked)
|
|
376
|
+
}
|
|
377
|
+
info('pre-merge: gate evaluated; the ledger reconciles on the default branch at merge — nothing pushed');
|
|
378
|
+
return { synced };
|
|
379
|
+
}
|
|
380
|
+
const target = defaultBranch; // CI only ever commits the ledger to the default branch
|
|
381
|
+
|
|
382
|
+
// Stage what this merge-phase run owns, per epic (everything lands on the default branch):
|
|
383
|
+
// - advanced → the whole epic (ledger advance + the status flip syncStatuses wrote into the .md).
|
|
384
|
+
// - merged but not advanced (merged before the rule passed) → the ledger (.sdlc) + the generated
|
|
385
|
+
// reviews/ summaries only; the artifact is the owner's, left untouched.
|
|
337
386
|
for (const e of touched) {
|
|
338
|
-
|
|
339
|
-
git('add', '-A', '--', path.join('epics', e, '.sdlc'));
|
|
340
|
-
git('add', '-A', '--', path.join('epics', e, 'reviews'));
|
|
387
|
+
if (advancedEpics.has(e)) git('add', '-A', '--', path.join('epics', e));
|
|
388
|
+
else { git('add', '-A', '--', path.join('epics', e, '.sdlc')); git('add', '-A', '--', path.join('epics', e, 'reviews')); }
|
|
341
389
|
}
|
|
342
390
|
if (git('diff', '--cached', '--quiet').ok) { info('ledger unchanged — nothing to commit'); return { synced }; }
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
391
|
+
// [skip ci]: the advance lands on the default branch (no PR trigger) but keeps the marker to guard
|
|
392
|
+
// sibling workflows. CI never pushes the review branch (Path B), so there is no synchronize loop.
|
|
393
|
+
const subject = !branch
|
|
394
|
+
? 'chore(gate): scheduled gate sync [skip ci]' // sweep is a batch; one subject for the run
|
|
395
|
+
: `chore(gate): advance ${jobs[0].epic}/${jobs[0].base} on merge [skip ci]`;
|
|
346
396
|
const cm = git('commit', '-m', subject);
|
|
347
397
|
if (!cm.ok) { fail(`commit failed: ${cm.stderr || cm.stdout}`); process.exitCode = 1; return { synced }; }
|
|
348
|
-
ok(`committed
|
|
398
|
+
ok(`committed gate update: ${c.dim(subject)}`);
|
|
349
399
|
if (!push) return { synced };
|
|
350
400
|
|
|
351
401
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
352
|
-
if (git('push', 'origin', `HEAD:${target}`).ok) { ok(`pushed
|
|
402
|
+
if (git('push', 'origin', `HEAD:${target}`).ok) { ok(`pushed to origin/${target}`); return { synced }; }
|
|
353
403
|
if (attempt < 3) {
|
|
354
404
|
info(`push rejected — rebasing onto origin/${target} and retrying (${attempt}/3)`);
|
|
355
405
|
if (!git('pull', '--rebase', 'origin', target).ok) git('rebase', '--abort'); // never leave a wedged rebase
|
|
356
406
|
}
|
|
357
407
|
}
|
|
358
|
-
fail(`could not push
|
|
408
|
+
fail(`could not push to origin/${target}${merged ? ' — protected default branch? allow the gate bot to push the merge advance (see yad-hub-bridge references/bridge.md)' : ''} — or run \`yad gate sync\` locally`);
|
|
359
409
|
process.exitCode = 1;
|
|
360
410
|
return { synced };
|
|
361
411
|
}
|
|
@@ -412,12 +462,22 @@ export async function gateOpen(root, { epic, artifact } = {}) {
|
|
|
412
462
|
const domains = touchedDomains(epicDir, step);
|
|
413
463
|
warnUnlockedContract(epicDir, artifact);
|
|
414
464
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
465
|
+
const bridge = isBridge(hub);
|
|
466
|
+
// Outside bridge mode (file-only, OR a platform with no gate-sync CI) there is no CI to write the
|
|
467
|
+
// ledger, so the local command marks the step in_review. In bridge mode CI is the sole writer.
|
|
468
|
+
if (!bridge) {
|
|
469
|
+
ledger.state = markInReview(ledger.state, step);
|
|
470
|
+
writeJSON(ledger.files.state, ledger.state);
|
|
471
|
+
}
|
|
472
|
+
if (!hub?.platform) {
|
|
473
|
+
warn('no hub platform — marked in_review file-only (no PR opened)');
|
|
474
|
+
ok(`${step.id} → in_review`);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
420
477
|
|
|
478
|
+
// Open the PR. In bridge mode CI records the hub-prs entry (and advances) on the default branch at
|
|
479
|
+
// merge — `yad gate open` never commits gate-state files (the ledger-guard check enforces that), and
|
|
480
|
+
// CI writes nothing pre-merge. Without the bridge, the local command records the PR itself (no CI will).
|
|
421
481
|
const body = fillHubTemplate({ epic, artifact, step, owner: ownerOf(epicDir), domains });
|
|
422
482
|
// Assignee = whoever opens the review PR (the committer); reviewers = the hub's reviewers +
|
|
423
483
|
// domain-owners of the touched repos, minus the committer (the owner/author is recorded, not asked
|
|
@@ -428,13 +488,16 @@ export async function gateOpen(root, { epic, artifact } = {}) {
|
|
|
428
488
|
const labels = isEscalated(step) ? domains.map((d) => `domain:${d}`) : [];
|
|
429
489
|
info(`opening review ${hub.platform === 'gitlab' ? 'MR' : 'PR'} on branch ${branch} …`);
|
|
430
490
|
const r = createPr(hub.platform, { title: `review: ${artifact} (${epic})`, body, base: hub.default_branch || 'main', head: branch, reviewers, assignees, labels, cwd: root });
|
|
431
|
-
if (!r.ok) { warn(`could not open PR (${r.reason || 'unknown'}); step is in_review file-only`); return; }
|
|
491
|
+
if (!r.ok) { warn(`could not open PR (${r.reason || 'unknown'})${bridge ? ' — open it manually; CI records the gate on merge' : '; step is in_review file-only'}`); return; }
|
|
432
492
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
493
|
+
if (!bridge) {
|
|
494
|
+
ledger.hubPrs = upsertHubPr(ledger.hubPrs, { step: step.id, artifact, platform: hub.platform, number: Number((r.url.match(/\/(\d+)(?:[/?#]|$)/) || [])[1]) || null, url: r.url, branch, lastSyncedAt: null });
|
|
495
|
+
writeJSON(ledger.files.hubPrs, ledger.hubPrs);
|
|
496
|
+
}
|
|
436
497
|
ok(`opened ${r.url}`);
|
|
437
|
-
hand(
|
|
498
|
+
hand(bridge
|
|
499
|
+
? 'reviewers approve/comment there; CI advances the gate on the default branch when it is merged'
|
|
500
|
+
: `reviewers approve/comment there; then run \`yad gate sync ${epic} ${artifact}\``);
|
|
438
501
|
}
|
|
439
502
|
|
|
440
503
|
// ---- helpers ------------------------------------------------------------------------------------
|
package/cli/manifest.mjs
CHANGED
|
@@ -195,6 +195,8 @@ export const wiringFor = (platform) => [
|
|
|
195
195
|
export const HUB_WIRING = {
|
|
196
196
|
common: [
|
|
197
197
|
{ src: 'skills/yad-checks/templates/checks/verified-commits.sh', dest: 'checks/verified-commits.sh', exec: true },
|
|
198
|
+
// The ledger is CI-owned: block non-bot commits to gate-state files on hub review PRs.
|
|
199
|
+
{ src: 'skills/yad-checks/templates/checks/ledger-guard.sh', dest: 'checks/ledger-guard.sh', exec: true },
|
|
198
200
|
// Pattern gates run on the hub too (profile: hub) — commit subject + PR title + PR body.
|
|
199
201
|
{ src: 'skills/yad-checks/templates/checks/commit-message.sh', dest: 'checks/commit-message.sh', exec: true },
|
|
200
202
|
{ src: 'skills/yad-pr-template/templates/checks/pr-title.sh', dest: 'checks/pr-title.sh', exec: true },
|