yadflow 2.14.0 → 2.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2 -2
- package/bin/yad.mjs +19 -5
- package/cli/artifact-status.mjs +102 -0
- package/cli/doctor.mjs +14 -1
- package/cli/epic-state.mjs +1 -1
- package/cli/gate.mjs +121 -58
- package/cli/manifest.mjs +2 -0
- package/cli/platform.mjs +44 -6
- package/package.json +1 -1
- package/skills/yad-checks/SKILL.md +7 -0
- package/skills/yad-checks/references/check-gates.md +25 -7
- package/skills/yad-checks/templates/checks/ledger-guard.sh +117 -0
- package/skills/yad-checks/templates/checks/verified-commits.sh +9 -1
- package/skills/yad-checks/templates/github/yad-hub-checks.yml +28 -6
- package/skills/yad-checks/templates/gitlab/yad-hub-checks.gitlab-ci.yml +18 -5
- package/skills/yad-hub-bridge/SKILL.md +41 -14
- package/skills/yad-hub-bridge/references/bridge.md +93 -51
- package/skills/yad-hub-bridge/templates/github/yad-gate-sync.yml +85 -35
- package/skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml +63 -32
- package/skills/yad-pr-template/templates/checks/pr-template.sh +53 -10
- package/skills/yad-pr-template/templates/checks/pr-title.sh +55 -18
- package/skills/yad-review-gate/SKILL.md +12 -10
|
@@ -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
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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 read — fail 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):
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
(
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
#
|
|
3
|
-
#
|
|
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
|
-
#
|
|
9
|
-
#
|
|
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
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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@
|
|
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
|
-
#
|
|
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
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
# the
|
|
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
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
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
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
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
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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 ${
|
|
104
|
+
[ "$rc" = 0 ] && echo "PASS [pr-template]: body uses the ${KIND} template (required sections present)."
|
|
62
105
|
exit "$rc"
|