yadflow 2.14.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.
@@ -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
@@ -165,21 +165,23 @@ If the predicate **passes**:
165
165
  now `ready-for-build`, with `test-cases` running in parallel).
166
166
 
167
167
  ### PR-driven automation (the `yad gate` CLI)
168
- When the hub has a platform, the mechanical `open`/`sync`/`advance` is performed deterministically by the
169
- **`yad gate` CLI** (`yad gate open|sync|comments|status`), which writes the same `.sdlc/` + `reviews/`
170
- records this skill describes. The skill's job is then the human half: presenting the artifact, helping the
171
- owner address comments, and narrating the gate. The CLI is the single implementation of the gh/glab
172
- mechanics do not hand-run gh/glab recipes when it is installed.
168
+ When the hub has a platform, **CI is the sole writer of the ledger**. `yad gate open` opens the review
169
+ PR only; CI (`yad gate ci`) writes the `.sdlc/` + `reviews/` records this skill describes. The skill's
170
+ job is the human half: presenting the artifact, helping the owner address comments, and narrating the
171
+ gate. Local `yad gate sync` is advisory in bridge mode (reads the platform, prints status, writes
172
+ nothing); a human must never commit gate-state files (the `ledger-guard` check rejects it).
173
173
 
174
174
  Under that CLI the gate **advances on merge**: a review PR/MR whose reviewer rule is satisfied, whose
175
175
  comment threads are **all resolved**, and which has been **merged** auto-marks the step `done` and
176
176
  unblocks the next step. (Until those three hold, the step stays `in_review`.)
177
177
 
178
- `sync` can also be **event-driven**: when the hub is wired with the gate-sync CI (`yad-hub-bridge`
179
- `wire` action), every approval / change request / dismissal / merge on the review PR/MR triggers
180
- `yad gate ci` on the hub, which runs the same sync and commits the ledger updates directly to the
181
- hub's default branch (pull to see them locally). The predicate, the human merge, and manual
182
- `yad gate sync` are all unchanged CI never approves and never merges.
178
+ The flow is **merge-driven** (wired by `yad-hub-bridge` `wire`): during review CI writes nothing — the
179
+ platform PR/MR is the source of truth (native approvals + threads), and CI never touches the review
180
+ branch (so an in-flight approval is never dismissed and required checks never strand). On the human
181
+ **merge** CI re-reads approvals, advances the step, and flips the artifact `status:` on the **default
182
+ branch**. After a merge, `git checkout <default> && git pull` to see it. The predicate and the human
183
+ merge are unchanged — CI never approves and never merges. File-only mode (no platform) keeps the local
184
+ write path.
183
185
 
184
186
  ### Hard rules (build plan §1, §5)
185
187
  - **The merge click is the human approval act.** A front step advances only when a human merges the