yadflow 3.7.1 → 3.8.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 +7 -2
- package/README.md +4 -2
- package/bin/yad.mjs +9 -2
- package/cli/manifest.mjs +7 -0
- package/cli/plan.mjs +47 -11
- package/cli/reconcile.mjs +68 -6
- package/cli/update-commit.mjs +195 -0
- package/package.json +1 -1
- package/skills/yad-checks/SKILL.md +7 -0
- package/skills/yad-checks/references/check-gates.md +37 -2
- package/skills/yad-checks/templates/checks/verified-commits.sh +11 -1
- package/skills/yad-checks/templates/github/yad-update-guard.yml +48 -0
- package/skills/yad-checks/templates/gitlab/yad-update-guard.gitlab-ci.yml +48 -0
- package/skills/yad-docs-overview/references/pipeline-model.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
|
|
1
|
+
# [3.8.0](https://github.com/abdelrahmannasr/yadflow/compare/v3.7.1...v3.8.0) (2026-07-05)
|
|
2
2
|
|
|
3
3
|
|
|
4
4
|
### Bug Fixes
|
|
5
5
|
|
|
6
|
-
* **
|
|
6
|
+
* **cli:** address CodeRabbit review on yad update --push ([1b1b2f5](https://github.com/abdelrahmannasr/yadflow/commit/1b1b2f59ebda541acd8775a5af3f1c8044efcb57))
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* **cli:** commit + push applied updates to the default branch (yad update --push) ([fa851c8](https://github.com/abdelrahmannasr/yadflow/commit/fa851c8784765d78df2fef153424ff9d46363731))
|
|
7
12
|
|
|
8
13
|
# [2.2.0](https://github.com/abdelrahmannasr/yadflow/compare/v2.1.0...v2.2.0) (2026-06-14)
|
|
9
14
|
|
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,
|
|
81
|
-
|
|
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
|
-
//
|
|
15
|
-
|
|
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
|
-
|
|
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'),
|
|
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),
|
|
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) =>
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "3.8.0",
|
|
4
4
|
"description": "Yadflow — the gated, team, multi-repo SDLC: author → review → build with a PR-driven review gate and a zero-dependency `yad` CLI (setup, gate, commit, open-pr, ship, repo, thread, reconcile). A BMAD module + 38 yad-* skills.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "AbdelRahman Nasr",
|
|
@@ -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
|
|
92
|
-
|
|
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
|
-
|
|
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` |
|