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.
@@ -73,64 +73,106 @@ comments, replies, the reviewer **resolves** their thread, then `sync` runs agai
73
73
  - **Merge → advance.** When the reviewer rule is satisfied, every thread is resolved, **and the review
74
74
  PR/MR is merged**, `sync` marks the step `done` and unblocks the next step. The merge is the human
75
75
  approval act — there is no separate machine advance. (`yad gate sync` performs this deterministically.)
76
- - **Revoke on artifact change.** Each bridge approval is stamped with the content hash it was given
77
- against (the file bytes; the locked contract surface for architecture). On re-sync, an approval whose
78
- stamped hash ≠ the current hash is **dropped** — the reviewer must re-approve the changed artifact. A
79
- genuinely newer review (later `submittedAt`) re-stamps against the new hash. This is "revoke only when
80
- the artifact changed", not "revoke on any PR commit".
81
-
82
- ## Event-driven sync (hub CI)
83
-
84
- The `wire` action (SKILL.md Step 4) installs a CI workflow on the hub so the platform events drive
85
- `yad gate ci` instead of waiting on a manual `yad gate sync`. The CLI entry is self-sufficient: it
86
- derives the epic + artifact from the `review/EP-<slug>/<artifact-base>` head branch and takes the PR/MR
87
- number from the event payload it even upserts the `hub-prs.json` entry when the author never committed
88
- it, so the first CI commit converges the views. `yad gate ci` with no `--branch` sweeps every open
89
- review PR (the scheduled path).
90
-
91
- | Platform event | CI action |
92
- |---|---|
93
- | review submitted (approve / changes requested) or dismissed | `gate ci --branch <head> --pr <n>` sync the ledger; the predicate may hold or pass |
94
- | PR/MR `synchronize` (new commits on the review branch) | same — promptly re-stamps/revokes approvals on artifact change |
95
- | PR/MR closed **and merged** (the human act) | same predicate sees `merged: true` and advances the step |
96
- | GitLab schedule (`*/15 * * * *`, `SDLC_GATE_SYNC=true`) | `gate ci` sweep — the **only** GitLab path that sees a bare approval (≤ ~15 min latency) |
97
-
98
- **The overlay.** Pre-merge, the artifact under review exists only on the review branch, while the
99
- ledger lives on the default branch. `gate ci` checks out the default branch, fetches the head ref and
100
- overlays just the artifact paths (for architecture: `architecture.md` + `contract.md` +
101
- `.sdlc/contract-lock.json`; for stories: `stories/`) so `artifactHash` binds each approval to **what the
102
- reviewers actually approved** then drops the overlay before committing. Only
103
- `epics/<epic>/.sdlc/*.json` + `reviews/*.md` are committed (message `chore(gate): … [skip ci]`); the
104
- artifact reaches the default branch exclusively via the human merge.
105
-
106
- **Loop prevention & races.** The GitHub triggers (`pull_request_review`, `pull_request`) cannot fire on
107
- a push to the default branch, `[skip ci]` guards every other workflow on both platforms, and a
108
- repo-wide concurrency group serializes runs; the ledger push retries with a rebase (ledger-only commits
109
- across epics touch disjoint files).
76
+ - **Revoke on artifact change (checked at merge).** Path B reconciles at merge, so an approval given
77
+ to an earlier revision must not count for the merged content. How that is enforced differs by
78
+ platform:
79
+ - **GitHub in code.** Each approval carries the **commit SHA it was made on**; `mapApprovers`
80
+ drops any approval whose commit ≠ the merged head (`headOid`). The reviewer approved an older
81
+ revision → their review is stale → dropped. No platform setting required.
82
+ - **GitLab — platform setting.** GitLab approvals expose no per-approval commit SHA (the reader omits
83
+ it), so the in-code SHA check does not apply and the approval is kept. Revoke-on-change there is the
84
+ platform's **"remove all approvals when commits are added to the source branch"** setting **enable
85
+ it** (required for the guarantee on GitLab; safe under Path B because CI never pushes the source
86
+ branch, so only the owner's own artifact pushes drop approvals).
87
+ - **Degraded GitHub readfail closed.** If the GitHub commit read fails (the reader returns a
88
+ `null` commit), the approval is **dropped**: a transient failure holds the gate rather than
89
+ advancing on approvals whose freshness cannot be proven (re-run `yad gate sync` to recover).
90
+ CHANGES_REQUESTED is still honored, so a degraded read can only ever *hold* the gate.
91
+ The `artifactHash` stamp still binds architecture approvals to the locked contract surface (see
92
+ "Contract re-lock" above).
93
+ - **Known limitation protect the hub default branch.** The advance hashes the artifact from the
94
+ default branch as it stands when CI runs, while approvals are SHA-bound to the reviewed PR/MR head.
95
+ Those can differ if the artifact changes on the **base** outside this review while the PR/MR is open
96
+ (the merge then integrates a change the reviewers never saw) or if a later out-of-band commit edits
97
+ the merged artifact before a delayed reconcile advances it. In both cases each approval's commit
98
+ still equals the reviewed head, so the SHA check passes, yet the live content was not reviewed. Close
99
+ it operationally: **require branch protection on the hub default branch so `epics/**` artifacts can
100
+ only change through their own review PR/MR** (one open review per artifact) — then the base copy of an
101
+ artifact cannot move while its review is open, so the merged/live content always equals the reviewed
102
+ content. (The complete in-code fix would hash the artifact at the reviewed PR-head revision before
103
+ advancing; deferred in favor of the branch-protection mitigation.)
104
+
105
+ ## Event-driven sync (hub CI) — Path B
106
+
107
+ The `wire` action (SKILL.md Step 4) installs CI on the hub so a **merge** drives `yad gate ci` —
108
+ **CI is the SOLE writer of the ledger, and it writes only at merge, only to the default branch.**
109
+ During review CI writes nothing: the platform PR/MR is the source of truth (native approvals +
110
+ threads). The CLI is self-sufficient at merge: it derives the epic + artifact from the
111
+ `review/EP-<slug>/<artifact-base>` head branch, takes the PR/MR number from the event (GitHub) or
112
+ resolves it from the platform (GitLab), upserts the `hub-prs.json` entry itself, and **re-reads
113
+ approvals fresh from the platform** — so no ledger needs to be pre-seeded on the branch.
114
+
115
+ | Platform event | Phase | CI action |
116
+ |---|---|---|
117
+ | PR/MR opened / reopened / pushed / reviewed | pre-merge | **none** — review state lives on the platform; CI never touches the branch |
118
+ | PR/MR closed **and merged** (the human act) | merge | `gate ci --branch <head> --pr <n> --merged` → re-read approvals, advance the step + flip the artifact `status:` **on the default branch** |
119
+ | Schedule (`*/15`) | reconcile | Safety net: enumerate recently-**merged** `review/EP-*` PRs/MRs via the API and advance any not yet `done` (idempotent). Recovers a merge whose merge-time run failed transiently, and on GitLab also picks up a squash merge whose commit dropped the branch name (and a bare approval — GitLab fires no pipeline on one). **GitHub:** a scheduled workflow, automatic once committed. **GitLab:** a pipeline schedule with `SDLC_GATE_SYNC=true` (one-time setup) |
120
+
121
+ **Why no pre-merge write fixes the gate.** Keeping CI off the PR head means an in-flight approval is
122
+ never dismissed by a CI commit, and the PR's required checks never strand on a `[skip ci]` CI commit.
123
+ Correctness is unaffected: at merge CI re-reads the PR/MR approvals from the platform and re-checks
124
+ each `artifactHash` against the merged content, so revoke-on-change still holds. The only ledger
125
+ commit — the advance plus the `draft → approved` status flip — lands on the **default branch** with
126
+ `[skip ci]`.
127
+
128
+ **The ledger is CI-owned (bridge mode only).** Humans never commit gate-state files: the `ledger-guard`
129
+ check (yad-checks) FAILs any commit on a review PR that touches `.sdlc/{state,approvals,comments,hub-prs}
130
+ .json` or `reviews/*.md` (`.sdlc/contract-lock.json` is artifact-side and allowed). Under Path B **no
131
+ CI commit lands in a review PR at all**, so the only ledger change the guard can see there is a human
132
+ edit — which it rejects. (The `verified-commits` gate still vets every commit's signature + author;
133
+ its gate-bot exemption is now vestigial in-PR because CI no longer commits there.) `yad gate open`
134
+ opens the PR only; local `yad gate sync` is advisory in bridge mode (writes nothing). After a merge,
135
+ everyone `git checkout <default> && git pull`. (Without the bridge, humans own the ledger locally and
136
+ these guards are no-ops.)
137
+
138
+ **Loop prevention & races.** The only ledger commit lands on the **default branch** at merge, which
139
+ fires no PR trigger; it carries `[skip ci]` to guard sibling workflows. Because CI never pushes the
140
+ review branch, there is no `synchronize` / MR-pipeline loop to prevent — and it is now **safe to enable**
141
+ the platform's **"dismiss stale approvals on push"** (GitHub) / **"remove all approvals when commits
142
+ are added to the source branch"** (GitLab): only the owner's own artifact pushes dismiss approvals,
143
+ which is exactly the intended revoke-on-change. Merge advances serialize on the default branch; the
144
+ push retries with a rebase.
110
145
 
111
146
  **Tokens.**
112
147
  - GitHub: the ephemeral `github.token` with `contents: write` + `pull-requests: read` — nothing stored.
148
+ Only the merge job pushes, and only the default branch.
113
149
  - GitLab: a masked `SDLC_GATE_TOKEN` project access token (`read_api` + `write_repository`) — the one
114
150
  documented bend of the no-stored-tokens rule; `CI_JOB_TOKEN` can neither read the approvals API nor
115
- push.
116
- - Protected default branch (GitHub): prefer a ruleset bypass for Actions; else a fine-grained PAT as
117
- `SDLC_GATE_TOKEN` passed to `actions/checkout`; else leave it the run fails **visibly** and manual
118
- `yad gate sync` remains the fallback. Never wire branch protection that couples the merge itself to
119
- the gate.
120
-
121
- **Manual sync stays first-class.** `yad gate sync <epic> [artifact]` is unchanged and always valid —
122
- the CI is the same sync on a trigger, and the file ledger is still the source of truth.
151
+ push. Used only for the merge-time default-branch push + the API reads.
152
+ - Protected default branch (GitHub): the merge advance needs to push it — prefer a ruleset bypass for
153
+ Actions, else a fine-grained PAT as `SDLC_GATE_TOKEN` on the mergesync checkout.
154
+
155
+ **Manual sync & recovery.** In bridge mode `yad gate sync` is **advisory** (read-only) — it prints the
156
+ predicate but writes nothing, so it is **not** a recovery path when CI fails. If a merge-time run fails
157
+ (can't push, API hiccup), recovery is the scheduled **reconcile** job (automatic; it re-advances merged
158
+ reviews not yet `done`). To force it immediately, a maintainer runs the same command CI runs, locally on
159
+ the default branch: `yad gate ci --branch <review-branch> --pr <n> --merged` (this writes + pushes,
160
+ unlike advisory `yad gate sync`). File-only mode (no platform) keeps `yad gate sync` as the local writer.
161
+ The file ledger is still the source of truth.
123
162
 
124
163
  ### Manual end-to-end verification (GitHub)
125
164
 
126
165
  1. On a scratch hub: `yad setup` (platform github, roster with a second account) → `yad check --fix`
127
166
  installs `.github/workflows/yad-gate-sync.yml`; commit + push it.
128
- 2. Author an epic → `yad gate open EP-x epic.md` → the review PR opens.
129
- 3. Second account **approves** → the Actions run commits `approvals.json` to the default branch.
130
- 4. Second account **requests changes** → the run records the blocking comment; `yad gate status EP-x`
131
- (after `git pull`) shows the gate held.
132
- 5. Resolve the thread, approve again, a human **merges** → the closed-event run advances `state.json`
133
- (`epic-review: done`, `currentStep: architecture`).
134
- 6. `git pull` locally `yad gate status EP-x` matches the platform history.
135
-
136
- GitLab variant: same flow on an MR; a bare approval appears after the next schedule tick.
167
+ 2. Author an epic → `yad gate open EP-x epic.md` → the review PR opens. CI writes nothing yet — review
168
+ state lives on the platform.
169
+ 3. Second account **approves** / **requests changes** → no CI run touches the branch; the PR's native
170
+ approvals + threads are the source of truth (local `yad gate sync` is advisory and shows the
171
+ predicate without writing).
172
+ 4. Resolve the thread, approve again, a human **merges** → the mergesync run advances `state.json`
173
+ (`epic-review: done`, `currentStep: architecture`) + flips `epic.md` to `approved` **on the
174
+ default branch**.
175
+ 5. `git checkout <default> && git pull` locally `yad gate status EP-x` matches the platform history.
176
+
177
+ GitLab variant: same flow on an MR; the advance lands on the merge push (or the next schedule tick if
178
+ a squash merge dropped the branch name).
@@ -1,52 +1,60 @@
1
1
  # yad-managed: yad-hub-bridge
2
- # Event-driven gate sync for the PRODUCT HUB. When a reviewer approves, requests changes, or a
3
- # human merges a review/EP-* PR, this workflow runs `yad gate ci`, which maps the platform state
4
- # into the file ledger (epics/<epic>/.sdlc/*.json + reviews/*.md) and commits ONLY those ledger
5
- # files to the default branch. It never approves and never merges — the merge click remains the
6
- # human approval act; the gate predicate is the same one `yad gate sync` runs locally.
2
+ # Merge-time gate sync for the PRODUCT HUB (Path B). CI is the SOLE writer of the ledger, and it
3
+ # writes ONLY at merge, ONLY to the default branch.
7
4
  #
8
- # Loop prevention: neither `pull_request_review` nor `pull_request` fires on a push to the default
9
- # branch, and the ledger commit carries [skip ci] to guard every other workflow.
5
+ # During review there is NO CI write: the platform PR is the source of truth (native approvals +
6
+ # review threads). CI never pushes to the review branch, so an in-flight approval is never dismissed
7
+ # by a CI commit and the PR's required checks never strand on one. Because nothing is pushed to the
8
+ # PR head, you can safely enable "Dismiss stale pull request approvals when new commits are pushed"
9
+ # — only the artifact owner's own pushes will dismiss approvals (the intended revoke-on-change).
10
10
  #
11
- # Protected default branch? The github.token push will fail (visibly — the run goes red and manual
12
- # `yad gate sync` still works). Options, in order of preference:
13
- # a) add a ruleset bypass so GitHub Actions may push to the default branch, or
14
- # b) store a fine-grained PAT as the SDLC_GATE_TOKEN secret and pass it to actions/checkout via
15
- # `with: { token: ${{ secrets.SDLC_GATE_TOKEN }} }` — the one exception to the bridge's
16
- # no-stored-tokens rule; see skills/yad-hub-bridge/references/bridge.md.
11
+ # Two jobs:
12
+ # MERGE (PR closed + merged): the fast path. Check out the default branch (the human merge already
13
+ # landed the artifact there) and run `yad gate ci --branch <head> --pr <n> --merged`. CI re-reads
14
+ # the PR's approvals fresh from the platform, advances the step, and flips the artifact `status:` to
15
+ # approved committing that advance to the default branch with [skip ci].
16
+ #
17
+ # RECONCILE (schedule, every 15 min): the safety net. The merge job's `closed` event fires once and
18
+ # never repeats, so a transient API/GraphQL/push failure (or a fail-closed degraded approval read)
19
+ # would otherwise strand a merged review until someone reran it by hand. This periodic job discovers
20
+ # recently-merged review PRs from the API and advances any not yet done (idempotent — a step already
21
+ # `done` is skipped). On GitHub a scheduled workflow runs automatically once committed (no setup).
22
+ #
23
+ # CI never approves and never merges — the merge click is the human approval act.
24
+ #
25
+ # Protected default branch? Only these jobs push there. If the default branch is protected against
26
+ # Actions:
27
+ # a) add a ruleset bypass so GitHub Actions may push to it, or
28
+ # b) store a fine-grained PAT as the SDLC_GATE_TOKEN secret and pass it to actions/checkout below
29
+ # via `with: { token: ${{ secrets.SDLC_GATE_TOKEN }} }` — see references/bridge.md.
17
30
  name: yad-gate-sync
18
31
  on:
19
- pull_request_review:
20
- types: [submitted, dismissed]
21
32
  pull_request:
22
- types: [closed, synchronize] # closed -> merged advance; synchronize -> prompt revoke-on-change
33
+ types: [closed]
34
+ schedule:
35
+ - cron: "*/15 * * * *"
23
36
 
24
37
  permissions:
25
- contents: write # push the ledger commit
38
+ contents: write # push the merge-advance ledger commit to the default branch
26
39
  pull-requests: read # gh pr view + reviewThreads GraphQL
27
40
 
28
- # Serialize runs repo-wide so ledger pushes never race; sync reads the FULL platform state each
29
- # time, so a queued superseded run loses nothing.
30
- concurrency:
31
- group: yad-gate-sync
32
- cancel-in-progress: false
33
-
34
41
  jobs:
35
- sync:
42
+ mergesync:
43
+ # The human merge of a review branch: advance the step + flip the artifact status on the default
44
+ # branch. Nothing runs pre-merge — review state lives on the platform until the merge.
36
45
  if: >
46
+ github.event_name == 'pull_request' &&
37
47
  startsWith(github.event.pull_request.head.ref, 'review/EP-') &&
38
- (github.event_name == 'pull_request_review' ||
39
- github.event.action == 'synchronize' ||
40
- github.event.pull_request.merged == true)
48
+ github.event.pull_request.merged == true
41
49
  runs-on: ubuntu-latest
50
+ # Serialize every advance with the reconcile job — they all push the default branch.
51
+ concurrency:
52
+ group: yad-gate-mergesync
53
+ cancel-in-progress: false
42
54
  env:
43
55
  GH_TOKEN: ${{ github.token }}
44
56
  steps:
45
- # Check out the BASE branch (not the PR merge ref): the ledger lives there, and `gate ci`
46
- # overlays the artifact from the head ref itself so approvals bind to the reviewed content.
47
- # A head branch auto-deleted on merge is fine: the event payload still carries head.ref (the
48
- # string `gate ci` parses), and a failed fetch just skips the overlay — by then the artifact
49
- # is already on the base branch via the merge.
57
+ # Check out the default branch the merge already landed the artifact here.
50
58
  - uses: actions/checkout@v4
51
59
  with:
52
60
  ref: ${{ github.event.pull_request.base.ref }}
@@ -54,10 +62,52 @@ jobs:
54
62
  - uses: actions/setup-node@v4
55
63
  with:
56
64
  node-version: "20"
57
- - name: Sync the gate ledger
65
+ - name: Advance the gate on merge
58
66
  run: |
59
67
  git config user.name "yad-gate-sync[bot]"
60
68
  git config user.email "yad-gate-sync[bot]@users.noreply.github.com"
61
- npx -y -p yadflow@2 yad gate ci \
69
+ npx -y -p yadflow@3 yad gate ci \
62
70
  --branch "${{ github.event.pull_request.head.ref }}" \
63
- --pr "${{ github.event.pull_request.number }}"
71
+ --pr "${{ github.event.pull_request.number }}" \
72
+ --merged
73
+
74
+ reconcile:
75
+ # Safety net: recover any merged review PR whose merge-time run failed transiently (the `closed`
76
+ # event never re-fires). Idempotent — `yad gate ci` skips a step that is already `done`.
77
+ if: github.event_name == 'schedule'
78
+ runs-on: ubuntu-latest
79
+ concurrency:
80
+ group: yad-gate-mergesync
81
+ cancel-in-progress: false
82
+ env:
83
+ GH_TOKEN: ${{ github.token }}
84
+ steps:
85
+ - uses: actions/checkout@v4
86
+ with:
87
+ ref: ${{ github.event.repository.default_branch }}
88
+ fetch-depth: 0
89
+ - uses: actions/setup-node@v4
90
+ with:
91
+ node-version: "20"
92
+ - name: Reconcile recently-merged review PRs
93
+ run: |
94
+ git config user.name "yad-gate-sync[bot]"
95
+ git config user.email "yad-gate-sync[bot]@users.noreply.github.com"
96
+ # A stuck merged review is always recent; window generously and let `gate ci` no-op the rest.
97
+ # Page the search API fully (no fixed result cap) so a stuck PR is never missed for volume.
98
+ # Aggregate failures and exit nonzero so a persistent API/push failure surfaces (red run)
99
+ # instead of silently stranding a merged gate behind a green safety-net. Read from a file,
100
+ # not a pipe, so the failure flag survives (a piped `while` runs in a subshell).
101
+ SINCE="$(date -u -d '7 days ago' +%Y-%m-%d 2>/dev/null || date -u -v-7d +%Y-%m-%d)"
102
+ rc=0
103
+ gh api --paginate -X GET search/issues \
104
+ --raw-field q="repo:${GITHUB_REPOSITORY} type:pr is:merged merged:>=${SINCE}" \
105
+ --jq '.items[].number' > /tmp/yad-merged-prs || rc=1
106
+ while read -r N; do
107
+ [ -n "$N" ] || continue
108
+ REF="$(gh pr view "$N" --json headRefName --jq '.headRefName' 2>/dev/null)" || rc=1
109
+ case "$REF" in
110
+ review/EP-*) npx -y -p yadflow@3 yad gate ci --branch "$REF" --pr "$N" --merged || rc=1 ;;
111
+ esac
112
+ done < /tmp/yad-merged-prs
113
+ exit $rc
@@ -1,33 +1,41 @@
1
1
  # yad-managed-include: yad-hub-bridge
2
- # Event-driven gate sync for the PRODUCT HUB, as an INCLUDABLE fragment. Pulled into the hub's
3
- # root .gitlab-ci.yml via:
2
+ # Merge-time gate sync for the PRODUCT HUB, as an INCLUDABLE fragment (Path B). Pulled into the
3
+ # hub's root .gitlab-ci.yml via:
4
4
  # include:
5
5
  # - local: '.gitlab/ci/yad-gate-sync.yml'
6
6
  # so wiring never edits the foreign root pipeline beyond that one include line. The job carries
7
7
  # `needs: []` and no `stage:` (same merge-safety as the yad-checks fragment).
8
8
  #
9
- # What it does: run `yad gate ci`, which maps MR approvals / change-request discussions / the
10
- # merge into the file ledger (epics/<epic>/.sdlc/*.json + reviews/*.md) and commits ONLY those
11
- # ledger files to the default branch. It never approves and never merges the merge click remains
12
- # the human approval act.
9
+ # CI is the SOLE writer of the ledger, and it writes ONLY at merge, ONLY to the default branch.
10
+ # During review there is NO CI write: the MR itself is the source of truth (native approvals +
11
+ # threads). CI never pushes to the MR source (review) branch, so an in-flight approval is never
12
+ # dismissed by a CI commit. Because nothing is pushed to the source branch, you can safely enable
13
+ # "Remove all approvals when commits are added to the source branch" — only the artifact owner's own
14
+ # pushes will dismiss approvals (the intended revoke-on-change).
13
15
  #
14
- # GitLab is the DEGRADED path, stated honestly: GitLab fires no pipeline on an approval alone.
15
- # - MR events (open / push to the review branch) and the merge are picked up near-immediately.
16
- # - Approvals are only seen by the SCHEDULED sweep. Create a pipeline schedule (one-time, cannot
17
- # be committed as code): cron `*/15 * * * *` with variable SDLC_GATE_SYNC=true so an approval
18
- # can take up to ~15 minutes to reach the ledger. UI: CI/CD > Schedules, or:
16
+ # GitLab is the DEGRADED platform, stated honestly: it fires no pipeline on an approval, and the
17
+ # merge push to the default branch may omit the source-branch name (squash / fast-forward). So the
18
+ # ledger is reconciled from the PLATFORM (glab API), not from any per-branch ledger:
19
+ # - MERGE push (default branch, commit names a review branch): resolve the merged MR's IID from its
20
+ # source branch, then advance + flip status on the default branch near-immediate.
21
+ # - SCHEDULED sweep (catch-up): enumerate recently-merged review MRs via the API and advance any
22
+ # not yet advanced (idempotent — a step already `done` is skipped). Covers bare approvals and
23
+ # squash merges whose commit message dropped the branch name. Create a pipeline schedule
24
+ # (one-time, cannot be committed as code): cron `*/15 * * * *` with variable SDLC_GATE_SYNC=true.
25
+ # UI: CI/CD > Schedules, or:
19
26
  # glab api projects/:id/pipeline_schedules -X POST \
20
27
  # -f description='yad gate sync' -f ref=main -f cron='*/15 * * * *'
21
28
  # glab api "projects/:id/pipeline_schedules/<id>/variables" -X POST \
22
29
  # -f key=SDLC_GATE_SYNC -f value=true
23
30
  #
24
- # Token (the one documented bend of the bridge's no-stored-tokens rule): CI_JOB_TOKEN can neither
25
- # read the approvals API nor push to a protected branch. Create a PROJECT ACCESS TOKEN with
26
- # `read_api` + `write_repository` (role Developer+, allowed to push to the default branch) and
27
- # store it as a masked CI/CD variable SDLC_GATE_TOKEN. Without it the job fails visibly and
28
- # manual `yad gate sync` remains the fallback.
31
+ # Token: CI_JOB_TOKEN can neither read the approvals API nor push to a protected branch. Create a
32
+ # PROJECT ACCESS TOKEN with `read_api` + `write_repository` (role Developer+, allowed to push to the
33
+ # default branch the merge advance pushes there) and store it as a masked CI/CD variable
34
+ # SDLC_GATE_TOKEN. Without it the job fails visibly; recover by setting the token, then re-run the
35
+ # pipeline or run `yad gate ci --branch <review-branch> --pr <iid> --merged` locally on the default
36
+ # branch (advisory `yad gate sync` is read-only in bridge mode and cannot recover a stuck gate).
29
37
  variables:
30
- GIT_DEPTH: "0" # full history: gate ci fetches the review branch and pushes the ledger
38
+ GIT_DEPTH: "0" # full history: gate ci pushes the advance to the default branch
31
39
 
32
40
  yad-gate-sync:
33
41
  needs: []
@@ -38,32 +46,55 @@ yad-gate-sync:
38
46
  tags: [$YAD_RUNNER_TAGS]
39
47
  image: node:20
40
48
  rules:
41
- # MR-event path: fires on MR open / push to the review branch — NOT on approval (see header).
42
- - if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_SOURCE_BRANCH_NAME =~ /^review\/EP-/
43
49
  # Merge path: branch pipeline on the default branch whose merge commit names a review branch.
44
50
  # Squash/fast-forward merges may omit the branch name — those advance on the next scheduled sweep.
45
51
  - if: $CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $CI_COMMIT_MESSAGE =~ /review\/EP-/
46
- # Scheduled sweep: the only path that picks up approvals.
52
+ # Scheduled sweep: the only path that picks up bare approvals (advances merged reviews it finds).
47
53
  - if: $CI_PIPELINE_SOURCE == "schedule" && $SDLC_GATE_SYNC == "true"
48
54
  script:
49
55
  # Pinned glab binary (node:20 has no glab). Alternative: image registry.gitlab.com/gitlab-org/cli.
50
56
  - GLAB_VERSION=1.55.0
51
57
  - curl -fsSL "https://gitlab.com/gitlab-org/cli/-/releases/v${GLAB_VERSION}/downloads/glab_${GLAB_VERSION}_linux_amd64.deb" -o /tmp/glab.deb && dpkg -i /tmp/glab.deb
52
- # The ledger lives on the default branch; gate ci overlays the artifact from the review branch itself.
53
- - git fetch origin "$CI_DEFAULT_BRANCH"
54
- - git checkout -B "$CI_DEFAULT_BRANCH" "origin/$CI_DEFAULT_BRANCH"
55
58
  - git config user.name "yad-gate-sync" && git config user.email "yad-gate-sync@noreply.${CI_SERVER_HOST}"
56
59
  - git remote set-url origin "https://oauth2:${SDLC_GATE_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git"
57
60
  - export GITLAB_TOKEN="$SDLC_GATE_TOKEN" GITLAB_HOST="$CI_SERVER_URL"
61
+ - git fetch origin "$CI_DEFAULT_BRANCH"
62
+ - git checkout -B "$CI_DEFAULT_BRANCH" "origin/$CI_DEFAULT_BRANCH"
58
63
  - |
59
- # Merge pushes have no CI_MERGE_REQUEST_IID — derive the just-merged review branch from the
60
- # merge commit message so the merge advances in event mode; the scheduled sweep stays the
61
- # catch-all (squash/FF merges whose message omits the branch land there).
62
- REVIEW_BRANCH="$(printf '%s' "$CI_COMMIT_MESSAGE" | grep -oE 'review/EP-[A-Za-z0-9._/-]+' | head -n1 || true)"
63
- if [ -n "$CI_MERGE_REQUEST_IID" ]; then
64
- npx -y -p yadflow@2 yad gate ci --branch "$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME" --pr "$CI_MERGE_REQUEST_IID"
65
- elif [ -n "$REVIEW_BRANCH" ]; then
66
- npx -y -p yadflow@2 yad gate ci --branch "$REVIEW_BRANCH"
64
+ rc=0
65
+ if [ "$CI_PIPELINE_SOURCE" = "schedule" ]; then
66
+ # SCHEDULED SWEEP discover merged review MRs from the platform (Path B keeps no per-branch
67
+ # ledger), then advance each. `gate ci` is idempotent: a step already `done` is skipped.
68
+ # A stuck review MR (a squash merge whose commit dropped the branch name, or a failed merge
69
+ # push) is always RECENT, so sweep a generous recent window and PAGINATE it fully (--paginate)
70
+ # this bounds cost without the old hard 50-row cap that could permanently strand older MRs.
71
+ # Aggregate failures into rc and exit nonzero so a persistent failure surfaces (red pipeline)
72
+ # instead of silently stranding a merged gate. Read from a file, not a pipe (a piped `while`
73
+ # runs in a subshell, losing rc). An MR stuck beyond the window needs manual recovery — run
74
+ # `yad gate ci --branch <review-branch> --pr <iid> --merged` locally on the default branch.
75
+ SINCE="$(date -u -d '7 days ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v-7d +%Y-%m-%dT%H:%M:%SZ)"
76
+ glab api --paginate "projects/:id/merge_requests?state=merged&updated_after=${SINCE}&per_page=100&order_by=updated_at" \
77
+ --jq '.[] | select(.source_branch | startswith("review/EP-")) | "\(.iid) \(.source_branch)"' > /tmp/yad-merged-mrs || rc=1
78
+ while read -r IID REF; do
79
+ [ -n "$IID" ] || continue
80
+ git checkout -q -B "$CI_DEFAULT_BRANCH" "origin/$CI_DEFAULT_BRANCH"
81
+ npx -y -p yadflow@3 yad gate ci --branch "$REF" --pr "$IID" --merged || rc=1
82
+ done < /tmp/yad-merged-mrs
67
83
  else
68
- npx -y -p yadflow@2 yad gate ci
84
+ # MERGE push to the default branch whose commit names a review branch: resolve the merged MR's
85
+ # IID from its source branch so `gate ci` can re-read approvals, then advance there.
86
+ REVIEW_BRANCH="$(printf '%s' "$CI_COMMIT_MESSAGE" | grep -oE 'review/EP-[A-Za-z0-9._/-]+' | head -n1 || true)"
87
+ if [ -n "$REVIEW_BRANCH" ]; then
88
+ IID="$(glab api "projects/:id/merge_requests?source_branch=${REVIEW_BRANCH}&state=merged" --jq '.[0].iid' 2>/dev/null || true)"
89
+ if [ -n "$IID" ]; then
90
+ # Pass --pr + IID as two distinct args (avoid a fragile, shell-dependent ${IID:+...} split).
91
+ npx -y -p yadflow@3 yad gate ci --branch "$REVIEW_BRANCH" --pr "$IID" --merged || rc=1
92
+ else
93
+ # Without the IID, gate ci cannot re-read approvals — fail visibly (the scheduled sweep
94
+ # retries) rather than running a green no-op that silently leaves the gate unadvanced.
95
+ echo "yad-gate-sync: could not resolve merged MR IID for ${REVIEW_BRANCH}" >&2
96
+ rc=1
97
+ fi
98
+ fi
69
99
  fi
100
+ exit $rc
@@ -7,21 +7,38 @@
7
7
  # requires `## Summary`, `## Impact & Risk`, `## Checklist`, and a filled `Risk level:` (low|medium|high).
8
8
  # --profile hub — the front-half artifact-review template (templates/hub/<platform>/):
9
9
  # requires `## Artifact under review`, `## Impact & Risk (front-half)`, `## Checklist`, and a `Risk tags:` line.
10
+ # BUT only for review/EP-* head branches. Every other hub PR is a tooling/code change to the hub
11
+ # itself and uses the code task template instead; pass the head ref via --head so the gate knows
12
+ # which template to require. With no --head, the hub profile stays strict (artifact-review template).
13
+ # Branch name is not enough on its own: a non-review head that actually changes front-half
14
+ # artifacts (epics/**) would otherwise slip past the review workflow with only the code template.
15
+ # Pass the PR's changed paths via --changed <file> (one path per line); when they touch epics/**
16
+ # on a non-review head the gate FAILS — artifact changes must go through a review/EP-* PR.
10
17
  # The body is passed as a FILE path (single positional arg); CI writes the event body to a temp file
11
18
  # (GitHub: github.event.pull_request.body; GitLab: $CI_MERGE_REQUEST_DESCRIPTION).
12
19
  set -euo pipefail
13
20
 
14
21
  PROFILE=code
22
+ HEADREF=""
23
+ CHANGED=""
15
24
  ARGS=()
16
25
  while [ $# -gt 0 ]; do
17
26
  case "$1" in
18
27
  --profile) PROFILE="${2:-code}"; shift 2 ;;
19
28
  --profile=*) PROFILE="${1#*=}"; shift ;;
29
+ --head) HEADREF="${2:-}"; shift 2 ;;
30
+ --head=*) HEADREF="${1#*=}"; shift ;;
31
+ --changed) CHANGED="${2:-}"; shift 2 ;;
32
+ --changed=*) CHANGED="${1#*=}"; shift ;;
20
33
  *) ARGS+=("$1"); shift ;;
21
34
  esac
22
35
  done
23
36
  case "$PROFILE" in code|hub) ;; *) echo "FAIL [pr-template]: unknown --profile '$PROFILE' (code|hub)."; exit 1 ;; esac
24
37
 
38
+ # True when the PR changes a front-half artifact (anything under epics/**). Reads the --changed list
39
+ # of paths CI computed from the PR diff; with no list (direct caller / test) it reports false.
40
+ artifact_changed() { [ -n "$CHANGED" ] && [ -f "$CHANGED" ] && grep -qE '^epics/' "$CHANGED"; }
41
+
25
42
  BODY="${ARGS[0]:-}"
26
43
  if [ -z "$BODY" ] || [ ! -f "$BODY" ]; then
27
44
  echo "FAIL [pr-template]: body file not found — pass the PR/MR description as a file path."
@@ -36,19 +53,14 @@ require_heading() {
36
53
  fi
37
54
  }
38
55
 
39
- if [ "$PROFILE" = hub ]; then
40
- require_heading '## Artifact under review' '## Artifact under review'
41
- require_heading '## Impact & Risk \(front-half\)' '## Impact & Risk (front-half)'
42
- require_heading '## Checklist' '## Checklist'
43
- if ! grep -qiE '(\*\*)?Risk tags:' "$BODY"; then
44
- echo "FAIL [pr-template]: missing 'Risk tags:' line (front-half Impact & Risk)."
45
- rc=1
46
- fi
47
- else
56
+ # The code task template: `## Summary`, `## Impact & Risk`, `## Checklist`, and a filled `Risk level:`.
57
+ # Used by the code profile and by hub tooling PRs (any head branch that is not review/EP-*).
58
+ check_code_body() {
48
59
  require_heading '## Summary' '## Summary'
49
60
  require_heading '## Impact & Risk' '## Impact & Risk'
50
61
  require_heading '## Checklist' '## Checklist'
51
62
  # Risk level must be present AND filled with a real value (not the <placeholder>).
63
+ local rl
52
64
  rl="$(grep -iE '(\*\*)?Risk level:' "$BODY" | head -1 \
53
65
  | sed -E 's/<!--.*$//; s/^[^:]*://; s/[*`]//g; s/^[[:space:]]*//; s/[[:space:]]*$//' || true)"
54
66
  rl="$(printf '%s' "$rl" | tr 'A-Z' 'a-z' | grep -oE 'low|medium|high' | head -1 || true)"
@@ -56,7 +68,38 @@ else
56
68
  echo "FAIL [pr-template]: 'Risk level:' missing or not set to low|medium|high."
57
69
  rc=1
58
70
  fi
71
+ }
72
+
73
+ # The front-half artifact-review template.
74
+ check_hub_body() {
75
+ require_heading '## Artifact under review' '## Artifact under review'
76
+ require_heading '## Impact & Risk \(front-half\)' '## Impact & Risk (front-half)'
77
+ require_heading '## Checklist' '## Checklist'
78
+ if ! grep -qiE '(\*\*)?Risk tags:' "$BODY"; then
79
+ echo "FAIL [pr-template]: missing 'Risk tags:' line (front-half Impact & Risk)."
80
+ rc=1
81
+ fi
82
+ }
83
+
84
+ KIND="$PROFILE"
85
+ if [ "$PROFILE" = hub ]; then
86
+ case "$HEADREF" in
87
+ review/EP-*|"") check_hub_body ;; # artifact-review PR (or unknown head — stay strict)
88
+ *)
89
+ # tooling/code change to the hub itself — UNLESS it changes front-half artifacts (epics/**),
90
+ # which must go through a review/EP-* PR. Without this guard a non-review head could carry an
91
+ # artifact change past the front-half review with only the code template.
92
+ if artifact_changed; then
93
+ echo "FAIL [pr-template]: head '${HEADREF}' changes front-half artifacts (epics/**) but is not a review/EP-* branch — artifact changes must go through a review PR."
94
+ rc=1
95
+ else
96
+ check_code_body; KIND="hub-tooling"
97
+ fi
98
+ ;;
99
+ esac
100
+ else
101
+ check_code_body
59
102
  fi
60
103
 
61
- [ "$rc" = 0 ] && echo "PASS [pr-template]: body uses the ${PROFILE} template (required sections present)."
104
+ [ "$rc" = 0 ] && echo "PASS [pr-template]: body uses the ${KIND} template (required sections present)."
62
105
  exit "$rc"