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/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
- // latestReviews collapses a reviewer's superseded reviews to their current one.
161
- const rev = run('gh', ['pr', 'view', String(n), '--json', 'latestReviews'], { cwd });
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.14.0",
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` → a front-half artifact-review title `review: <artifact> (EP-<slug>)`, the shape
125
- `yad gate open` creates.
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` → requires `## Artifact under review`, `## Impact & Risk (front-half)`, `## Checklist`,
135
- and a `Risk tags:` line.
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`, so the front-half review PRs follow the hub conventions Conventional-Commits commit
206
- subjects, a `review: <artifact> (EP-<slug>)` title, and a body that uses the hub artifact-review
207
- template. `yad check --fix` installs the same `checks/*.sh` scripts plus a standalone hub workflow
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: every PR (including the front-half review/EP-* PRs) must follow
3
- # the hub conventions — Conventional-Commits commit subjects, a `review: <artifact> (EP-<slug>)` PR
4
- # title, and a PR body that uses the hub artifact-review template. They run with `--profile hub`.
5
- # Standalone workflow so it never collides with the code-repo yad-checks workflow.
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
- - run: bash checks/pr-title.sh --profile hub "$PR_TITLE"
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
- # Every MR (including the front-half review/EP-* MRs) must follow the hub conventions — a
7
- # Conventional-Commits commit subject, a `review: <artifact> (EP-<slug>)` MR title, and an MR body
8
- # that uses the hub artifact-review template. They run with `--profile hub`.
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
- - bash checks/pr-title.sh --profile hub "$CI_MERGE_REQUEST_TITLE"
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 event-driven sync on the hub: a CI workflow that runs `yad gate ci` whenever a reviewer approves / requests changes / a human merges, committing the ledger to the default 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.'
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. Upsert a record into `epics/<epic>/.sdlc/hub-prs.json`:
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-review-gate action: sync` pulls it back.
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` (event-driven sync on the hub)
74
- Install the hub CI that turns platform actions a review **approval**, a **change request**, a review
75
- dismissal, or the human **merge** into an automatic `yad gate ci` run that syncs the ledger and
76
- commits it to the hub's default branch. No more waiting on a manual `yad gate sync`; the manual command
77
- remains valid and is the fallback whenever CI cannot push.
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, so the schedule is the path that picks approvals up (≤ ~15 min
91
- latency); MR events and the merge are near-immediate.
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 ledger. If the default branch is protected, see the workflow header for the
102
- bypass / PAT options; until then the run fails visibly and manual `yad gate sync` still works.
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 and commits the **ledger files only** to the default branch; the artifact lands
115
- on the default branch exclusively via the human merge. Front gates stay permanently human.
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