yadflow 2.14.0 → 2.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2 -2
- package/bin/yad.mjs +19 -5
- package/cli/artifact-status.mjs +102 -0
- package/cli/doctor.mjs +14 -1
- package/cli/epic-state.mjs +1 -1
- package/cli/gate.mjs +121 -58
- package/cli/manifest.mjs +2 -0
- package/cli/platform.mjs +44 -6
- package/package.json +1 -1
- package/skills/yad-checks/SKILL.md +7 -0
- package/skills/yad-checks/references/check-gates.md +25 -7
- package/skills/yad-checks/templates/checks/ledger-guard.sh +117 -0
- package/skills/yad-checks/templates/checks/verified-commits.sh +9 -1
- package/skills/yad-checks/templates/github/yad-hub-checks.yml +28 -6
- package/skills/yad-checks/templates/gitlab/yad-hub-checks.gitlab-ci.yml +18 -5
- package/skills/yad-hub-bridge/SKILL.md +41 -14
- package/skills/yad-hub-bridge/references/bridge.md +93 -51
- package/skills/yad-hub-bridge/templates/github/yad-gate-sync.yml +85 -35
- package/skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml +63 -32
- package/skills/yad-pr-template/templates/checks/pr-template.sh +53 -10
- package/skills/yad-pr-template/templates/checks/pr-title.sh +55 -18
- package/skills/yad-review-gate/SKILL.md +12 -10
package/cli/platform.mjs
CHANGED
|
@@ -141,10 +141,21 @@ export function resolveLogin(login, roster = [], repos = [], touchedDomains = []
|
|
|
141
141
|
|
|
142
142
|
// Normalized PR reviews -> approval records (only APPROVED states count). `submittedAt` rides along
|
|
143
143
|
// so the gate can tell a fresh re-approval from a stale one (revoke-on-change).
|
|
144
|
-
export function mapApprovers(reviews = [], { roster, repos, touchedDomains }) {
|
|
144
|
+
export function mapApprovers(reviews = [], { roster, repos, touchedDomains, headOid } = {}) {
|
|
145
145
|
const out = [];
|
|
146
146
|
for (const r of reviews) {
|
|
147
147
|
if (r.state !== 'APPROVED') continue;
|
|
148
|
+
// Revoke-on-change, enforced in code where the platform binds an approval to a commit. The reader
|
|
149
|
+
// sets `commit` to the review's SHA (GitHub), to `null` when that read DEGRADED, or leaves it
|
|
150
|
+
// ABSENT when the platform exposes no per-approval SHA (GitLab):
|
|
151
|
+
// - `null` (degraded read) → FAIL CLOSED → drop, independently of headOid: we cannot prove the
|
|
152
|
+
// approval is for the merged content, so a transient failure holds
|
|
153
|
+
// the gate rather than advancing on unverifiable approvals;
|
|
154
|
+
// - a known SHA ≠ head → the approval is stale (artifact moved) → drop;
|
|
155
|
+
// - absent (GitLab) → keep: revoke-on-change is the platform's "remove approvals on new
|
|
156
|
+
// commits" setting.
|
|
157
|
+
if (r.commit === null) continue;
|
|
158
|
+
if (headOid && r.commit !== undefined && r.commit !== headOid) continue;
|
|
148
159
|
for (const rec of resolveLogin(r.login, roster, repos, touchedDomains)) {
|
|
149
160
|
out.push({ ...rec, submittedAt: r.submittedAt || null });
|
|
150
161
|
}
|
|
@@ -157,17 +168,35 @@ function readPrGitHub(n, { cwd } = {}) {
|
|
|
157
168
|
const view = run('gh', ['pr', 'view', String(n), '--json', 'state,mergedAt,headRefOid'], { cwd });
|
|
158
169
|
if (!view.ok) return { ok: false, reason: view.stderr || 'gh pr view failed' };
|
|
159
170
|
const meta = JSON.parse(view.stdout);
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const reviews = rev.ok
|
|
163
|
-
? (JSON.parse(rev.stdout).latestReviews || []).map((x) => ({ login: x.author?.login, state: x.state, submittedAt: x.submittedAt }))
|
|
164
|
-
: [];
|
|
171
|
+
let reviews = [];
|
|
172
|
+
let reviewsOk = false;
|
|
165
173
|
// Review-thread resolution via GraphQL (REST does not expose isResolved). Paginate so a PR with
|
|
166
174
|
// >100 threads is not mistakenly read as "all resolved".
|
|
167
175
|
let threads = [];
|
|
168
176
|
const nwo = run('gh', ['repo', 'view', '--json', 'owner,name'], { cwd });
|
|
169
177
|
if (nwo.ok) {
|
|
170
178
|
const { owner, name } = JSON.parse(nwo.stdout);
|
|
179
|
+
// latestReviews collapses a reviewer's superseded reviews to their current one; commit.oid binds
|
|
180
|
+
// each approval to the revision it was made on, so an approval on an older commit than the merged
|
|
181
|
+
// head is dropped as stale (revoke-on-change in code — see mapApprovers). `gh pr view --json
|
|
182
|
+
// latestReviews` does not expose the commit, so read it via GraphQL. Paginate so a PR with >100
|
|
183
|
+
// reviewers never silently omits one; any page failure aborts to the commitless fallback below,
|
|
184
|
+
// which fails closed rather than advancing on a partial read.
|
|
185
|
+
const rq = `query($o:String!,$r:String!,$n:Int!,$c:String){repository(owner:$o,name:$r){pullRequest(number:$n){latestReviews(first:100,after:$c){pageInfo{hasNextPage endCursor} nodes{author{login} state submittedAt commit{oid}}}}}}`;
|
|
186
|
+
let rcursor = null;
|
|
187
|
+
reviewsOk = true;
|
|
188
|
+
for (let guard = 0; guard < 50; guard++) {
|
|
189
|
+
const args = ['api', 'graphql', '-f', `query=${rq}`, '-F', `o=${owner.login}`, '-F', `r=${name}`, '-F', `n=${n}`];
|
|
190
|
+
if (rcursor) args.push('-F', `c=${rcursor}`);
|
|
191
|
+
const rg = run('gh', args, { cwd });
|
|
192
|
+
if (!rg.ok) { reviewsOk = false; reviews = []; break; }
|
|
193
|
+
const conn = JSON.parse(rg.stdout)?.data?.repository?.pullRequest?.latestReviews;
|
|
194
|
+
for (const x of conn?.nodes || []) {
|
|
195
|
+
reviews.push({ login: x.author?.login, state: x.state, submittedAt: x.submittedAt, commit: x.commit?.oid || null });
|
|
196
|
+
}
|
|
197
|
+
if (!conn?.pageInfo?.hasNextPage) break;
|
|
198
|
+
rcursor = conn.pageInfo.endCursor;
|
|
199
|
+
}
|
|
171
200
|
const q = `query($o:String!,$r:String!,$n:Int!,$c:String){repository(owner:$o,name:$r){pullRequest(number:$n){reviewThreads(first:100,after:$c){pageInfo{hasNextPage endCursor} nodes{isResolved comments(first:1){nodes{author{login} body}}}}}}}`;
|
|
172
201
|
let cursor = null;
|
|
173
202
|
for (let guard = 0; guard < 50; guard++) {
|
|
@@ -188,6 +217,15 @@ function readPrGitHub(n, { cwd } = {}) {
|
|
|
188
217
|
cursor = page.pageInfo.endCursor;
|
|
189
218
|
}
|
|
190
219
|
}
|
|
220
|
+
// Fallback if the GraphQL reviews read failed (no nwo / API hiccup): take the plain JSON view with
|
|
221
|
+
// commit=null. Approvals then FAIL CLOSED in mapApprovers (a degraded read cannot prove an approval
|
|
222
|
+
// is for the merged content), while CHANGES_REQUESTED is still honored — so a transient failure
|
|
223
|
+
// holds the gate, never advances it.
|
|
224
|
+
if (!reviewsOk) {
|
|
225
|
+
const rev = run('gh', ['pr', 'view', String(n), '--json', 'latestReviews'], { cwd });
|
|
226
|
+
if (rev.ok) reviews = (JSON.parse(rev.stdout).latestReviews || [])
|
|
227
|
+
.map((x) => ({ login: x.author?.login, state: x.state, submittedAt: x.submittedAt, commit: null }));
|
|
228
|
+
}
|
|
191
229
|
return {
|
|
192
230
|
ok: true,
|
|
193
231
|
state: meta.state,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yadflow",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.16.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). A BMAD module + 30 yad-* skills.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "AbdelRahman Nasr",
|
|
@@ -38,6 +38,13 @@ and GitLab CI. This step is **by hand** in Phase 3 — run the gates with the sk
|
|
|
38
38
|
- Canonical gate sources live in this skill's `templates/` (the source of truth that gets installed
|
|
39
39
|
into each code repo):
|
|
40
40
|
- `templates/checks/{spec-link,contract-check,build-test-lint,verified-commits}.sh`
|
|
41
|
+
- `templates/checks/ledger-guard.sh` → **hub-only** gate, active **only in bridge mode** (a no-op
|
|
42
|
+
when humans legitimately own the ledger). On review PRs it FAILs any commit that touches the
|
|
43
|
+
CI-owned gate ledger (`.sdlc/{state,approvals,comments,hub-prs}.json`, `reviews/*.md`) unless it
|
|
44
|
+
is a **verified gate-bot commit** — bot-authored AND platform-Verified, since author text alone is
|
|
45
|
+
spoofable. `.sdlc/contract-lock.json` is artifact-side and exempt. Runs in `yad-hub-checks`
|
|
46
|
+
alongside `verified-commits` (which waives the allowlist for the bot but still requires its
|
|
47
|
+
signature). See `yad-hub-bridge`.
|
|
41
48
|
- `templates/github/yad-verified-commits.yml` + `templates/gitlab/yad-verified-commits.gitlab-ci.yml`
|
|
42
49
|
→ the standalone hub-side verified-commits CI (installed by `yad check --fix` with the hub wiring)
|
|
43
50
|
- `templates/github/yad-checks.yml` → installs to `.github/workflows/yad-checks.yml` (marked `# yad-managed: yad-checks`)
|
|
@@ -121,8 +121,17 @@ from the event payload):
|
|
|
121
121
|
- `--profile code` (default) → a Conventional-Commits subject `<type>: <description>`, no trailing
|
|
122
122
|
period (`config.yaml build.pr_title_style: same_as_commit_subject` — one task = one PR, the title is
|
|
123
123
|
the squash-merge subject).
|
|
124
|
-
- `--profile hub` →
|
|
125
|
-
`
|
|
124
|
+
- `--profile hub` → splits by the PR/MR **head branch** (passed via `--head`, injected by CI):
|
|
125
|
+
- `review/EP-*` head (or no `--head` — stays strict) → a front-half artifact-review title
|
|
126
|
+
`review: <artifact> (EP-<slug>)`, the shape `yad gate open` creates.
|
|
127
|
+
- any other head → a tooling/code change to the hub itself, so it follows the `code` convention (a
|
|
128
|
+
Conventional-Commits subject). This is what lets a PR that changes the hub's own workflows/checks
|
|
129
|
+
pass — it has no EP artifact to review.
|
|
130
|
+
- **Anti-bypass guard.** The branch name alone is not trusted: a non-review head that actually
|
|
131
|
+
changes front-half artifacts (any path under `epics/**`) **FAILS** — those changes must go through
|
|
132
|
+
a `review/EP-*` PR and the artifact-review workflow. CI passes the PR's changed paths via
|
|
133
|
+
`--changed <file>` (computed from the diff against the base ref); without that list (a direct
|
|
134
|
+
by-hand caller) the guard is inert and the branch split alone applies.
|
|
126
135
|
|
|
127
136
|
## 7. pr-template (`templates/checks/pr-template.sh`)
|
|
128
137
|
|
|
@@ -131,8 +140,14 @@ catches a free-form description that bypassed it:
|
|
|
131
140
|
|
|
132
141
|
- `--profile code` (default) → requires `## Summary`, `## Impact & Risk`, `## Checklist`, and a filled
|
|
133
142
|
`Risk level:` (`low|medium|high`).
|
|
134
|
-
- `--profile hub` →
|
|
135
|
-
|
|
143
|
+
- `--profile hub` → splits by the PR/MR **head branch** (passed via `--head`, injected by CI):
|
|
144
|
+
- `review/EP-*` head (or no `--head`) → requires the artifact-review template: `## Artifact under
|
|
145
|
+
review`, `## Impact & Risk (front-half)`, `## Checklist`, and a `Risk tags:` line.
|
|
146
|
+
- any other head → a hub tooling PR, so it requires the `code` task template (`## Summary`,
|
|
147
|
+
`## Impact & Risk`, `## Checklist`, filled `Risk level:`).
|
|
148
|
+
- **Anti-bypass guard** (same as pr-title): a non-review head that changes front-half artifacts
|
|
149
|
+
(`epics/**`, detected from the CI-supplied `--changed <file>` list) **FAILS** — artifact changes
|
|
150
|
+
must go through a `review/EP-*` PR.
|
|
136
151
|
|
|
137
152
|
## CI wiring (both platforms)
|
|
138
153
|
|
|
@@ -202,9 +217,12 @@ its one include line) whenever `.sdlc/hub.json` has a platform with the bridge e
|
|
|
202
217
|
front-half review PRs are held to the same rule as code-repo PRs: signed, known authors only.
|
|
203
218
|
|
|
204
219
|
The hub **also** runs the three pattern gates (`commit-message`, `pr-title`, `pr-template`) with
|
|
205
|
-
`--profile hub
|
|
206
|
-
|
|
207
|
-
|
|
220
|
+
`--profile hub`. The pattern gates split by the PR/MR **head branch** (passed via `--head`): a
|
|
221
|
+
`review/EP-*` head is a front-half review PR — Conventional-Commits commit subjects, a
|
|
222
|
+
`review: <artifact> (EP-<slug>)` title, and the hub artifact-review template body; **any other head is
|
|
223
|
+
a tooling/code change to the hub itself** and follows the `code` convention (a Conventional-Commits
|
|
224
|
+
title + the code task template), so a PR that changes the hub's own workflows/checks can pass.
|
|
225
|
+
`yad check --fix` installs the same `checks/*.sh` scripts plus a standalone hub workflow
|
|
208
226
|
(`templates/github/yad-hub-checks.yml` → `.github/workflows/yad-hub-checks.yml`, or the GitLab fragment
|
|
209
227
|
`templates/gitlab/yad-hub-checks.gitlab-ci.yml` → `.gitlab/ci/yad-hub-checks.yml` + its one include
|
|
210
228
|
line). Code repos run the same three with `--profile code` inside the main `yad-checks` workflow.
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ledger-guard gate.
|
|
3
|
+
# In BRIDGE mode the gate ledger is CI-owned: only the yad gate-sync bot may change the
|
|
4
|
+
# machine-written gate-state files. A commit on a review PR by anyone else that modifies them is
|
|
5
|
+
# rejected — the human keeps the artifact, CI keeps the ledger. This makes "CI is the sole writer of
|
|
6
|
+
# the ledger" a mechanical guarantee instead of a convention.
|
|
7
|
+
#
|
|
8
|
+
# Protected (gate-state, machine-written):
|
|
9
|
+
# epics/*/.sdlc/state.json, approvals.json, comments.json, hub-prs.json
|
|
10
|
+
# epics/*/reviews/*.md
|
|
11
|
+
# NOT protected:
|
|
12
|
+
# epics/*/.sdlc/contract-lock.json — artifact-side: the architect locks the contract surface in
|
|
13
|
+
# `gate open`, so a human legitimately commits it alongside the architecture artifact.
|
|
14
|
+
#
|
|
15
|
+
# A "bot commit" must be BOTH authored by the gate bot (name/email contains yad-gate-sync) AND
|
|
16
|
+
# platform-VERIFIED — author/committer text alone is user-controlled and spoofable, so the platform
|
|
17
|
+
# Verified signature (a key the contributor cannot forge under the bot identity) is what actually
|
|
18
|
+
# distinguishes CI-generated commits. A spoofed-author commit that is not Verified is treated as a
|
|
19
|
+
# human edit and rejected.
|
|
20
|
+
#
|
|
21
|
+
# Scope: enforced ONLY when the bridge is enabled (a platform + gate-sync CI). Without the bridge
|
|
22
|
+
# (file-only / non-bridge) humans legitimately write the ledger locally, so the gate is a no-op.
|
|
23
|
+
#
|
|
24
|
+
# Degradation: a base ref that cannot be resolved FAILs closed; no platform (cannot read the Verified
|
|
25
|
+
# badge) WARNs and waives the signature half — the same stance verified-commits takes.
|
|
26
|
+
set -euo pipefail
|
|
27
|
+
|
|
28
|
+
# ---- bridge gate: only CI-owned ledgers are guarded -------------------------------------------
|
|
29
|
+
HUB="${SDLC_HUB_CONFIG:-.sdlc/hub.json}"
|
|
30
|
+
if [ ! -f "$HUB" ] || ! grep -Eq '"(bridge_enabled|bridge)"[[:space:]]*:[[:space:]]*true' "$HUB"; then
|
|
31
|
+
echo "PASS [ledger-guard]: bridge not enabled — the ledger is locally owned, nothing to guard."
|
|
32
|
+
exit 0
|
|
33
|
+
fi
|
|
34
|
+
|
|
35
|
+
BASE="${1:-${SDLC_BASE:-origin/main}}"
|
|
36
|
+
if ! git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null; then
|
|
37
|
+
echo "FAIL [ledger-guard]: base ref '${BASE}' not found — fetch full history / check the base branch."
|
|
38
|
+
exit 1
|
|
39
|
+
fi
|
|
40
|
+
RANGE="${BASE}..HEAD"
|
|
41
|
+
|
|
42
|
+
commits="$(git rev-list "$RANGE")"
|
|
43
|
+
if [ -z "$commits" ]; then
|
|
44
|
+
echo "PASS [ledger-guard]: no commits in ${RANGE}"
|
|
45
|
+
exit 0
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
# ---- platform for the signature check (mirrors verified-commits) ------------------------------
|
|
49
|
+
remote="$(git remote get-url origin 2>/dev/null || true)"
|
|
50
|
+
platform=""
|
|
51
|
+
case "$remote" in
|
|
52
|
+
*github*) platform=github ;;
|
|
53
|
+
*gitlab*) platform=gitlab ;;
|
|
54
|
+
esac
|
|
55
|
+
platform="${SDLC_PLATFORM:-$platform}"
|
|
56
|
+
case "$platform" in
|
|
57
|
+
github|gitlab) ;;
|
|
58
|
+
""|none) platform=""; echo "WARN [ledger-guard]: no GitHub/GitLab remote — bot signature NOT verified (the Verified badge is a platform concept)." ;;
|
|
59
|
+
*) echo "FAIL [ledger-guard]: unknown platform '${platform}' (SDLC_PLATFORM must be github|gitlab|none)."; exit 1 ;;
|
|
60
|
+
esac
|
|
61
|
+
|
|
62
|
+
# 0 when the platform marks the commit's signature verified.
|
|
63
|
+
signature_verified() {
|
|
64
|
+
local sha v body
|
|
65
|
+
sha="$1"
|
|
66
|
+
case "$platform" in
|
|
67
|
+
github)
|
|
68
|
+
v="$(gh api "repos/{owner}/{repo}/commits/${sha}" --jq '.commit.verification.verified' 2>/dev/null || echo api-error)"
|
|
69
|
+
[ "$v" = "true" ]
|
|
70
|
+
;;
|
|
71
|
+
gitlab)
|
|
72
|
+
if [ -n "${CI_API_V4_URL:-}" ] && [ -n "${CI_PROJECT_ID:-}" ]; then
|
|
73
|
+
body="$(curl -fsS --header "PRIVATE-TOKEN: ${GITLAB_TOKEN:-${SDLC_API_TOKEN:-}}" \
|
|
74
|
+
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/repository/commits/${sha}/signature" 2>/dev/null || true)"
|
|
75
|
+
else
|
|
76
|
+
body="$(glab api "projects/:id/repository/commits/${sha}/signature" 2>/dev/null || true)"
|
|
77
|
+
fi
|
|
78
|
+
printf '%s' "$body" | grep -qE '"verification_status"[[:space:]]*:[[:space:]]*"verified"'
|
|
79
|
+
;;
|
|
80
|
+
*) return 1 ;;
|
|
81
|
+
esac
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
# A trusted bot commit = bot-attributed AND (platform-Verified, or no platform to check against).
|
|
85
|
+
trusted_bot() {
|
|
86
|
+
case "$(git show -s --format='%an|%ae' "$1")" in
|
|
87
|
+
*yad-gate-sync*) ;;
|
|
88
|
+
*) return 1 ;;
|
|
89
|
+
esac
|
|
90
|
+
[ -z "$platform" ] && return 0 # degraded: cannot read the Verified badge — waive (warned above)
|
|
91
|
+
signature_verified "$1"
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
violations=0
|
|
95
|
+
for sha in $commits; do
|
|
96
|
+
touches_ledger=0
|
|
97
|
+
while IFS= read -r f; do
|
|
98
|
+
[ -n "$f" ] || continue
|
|
99
|
+
case "$f" in
|
|
100
|
+
epics/*/.sdlc/contract-lock.json) ;; # artifact-side — allowed
|
|
101
|
+
epics/*/.sdlc/state.json|epics/*/.sdlc/approvals.json|epics/*/.sdlc/comments.json|epics/*/.sdlc/hub-prs.json|epics/*/reviews/*.md)
|
|
102
|
+
touches_ledger=1
|
|
103
|
+
echo " ${sha} (author $(git show -s --format='%an' "$sha")) → $f"
|
|
104
|
+
;;
|
|
105
|
+
esac
|
|
106
|
+
done < <(git diff-tree --no-commit-id --name-only -r "$sha")
|
|
107
|
+
if [ "$touches_ledger" = 1 ] && ! trusted_bot "$sha"; then
|
|
108
|
+
violations=$((violations + 1))
|
|
109
|
+
fi
|
|
110
|
+
done
|
|
111
|
+
|
|
112
|
+
if [ "$violations" -gt 0 ]; then
|
|
113
|
+
echo "FAIL [ledger-guard]: ${violations} commit(s) change CI-owned gate files without a verified gate-bot signature. The ledger is CI-owned — let CI sync the gate; do not commit .sdlc/*.json or reviews/*.md yourself."
|
|
114
|
+
exit 1
|
|
115
|
+
fi
|
|
116
|
+
echo "PASS [ledger-guard]: every CI-owned gate change in ${RANGE} is a verified gate-bot commit."
|
|
117
|
+
exit 0
|
|
@@ -93,8 +93,14 @@ while IFS= read -r sha; do
|
|
|
93
93
|
[ -z "$sha" ] && continue
|
|
94
94
|
short="$(git log -1 --format=%h "$sha")"
|
|
95
95
|
author="$(git log -1 --format=%ae "$sha" | tr '[:upper:]' '[:lower:]')"
|
|
96
|
+
# The gate-sync bot is a machine identity, not a roster human — waive the allowlist for it. Its
|
|
97
|
+
# commits are still held to the SIGNATURE check below, so a contributor cannot spoof the bot author
|
|
98
|
+
# to dodge the allowlist (a forged-author commit is not platform-Verified). Mirrors how the
|
|
99
|
+
# platform-committer merge commits are allowlist-exempt but signature-covered.
|
|
100
|
+
is_bot=0
|
|
101
|
+
case "${author}|$(git log -1 --format=%an "$sha" | tr '[:upper:]' '[:lower:]')" in *yad-gate-sync*) is_bot=1 ;; esac
|
|
96
102
|
|
|
97
|
-
if [ "$authors_on" = 1 ]; then
|
|
103
|
+
if [ "$authors_on" = 1 ] && [ "$is_bot" = 0 ]; then
|
|
98
104
|
# tolerate CRLF / stray surrounding whitespace in a hand-edited allowlist
|
|
99
105
|
if grep -vE '^[[:space:]]*(#|$)' "$ALLOWLIST" | tr -d '\r' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' \
|
|
100
106
|
| tr '[:upper:]' '[:lower:]' | grep -qxF "$author"; then
|
|
@@ -103,6 +109,8 @@ while IFS= read -r sha; do
|
|
|
103
109
|
echo "FAIL [verified-commits]: ${short} author <${author}> is not in ${ALLOWLIST} — unverified user."
|
|
104
110
|
rc=1
|
|
105
111
|
fi
|
|
112
|
+
elif [ "$is_bot" = 1 ]; then
|
|
113
|
+
echo "PASS [verified-commits]: ${short} gate-sync bot — allowlist waived (signature still required)"
|
|
106
114
|
fi
|
|
107
115
|
|
|
108
116
|
if [ -n "$platform" ]; then
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
# yad-managed: yad-checks
|
|
2
|
-
# Pattern gates for the PRODUCT HUB
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
2
|
+
# Pattern gates for the PRODUCT HUB. They run with `--profile hub` and split by head branch:
|
|
3
|
+
# review/EP-* PRs are front-half artifact-review vehicles — a `review: <artifact> (EP-<slug>)` title
|
|
4
|
+
# and the hub artifact-review template body.
|
|
5
|
+
# every other PR is a tooling/code change to the hub itself — a Conventional-Commits title and the
|
|
6
|
+
# code task template body (same convention the code repos use).
|
|
7
|
+
# The head ref is passed via --head so the gate can tell the two apart. Commit subjects are always
|
|
8
|
+
# Conventional-Commits. Standalone workflow so it never collides with the code-repo yad-checks workflow.
|
|
6
9
|
name: yad-hub-checks
|
|
7
10
|
on:
|
|
8
11
|
pull_request:
|
|
@@ -17,20 +20,39 @@ jobs:
|
|
|
17
20
|
- run: bash checks/commit-message.sh --profile hub "origin/${{ github.base_ref }}"
|
|
18
21
|
|
|
19
22
|
# Pass the title via env (never interpolate untrusted ${{ }} into a run line — injection-safe).
|
|
23
|
+
# fetch-depth: 0 + the base ref give us the PR's changed paths, so the gate can reject an artifact
|
|
24
|
+
# change (epics/**) riding a non-review head past the front-half review with a plain code title.
|
|
20
25
|
pr-title:
|
|
21
26
|
runs-on: ubuntu-latest
|
|
22
27
|
env:
|
|
23
28
|
PR_TITLE: ${{ github.event.pull_request.title }}
|
|
29
|
+
PR_HEAD: ${{ github.event.pull_request.head.ref }}
|
|
30
|
+
BASE_REF: ${{ github.base_ref }}
|
|
24
31
|
steps:
|
|
25
32
|
- uses: actions/checkout@v4
|
|
26
|
-
|
|
33
|
+
with: { fetch-depth: 0 }
|
|
34
|
+
- run: |
|
|
35
|
+
changed="$(mktemp)"; git diff --name-only "origin/${BASE_REF}...HEAD" > "$changed"
|
|
36
|
+
bash checks/pr-title.sh --profile hub --head "$PR_HEAD" --changed "$changed" "$PR_TITLE"
|
|
27
37
|
|
|
28
38
|
pr-template:
|
|
29
39
|
runs-on: ubuntu-latest
|
|
30
40
|
env:
|
|
31
41
|
PR_BODY: ${{ github.event.pull_request.body }}
|
|
42
|
+
PR_HEAD: ${{ github.event.pull_request.head.ref }}
|
|
43
|
+
BASE_REF: ${{ github.base_ref }}
|
|
32
44
|
steps:
|
|
33
45
|
- uses: actions/checkout@v4
|
|
46
|
+
with: { fetch-depth: 0 }
|
|
34
47
|
- run: |
|
|
48
|
+
changed="$(mktemp)"; git diff --name-only "origin/${BASE_REF}...HEAD" > "$changed"
|
|
35
49
|
body="$(mktemp)"; printf '%s' "$PR_BODY" > "$body"
|
|
36
|
-
bash checks/pr-template.sh --profile hub "$body"
|
|
50
|
+
bash checks/pr-template.sh --profile hub --head "$PR_HEAD" --changed "$changed" "$body"
|
|
51
|
+
|
|
52
|
+
# The gate ledger is CI-owned: reject non-bot commits to .sdlc/*.json or reviews/*.md.
|
|
53
|
+
ledger-guard:
|
|
54
|
+
runs-on: ubuntu-latest
|
|
55
|
+
steps:
|
|
56
|
+
- uses: actions/checkout@v4
|
|
57
|
+
with: { fetch-depth: 0 }
|
|
58
|
+
- run: bash checks/ledger-guard.sh "origin/${{ github.base_ref }}"
|
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
# .gitlab-ci.yml via:
|
|
4
4
|
# include:
|
|
5
5
|
# - local: '.gitlab/ci/yad-hub-checks.yml'
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
6
|
+
# MRs run with `--profile hub` and split by source branch: review/EP-* MRs are front-half artifact-
|
|
7
|
+
# review vehicles (a `review: <artifact> (EP-<slug>)` title + the hub artifact-review template body);
|
|
8
|
+
# every other MR is a tooling/code change to the hub itself and follows the code convention (a
|
|
9
|
+
# Conventional-Commits title + the code task template body). The source branch is passed via --head so
|
|
10
|
+
# the gate can tell the two apart. Commit subjects are always Conventional-Commits.
|
|
9
11
|
# Job names are yad-hub-prefixed so they coexist with any code-repo fragment in one pipeline; jobs use
|
|
10
12
|
# `needs: []` and no `stage:` so a foreign `stages:` list can neither break nor reorder them.
|
|
11
13
|
default:
|
|
@@ -25,15 +27,26 @@ yad-hub-commit-message:
|
|
|
25
27
|
script:
|
|
26
28
|
- bash checks/commit-message.sh --profile hub "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
|
|
27
29
|
|
|
30
|
+
# GIT_DEPTH: 0 (above) gives the changed paths, so the gate can reject an artifact change (epics/**)
|
|
31
|
+
# riding a non-review source branch past the front-half review with a plain code title/template.
|
|
28
32
|
yad-hub-pr-title:
|
|
29
33
|
extends: .yad_hub_mr_only
|
|
30
34
|
needs: []
|
|
31
35
|
script:
|
|
32
|
-
-
|
|
36
|
+
- changed="$(mktemp)"; git diff --name-only "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME...HEAD" > "$changed"
|
|
37
|
+
- bash checks/pr-title.sh --profile hub --head "$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME" --changed "$changed" "$CI_MERGE_REQUEST_TITLE"
|
|
33
38
|
|
|
34
39
|
yad-hub-pr-template:
|
|
35
40
|
extends: .yad_hub_mr_only
|
|
36
41
|
needs: []
|
|
37
42
|
script:
|
|
43
|
+
- changed="$(mktemp)"; git diff --name-only "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME...HEAD" > "$changed"
|
|
38
44
|
- body="$(mktemp)"; printf '%s' "$CI_MERGE_REQUEST_DESCRIPTION" > "$body"
|
|
39
|
-
- bash checks/pr-template.sh --profile hub "$body"
|
|
45
|
+
- bash checks/pr-template.sh --profile hub --head "$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME" --changed "$changed" "$body"
|
|
46
|
+
|
|
47
|
+
# The gate ledger is CI-owned: reject non-bot commits to .sdlc/*.json or reviews/*.md.
|
|
48
|
+
yad-hub-ledger-guard:
|
|
49
|
+
extends: .yad_hub_mr_only
|
|
50
|
+
needs: []
|
|
51
|
+
script:
|
|
52
|
+
- bash checks/ledger-guard.sh "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: yad-hub-bridge
|
|
3
|
-
description: 'The templated PR/MR bridge for the front-half review gate. When the product hub has a platform (.sdlc/hub.json), it opens a review PR/MR on the hub for an authored artifact (the optional analysis / epic / architecture+contract / ui-design / stories / test-cases), sets the required reviewers/labels from the routing rule, and provides the read-only gh/glab recipes that yad-review-gate uses to pull platform comments + approvals back into the file ledger. Can also wire
|
|
3
|
+
description: 'The templated PR/MR bridge for the front-half review gate. When the product hub has a platform (.sdlc/hub.json), it opens a review PR/MR on the hub for an authored artifact (the optional analysis / epic / architecture+contract / ui-design / stories / test-cases), sets the required reviewers/labels from the routing rule, and provides the read-only gh/glab recipes that yad-review-gate uses to pull platform comments + approvals back into the file ledger. Can also wire merge-time sync on the hub: a CI workflow that runs `yad gate ci` when a human merges a review PR/MR — CI is the sole ledger writer and writes only at merge, on the default branch (during review the platform PR/MR is the source of truth; CI never touches the review branch). Local-user auth only — no stored tokens. The file ledger stays the source of truth; degrades to the file-only gate when there is no platform / no CLI. Use when the user says "open the review PR", "route the review", "wire the gate sync", or it is invoked by yad-review-gate open/sync.'
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# SDLC — Hub Review Bridge (the templated PR/MR bridge)
|
|
@@ -57,24 +57,40 @@ each required domain-owner to a platform `login` via the roster (a roster `name`
|
|
|
57
57
|
filled with the epic, artifact, gate step, owner, `epic.repos`, and the step's risk tags.
|
|
58
58
|
3. **Request the required reviewers** (their logins) and add a `domain:<repo>` label per touched repo so
|
|
59
59
|
per-repo routing is legible on the PR.
|
|
60
|
-
4.
|
|
60
|
+
4. **Do not write the ledger.** CI is the sole writer and writes only at merge. During review nothing
|
|
61
|
+
is recorded in the ledger — the platform PR/MR holds the review state (native approvals + threads).
|
|
62
|
+
At merge, CI records the `hub-prs.json` entry (in the shape below) and advances on the default
|
|
63
|
+
branch:
|
|
61
64
|
```json
|
|
62
65
|
{ "step": "<review step id>", "artifact": "<artifact>", "platform": "github|gitlab",
|
|
63
66
|
"number": <n>, "url": "<pr/mr url>", "branch": "review/EP-<slug>/<artifact-base>", "lastSyncedAt": null }
|
|
64
67
|
```
|
|
68
|
+
A human commit touching the gate-state files (`.sdlc/{state,approvals,comments,hub-prs}.json` or
|
|
69
|
+
`reviews/*.md`; `.sdlc/contract-lock.json` is artifact-side and allowed) on a review PR is rejected
|
|
70
|
+
by the `ledger-guard` check. (The `yad gate open` CLI behaves the same: in bridge mode it opens the
|
|
71
|
+
PR only and writes no ledger.)
|
|
65
72
|
5. Report the PR/MR URL and the required reviewers. **Do not** record approvals or advance — reviewers
|
|
66
|
-
act on the platform; `yad
|
|
73
|
+
act on the platform; CI (`yad gate ci`) reconciles it onto the default branch at merge.
|
|
67
74
|
|
|
68
75
|
### Step 3 — `route` (print required reviewers)
|
|
69
76
|
Compute and print the required reviewers as above. Use `templates/checks/hub-route.sh <body>` to parse a
|
|
70
77
|
PR/MR body's Impact & Risk block when given one; otherwise derive from `epic.repos` + the step's
|
|
71
78
|
`risk_tags`. Advisory only — it routes the human review, it does not approve.
|
|
72
79
|
|
|
73
|
-
### Step 4 — `wire` (
|
|
74
|
-
Install the hub CI that turns
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
80
|
+
### Step 4 — `wire` (merge-time sync on the hub)
|
|
81
|
+
Install the hub CI that turns the human **merge** into a `yad gate ci` run, with CI as the **sole
|
|
82
|
+
writer** of the ledger. There is no pre-merge CI write — during review the platform PR/MR is the
|
|
83
|
+
source of truth (native approvals + threads). On merge, CI re-reads approvals from the platform,
|
|
84
|
+
advances the step, and flips the artifact `status:` on the **default branch** (the only place CI ever
|
|
85
|
+
commits). Also install the `ledger-guard` check (yad-checks) so humans cannot commit gate-state files.
|
|
86
|
+
Revoke-on-change is enforced at merge: on **GitHub** in code (an approval whose commit ≠ the merged
|
|
87
|
+
head is dropped — no setting needed); on **GitLab** it has no per-approval commit SHA, so enabling the
|
|
88
|
+
platform's **"remove all approvals when commits are added to the source branch"** is **required** for
|
|
89
|
+
the guarantee. Either way it is safe because CI never pushes the review branch — only the owner's own
|
|
90
|
+
artifact pushes dismiss approvals. In bridge mode `yad gate sync` is advisory (read-only) and is **not**
|
|
91
|
+
a recovery path; if a merge-time run fails, the scheduled reconcile job re-advances it automatically, or
|
|
92
|
+
a maintainer can force it with `yad gate ci --branch <review-branch> --pr <n> --merged` locally on the
|
|
93
|
+
default branch. (File-only mode keeps `yad gate sync` as the local writer.)
|
|
78
94
|
|
|
79
95
|
1. Run `yad check --fix` (the wiring is manifest-driven, like `yad-checks`): with a platform +
|
|
80
96
|
enabled bridge in `.sdlc/hub.json` it installs
|
|
@@ -87,8 +103,9 @@ remains valid and is the fallback whenever CI cannot push.
|
|
|
87
103
|
`templates/gitlab/gitlab-ci.include-root.yml` as the root when none exists;
|
|
88
104
|
- create the 15-minute pipeline **schedule** (variable `SDLC_GATE_SYNC=true`) and the masked
|
|
89
105
|
`SDLC_GATE_TOKEN` project-access-token variable (`read_api` + `write_repository`). GitLab fires no
|
|
90
|
-
pipeline on an approval alone,
|
|
91
|
-
|
|
106
|
+
pipeline on an approval alone, and a squash merge can drop the branch name, so the schedule is the
|
|
107
|
+
catch-up path that advances merged reviews it discovers via the API (≤ ~15 min latency); a merge
|
|
108
|
+
push whose commit names the review branch advances near-immediately.
|
|
92
109
|
- **If your runners are tag-locked** (`run_untagged: false`, common on self-hosted): set a
|
|
93
110
|
`YAD_RUNNER_TAGS` CI/CD variable (e.g. `dind_runner`) so the docker-image `yad-gate-sync` job
|
|
94
111
|
is routed to a runner — otherwise it sits `pending` forever. The fragment emits
|
|
@@ -98,8 +115,12 @@ remains valid and is the fallback whenever CI cannot push.
|
|
|
98
115
|
every `yad` sync. Single value only — `tags: [$VAR]` is one tag equal to the whole variable,
|
|
99
116
|
not a comma-split.
|
|
100
117
|
3. Commit the workflow to the hub. GitHub needs nothing else — the ephemeral `github.token` reads the
|
|
101
|
-
PR and pushes the
|
|
102
|
-
|
|
118
|
+
PR and pushes the merge advance to the default branch, and the workflow's reconcile **schedule**
|
|
119
|
+
runs automatically (no setup) as the safety net that recovers a merge whose run failed transiently.
|
|
120
|
+
If the default branch is protected, see the workflow header for the bypass / PAT options; until then
|
|
121
|
+
the run fails visibly — recover by granting the push path, then re-run the workflow or run
|
|
122
|
+
`yad gate ci --branch <review-branch> --pr <n> --merged` locally on the default branch (advisory
|
|
123
|
+
`yad gate sync` cannot recover a bridge gate).
|
|
103
124
|
|
|
104
125
|
## Hard rules
|
|
105
126
|
|
|
@@ -111,12 +132,18 @@ remains valid and is the fallback whenever CI cannot push.
|
|
|
111
132
|
act) — `yad gate sync` records the approvals + resolution + merged state and advances; unresolved
|
|
112
133
|
comments or a changed artifact hold it `in_review`. The mechanical sync is the `yad gate` CLI.
|
|
113
134
|
- **CI never approves and never merges.** The wired workflow only runs `gate ci` — the same sync +
|
|
114
|
-
unchanged predicate
|
|
115
|
-
|
|
135
|
+
unchanged predicate. It does **nothing pre-merge** (the platform PR/MR holds the review state); at
|
|
136
|
+
merge it re-reads approvals from the platform, advances the step, and flips the artifact status on
|
|
137
|
+
the **default branch** (the only place CI ever commits). CI is the sole ledger writer; front gates
|
|
138
|
+
stay permanently human.
|
|
116
139
|
- **The CI tokens are the one documented bend of "no stored tokens".** GitHub uses the platform's own
|
|
117
140
|
ephemeral `github.token` (nothing stored). GitLab requires a stored masked `SDLC_GATE_TOKEN`
|
|
118
141
|
project access token because `CI_JOB_TOKEN` can neither read the approvals API nor push — say so
|
|
119
142
|
when wiring, and scope it to `read_api` + `write_repository` only.
|
|
143
|
+
- **Protect the hub default branch.** Require that `epics/**` artifacts change only through a review
|
|
144
|
+
PR/MR (branch protection). This keeps revoke-on-change sound: it removes the only window where a
|
|
145
|
+
delayed reconcile could advance on an out-of-band post-merge artifact change (see `references/bridge.md`,
|
|
146
|
+
"Known limitation").
|
|
120
147
|
- **Degrade gracefully.** No platform / disabled bridge / no CLI → the gate runs file-only with no error.
|
|
121
148
|
|
|
122
149
|
## Reference
|