yadflow 3.7.1 → 3.8.1

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,9 +1,9 @@
1
- ## [3.7.1](https://github.com/abdelrahmannasr/yadflow/compare/v3.7.0...v3.7.1) (2026-07-04)
1
+ ## [3.8.1](https://github.com/abdelrahmannasr/yadflow/compare/v3.8.0...v3.8.1) (2026-07-05)
2
2
 
3
3
 
4
4
  ### Bug Fixes
5
5
 
6
- * **ledger:** sanitize shard-name components against path traversal ([5d85286](https://github.com/abdelrahmannasr/yadflow/commit/5d85286591cc9e6c78e449551c82d87683accba5))
6
+ * **hub-bridge:** filter glab api output with jq in gate-sync ([352c681](https://github.com/abdelrahmannasr/yadflow/commit/352c681f9e5c6d43688410cfc5092c2382ec881e)), closes [#108](https://github.com/abdelrahmannasr/yadflow/issues/108)
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/README.md CHANGED
@@ -77,8 +77,10 @@ In one pass it produces:
77
77
  - **`.sdlc/` config** — the product hub, connected repos, reviewer roster, and tool connections
78
78
  (design, testing, learning), all as plain JSON you can read and diff.
79
79
  - **CI gates**, wired into every connected repo and the hub as **GitHub Actions or GitLab CI** —
80
- spec-link, contract-check, verified-commits, build/test/lint, and the feature-thread gates, shipped
81
- as CI-agnostic bash under `checks/`.
80
+ spec-link, contract-check, verified-commits, build/test/lint, the feature-thread gates, and the
81
+ push-on-main **`yad-update-guard`** (which re-checks any direct-to-default commit — e.g. from
82
+ `yad update --push` — with just `verified-commits` + `commit-message`), shipped as CI-agnostic bash
83
+ under `checks/`.
82
84
  - **PR/MR templates** and an opt-in CodeRabbit config.
83
85
 
84
86
  Your first `yad-epic` seeds the `epics/EP-<slug>/` ledger — state, approvals, and the contract lock —
package/bin/yad.mjs CHANGED
@@ -33,6 +33,11 @@ ${c.bold('Setup & maintenance')}
33
33
  yad update Apply drift only (alias for: check --fix --scope=changed);
34
34
  installs newly-added skills, updates changed skills + gate scripts,
35
35
  and migrates pre-2.0 sdlc-* installs to the yad-* names
36
+ yad update --push Also commit each repo's applied changes and push them straight to the
37
+ default branch of the hub + every connected repo (a chore(yad-update)
38
+ commit; no PR — the push-on-main yad-update-guard runs verified-commits
39
+ + commit-message). Announce the team & pause merges first. Also works as
40
+ 'yad check --fix --push'; --allow-branch permits a non-default branch
36
41
  yad doctor [--json] Environment + state health: tools/auth, config files,
37
42
  repo paths, epic ledgers (exit 1 on any failure)
38
43
  yad sync-status [epic] Update artifact frontmatter status (draft/in-review/approved)
@@ -130,6 +135,8 @@ ${c.bold('Options')}
130
135
  --pr <n> gate ci: the PR/MR number from the CI event
131
136
  --merged gate ci: merge phase — advance the step on the default branch
132
137
  --no-push gate ci: commit the ledger but do not push
138
+ --push check --fix / update: commit + push applied changes to the default branch
139
+ --allow-branch check --fix --push / update --push: allow committing on a non-default branch
133
140
  -h, --help Show this help
134
141
  -v, --version Print version`;
135
142
 
@@ -198,10 +205,10 @@ async function main() {
198
205
  });
199
206
  break;
200
207
  case 'check':
201
- await reconcile(o.dir, { fix: o.fix, scope: o.scope, force: o.force, today });
208
+ await reconcile(o.dir, { fix: o.fix, scope: o.scope, force: o.force, push: o.push, allowBranch: o.allowBranch, today });
202
209
  break;
203
210
  case 'update':
204
- await reconcile(o.dir, { fix: true, scope: 'changed', force: o.force, today });
211
+ await reconcile(o.dir, { fix: true, scope: 'changed', force: o.force, push: o.push, allowBranch: o.allowBranch, today });
205
212
  break;
206
213
  case 'doctor':
207
214
  await runDoctor(o.dir, { json: o.json });
package/cli/manifest.mjs CHANGED
@@ -209,10 +209,13 @@ export const REPO_WIRING = {
209
209
  ],
210
210
  github: [
211
211
  { src: 'skills/yad-checks/templates/github/yad-checks.yml', dest: '.github/workflows/yad-checks.yml' },
212
+ // Integrity gate for direct-to-default pushes (`yad update --push`): verified-commits + commit-message.
213
+ { src: 'skills/yad-checks/templates/github/yad-update-guard.yml', dest: '.github/workflows/yad-update-guard.yml' },
212
214
  { src: 'skills/yad-pr-template/templates/github/pull_request_template.md', dest: '.github/pull_request_template.md' },
213
215
  ],
214
216
  gitlab: [
215
217
  { src: 'skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml', dest: '.gitlab/ci/yad-checks.yml' },
218
+ { src: 'skills/yad-checks/templates/gitlab/yad-update-guard.gitlab-ci.yml', dest: '.gitlab/ci/yad-update-guard.yml' },
216
219
  { src: 'skills/yad-pr-template/templates/gitlab/merge_request_templates/Default.md', dest: '.gitlab/merge_request_templates/Default.md' },
217
220
  ],
218
221
  };
@@ -240,10 +243,14 @@ export const HUB_WIRING = {
240
243
  { src: 'skills/yad-hub-bridge/templates/github/yad-gate-sync.yml', dest: '.github/workflows/yad-gate-sync.yml' },
241
244
  { src: 'skills/yad-checks/templates/github/yad-verified-commits.yml', dest: '.github/workflows/yad-verified-commits.yml' },
242
245
  { src: 'skills/yad-checks/templates/github/yad-hub-checks.yml', dest: '.github/workflows/yad-hub-checks.yml' },
246
+ // Integrity gate for the hub's own direct-to-default pushes (`yad update --push`; the machine-
247
+ // state `yad checkpoint`/`gate ci` commits carry [skip ci] and are intentionally not re-checked).
248
+ { src: 'skills/yad-checks/templates/github/yad-update-guard.yml', dest: '.github/workflows/yad-update-guard.yml' },
243
249
  ],
244
250
  gitlab: [
245
251
  { src: 'skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml', dest: '.gitlab/ci/yad-gate-sync.yml' },
246
252
  { src: 'skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml', dest: '.gitlab/ci/yad-verified-commits.yml' },
247
253
  { src: 'skills/yad-checks/templates/gitlab/yad-hub-checks.gitlab-ci.yml', dest: '.gitlab/ci/yad-hub-checks.yml' },
254
+ { src: 'skills/yad-checks/templates/gitlab/yad-update-guard.gitlab-ci.yml', dest: '.gitlab/ci/yad-update-guard.yml' },
248
255
  ],
249
256
  };
package/cli/plan.mjs CHANGED
@@ -11,17 +11,28 @@ import {
11
11
  LEGACY_SKILLS, REMOVED_SKILLS, LEGACY_MARKER, LEGACY_REPO_FILES, LEGACY_HUB_FILES,
12
12
  } from './manifest.mjs';
13
13
 
14
- // status: 'ok' | 'missing' | 'outdated'
15
- const fileAction = (scope, item, src, dest, opts = {}) => ({
14
+ // A git pathspec (forward slashes, relative to a repo root) for `dest` under `root`. Actions carry
15
+ // these so `yad update --push` can stage an EXPLICIT allowlist of exactly what it wrote per repo —
16
+ // never `git add -A` (see cli/update-commit.mjs). A directory pathspec stages every added/changed/
17
+ // removed file underneath it, so a dirAction needs only its top-level dest.
18
+ const rel = (root, dest) => path.relative(root, dest).split(path.sep).join('/');
19
+
20
+ // status: 'ok' | 'missing' | 'outdated'. `root` is the repo the write lands in (the hub for module
21
+ // installs, a connected repo for its wiring); `paths` is the pathspec(s) touched, for the push stage.
22
+ const fileAction = (scope, item, src, dest, { root, exec = false } = {}) => ({
16
23
  scope,
17
24
  item,
18
25
  status: !exists(dest) ? 'missing' : sameContent(src, dest) ? 'ok' : 'outdated',
19
- apply: () => copyFile(src, dest, opts),
26
+ root,
27
+ paths: root ? [rel(root, dest)] : [],
28
+ apply: () => copyFile(src, dest, { exec }),
20
29
  });
21
- const dirAction = (scope, item, src, dest) => ({
30
+ const dirAction = (scope, item, src, dest, { root } = {}) => ({
22
31
  scope,
23
32
  item,
24
33
  status: !exists(dest) ? 'missing' : dirMatches(src, dest) ? 'ok' : 'outdated',
34
+ root,
35
+ paths: root ? [rel(root, dest)] : [],
25
36
  apply: () => copyDir(src, dest),
26
37
  });
27
38
 
@@ -50,6 +61,7 @@ export function moduleActions(root, ideTargets = ideTargetsFor(root)) {
50
61
  ide, s,
51
62
  asset('skills', s, 'SKILL.md'),
52
63
  path.join(root, IDE_OPENCODE_DIR, `${s}.md`),
64
+ { root },
53
65
  )));
54
66
  }
55
67
  } else {
@@ -58,6 +70,7 @@ export function moduleActions(root, ideTargets = ideTargetsFor(root)) {
58
70
  ide, s,
59
71
  asset('skills', s),
60
72
  path.join(root, ide, 'skills', s),
73
+ { root },
61
74
  )));
62
75
  }
63
76
  }
@@ -67,6 +80,7 @@ export function moduleActions(root, ideTargets = ideTargetsFor(root)) {
67
80
  '_bmad', f,
68
81
  asset('skills', 'sdlc', f),
69
82
  path.join(root, '_bmad', 'sdlc', f),
83
+ { root },
70
84
  ));
71
85
  }
72
86
  return actions;
@@ -83,26 +97,32 @@ export function legacyModuleActions(root, ideTargets = ideTargetsFor(root)) {
83
97
  for (const [skill, old] of Object.entries(LEGACY_SKILLS)) {
84
98
  if (ide === '.opencode') {
85
99
  const oldDest = path.join(root, IDE_OPENCODE_DIR, `${old}.md`);
100
+ const newDest = path.join(root, IDE_OPENCODE_DIR, `${skill}.md`);
86
101
  if (!exists(oldDest)) continue;
87
102
  actions.push({
88
103
  scope: ide,
89
104
  item: `${old}.md → ${skill}.md`,
90
105
  status: 'legacy',
106
+ root,
107
+ paths: [rel(root, oldDest), rel(root, newDest)],
91
108
  apply: () => {
92
109
  fs.rmSync(oldDest, { force: true });
93
- copyFile(asset('skills', skill, 'SKILL.md'), path.join(root, IDE_OPENCODE_DIR, `${skill}.md`));
110
+ copyFile(asset('skills', skill, 'SKILL.md'), newDest);
94
111
  },
95
112
  });
96
113
  } else {
97
114
  const oldDest = path.join(root, ide, 'skills', old);
115
+ const newDest = path.join(root, ide, 'skills', skill);
98
116
  if (!exists(oldDest)) continue;
99
117
  actions.push({
100
118
  scope: ide,
101
119
  item: `${old} → ${skill}`,
102
120
  status: 'legacy',
121
+ root,
122
+ paths: [rel(root, oldDest), rel(root, newDest)],
103
123
  apply: () => {
104
124
  fs.rmSync(oldDest, { recursive: true, force: true });
105
- copyDir(asset('skills', skill), path.join(root, ide, 'skills', skill));
125
+ copyDir(asset('skills', skill), newDest);
106
126
  },
107
127
  });
108
128
  }
@@ -127,6 +147,8 @@ export function removedModuleActions(root, ideTargets = ideTargetsFor(root)) {
127
147
  scope: ide,
128
148
  item: `${skill}.md (removed)`,
129
149
  status: 'removed',
150
+ root,
151
+ paths: [rel(root, dest)],
130
152
  apply: () => fs.rmSync(dest, { force: true }),
131
153
  });
132
154
  } else {
@@ -136,6 +158,8 @@ export function removedModuleActions(root, ideTargets = ideTargetsFor(root)) {
136
158
  scope: ide,
137
159
  item: `${skill} (removed)`,
138
160
  status: 'removed',
161
+ root,
162
+ paths: [rel(root, dest)],
139
163
  apply: () => fs.rmSync(dest, { recursive: true, force: true }),
140
164
  });
141
165
  }
@@ -162,10 +186,17 @@ function legacyFileActions(scope, baseRoot, fileMap, wiring) {
162
186
  if (!ownedByOldInstall(oldPath)) continue;
163
187
  const w = wiring.find((x) => x.dest === newDest);
164
188
  if (!w) continue; // never delete a working file without a replacement to install
189
+ // Only claim the root .gitlab-ci.yml when apply() will actually rewrite it (it references the old
190
+ // fragment) — else a --push would sweep the user's unrelated edits to that shared-ownership file
191
+ // into the chore(yad-update) commit. The old (deletion) + new (add) paths are always ours.
192
+ let rewritesRootCi = false;
193
+ try { rewritesRootCi = fs.readFileSync(path.join(baseRoot, '.gitlab-ci.yml'), 'utf8').includes(oldDest); } catch { /* no root ci */ }
165
194
  actions.push({
166
195
  scope,
167
196
  item: `${oldDest} → ${newDest}`,
168
197
  status: 'legacy',
198
+ root: baseRoot,
199
+ paths: rewritesRootCi ? [oldDest, newDest, '.gitlab-ci.yml'] : [oldDest, newDest],
169
200
  apply: () => {
170
201
  fs.rmSync(oldPath, { force: true });
171
202
  copyFile(asset(w.src), path.join(baseRoot, newDest), { exec: !!w.exec });
@@ -195,7 +226,7 @@ export function legacyHubActions(root) {
195
226
  export function repoActions(root, repo) {
196
227
  const repoRoot = path.resolve(root, repo.path);
197
228
  return wiringFor(repo.platform).map((w) =>
198
- fileAction(repo.name, w.dest, asset(w.src), path.join(repoRoot, w.dest), { exec: !!w.exec }),
229
+ fileAction(repo.name, w.dest, asset(w.src), path.join(repoRoot, w.dest), { root: repoRoot, exec: !!w.exec }),
199
230
  );
200
231
  }
201
232
 
@@ -207,7 +238,7 @@ export function hubActions(root) {
207
238
  // wrote `bridge` — accept an explicit true in either spelling, wire nothing otherwise.
208
239
  if (!hub?.platform || !(hub.bridge_enabled === true || hub.bridge === true)) return [];
209
240
  return [...HUB_WIRING.common, ...(HUB_WIRING[hub.platform] || [])].map((w) =>
210
- fileAction('hub', w.dest, asset(w.src), path.join(root, w.dest), { exec: !!w.exec }),
241
+ fileAction('hub', w.dest, asset(w.src), path.join(root, w.dest), { root, exec: !!w.exec }),
211
242
  );
212
243
  }
213
244
 
@@ -238,13 +269,18 @@ export function authorsActions(root, repos = []) {
238
269
  ...emails,
239
270
  ].join('\n') + '\n';
240
271
  const targets = [
241
- { scope: 'hub', dest: path.join(root, '.sdlc', 'verified-authors') },
242
- ...repos.map((r) => ({ scope: r.name, dest: path.join(path.resolve(root, r.path), '.sdlc', 'verified-authors') })),
272
+ { scope: 'hub', root, dest: path.join(root, '.sdlc', 'verified-authors') },
273
+ ...repos.map((r) => {
274
+ const repoRoot = path.resolve(root, r.path);
275
+ return { scope: r.name, root: repoRoot, dest: path.join(repoRoot, '.sdlc', 'verified-authors') };
276
+ }),
243
277
  ];
244
- return targets.map(({ scope, dest }) => ({
278
+ return targets.map(({ scope, root: targetRoot, dest }) => ({
245
279
  scope,
246
280
  item: '.sdlc/verified-authors',
247
281
  status: !exists(dest) ? 'missing' : fs.readFileSync(dest, 'utf8') === desired ? 'ok' : 'outdated',
282
+ root: targetRoot,
283
+ paths: [rel(targetRoot, dest)],
248
284
  apply: () => {
249
285
  fs.mkdirSync(path.dirname(dest), { recursive: true });
250
286
  fs.writeFileSync(dest, desired);
package/cli/reconcile.mjs CHANGED
@@ -1,20 +1,41 @@
1
1
  // `yad check` (report) and `yad check --fix` (reconcile) — and `yad update`
2
2
  // as a thin alias (--scope=changed). Inspects actual project state against the
3
3
  // manifest: missing setup, drifted files, stale code-context.
4
+ import fs from 'node:fs';
4
5
  import path from 'node:path';
5
6
  import {
6
- c, log, ok, info, warn, hand, readJSON, writeJSON, exists,
7
+ c, log, ok, info, warn, hand, readJSON, writeJSON, exists, run,
7
8
  } from './lib.mjs';
9
+
10
+ const readFileSafe = (p) => { try { return fs.readFileSync(p, 'utf8'); } catch { return ''; } };
11
+
12
+ // The yad-update-guard will reject the very commits `--push` is about to create unless they are
13
+ // signed AND their author email is allowlisted. Warn up front (never block) so the operator isn't
14
+ // surprised by a reddened default branch across every repo. Best-effort, hub-identity based.
15
+ function preflightGuardReadiness(root) {
16
+ const gitcfg = (k) => run('git', ['config', '--get', k], { cwd: root }).stdout;
17
+ // Only commit.gpgsign actually enables signing — user.signingkey merely picks WHICH key once
18
+ // signing is on, so it must not count (it would hide the warning while commits stay unsigned).
19
+ const signing = run('git', ['config', '--bool', '--get', 'commit.gpgsign'], { cwd: root }).stdout === 'true';
20
+ if (!signing) warn('commit signing is not enabled (git config commit.gpgsign true) — the yad-update-guard requires a platform-Verified signature; unsigned pushes will fail the gate.');
21
+ const email = gitcfg('user.email').toLowerCase();
22
+ const allow = readFileSafe(path.join(root, '.sdlc', 'verified-authors'));
23
+ const known = allow.split('\n').map((l) => l.trim().toLowerCase()).filter((l) => l && !l.startsWith('#'));
24
+ if (known.length && email && !known.includes(email)) {
25
+ warn(`your git email <${email}> is not in .sdlc/verified-authors — the yad-update-guard will reject these commits (add it to the hub roster and re-run \`yad check --fix\`).`);
26
+ }
27
+ }
8
28
  import { VERSION, PROJECT_FILES } from './manifest.mjs';
9
29
  import {
10
30
  moduleActions, repoActions, hubActions, authorsActions,
11
31
  legacyModuleActions, removedModuleActions, legacyRepoActions, legacyHubActions,
12
32
  } from './plan.mjs';
13
33
  import { gitHead, packRepo } from './setup.mjs';
34
+ import { groupByRoot, commitUpdates } from './update-commit.mjs';
14
35
 
15
36
  const MARK = { missing: c.red('missing'), new: c.cyan('new'), outdated: c.yellow('outdated'), stale: c.yellow('stale'), legacy: c.yellow('legacy'), removed: c.yellow('removed'), ok: c.green('ok') };
16
37
 
17
- export async function reconcile(root, { fix = false, scope = 'all', force = false } = {}) {
38
+ export async function reconcile(root, { fix = false, scope = 'all', force = false, push = false, allowBranch = false } = {}) {
18
39
  log(c.bold(`\nSDLC reconcile ${c.dim('v' + VERSION)}`));
19
40
  log(c.dim(`target: ${root}\n`));
20
41
 
@@ -41,7 +62,18 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
41
62
  const head = gitHead(path.resolve(root, repo.path));
42
63
  if (head && repo.syncedHead && head !== repo.syncedHead) {
43
64
  staleRepos.push(repo);
44
- actions.push({ scope: repo.name, item: 'code-context', status: 'stale', apply: () => packRepo(root, repo) });
65
+ // packRepo writes the repomix cache under the HUB root (root/repo.contextPack, e.g.
66
+ // .sdlc/code-context/<name>/pack.md), so the touched path belongs to the hub — and is commonly
67
+ // gitignored, in which case the push stage's check-ignore drops it. codeMap is AI-generated
68
+ // later, not here, so only the pack is claimed.
69
+ actions.push({
70
+ scope: repo.name,
71
+ item: 'code-context',
72
+ status: 'stale',
73
+ root,
74
+ paths: repo.contextPack ? [repo.contextPack] : [],
75
+ apply: () => packRepo(root, repo),
76
+ });
45
77
  }
46
78
  }
47
79
 
@@ -68,25 +100,55 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
68
100
  log(c.dim(`summary: ${counts.missing} missing, ${counts.new} new, ${counts.outdated} outdated, ${counts.stale} stale, ${counts.legacy} legacy, ${counts.removed} removed, ${counts.ok} ok`));
69
101
 
70
102
  if (!fix) {
103
+ if (push) warn('--push has no effect without --fix (there is nothing applied to commit).');
71
104
  if (fixable.length || gaps.length) hand('run `yad check --fix` to reconcile (or `yad setup` for missing one-time setup).');
72
105
  return { counts, gaps, applied: 0 };
73
106
  }
74
107
 
75
- // --- apply ---
108
+ // --- apply --- (collect the applied actions so --push can stage each repo's exact allowlist) ---
76
109
  log('');
77
110
  let applied = 0;
111
+ const appliedActions = [];
78
112
  for (const a of fixable) {
79
113
  a.apply();
80
114
  applied++;
115
+ appliedActions.push(a);
81
116
  info(`${a.status} → fixed: ${a.scope}/${a.item}`);
82
117
  }
83
118
  if (force) {
84
- for (const a of actions.filter((a) => a.status === 'ok')) a.apply();
119
+ for (const a of actions.filter((a) => a.status === 'ok')) { a.apply(); appliedActions.push(a); }
85
120
  }
86
- // refresh the version stamp (preserve recorded ideTargets)
121
+ // refresh the version stamp (preserve recorded ideTargets) and let it ride the hub's update commit
87
122
  const rec = readJSON(path.join(root, PROJECT_FILES.version), {});
88
123
  writeJSON(path.join(root, PROJECT_FILES.version), { ...rec, version: VERSION });
124
+ appliedActions.push({ scope: 'hub', item: PROJECT_FILES.version, status: 'stamp', root, paths: [PROJECT_FILES.version] });
89
125
  applied ? ok(`reconciled ${applied} item(s)`) : info('nothing to fix');
90
126
  if (gaps.length) hand('one-time setup still missing — run `yad setup`.');
127
+
128
+ // --- publish: commit each repo's applied changes and push directly to its default branch ---
129
+ if (push) {
130
+ preflightGuardReadiness(root);
131
+ const hub = readJSON(path.join(root, PROJECT_FILES.hubConfig), {});
132
+ const defByRoot = new Map([[root, hub?.default_branch]]);
133
+ const platformByRoot = new Map([[root, hub?.platform]]);
134
+ for (const repo of registry.repos) {
135
+ const repoRoot = path.resolve(root, repo.path);
136
+ defByRoot.set(repoRoot, repo.default_branch);
137
+ platformByRoot.set(repoRoot, repo.platform);
138
+ }
139
+ commitUpdates(root, groupByRoot(appliedActions), {
140
+ push: true, allowBranch,
141
+ defaultBranchFor: (r) => defByRoot.get(r),
142
+ // GitLab's yad-update-guard is an includable fragment: unlike a GitHub workflow it does nothing
143
+ // until the root .gitlab-ci.yml `include:`s it (added by the yad-checks skill wire step, not by
144
+ // reconcile). Flag a gitlab repo whose root pipeline lacks the include so the direct-to-default
145
+ // push isn't left silently unguarded.
146
+ guardInactiveGitlab: (r) => {
147
+ if (platformByRoot.get(r) !== 'gitlab') return false;
148
+ const ci = path.join(r, '.gitlab-ci.yml');
149
+ return !exists(ci) || !readFileSafe(ci).includes('yad-update-guard.yml');
150
+ },
151
+ });
152
+ }
91
153
  return { counts, gaps, applied };
92
154
  }
@@ -0,0 +1,195 @@
1
+ // `yad update --push` / `yad check --fix --push` — after reconcile applies drift into the working
2
+ // trees, commit the applied changes PER REPO (the hub and every connected repo) and push them
3
+ // straight to the default branch, so a package update "just lands" everywhere instead of leaving
4
+ // dirty trees for someone to hand-commit across N repos. This is the update-flow analogue of the
5
+ // back-half `yad checkpoint` (cli/checkpoint.mjs) and reuses its machine-commit machinery.
6
+ //
7
+ // Invariants (shared with checkpoint):
8
+ // 1. Stage an EXPLICIT per-repo allowlist — exactly the paths the reconcile actions declared they
9
+ // wrote (never `git add -A`, which would sweep unrelated work in a connected repo). Ignored
10
+ // paths (a consumer repo that .gitignores .claude/ or the repomix code-context cache) are
11
+ // dropped, and the commit is scoped to the staged allowlist so any pre-staged unrelated file
12
+ // never rides along.
13
+ // 2. Commit ONLY on each repo's default branch; a repo on a feature branch is SKIPPED with a
14
+ // warning (unless --allow-branch) — never disrupted.
15
+ //
16
+ // UNLIKE checkpoint, the commit is NOT marked `[skip ci]`: it goes straight to the default branch
17
+ // with NO PR (so the pull_request gate suite never fires), and a push-on-main integrity workflow
18
+ // (yad-update-guard) runs ONLY verified-commits + commit-message over it — the "skipped from CI
19
+ // except verified-commits + the pattern gates" contract. The subject is a valid `chore` commit so it
20
+ // passes commit-message and the story-linking gates treat it as an exempt maintenance commit; no
21
+ // Task trailer, no Co-Authored-By (human-owned machine state, not an authored code change).
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import { c, log, ok, info, warn, fail, hand, run, pushWithRebase } from './lib.mjs';
25
+ import { VERSION } from './manifest.mjs';
26
+ import { hubGit, resolveDefaultBranch } from './hubcommit.mjs';
27
+
28
+ // Collapse whitespace/newline runs to a single space — keeps a stray path or hostile value from
29
+ // breaking the one-line subject or injecting a fake trailer line.
30
+ const oneLine = (s = '') => String(s).replace(/\s+/g, ' ').trim();
31
+
32
+ // A short, human label for a repo root relative to the hub: 'hub' for the hub itself, else the
33
+ // registered path (e.g. demo-repos/backend), else the basename.
34
+ export function repoLabel(hubRoot, root) {
35
+ if (root === hubRoot) return 'hub';
36
+ const r = path.relative(hubRoot, root);
37
+ return r && !r.startsWith('..') ? r.split(path.sep).join('/') : path.basename(root);
38
+ }
39
+
40
+ // PURE — group applied reconcile actions by the repo root they wrote to. Each group carries the
41
+ // deduped pathspecs to stage plus the human-readable item labels for the commit body. Actions with
42
+ // no root/paths (e.g. gaps, or a status:'ok' that was force-reapplied without a root) are ignored.
43
+ // Order follows first-encounter, so the hub (its module/_bmad/hub actions come first in reconcile)
44
+ // leads and connected repos follow in registry order.
45
+ export function groupByRoot(actions = []) {
46
+ const groups = new Map();
47
+ for (const a of actions) {
48
+ if (!a || !a.root || !Array.isArray(a.paths) || !a.paths.length) continue;
49
+ if (!groups.has(a.root)) groups.set(a.root, { root: a.root, paths: new Set(), items: [] });
50
+ const g = groups.get(a.root);
51
+ for (const p of a.paths) g.paths.add(p);
52
+ // Stage every path, but keep only real *changes* in the body list: a `--force` re-copy of an
53
+ // already-correct file (status 'ok') produces no diff, and the version stamp ('stamp') is implied
54
+ // by the subject — neither belongs in the "Updated:" list.
55
+ if (a.status !== 'ok' && a.status !== 'stamp') g.items.push(`${a.scope}/${a.item}`);
56
+ }
57
+ return [...groups.values()].map((g) => ({ root: g.root, paths: [...g.paths], items: g.items }));
58
+ }
59
+
60
+ // PURE — the update commit message. Subject `chore(yad-update): sync SDLC install to yadflow vX.Y.Z`
61
+ // passes the commit-message gate (valid type `chore`, lowercase scope, non-empty description, no
62
+ // trailing period) and the `chore` type makes it an exempt maintenance commit for the story-linking
63
+ // gates. Deliberately NO `[skip ci]` (we want the yad-update-guard integrity workflow to run), NO
64
+ // Task trailer, NO Co-Authored-By. Body lists the changed items; each is a `- ` bullet (never a
65
+ // `key: value` line) so `git interpret-trailers` can't mistake it for a trailer.
66
+ export function buildUpdateMessage({ version = VERSION, items = [] } = {}) {
67
+ const subject = oneLine(`chore(yad-update): sync SDLC install to yadflow v${version}`);
68
+ const body = items.length ? `Updated:\n${items.map((i) => `- ${oneLine(i)}`).join('\n')}` : '';
69
+ return body ? `${subject}\n\n${body}` : subject;
70
+ }
71
+
72
+ // Stage exactly `paths` under `root`, keeping only pathspecs safe to hand `git add`:
73
+ // - not git-ignored (naming an ignored path to `git add` is a hard error, not a no-op — so a
74
+ // consumer repo that .gitignores .claude/ or the repomix code-context cache is respected), AND
75
+ // - present in the working tree (an add/modify) OR tracked in the index (so a deletion — e.g. a
76
+ // legacy rename's old file — stages) — this also drops a no-match pathspec (the root
77
+ // .gitlab-ci.yml a github repo never has) that would otherwise abort `git add`.
78
+ // Returns the pathspecs actually staged.
79
+ function stageAllowlist(git, root, paths) {
80
+ const keep = paths.filter((p) => {
81
+ if (run('git', ['check-ignore', '-q', '--', p], { cwd: root }).ok) return false;
82
+ if (fs.existsSync(path.join(root, p))) return true;
83
+ return run('git', ['ls-files', '--error-unmatch', '--', p], { cwd: root }).ok;
84
+ });
85
+ if (!keep.length) return { staged: [] };
86
+ // `git add` can exit non-zero (index lock, permissions) yet run() never throws — surface it rather
87
+ // than silently reporting "nothing to commit". (A directory pathspec whose contents are ALL ignored
88
+ // also exits 1 after staging the rest; the empty-diff below then correctly drops it.)
89
+ const add = git('add', '--', ...keep);
90
+ const staged = git('diff', '--cached', '--name-only', '--', ...keep).stdout.split('\n').filter(Boolean);
91
+ return { staged, addError: !add.ok ? (add.stderr.split('\n')[0] || `exit ${add.code}`) : null };
92
+ }
93
+
94
+ // Commit (and, with push, push) one repo group. `defaultBranch` is the repo's configured default
95
+ // (hub.default_branch / repo.default_branch); falls back to origin/HEAD then 'main'. Returns a small
96
+ // result object; never throws. On any hard error it sets process.exitCode so the CLI reports failure.
97
+ export function commitAndPush(group, { push = false, allowBranch = false, hubRoot, defaultBranch } = {}) {
98
+ const { root, paths, items } = group;
99
+ const label = repoLabel(hubRoot ?? root, root);
100
+ const git = hubGit(root);
101
+
102
+ // `root` must be the TOP of its OWN git repo — not merely "inside a work tree". A registered repo
103
+ // whose clone is missing (reconcile's apply() happily recreates the wiring files) but whose path
104
+ // sits under the hub would otherwise report inside-work-tree=true against the HUB: git resolves the
105
+ // pathspecs relative to cwd, so we would stage the connected repo's files into the HUB's index and
106
+ // push them to the HUB's remote, mislabeled. Require the worktree top to BE this root.
107
+ const top = git('rev-parse', '--show-toplevel');
108
+ const sameRepo = top.ok && (() => {
109
+ try { return fs.realpathSync(top.stdout) === fs.realpathSync(root); } catch { return false; }
110
+ })();
111
+ if (!sameRepo) {
112
+ warn(`${label}: not its own git repo (missing/renamed clone?) — skipped (changes left in the working tree)`);
113
+ return { label, committed: false, skipped: true };
114
+ }
115
+ const branch = git('rev-parse', '--abbrev-ref', 'HEAD').stdout;
116
+ const target = defaultBranch || resolveDefaultBranch(git);
117
+
118
+ if (branch !== target) {
119
+ if (allowBranch) {
120
+ warn(`${label}: on '${branch}', not default '${target}' — --allow-branch: commit/push go to origin/${branch}`);
121
+ } else {
122
+ warn(`${label}: on '${branch}', not the default branch '${target}' — skipped (switch to '${target}' or pass --allow-branch)`);
123
+ return { label, committed: false, skipped: true };
124
+ }
125
+ }
126
+
127
+ const { staged, addError } = stageAllowlist(git, root, paths);
128
+ if (addError) warn(`${label}: git add reported "${addError}" — staging may be incomplete`);
129
+ if (!staged.length) { info(`${label}: nothing to commit (unchanged or ignored)`); return { label, committed: false }; }
130
+
131
+ // Pushing HEAD lands any local commits ahead of the remote too. Warn before we add ours so the
132
+ // operator sees unpublished WIP about to ride the update push (never silently publish it).
133
+ if (push) {
134
+ const ahead = git('rev-list', '--count', `origin/${branch}..HEAD`);
135
+ const n = Number(ahead.stdout);
136
+ if (ahead.ok && n > 0) warn(`${label}: ${n} local commit(s) ahead of origin/${branch} will also be pushed`);
137
+ }
138
+
139
+ const message = buildUpdateMessage({ items });
140
+ const cm = git('commit', '-m', message, '--', ...staged);
141
+ if (!cm.ok) {
142
+ git('reset', '-q', '--', ...staged); // don't leave our allowlist staged for an unrelated commit to sweep up
143
+ fail(`${label}: git commit failed — ${cm.stderr.split('\n')[0] || cm.code}`);
144
+ process.exitCode = 1;
145
+ return { label, committed: false, error: true };
146
+ }
147
+ ok(`${label}: committed ${staged.length} file(s)`);
148
+
149
+ if (!push) return { label, committed: true };
150
+ if (pushWithRebase(root, branch).ok) { ok(`${label}: pushed to origin/${branch}`); return { label, committed: true, pushed: true }; }
151
+ // The commit already landed locally — a re-run of `yad update --push` would see no drift and skip
152
+ // this repo, so point the operator at the direct push of the commit that already exists.
153
+ fail(`${label}: could not push to origin/${branch} — a protected branch, or an unresolvable rebase conflict`);
154
+ hand(`resolve it in ${label === 'hub' ? '.' : label}, then push the existing commit with \`git push origin ${branch}\``);
155
+ process.exitCode = 1;
156
+ return { label, committed: true, pushed: false, error: true };
157
+ }
158
+
159
+ // Orchestrate the per-repo commit/push over the grouped applied actions, bookended by the announce
160
+ // banners. `defaultBranchFor(root)` yields each repo's configured default branch (undefined ->
161
+ // resolve from the remote). Prints, commits/pushes each group, then the done banner.
162
+ export function commitUpdates(hubRoot, groups, { push = false, allowBranch = false, defaultBranchFor, guardInactiveGitlab } = {}) {
163
+ if (!groups.length) { info('no committable changes were applied'); return []; }
164
+
165
+ const labels = groups.map((g) => repoLabel(hubRoot, g.root));
166
+ log('');
167
+ log(c.bold(`yad update — ${push ? 'publishing to default branches' : 'committing locally (no --push)'}`));
168
+ warn(`about to ${push ? 'commit + push directly to the default branch' : 'commit'} on: ${labels.join(', ')}`);
169
+ hand('announce the team and pause merges on these repos until this completes');
170
+
171
+ const results = [];
172
+ for (const g of groups) {
173
+ const r = commitAndPush(g, { push, allowBranch, hubRoot, defaultBranch: defaultBranchFor?.(g.root) });
174
+ if (push && r.pushed && guardInactiveGitlab?.(g.root)) {
175
+ hand(`${r.label}: GitLab yad-update-guard is not active — add \`- local: '.gitlab/ci/yad-update-guard.yml'\` to the root .gitlab-ci.yml include (re-run the yad-checks wire step) so this direct-to-default push is gated`);
176
+ }
177
+ results.push(r);
178
+ }
179
+
180
+ const pushed = results.filter((r) => r.pushed).length;
181
+ const committed = results.filter((r) => r.committed).length;
182
+ const errored = results.filter((r) => r.error).length;
183
+ const skipped = results.filter((r) => r.skipped).length;
184
+ log('');
185
+ if (push) {
186
+ if (errored || skipped) {
187
+ // Some repos did NOT receive the update (a push/commit failure, or a branch/clone skip) — do not
188
+ // signal "all clear". commitAndPush already set process.exitCode on hard errors.
189
+ warn(`update incomplete — ${pushed}/${groups.length} repo(s) pushed, ${errored} failed, ${skipped} skipped; keep merges paused and finish the rest (see above).`);
190
+ } else {
191
+ ok(`update published — ${pushed}/${groups.length} repo(s) pushed; merges can resume`);
192
+ }
193
+ } else ok(`update committed locally — ${committed}/${groups.length} repo(s); push with \`yad update --push\``);
194
+ return results;
195
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yadflow",
3
- "version": "3.7.1",
3
+ "version": "3.8.1",
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",
@@ -96,6 +96,10 @@ Copy from this skill's `templates/`:
96
96
  - A **foreign** file occupies that path/name → write to a non-colliding filename
97
97
  (`yad-checks.gen.yml`) and ensure its `name:` does not clash. Never merge jobs into a foreign
98
98
  workflow; never edit one.
99
+ - Also install `templates/github/yad-update-guard.yml` → `.github/workflows/yad-update-guard.yml`
100
+ (marker `# yad-managed: yad-checks`) — the push-on-default integrity gate for direct-to-default
101
+ commits (`yad update --push`); it runs only `verified-commits` + `commit-message`. Same
102
+ own-a-file rules as above.
99
103
 
100
104
  **GitLab** (detect by a root `.gitlab-ci.yml` and/or `.gitlab/ci/*.yml`): install the includable
101
105
  fragment `templates/gitlab/yad-checks.gitlab-ci.yml` → `<repo>/.gitlab/ci/yad-checks.yml` (its jobs
@@ -105,6 +109,9 @@ Copy from this skill's `templates/`:
105
109
  - Root exists → read its top-level `include:`. Add the `include:` key if absent; append
106
110
  `- local: '.gitlab/ci/yad-checks.yml'` if the key exists but the entry is missing; **no-op** if it
107
111
  is already listed. Touch nothing else in the root.
112
+ - Also install `templates/gitlab/yad-update-guard.gitlab-ci.yml` → `<repo>/.gitlab/ci/yad-update-guard.yml`
113
+ (marker `# yad-managed-include: yad-checks`) — the push-on-default integrity gate — and append
114
+ `- local: '.gitlab/ci/yad-update-guard.yml'` to the root `include:` the same additive, idempotent way.
108
115
  - If the existing YAML cannot be parsed safely → **STOP** and print the exact include snippet for the
109
116
  human to paste (graceful degradation — never guess-edit a pipeline you cannot parse).
110
117
  - The legacy standalone `templates/gitlab/.gitlab-ci.yml` is retained only for a clean greenfield repo
@@ -88,8 +88,11 @@ repo. For each commit in `<base>..HEAD`, two independent checks:
88
88
  - **Known author** — the commit's **author email** must appear in `.sdlc/verified-authors`, generated
89
89
  by `yad check --fix` from the hub roster's `email`/`emails` fields plus hub.json's
90
90
  `verified_authors` list (edit hub.json, never the generated file). Only the author is checked:
91
- platform-generated merge/squash commits set the platform as committer, and their integrity is
92
- covered by the signature check.
91
+ platform-generated squash commits keep the PR author (who is on the roster). Two identities are
92
+ **allowlist-waived but still signature-covered**: the `yad-gate-sync` bot, and any **merge commit**
93
+ (2+ parents) — a merge's author is whoever pressed merge (often a platform noreply), not a roster
94
+ human, and its content already passed the PR gate suite. This waiver matters for the push-on-default
95
+ `yad-update-guard` (§9), which — unlike this PR-triggered gate — sees merge commits.
93
96
 
94
97
  Degradation is explicit, never silent: a missing allowlist SKIPs the author check with a warning
95
98
  (configure roster emails, re-wire); no GitHub/GitLab remote SKIPs the signature check (the badge is a
@@ -174,6 +177,38 @@ that path, and spec-link still proves the story link.
174
177
  **FAILS** the change (the thread is frozen until the hotfix debt is paid: artifacts updated + a
175
178
  regression test added, then `status: paid`). Thread-scoped — only the affected thread freezes.
176
179
 
180
+ ## 9. yad-update-guard (`templates/github/yad-update-guard.yml`, `templates/gitlab/yad-update-guard.gitlab-ci.yml`)
181
+
182
+ The **integrity gate for direct pushes to the default branch**. `yad update --push` (`cli/update-commit.mjs`)
183
+ commits the applied SDLC drift (skills, gate scripts, CI wiring, `verified-authors`) and pushes it
184
+ **straight to the default branch with no PR/MR** — so the `pull_request`/`merge_request` gate suite never
185
+ fires. This workflow is the "skipped from CI **except** verified-commits + the pattern gate" contract: on a
186
+ **push** to the default branch it runs **only** `verified-commits` and `commit-message` over the pushed
187
+ range (`github.event.before..HEAD` / `$CI_COMMIT_BEFORE_SHA..HEAD`, falling back to `HEAD~1` when the
188
+ before-SHA is a zero/unresolvable ref). It is deliberately **not** scoped to `chore(yad-update)` commits:
189
+ **any** direct-to-default commit (a hotfix, a force-push) is then signature- + subject-format-checked, a
190
+ strictly-good invariant. Normal PR merges sail through — merge commits are platform-Verified and
191
+ `commit-message` skips merges. The `yad update --push` commit itself carries **no `[skip ci]`** (unlike the
192
+ machine-state `yad checkpoint`/`gate ci` commits) precisely so this guard runs on it.
193
+
194
+ Wired into **every connected repo and the hub** (`REPO_WIRING`/`HUB_WIRING` in `cli/manifest.mjs`). On
195
+ GitHub it is a self-contained workflow (`.github/workflows/yad-update-guard.yml`, marker `# yad-managed:
196
+ yad-checks`), gated to the default branch by a job-level `if: github.ref_name ==
197
+ github.event.repository.default_branch`. On GitLab it is an includable fragment
198
+ (`.gitlab/ci/yad-update-guard.yml`, marker `# yad-managed-include: yad-checks`) whose jobs run on
199
+ `$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH`; its `include:` line
200
+ (`- local: '.gitlab/ci/yad-update-guard.yml'`) is added to the root `.gitlab-ci.yml` the same additive way
201
+ as the other fragments (see *Sync with existing CI* below). **On GitLab the fragment is inert until that
202
+ include line exists** — `yad update --push` warns when it pushes to a gitlab repo whose root pipeline
203
+ lacks it.
204
+
205
+ **Prerequisites / caveats.** Direct pushes to the default branch must be permitted for the committer
206
+ (adjust branch protection). The commits `yad update --push` creates must be **signed** and their author
207
+ **allowlisted**, or this guard rejects them — `yad update --push` runs a pre-flight that warns when local
208
+ commit signing is unset or the operator's git email is not in `.sdlc/verified-authors`. Ordinary PR merges
209
+ pass (merge commits are allowlist-waived + Verified), but a **rebase-merge** recreates the PR commits
210
+ without the platform signature, so a rebase-merge team should sign commits or not wire this guard.
211
+
177
212
  ## CI wiring (both platforms)
178
213
 
179
214
  The gates run identically under either CI; the config just invokes the scripts with the PR/MR base.
@@ -100,7 +100,15 @@ while IFS= read -r sha; do
100
100
  is_bot=0
101
101
  case "${author}|$(git log -1 --format=%an "$sha" | tr '[:upper:]' '[:lower:]')" in *yad-gate-sync*) is_bot=1 ;; esac
102
102
 
103
- if [ "$authors_on" = 1 ] && [ "$is_bot" = 0 ]; then
103
+ # A merge commit (2+ parents) is authored by whoever pressed "merge" often a platform noreply
104
+ # address, not a roster human — while its CONTENT already passed the PR gate suite. Waive the
105
+ # allowlist for it (like the bot), but STILL require the signature below: a platform merge commit is
106
+ # Verified, so a locally-forged merge pushed direct-to-default cannot dodge the check. This matters
107
+ # for the push-on-default yad-update-guard, which (unlike the PR-triggered gates) sees merge commits.
108
+ is_merge=0
109
+ [ "$(git log -1 --format=%P "$sha" | wc -w | tr -d '[:space:]')" -ge 2 ] && is_merge=1
110
+
111
+ if [ "$authors_on" = 1 ] && [ "$is_bot" = 0 ] && [ "$is_merge" = 0 ]; then
104
112
  # tolerate CRLF / stray surrounding whitespace in a hand-edited allowlist
105
113
  if grep -vE '^[[:space:]]*(#|$)' "$ALLOWLIST" | tr -d '\r' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' \
106
114
  | tr '[:upper:]' '[:lower:]' | grep -qxF "$author"; then
@@ -111,6 +119,8 @@ while IFS= read -r sha; do
111
119
  fi
112
120
  elif [ "$is_bot" = 1 ]; then
113
121
  echo "PASS [verified-commits]: ${short} gate-sync bot — allowlist waived (signature still required)"
122
+ elif [ "$is_merge" = 1 ]; then
123
+ echo "PASS [verified-commits]: ${short} merge commit — allowlist waived (signature still required)"
114
124
  fi
115
125
 
116
126
  if [ -n "$platform" ]; then
@@ -0,0 +1,48 @@
1
+ # yad-managed: yad-checks
2
+ # yad-update-guard — integrity gate for DIRECT pushes to the default branch.
3
+ #
4
+ # `yad update --push` commits the applied SDLC drift (skills, gate scripts, CI wiring, verified-
5
+ # authors) and pushes it straight to the default branch with NO pull request — so the pull_request
6
+ # gate suite never fires. This workflow is the "skipped from CI EXCEPT verified-commits + the pattern
7
+ # gate" contract: on every push to the default branch it runs ONLY those two gates over the pushed
8
+ # range. It is not scoped to yad-update commits on purpose — ANY direct-to-main commit (a hotfix, a
9
+ # force-push) then gets signature + subject-format checked, which is a strictly-good invariant. A
10
+ # normal PR merge sails through: a GitHub merge/squash commit is platform-Verified, verified-commits.sh
11
+ # waives the allowlist for merge commits (whose author is whoever clicked merge), and commit-message.sh
12
+ # skips merges. CAVEAT: a *rebase-merge* recreates the PR commits WITHOUT GitHub's signature, so they
13
+ # lose the Verified badge and fail the signature check on the direct push — teams using rebase-merge
14
+ # should sign their commits or not wire this guard.
15
+ name: yad-update-guard
16
+ on:
17
+ push:
18
+ branches: ["**"]
19
+
20
+ permissions:
21
+ contents: read
22
+
23
+ jobs:
24
+ update-guard:
25
+ # Only the default branch — pushes to feature branches are reviewed via their PR gate suite.
26
+ if: github.ref_name == github.event.repository.default_branch
27
+ runs-on: ubuntu-latest
28
+ env:
29
+ GH_TOKEN: ${{ github.token }} # read-only: gh api commits/<sha> for the Verified badge
30
+ steps:
31
+ - uses: actions/checkout@v4
32
+ with: { fetch-depth: 0 }
33
+ - name: Resolve the pushed range base
34
+ id: base
35
+ run: |
36
+ before="${{ github.event.before }}"
37
+ # First push / branch create / an unfetched before-SHA: fall back to the commit's parent.
38
+ # This checks only the tip commit (HEAD~1..HEAD) — a rare fail-open for the multi-commit
39
+ # first-push case, preferred over failing closed on a missing ref.
40
+ if [ -z "$before" ] || [ "$before" = "0000000000000000000000000000000000000000" ] \
41
+ || ! git rev-parse --verify --quiet "${before}^{commit}" >/dev/null; then
42
+ before="$(git rev-parse --verify --quiet HEAD~1 || git rev-parse HEAD)"
43
+ fi
44
+ echo "base=$before" >> "$GITHUB_OUTPUT"
45
+ - name: verified-commits
46
+ run: bash checks/verified-commits.sh "${{ steps.base.outputs.base }}"
47
+ - name: commit-message
48
+ run: bash checks/commit-message.sh "${{ steps.base.outputs.base }}"
@@ -0,0 +1,48 @@
1
+ # yad-managed-include: yad-checks
2
+ # yad-update-guard — integrity gate for DIRECT pushes to the default branch, as an INCLUDABLE
3
+ # fragment. Pulled into the root .gitlab-ci.yml via:
4
+ # include:
5
+ # - local: '.gitlab/ci/yad-update-guard.yml'
6
+ #
7
+ # `yad update --push` commits the applied SDLC drift and pushes it straight to the default branch with
8
+ # NO merge request — so the MR gate suite never fires. This fragment is the "skipped from CI EXCEPT
9
+ # verified-commits + the pattern gate" contract: on a push pipeline for the default branch it runs
10
+ # ONLY those two gates over the pushed range ($CI_COMMIT_BEFORE_SHA..HEAD). Not scoped to yad-update
11
+ # commits on purpose — ANY direct-to-default commit gets signature + subject-format checked.
12
+ #
13
+ # verified-commits needs a CI/CD variable GITLAB_TOKEN (or SDLC_API_TOKEN) with read_api scope —
14
+ # CI_JOB_TOKEN cannot read the commit-signature API (same requirement as yad-verified-commits).
15
+ # Job names are yad-prefixed with `needs: []` and no `stage:` so they coexist with any other fragment.
16
+ .yad_update_guard_rules:
17
+ rules:
18
+ - if: $CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
19
+
20
+ .yad_update_guard_base: &yad_update_guard_base
21
+ tags: [$YAD_RUNNER_TAGS]
22
+ image: node:20
23
+ variables:
24
+ GIT_DEPTH: "0" # full history so the gates can diff the pushed range
25
+ before_script:
26
+ # First push / an unresolvable before-SHA: fall back to the commit's parent (tip commit only —
27
+ # a rare fail-open for a multi-commit first push, preferred over failing closed).
28
+ - |
29
+ BASE="$CI_COMMIT_BEFORE_SHA"
30
+ if [ -z "$BASE" ] || [ "$BASE" = "0000000000000000000000000000000000000000" ] \
31
+ || ! git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null; then
32
+ BASE="$(git rev-parse --verify --quiet HEAD~1 || git rev-parse HEAD)"
33
+ fi
34
+ export BASE
35
+
36
+ yad-update-guard-verified-commits:
37
+ <<: *yad_update_guard_base
38
+ extends: .yad_update_guard_rules
39
+ needs: []
40
+ script:
41
+ - bash checks/verified-commits.sh "$BASE"
42
+
43
+ yad-update-guard-commit-message:
44
+ <<: *yad_update_guard_base
45
+ extends: .yad_update_guard_rules
46
+ needs: []
47
+ script:
48
+ - bash checks/commit-message.sh "$BASE"
@@ -66,7 +66,7 @@ Per-story, per-repo: `spec → tasks → implement → checks → engineer-revie
66
66
  |--------------|------------------------|
67
67
  | `yad-spec` | `specs/<story-id>/` (Spec Kit layout), `link.md` |
68
68
  | `yad-implement` | a branch + commit per atomic task |
69
- | `yad-checks` | `checks/*.sh`, CI workflows — the gate set: `spec-link · contract-check · build-test-lint · verified-commits · commit-message · pr-title · pr-template · lineage-check · epic-open · reconcile-debt` |
69
+ | `yad-checks` | `checks/*.sh`, CI workflows — the gate set: `spec-link · contract-check · build-test-lint · verified-commits · commit-message · pr-title · pr-template · lineage-check · epic-open · reconcile-debt`, plus `yad-update-guard` (push-on-default: re-checks any direct-to-default commit with `verified-commits · commit-message`) |
70
70
  | `yad-pr-template` | PR/MR template + routing helpers |
71
71
  | `yad-commit` / `yad-open-pr` / `yad-ship` | one commit / one PR/MR |
72
72
  | `yad-engineer-review` | engineer review + ship recorded in `build-log.json` |
@@ -55,6 +55,11 @@ yad-gate-sync:
55
55
  # Pinned glab binary (node:20 has no glab). Alternative: image registry.gitlab.com/gitlab-org/cli.
56
56
  - GLAB_VERSION=1.55.0
57
57
  - curl -fsSL "https://gitlab.com/gitlab-org/cli/-/releases/v${GLAB_VERSION}/downloads/glab_${GLAB_VERSION}_linux_amd64.deb" -o /tmp/glab.deb && dpkg -i /tmp/glab.deb
58
+ # Pinned jq binary (node:20 has none, and `glab api` has NO built-in --jq like `gh api` does —
59
+ # it errors "unknown flag: --jq"). We fetch raw JSON from glab and filter it through real jq;
60
+ # jq streams glab's concatenated per-page --paginate arrays natively.
61
+ - JQ_VERSION=1.7.1
62
+ - curl -fsSL "https://github.com/jqlang/jq/releases/download/jq-${JQ_VERSION}/jq-linux-amd64" -o /usr/local/bin/jq && chmod +x /usr/local/bin/jq
58
63
  - git config user.name "yad-gate-sync" && git config user.email "yad-gate-sync@noreply.${CI_SERVER_HOST}"
59
64
  - git remote set-url origin "https://oauth2:${SDLC_GATE_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git"
60
65
  - export GITLAB_TOKEN="$SDLC_GATE_TOKEN" GITLAB_HOST="$CI_SERVER_URL"
@@ -73,8 +78,8 @@ yad-gate-sync:
73
78
  # runs in a subshell, losing rc). An MR stuck beyond the window needs manual recovery — run
74
79
  # `yad gate ci --branch <review-branch> --pr <iid> --merged` locally on the default branch.
75
80
  SINCE="$(date -u -d '7 days ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v-7d +%Y-%m-%dT%H:%M:%SZ)"
76
- glab api --paginate "projects/:id/merge_requests?state=merged&updated_after=${SINCE}&per_page=100&order_by=updated_at" \
77
- --jq '.[] | select(.source_branch | startswith("review/EP-")) | "\(.iid) \(.source_branch)"' > /tmp/yad-merged-mrs || rc=1
81
+ glab api --paginate "projects/:id/merge_requests?state=merged&updated_after=${SINCE}&per_page=100&order_by=updated_at" > /tmp/yad-raw-mrs 2>/dev/null || rc=1
82
+ jq -r '.[] | select(.source_branch | startswith("review/EP-")) | "\(.iid) \(.source_branch)"' /tmp/yad-raw-mrs > /tmp/yad-merged-mrs || rc=1
78
83
  while read -r IID REF; do
79
84
  [ -n "$IID" ] || continue
80
85
  git checkout -q -B "$CI_DEFAULT_BRANCH" "origin/$CI_DEFAULT_BRANCH"
@@ -85,7 +90,9 @@ yad-gate-sync:
85
90
  # IID from its source branch so `gate ci` can re-read approvals, then advance there.
86
91
  REVIEW_BRANCH="$(printf '%s' "$CI_COMMIT_MESSAGE" | grep -oE 'review/EP-[A-Za-z0-9._/-]+' | head -n1 || true)"
87
92
  if [ -n "$REVIEW_BRANCH" ]; then
88
- IID="$(glab api "projects/:id/merge_requests?source_branch=${REVIEW_BRANCH}&state=merged" --jq '.[0].iid' 2>/dev/null || true)"
93
+ # `.[0].iid // empty` so an empty MR array yields an empty IID (jq prints "null" otherwise),
94
+ # which keeps the "could not resolve" branch below correct.
95
+ IID="$(glab api "projects/:id/merge_requests?source_branch=${REVIEW_BRANCH}&state=merged" 2>/dev/null | jq -r '.[0].iid // empty' || true)"
89
96
  if [ -n "$IID" ]; then
90
97
  # Pass --pr + IID as two distinct args (avoid a fragile, shell-dependent ${IID:+...} split).
91
98
  npx -y -p yadflow@3 yad gate ci --branch "$REVIEW_BRANCH" --pr "$IID" --merged || rc=1