yadflow 2.13.0 → 2.15.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.
@@ -34,3 +34,11 @@ jobs:
34
34
  - run: |
35
35
  body="$(mktemp)"; printf '%s' "$PR_BODY" > "$body"
36
36
  bash checks/pr-template.sh --profile hub "$body"
37
+
38
+ # The gate ledger is CI-owned: reject non-bot commits to .sdlc/*.json or reviews/*.md.
39
+ ledger-guard:
40
+ runs-on: ubuntu-latest
41
+ steps:
42
+ - uses: actions/checkout@v4
43
+ with: { fetch-depth: 0 }
44
+ - run: bash checks/ledger-guard.sh "origin/${{ github.base_ref }}"
@@ -37,3 +37,10 @@ yad-hub-pr-template:
37
37
  script:
38
38
  - body="$(mktemp)"; printf '%s' "$CI_MERGE_REQUEST_DESCRIPTION" > "$body"
39
39
  - bash checks/pr-template.sh --profile hub "$body"
40
+
41
+ # The gate ledger is CI-owned: reject non-bot commits to .sdlc/*.json or reviews/*.md.
42
+ yad-hub-ledger-guard:
43
+ extends: .yad_hub_mr_only
44
+ needs: []
45
+ script:
46
+ - bash checks/ledger-guard.sh "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
@@ -40,7 +40,10 @@ exists for the idea.
40
40
 
41
41
  Either mode runs Step 3b (branch), Step 4 (write the epic), and Step 6 (stop at the gate).
42
42
 
43
- If `state.json` exists but `currentStep != "epic"`, stop and point the user at `yad-status` / the gate.
43
+ **Precondition gate (rail):** if `state.json` already exists (analysis-ran or re-entry), run
44
+ `yad next EP-<slug> --check epic` — if it exits non-zero, **STOP** and surface the blocker, pointing the
45
+ user at `yad next EP-<slug>`. When no `state.json` exists yet, this is the greenfield entry point — the
46
+ check is not applicable, so proceed and seed state.
44
47
 
45
48
  ### Step 2 — Shape the idea (assist: analyst) — or read the analysis
46
49
  - **Analysis skipped:** ask the user for a one-line feature idea if not provided, then adopt the
@@ -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
@@ -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