yadflow 2.15.0 → 2.16.1

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 CHANGED
@@ -1,9 +1,9 @@
1
- # [2.15.0](https://github.com/abdelrahmannasr/yadflow/compare/v2.14.0...v2.15.0) (2026-06-24)
1
+ ## [2.16.1](https://github.com/abdelrahmannasr/yadflow/compare/v2.16.0...v2.16.1) (2026-06-25)
2
2
 
3
3
 
4
- ### Features
4
+ ### Bug Fixes
5
5
 
6
- * merge-driven review gate (Path B) CI never pushes the review branch ([#78](https://github.com/abdelrahmannasr/yadflow/issues/78)) ([d4d983a](https://github.com/abdelrahmannasr/yadflow/commit/d4d983ab4efddb4e6ec259bb940b393e9237f9cf)), closes [#76](https://github.com/abdelrahmannasr/yadflow/issues/76)
6
+ * **open-pr:** make build helpers stage-aware on the hub (closes [#80](https://github.com/abdelrahmannasr/yadflow/issues/80)) ([#81](https://github.com/abdelrahmannasr/yadflow/issues/81)) ([8d74e3d](https://github.com/abdelrahmannasr/yadflow/commit/8d74e3d267d4c056992d3bc6f5a2a7a15a66b431))
7
7
 
8
8
  # [2.2.0](https://github.com/abdelrahmannasr/yadflow/compare/v2.1.0...v2.2.0) (2026-06-14)
9
9
 
package/README.md CHANGED
@@ -98,8 +98,8 @@ with `npx` from your **product hub** repo — no clone needed.
98
98
  | `yad gate status <epic>` | Show each review step and its recorded approvals. |
99
99
  | `yad gate ci [--branch <head>] [--pr <n>]` | The CI entry the hub workflow calls on review/merge events: derive the epic/artifact from the `review/EP-*` branch, run the same sync, and commit **only the ledger** to the hub default branch (sweep every open review PR when no `--branch`). |
100
100
  | `yad commit --type <t> -m <subject>` | Commit by the SDLC convention — Conventional subject, `Task`/`Contract-Change`/`Co-Authored-By` trailers, atomic-file guard. |
101
- | `yad open-pr [--repo <name>]` | Open a code-repo **task** PR/MR from the repo's platform template (build half). |
102
- | `yad ship --type <t> -m <subject>` | Commit **and** open the task PR/MR in one step (`yad commit` then `yad open-pr`). |
101
+ | `yad open-pr [--repo <name>]` | Open a **task** PR/MR from the platform template (build half). **Stage-aware on the hub:** a `review/EP-*` branch opens the front-half artifact-review PR (delegates to `yad gate open`); any other hub branch uses the code-task template (so hub tooling PRs pass the `pr-template` gate). |
102
+ | `yad ship --type <t> -m <subject>` | Commit **and** open the task PR/MR in one step (`yad commit` then `yad open-pr`) — stage-aware, same as `open-pr`. |
103
103
  | `yad repo list` / `yad repo refresh [name]` | List connected repos as **fresh / stale**, and re-pack a stale one — staleness is now an explicit human decision, never an automatic skill side-effect. |
104
104
  | `yad repo sync [name]` | Switch every connected repo to its **default branch** and fast-forward it from origin (one or all). Dirty repos are skipped, never overwritten; fast-forward only. |
105
105
  | `npx yadflow --version` | Print the installed CLI version. |
package/bin/yad.mjs CHANGED
@@ -56,8 +56,10 @@ ${c.bold('Review gate (front half)')}
56
56
 
57
57
  ${c.bold('Build helpers')}
58
58
  yad commit --type <t> -m <subject> Commit by convention (trailers, atomic guard)
59
- yad open-pr [--repo <name>] Open a code-repo task PR/MR from the template
60
- yad ship --type <t> -m <subject> Commit AND open the task PR/MR in one step
59
+ yad open-pr [--repo <name>] Open a task PR/MR stage-aware on the hub: a review/EP-*
60
+ branch opens the front-half artifact-review PR (delegates to
61
+ gate open), any other hub branch uses the code-task template
62
+ yad ship --type <t> -m <subject> Commit AND open the task PR/MR in one step (stage-aware)
61
63
  yad repo list Show connected repos (fresh / stale)
62
64
  yad repo refresh [name] Re-pack a stale repo (a human decision)
63
65
 
@@ -181,7 +183,7 @@ async function main() {
181
183
  // In bridge mode CI is the sole ledger writer: `open` only opens the PR, and local `sync` is
182
184
  // advisory (reads the platform, prints status, writes nothing). The artifact status flip is
183
185
  // CI's job at merge — never wired into the local gate. File-only mode keeps local writes.
184
- if (action === 'open') await gateOpen(o.dir, { epic, artifact, today });
186
+ if (action === 'open') await gateOpen(o.dir, { epic, artifact });
185
187
  else if (action === 'sync') await gateSync(o.dir, { epic, artifact, today, local: true });
186
188
  else if (action === 'comments') await gateComments(o.dir, { epic, artifact, today });
187
189
  else if (action === 'status') await gateStatus(o.dir, { epic });
package/cli/commit.mjs CHANGED
@@ -7,7 +7,7 @@ import fs from 'node:fs';
7
7
  import { c, log, ok, info, warn, fail, run, exists } from './lib.mjs';
8
8
  import {
9
9
  COMMIT_TYPES, AI_COAUTHORS, ATOMIC_FILE_LIMIT,
10
- TASK_TRAILER, CONTRACT_CHANGE_TRAILER, COAUTHOR_TRAILER,
10
+ TASK_TRAILER, CONTRACT_CHANGE_TRAILER, COAUTHOR_TRAILER, PROJECT_FILES,
11
11
  } from './manifest.mjs';
12
12
 
13
13
  // PURE — unit tested directly. Build the full commit message text.
@@ -51,7 +51,14 @@ export async function runCommit(root, opts = {}) {
51
51
 
52
52
  const branch = run('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root }).stdout;
53
53
  const task = opts.task || taskFromBranch(branch);
54
- if (!task) warn('no Task trailer (none given and branch has no -S0N-T0N) — spec-link gate will fail on a code repo');
54
+ if (!task) {
55
+ // spec-link is a code-repo gate (REPO_WIRING.common), not a hub gate — so a missing Task trailer
56
+ // is expected on a hub PR (front-half artifact review or hub tooling) and only matters on a repo.
57
+ const onHub = exists(path.join(root, PROJECT_FILES.hubConfig));
58
+ warn(onHub
59
+ ? 'no Task trailer (none given and branch has no -S0N-T0N) — fine for a hub PR; required on code-repo tasks (spec-link gate)'
60
+ : 'no Task trailer (none given and branch has no -S0N-T0N) — spec-link gate will fail on a code repo');
61
+ }
55
62
 
56
63
  let message;
57
64
  try {
package/cli/gate.mjs CHANGED
@@ -449,7 +449,11 @@ export async function gateStatus(root, { epic } = {}) {
449
449
  }
450
450
  }
451
451
 
452
- export async function gateOpen(root, { epic, artifact } = {}) {
452
+ // `head` overrides the review branch the PR is opened against — `open-pr` delegates here after pushing
453
+ // the user's checked-out branch, which for a per-story review (review/EP-*/stories-S01) does NOT equal
454
+ // the branch this would otherwise recompute (artifactFromBase collapses stories-S01 → stories/). Pass
455
+ // the real pushed head so the PR targets a branch that exists. `creator` is injected in tests.
456
+ export async function gateOpen(root, { epic, artifact, head, creator = createPr } = {}) {
453
457
  const { hub } = loadHub(root);
454
458
  const epicDir = epicRoot(root, epic);
455
459
  const ledger = loadLedger(epicDir);
@@ -458,7 +462,7 @@ export async function gateOpen(root, { epic, artifact } = {}) {
458
462
  const step = findReviewStep(ledger.state, artifact);
459
463
  if (!step) { fail(`no review step for ${artifact}`); process.exitCode = 1; return; }
460
464
  const b = base(artifact);
461
- const branch = `review/${epic}/${b}`;
465
+ const branch = head || `review/${epic}/${b}`;
462
466
  const domains = touchedDomains(epicDir, step);
463
467
  warnUnlockedContract(epicDir, artifact);
464
468
 
@@ -487,7 +491,7 @@ export async function gateOpen(root, { epic, artifact } = {}) {
487
491
  const assignees = committer ? [committer] : [];
488
492
  const labels = isEscalated(step) ? domains.map((d) => `domain:${d}`) : [];
489
493
  info(`opening review ${hub.platform === 'gitlab' ? 'MR' : 'PR'} on branch ${branch} …`);
490
- const r = createPr(hub.platform, { title: `review: ${artifact} (${epic})`, body, base: hub.default_branch || 'main', head: branch, reviewers, assignees, labels, cwd: root });
494
+ const r = creator(hub.platform, { title: `review: ${artifact} (${epic})`, body, base: hub.default_branch || 'main', head: branch, reviewers, assignees, labels, cwd: root });
491
495
  if (!r.ok) { warn(`could not open PR (${r.reason || 'unknown'})${bridge ? ' — open it manually; CI records the gate on merge' : '; step is in_review file-only'}`); return; }
492
496
 
493
497
  if (!bridge) {
@@ -498,6 +502,7 @@ export async function gateOpen(root, { epic, artifact } = {}) {
498
502
  hand(bridge
499
503
  ? 'reviewers approve/comment there; CI advances the gate on the default branch when it is merged'
500
504
  : `reviewers approve/comment there; then run \`yad gate sync ${epic} ${artifact}\``);
505
+ return { url: r.url };
501
506
  }
502
507
 
503
508
  // ---- helpers ------------------------------------------------------------------------------------
package/cli/openpr.mjs CHANGED
@@ -8,6 +8,8 @@ import { c, log, ok, info, hand, fail, run, exists, readJSON } from './lib.mjs';
8
8
  import { PROJECT_FILES } from './manifest.mjs';
9
9
  import { detectPlatform, createPr, reviewersForScopes, resolveCommitterLogin } from './platform.mjs';
10
10
  import { taskFromBranch } from './commit.mjs';
11
+ import { parseReviewBranch, artifactFromBase } from './epic-state.mjs';
12
+ import { gateOpen } from './gate.mjs';
11
13
 
12
14
  // Resolve the target code repo: --repo <name> from the registry, else --dir, else cwd.
13
15
  function resolveRepo(root, { repo, dir }) {
@@ -19,11 +21,59 @@ function resolveRepo(root, { repo, dir }) {
19
21
  return { repoRoot: path.resolve(root, dir || '.'), meta: null };
20
22
  }
21
23
 
22
- function templateBody(repoRoot, platform, { task, risk, contract, domains }) {
23
- const tplPath = platform === 'gitlab'
24
- ? path.join(repoRoot, '.gitlab/merge_request_templates/Default.md')
25
- : path.join(repoRoot, '.github/pull_request_template.md');
26
- const base = exists(tplPath) ? fs.readFileSync(tplPath, 'utf8') : '## Summary\n\n## Impact & Risk\n';
24
+ // Which SDLC stage is this PR? The hub serves two vehicles; a code repo only one. Mirrors the
25
+ // `--head` split the hub pattern gates (pr-title.sh/pr-template.sh) already apply:
26
+ // code-repo NOT the product hub (a registry repo via --repo, or root is not a hub).
27
+ // hub-front the hub itself AND head is a review/EP-* branch (artifact-review PR).
28
+ // hub-tooling the hub itself AND head is anything else (a tooling/CI change to the hub).
29
+ // `meta` (truthy when resolved from the repos registry via --repo) is a connected code repo, so it is
30
+ // never the hub regardless of its path. Otherwise "is the hub" = repoRoot resolves to root AND root
31
+ // carries .sdlc/hub.json. path.resolve normalises `--dir .` / trailing slashes.
32
+ export function detectStage(root, repoRoot, head, meta) {
33
+ if (meta) return 'code-repo';
34
+ const isHub = path.resolve(repoRoot) === path.resolve(root)
35
+ && exists(path.join(root, PROJECT_FILES.hubConfig));
36
+ if (!isHub) return 'code-repo';
37
+ return /^review\/EP-[a-z0-9-]+\//.test(head || '') ? 'hub-front' : 'hub-tooling';
38
+ }
39
+
40
+ // The bundled code-task template — the same file `REPO_WIRING` installs into code repos, resolved
41
+ // from the package (mirrors how manifest.mjs reads ../package.json). Used for a hub-tooling PR, whose
42
+ // `.github/pull_request_template.md` is the ARTIFACT-REVIEW template (wrong shape for the code-task
43
+ // hub gate). Falls back to a minimal body that still carries every section the gate requires.
44
+ function codeTaskTemplate(platform) {
45
+ const rel = platform === 'gitlab'
46
+ ? '../skills/yad-pr-template/templates/gitlab/merge_request_templates/Default.md'
47
+ : '../skills/yad-pr-template/templates/github/pull_request_template.md';
48
+ try {
49
+ return fs.readFileSync(new URL(rel, import.meta.url), 'utf8');
50
+ } catch {
51
+ return [
52
+ '## Summary', '',
53
+ '## Impact & Risk',
54
+ '- **Domains / repos touched:** <repo>',
55
+ '- **Contract surface touched:** no',
56
+ '- **Risk level:** low',
57
+ '',
58
+ '## Checklist',
59
+ '- [ ] Lint, build, and tests pass (build/test/lint gate)',
60
+ '',
61
+ ].join('\n');
62
+ }
63
+ }
64
+
65
+ export function templateBody(repoRoot, platform, { task, risk, contract, domains, stage }) {
66
+ // hub-tooling: the hub's own template is artifact-review — use the bundled code-task template so the
67
+ // body matches the shape the hub `pr-template` gate demands for a non-review head.
68
+ let base;
69
+ if (stage === 'hub-tooling') {
70
+ base = codeTaskTemplate(platform);
71
+ } else {
72
+ const tplPath = platform === 'gitlab'
73
+ ? path.join(repoRoot, '.gitlab/merge_request_templates/Default.md')
74
+ : path.join(repoRoot, '.github/pull_request_template.md');
75
+ base = exists(tplPath) ? fs.readFileSync(tplPath, 'utf8') : codeTaskTemplate(platform);
76
+ }
27
77
  // Fill the obvious fields; leave the rest of the committed template intact for the author.
28
78
  return base
29
79
  .replace(/EP-<slug>-S0N-T0N/g, task || 'EP-<slug>-S0N-T0N')
@@ -45,6 +95,28 @@ export async function runOpenPr(root, opts = {}) {
45
95
  const baseBranch = opts.base || meta?.default_branch || 'main';
46
96
  if (branch === baseBranch) { fail(`on ${baseBranch} — switch to your task branch first`); process.exitCode = 1; return; }
47
97
 
98
+ const stage = detectStage(root, repoRoot, branch, meta);
99
+
100
+ // hub-front: this is a front-half artifact-review PR (review/EP-*/<artifact> head on the hub). The
101
+ // artifact-review title, body, and ledger bookkeeping all live in `yad gate open` — delegate to it
102
+ // rather than emit the code-task shape (which the hub gate would reject). Push first (gateOpen does
103
+ // not push), then hand off; any --title/--message is dropped (gateOpen sets `review: …`).
104
+ if (stage === 'hub-front') {
105
+ const parsed = parseReviewBranch(branch);
106
+ if (!parsed) { fail(`could not parse review branch '${branch}' (expected review/EP-<slug>/<artifact>)`); process.exitCode = 1; return; }
107
+ info(`pushing ${branch} …`);
108
+ const fpush = run('git', ['push', '-u', 'origin', branch], { cwd: repoRoot });
109
+ if (!fpush.ok) { fail(`git push failed — ${fpush.stderr.split('\n')[0] || 'unknown'}`); process.exitCode = 1; return; }
110
+ // Pass the branch we just pushed as the head so gateOpen opens the PR against it (its own
111
+ // recompute would collapse a per-story base). gateOpen signals failure by returning no url —
112
+ // mirror open-pr's own error contract so `ship` sees the non-zero exit and never reports success.
113
+ // (On a platform-less hub gateOpen marks the step in_review file-only and returns no url; open-pr's
114
+ // job is to open a PR, so "no PR opened" is a non-zero outcome here, unlike `yad gate open`.)
115
+ const res = await gateOpen(root, { epic: parsed.epic, artifact: artifactFromBase(parsed.base), head: branch });
116
+ if (!res?.url) process.exitCode = 1;
117
+ return res;
118
+ }
119
+
48
120
  // Push the branch (sets upstream) using the user's own auth. Abort on failure — creating a PR for a
49
121
  // branch that is not on the remote just fails with a more confusing error.
50
122
  info(`pushing ${branch} …`);
@@ -54,7 +126,7 @@ export async function runOpenPr(root, opts = {}) {
54
126
  const task = opts.task || taskFromBranch(branch);
55
127
  const title = opts.title || run('git', ['log', '-1', '--format=%s'], { cwd: repoRoot }).stdout || `task ${task || branch}`;
56
128
  const body = templateBody(repoRoot, platform, {
57
- task, risk: opts.risk || 'low', contract: !!opts.contractChange, domains: meta?.name,
129
+ task, risk: opts.risk || 'low', contract: !!opts.contractChange, domains: meta?.name, stage,
58
130
  });
59
131
 
60
132
  // Auto-assign from the hub roster, scoped to this repo: assignee = the committer (resolved from
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yadflow",
3
- "version": "2.15.0",
3
+ "version": "2.16.1",
4
4
  "description": "Yadflow — the gated, team, multi-repo SDLC: author → review → build with a PR-driven review gate and a zero-dependency `yad` CLI (setup, gate, commit, open-pr, ship, repo). A BMAD module + 30 yad-* skills.",
5
5
  "type": "module",
6
6
  "author": "AbdelRahman Nasr",
@@ -121,8 +121,17 @@ from the event payload):
121
121
  - `--profile code` (default) → a Conventional-Commits subject `<type>: <description>`, no trailing
122
122
  period (`config.yaml build.pr_title_style: same_as_commit_subject` — one task = one PR, the title is
123
123
  the squash-merge subject).
124
- - `--profile hub` → a front-half artifact-review title `review: <artifact> (EP-<slug>)`, the shape
125
- `yad gate open` creates.
124
+ - `--profile hub` → splits by the PR/MR **head branch** (passed via `--head`, injected by CI):
125
+ - `review/EP-*` head (or no `--head` — stays strict) → a front-half artifact-review title
126
+ `review: <artifact> (EP-<slug>)`, the shape `yad gate open` creates.
127
+ - any other head → a tooling/code change to the hub itself, so it follows the `code` convention (a
128
+ Conventional-Commits subject). This is what lets a PR that changes the hub's own workflows/checks
129
+ pass — it has no EP artifact to review.
130
+ - **Anti-bypass guard.** The branch name alone is not trusted: a non-review head that actually
131
+ changes front-half artifacts (any path under `epics/**`) **FAILS** — those changes must go through
132
+ a `review/EP-*` PR and the artifact-review workflow. CI passes the PR's changed paths via
133
+ `--changed <file>` (computed from the diff against the base ref); without that list (a direct
134
+ by-hand caller) the guard is inert and the branch split alone applies.
126
135
 
127
136
  ## 7. pr-template (`templates/checks/pr-template.sh`)
128
137
 
@@ -131,8 +140,14 @@ catches a free-form description that bypassed it:
131
140
 
132
141
  - `--profile code` (default) → requires `## Summary`, `## Impact & Risk`, `## Checklist`, and a filled
133
142
  `Risk level:` (`low|medium|high`).
134
- - `--profile hub` → requires `## Artifact under review`, `## Impact & Risk (front-half)`, `## Checklist`,
135
- and a `Risk tags:` line.
143
+ - `--profile hub` → splits by the PR/MR **head branch** (passed via `--head`, injected by CI):
144
+ - `review/EP-*` head (or no `--head`) requires the artifact-review template: `## Artifact under
145
+ review`, `## Impact & Risk (front-half)`, `## Checklist`, and a `Risk tags:` line.
146
+ - any other head → a hub tooling PR, so it requires the `code` task template (`## Summary`,
147
+ `## Impact & Risk`, `## Checklist`, filled `Risk level:`).
148
+ - **Anti-bypass guard** (same as pr-title): a non-review head that changes front-half artifacts
149
+ (`epics/**`, detected from the CI-supplied `--changed <file>` list) **FAILS** — artifact changes
150
+ must go through a `review/EP-*` PR.
136
151
 
137
152
  ## CI wiring (both platforms)
138
153
 
@@ -202,9 +217,12 @@ its one include line) whenever `.sdlc/hub.json` has a platform with the bridge e
202
217
  front-half review PRs are held to the same rule as code-repo PRs: signed, known authors only.
203
218
 
204
219
  The hub **also** runs the three pattern gates (`commit-message`, `pr-title`, `pr-template`) with
205
- `--profile hub`, so the front-half review PRs follow the hub conventions Conventional-Commits commit
206
- subjects, a `review: <artifact> (EP-<slug>)` title, and a body that uses the hub artifact-review
207
- template. `yad check --fix` installs the same `checks/*.sh` scripts plus a standalone hub workflow
220
+ `--profile hub`. The pattern gates split by the PR/MR **head branch** (passed via `--head`): a
221
+ `review/EP-*` head is a front-half review PR Conventional-Commits commit subjects, a
222
+ `review: <artifact> (EP-<slug>)` title, and the hub artifact-review template body; **any other head is
223
+ a tooling/code change to the hub itself** and follows the `code` convention (a Conventional-Commits
224
+ title + the code task template), so a PR that changes the hub's own workflows/checks can pass.
225
+ `yad check --fix` installs the same `checks/*.sh` scripts plus a standalone hub workflow
208
226
  (`templates/github/yad-hub-checks.yml` → `.github/workflows/yad-hub-checks.yml`, or the GitLab fragment
209
227
  `templates/gitlab/yad-hub-checks.gitlab-ci.yml` → `.gitlab/ci/yad-hub-checks.yml` + its one include
210
228
  line). Code repos run the same three with `--profile code` inside the main `yad-checks` workflow.
@@ -1,43 +1,64 @@
1
1
  # yad-managed: yad-checks
2
- # Pattern gates for the PRODUCT HUB: every PR (including the front-half review/EP-* PRs) must follow
3
- # the hub conventions — Conventional-Commits commit subjects, a `review: <artifact> (EP-<slug>)` PR
4
- # title, and a PR body that uses the hub artifact-review template. They run with `--profile hub`.
5
- # Standalone workflow so it never collides with the code-repo yad-checks workflow.
2
+ # Pattern gates for the PRODUCT HUB. They run with `--profile hub` and split by head branch:
3
+ # review/EP-* PRs are front-half artifact-review vehicles a `review: <artifact> (EP-<slug>)` title
4
+ # and the hub artifact-review template body.
5
+ # every other PR is a tooling/code change to the hub itself — a Conventional-Commits title and the
6
+ # code task template body (same convention the code repos use).
7
+ # The head ref is passed via --head so the gate can tell the two apart. Commit subjects are always
8
+ # Conventional-Commits. Standalone workflow so it never collides with the code-repo yad-checks workflow.
6
9
  name: yad-hub-checks
7
10
  on:
8
11
  pull_request:
12
+ # `edited` (beyond the opened/synchronize/reopened defaults) so a PR title/body correction
13
+ # re-runs the pattern gates without a close/reopen — e.g. fixing a body the pr-template gate held.
14
+ types: [opened, synchronize, reopened, edited]
9
15
  branches: ["**"]
10
16
 
11
17
  jobs:
18
+ # commit-message + ledger-guard read the commit range, not the PR title/body — skip them on a bare
19
+ # `edited` event (only pr-title/pr-template need to re-check a title/body fix).
12
20
  commit-message:
13
21
  runs-on: ubuntu-latest
22
+ if: github.event.action != 'edited'
14
23
  steps:
15
24
  - uses: actions/checkout@v4
16
25
  with: { fetch-depth: 0 }
17
26
  - run: bash checks/commit-message.sh --profile hub "origin/${{ github.base_ref }}"
18
27
 
19
28
  # Pass the title via env (never interpolate untrusted ${{ }} into a run line — injection-safe).
29
+ # fetch-depth: 0 + the base ref give us the PR's changed paths, so the gate can reject an artifact
30
+ # change (epics/**) riding a non-review head past the front-half review with a plain code title.
20
31
  pr-title:
21
32
  runs-on: ubuntu-latest
22
33
  env:
23
34
  PR_TITLE: ${{ github.event.pull_request.title }}
35
+ PR_HEAD: ${{ github.event.pull_request.head.ref }}
36
+ BASE_REF: ${{ github.base_ref }}
24
37
  steps:
25
38
  - uses: actions/checkout@v4
26
- - run: bash checks/pr-title.sh --profile hub "$PR_TITLE"
39
+ with: { fetch-depth: 0 }
40
+ - run: |
41
+ changed="$(mktemp)"; git diff --name-only "origin/${BASE_REF}...HEAD" > "$changed"
42
+ bash checks/pr-title.sh --profile hub --head "$PR_HEAD" --changed "$changed" "$PR_TITLE"
27
43
 
28
44
  pr-template:
29
45
  runs-on: ubuntu-latest
30
46
  env:
31
47
  PR_BODY: ${{ github.event.pull_request.body }}
48
+ PR_HEAD: ${{ github.event.pull_request.head.ref }}
49
+ BASE_REF: ${{ github.base_ref }}
32
50
  steps:
33
51
  - uses: actions/checkout@v4
52
+ with: { fetch-depth: 0 }
34
53
  - run: |
54
+ changed="$(mktemp)"; git diff --name-only "origin/${BASE_REF}...HEAD" > "$changed"
35
55
  body="$(mktemp)"; printf '%s' "$PR_BODY" > "$body"
36
- bash checks/pr-template.sh --profile hub "$body"
56
+ bash checks/pr-template.sh --profile hub --head "$PR_HEAD" --changed "$changed" "$body"
37
57
 
38
58
  # The gate ledger is CI-owned: reject non-bot commits to .sdlc/*.json or reviews/*.md.
39
59
  ledger-guard:
40
60
  runs-on: ubuntu-latest
61
+ if: github.event.action != 'edited'
41
62
  steps:
42
63
  - uses: actions/checkout@v4
43
64
  with: { fetch-depth: 0 }
@@ -3,9 +3,11 @@
3
3
  # .gitlab-ci.yml via:
4
4
  # include:
5
5
  # - local: '.gitlab/ci/yad-hub-checks.yml'
6
- # Every MR (including the front-half review/EP-* MRs) must follow the hub conventions — a
7
- # Conventional-Commits commit subject, a `review: <artifact> (EP-<slug>)` MR title, and an MR body
8
- # that uses the hub artifact-review template. They run with `--profile hub`.
6
+ # MRs run with `--profile hub` and split by source branch: review/EP-* MRs are front-half artifact-
7
+ # review vehicles (a `review: <artifact> (EP-<slug>)` title + the hub artifact-review template body);
8
+ # every other MR is a tooling/code change to the hub itself and follows the code convention (a
9
+ # Conventional-Commits title + the code task template body). The source branch is passed via --head so
10
+ # the gate can tell the two apart. Commit subjects are always Conventional-Commits.
9
11
  # Job names are yad-hub-prefixed so they coexist with any code-repo fragment in one pipeline; jobs use
10
12
  # `needs: []` and no `stage:` so a foreign `stages:` list can neither break nor reorder them.
11
13
  default:
@@ -25,18 +27,22 @@ yad-hub-commit-message:
25
27
  script:
26
28
  - bash checks/commit-message.sh --profile hub "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
27
29
 
30
+ # GIT_DEPTH: 0 (above) gives the changed paths, so the gate can reject an artifact change (epics/**)
31
+ # riding a non-review source branch past the front-half review with a plain code title/template.
28
32
  yad-hub-pr-title:
29
33
  extends: .yad_hub_mr_only
30
34
  needs: []
31
35
  script:
32
- - bash checks/pr-title.sh --profile hub "$CI_MERGE_REQUEST_TITLE"
36
+ - changed="$(mktemp)"; git diff --name-only "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME...HEAD" > "$changed"
37
+ - bash checks/pr-title.sh --profile hub --head "$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME" --changed "$changed" "$CI_MERGE_REQUEST_TITLE"
33
38
 
34
39
  yad-hub-pr-template:
35
40
  extends: .yad_hub_mr_only
36
41
  needs: []
37
42
  script:
43
+ - changed="$(mktemp)"; git diff --name-only "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME...HEAD" > "$changed"
38
44
  - body="$(mktemp)"; printf '%s' "$CI_MERGE_REQUEST_DESCRIPTION" > "$body"
39
- - bash checks/pr-template.sh --profile hub "$body"
45
+ - bash checks/pr-template.sh --profile hub --head "$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME" --changed "$changed" "$body"
40
46
 
41
47
  # The gate ledger is CI-owned: reject non-bot commits to .sdlc/*.json or reviews/*.md.
42
48
  yad-hub-ledger-guard:
@@ -21,7 +21,8 @@ and `yad-ship` use. It **never auto-advances**; it just commits.
21
21
  `feat|fix|docs|refactor|test|perf|build|ci|chore|revert`; proper nouns/acronyms keep their case.
22
22
  - **Task trailer** — required on a code repo (anchors the `spec-link` + `commit-message` gates). Given
23
23
  with `--task`, else derived from the branch (`feat/<story>-<task>-…`). Hub commits are not
24
- task-scoped, so the trailer is optional there.
24
+ task-scoped, so the trailer is optional there — a missing-Task warning is informational on the hub
25
+ (`spec-link` is a code-repo gate) and only flags a real gate failure in a code repo.
25
26
  - **Contract-Change trailer** — `--contract-change` only when the diff touches the locked contract
26
27
  surface; it routes the change back to the architecture gate.
27
28
  - **AI co-author footer — OFF by default.** No `Co-Authored-By` trailer is written unless `--ai <id>`
@@ -22,6 +22,14 @@ the product hub.
22
22
  `.gitlab/merge_request_templates/Default.md`) with `Task:`, `Risk level:`, `Contract surface
23
23
  touched:`, and `Domains` prefilled; the rest is left for the author. This satisfies the `pr-template`
24
24
  gate.
25
+ - **Stage-aware on the product hub** — `open-pr` mirrors the `--head` split the hub gates apply:
26
+ - a **`review/EP-*/<artifact>`** branch is a front-half artifact-review PR → it **delegates to
27
+ `yad gate open`** (artifact-review title `review: <artifact> (EP-<slug>)`, the hub artifact-review
28
+ body, and the gate ledger bookkeeping all in one place). Any `--title`/`-m` is ignored here.
29
+ - any **other hub branch** is a tooling/CI change → it uses the bundled **code-task** template
30
+ (`## Summary` / `Risk level:` / `## Checklist`) instead of the hub's artifact-review
31
+ `pull_request_template.md`, so the hub `pr-template` gate passes.
32
+ In a code repo nothing changes — it reads the repo's own committed code-task template.
25
33
  - **Auto-assign** — from the hub roster scoped to this repo: assignee = the committer (resolved from
26
34
  the local git identity), reviewers = the repo's `reviewer`/`domain-owner` logins minus the committer.
27
35
  Degrades cleanly when there is no roster.
@@ -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"
@@ -5,22 +5,39 @@
5
5
  # period (config.yaml build.pr_title_style: same_as_commit_subject; one task = one PR, the title is
6
6
  # the squash-merge subject). Keep <type> in sync with cli/manifest.mjs COMMIT_TYPES.
7
7
  # --profile hub — a front-half artifact-review title "review: <artifact> (EP-<slug>)", the shape
8
- # `yad gate open` creates (cli/gate.mjs).
8
+ # `yad gate open` creates (cli/gate.mjs) — BUT only for review/EP-* head branches. Every other
9
+ # hub PR is a tooling/code change to the hub itself and follows the code convention; pass the
10
+ # head ref via --head so the gate can tell the two apart (a tooling PR has no EP artifact to
11
+ # review). With no --head, the hub profile stays strict (requires the review shape).
12
+ # Branch name is not enough on its own: a non-review head that actually changes front-half
13
+ # artifacts (epics/**) would otherwise slip past the review workflow with a plain code title.
14
+ # Pass the PR's changed paths via --changed <file> (one path per line); when they touch epics/**
15
+ # on a non-review head the gate FAILS — artifact changes must go through a review/EP-* PR.
9
16
  # The title is passed as the (single) positional arg; CI injects it from the event payload
10
17
  # (GitHub: github.event.pull_request.title; GitLab: $CI_MERGE_REQUEST_TITLE).
11
18
  set -euo pipefail
12
19
 
13
20
  PROFILE=code
21
+ HEADREF=""
22
+ CHANGED=""
14
23
  ARGS=()
15
24
  while [ $# -gt 0 ]; do
16
25
  case "$1" in
17
26
  --profile) PROFILE="${2:-code}"; shift 2 ;;
18
27
  --profile=*) PROFILE="${1#*=}"; shift ;;
28
+ --head) HEADREF="${2:-}"; shift 2 ;;
29
+ --head=*) HEADREF="${1#*=}"; shift ;;
30
+ --changed) CHANGED="${2:-}"; shift 2 ;;
31
+ --changed=*) CHANGED="${1#*=}"; shift ;;
19
32
  *) ARGS+=("$1"); shift ;;
20
33
  esac
21
34
  done
22
35
  case "$PROFILE" in code|hub) ;; *) echo "FAIL [pr-title]: unknown --profile '$PROFILE' (code|hub)."; exit 1 ;; esac
23
36
 
37
+ # True when the PR changes a front-half artifact (anything under epics/**). Reads the --changed list
38
+ # of paths CI computed from the PR diff; with no list (direct caller / test) it reports false.
39
+ artifact_changed() { [ -n "$CHANGED" ] && [ -f "$CHANGED" ] && grep -qE '^epics/' "$CHANGED"; }
40
+
24
41
  TITLE="${ARGS[0]:-}"
25
42
  if [ -z "$TITLE" ]; then
26
43
  echo "FAIL [pr-title]: empty title — pass the PR/MR title as the argument."
@@ -29,23 +46,43 @@ fi
29
46
 
30
47
  TYPES='feat|fix|docs|refactor|test|perf|build|ci|chore|revert'
31
48
 
32
- if [ "$PROFILE" = hub ]; then
33
- # review: <artifact> (EP-<slug>)
34
- if printf '%s' "$TITLE" | grep -qE '^review: .+ \(EP-[a-z0-9-]+\)$'; then
35
- echo "PASS [pr-title]: '${TITLE}' (profile: hub)"
36
- exit 0
49
+ # Conventional-Commits subject (optional scope + breaking `!`), no trailing period. Used by the code
50
+ # profile and by hub tooling PRs (any head branch that is not review/EP-*).
51
+ check_code_title() {
52
+ if ! printf '%s' "$TITLE" | grep -qE "^(${TYPES})(\([a-z0-9._-]+\))?!?: .+"; then
53
+ echo "FAIL [pr-title]: '${TITLE}' is not '<type>(<scope>)?!?: <description>' (type one of: ${TYPES//|/, })."
54
+ exit 1
37
55
  fi
38
- echo "FAIL [pr-title]: '${TITLE}' is not a hub review title 'review: <artifact> (EP-<slug>)'."
39
- exit 1
40
- fi
56
+ if printf '%s' "$TITLE" | grep -qE '\.$'; then
57
+ echo "FAIL [pr-title]: '${TITLE}' must not end with a period."
58
+ exit 1
59
+ fi
60
+ echo "PASS [pr-title]: '${TITLE}' (profile: ${PROFILE}, tooling/code)"
61
+ exit 0
62
+ }
41
63
 
42
- # code profile Conventional-Commits subject (optional scope + breaking `!`), no trailing period.
43
- if ! printf '%s' "$TITLE" | grep -qE "^(${TYPES})(\([a-z0-9._-]+\))?!?: .+"; then
44
- echo "FAIL [pr-title]: '${TITLE}' is not '<type>(<scope>)?!?: <description>' (type one of: ${TYPES//|/, })."
45
- exit 1
46
- fi
47
- if printf '%s' "$TITLE" | grep -qE '\.$'; then
48
- echo "FAIL [pr-title]: '${TITLE}' must not end with a period."
49
- exit 1
64
+ if [ "$PROFILE" = hub ]; then
65
+ # review/EP-* head branch (or unknown head ref) => front-half artifact-review PR: 'review: <artifact> (EP-<slug>)'.
66
+ case "$HEADREF" in
67
+ review/EP-*|"")
68
+ if printf '%s' "$TITLE" | grep -qE '^review: .+ \(EP-[a-z0-9-]+\)$'; then
69
+ echo "PASS [pr-title]: '${TITLE}' (profile: hub, artifact-review)"
70
+ exit 0
71
+ fi
72
+ echo "FAIL [pr-title]: '${TITLE}' is not a hub review title 'review: <artifact> (EP-<slug>)'."
73
+ exit 1
74
+ ;;
75
+ *)
76
+ # Any other hub PR is a tooling/code change to the hub itself — UNLESS it changes front-half
77
+ # artifacts (epics/**), which must go through a review/EP-* PR. Without this guard a non-review
78
+ # head could carry an artifact change past the front-half review with only a code title.
79
+ if artifact_changed; then
80
+ echo "FAIL [pr-title]: head '${HEADREF}' changes front-half artifacts (epics/**) but is not a review/EP-* branch — artifact changes must go through a review PR."
81
+ exit 1
82
+ fi
83
+ # tooling only — fall through to the code convention.
84
+ ;;
85
+ esac
50
86
  fi
51
- echo "PASS [pr-title]: '${TITLE}' (profile: code)"
87
+
88
+ check_code_title
@@ -22,6 +22,10 @@ its own and **never merges**. The engineer review + merge are Step E (`yad-engin
22
22
  roster auto-assign, risk routing (`../yad-open-pr/SKILL.md`).
23
23
  - **Order matters:** the PR/MR is opened **only if the commit lands**. A failed commit, a tripped
24
24
  atomic guard, or `--dry-run` stops the step before anything is pushed.
25
+ - **Stage-aware on the product hub** (via `yad-open-pr`): on a `review/EP-*` branch `ship` opens the
26
+ front-half **artifact-review** PR (delegating to `yad gate open` — `--title` is ignored); on any
27
+ other hub branch it opens the **code-task** PR from the bundled code-task template. In a code repo
28
+ it is unchanged.
25
29
 
26
30
  ## Inputs
27
31