yadflow 4.0.0-next.3 → 4.0.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 +699 -0
- package/README.md +85 -22
- package/bin/commands.mjs +3 -1
- package/bin/yad.mjs +183 -29
- package/cli/capture.mjs +4 -2
- package/cli/checkpoint.mjs +10 -10
- package/cli/claims.mjs +4 -4
- package/cli/commit.mjs +48 -12
- package/cli/docs.mjs +15 -13
- package/cli/doctor.mjs +557 -141
- package/cli/epic-state.mjs +41 -18
- package/cli/errors.mjs +3 -2
- package/cli/find-product.mjs +197 -0
- package/cli/fold.mjs +4 -4
- package/cli/gate-local.mjs +13 -13
- package/cli/gate.mjs +111 -103
- package/cli/history.mjs +13 -13
- package/cli/hook.mjs +201 -30
- package/cli/index-command.mjs +6 -6
- package/cli/lib.mjs +91 -29
- package/cli/manifest.mjs +171 -104
- package/cli/migrate.mjs +208 -17
- package/cli/mode.mjs +22 -22
- package/cli/next.mjs +6 -6
- package/cli/openpr.mjs +32 -24
- package/cli/people.mjs +22 -10
- package/cli/plan.mjs +718 -45
- package/cli/platform.mjs +13 -7
- package/cli/product-index.mjs +4 -4
- package/cli/{hubcommit.mjs → productcommit.mjs} +11 -7
- package/cli/protection.mjs +19 -9
- package/cli/reconcile.mjs +98 -23
- package/cli/repo-publish.mjs +22 -16
- package/cli/repo.mjs +128 -33
- package/cli/report.mjs +4 -2
- package/cli/review.mjs +26 -19
- package/cli/setup.mjs +105 -36
- package/cli/ship.mjs +1 -1
- package/cli/skip.mjs +4 -4
- package/cli/tidy.mjs +7 -7
- package/cli/update-commit.mjs +22 -19
- package/cli/workspace.mjs +495 -0
- package/package.json +7 -4
- package/skills/sdlc/config.yaml +14 -13
- package/skills/sdlc/module-help.csv +8 -8
- package/skills/yad-analysis/SKILL.md +6 -4
- package/skills/yad-architecture/SKILL.md +4 -3
- package/skills/yad-backfill/SKILL.md +11 -5
- package/skills/yad-backfill/references/backfill.md +33 -3
- package/skills/yad-backfill/templates/checks/backfill-check.sh +200 -17
- package/skills/yad-change/SKILL.md +1 -1
- package/skills/yad-checks/SKILL.md +48 -27
- package/skills/yad-checks/references/check-gates.md +213 -53
- package/skills/yad-checks/templates/checks/commit-message.sh +39 -13
- package/skills/yad-checks/templates/checks/contract-check.sh +472 -23
- package/skills/yad-checks/templates/checks/epic-open.sh +398 -8
- package/skills/yad-checks/templates/checks/ledger-guard.sh +75 -25
- package/skills/yad-checks/templates/checks/lineage-check.sh +391 -7
- package/skills/yad-checks/templates/checks/product-checkout.sh +141 -0
- package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +399 -8
- package/skills/yad-checks/templates/checks/risk-map-check.sh +35 -3
- package/skills/yad-checks/templates/checks/spec-link.sh +36 -3
- package/skills/yad-checks/templates/checks/verified-commits.sh +26 -2
- package/skills/yad-checks/templates/github/yad-checks.yml +33 -13
- package/skills/yad-checks/templates/github/{yad-hub-checks.yml → yad-product-checks.yml} +14 -10
- package/skills/yad-checks/templates/github/yad-update-guard.yml +1 -1
- package/skills/yad-checks/templates/github/yad-verified-commits.yml +1 -1
- package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +12 -0
- package/skills/yad-checks/templates/gitlab/{yad-hub-checks.gitlab-ci.yml → yad-product-checks.gitlab-ci.yml} +16 -16
- package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +2 -2
- package/skills/yad-checks/templates/hooks/ledger-guard-cursor.mjs +130 -0
- package/skills/yad-checks/templates/hooks/ledger-guard.mjs +117 -0
- package/skills/yad-checks/templates/hooks/yad-capture.mjs +117 -0
- package/skills/yad-commit/SKILL.md +18 -7
- package/skills/yad-connect-design/SKILL.md +1 -1
- package/skills/yad-connect-design/references/design-registry.md +1 -1
- package/skills/yad-connect-docs/SKILL.md +16 -11
- package/skills/yad-connect-docs/references/docs-registry.md +8 -8
- package/skills/yad-connect-learning/SKILL.md +1 -1
- package/skills/yad-connect-learning/references/learning-registry.md +1 -1
- package/skills/yad-connect-repos/SKILL.md +31 -8
- package/skills/yad-connect-repos/references/code-context.md +1 -1
- package/skills/yad-connect-repos/references/{hub-config.md → product-config.md} +19 -11
- package/skills/yad-connect-repos/references/repos-registry.md +1 -1
- package/skills/yad-connect-testing/SKILL.md +1 -1
- package/skills/yad-connect-testing/references/testing-registry.md +1 -1
- package/skills/yad-discovery/SKILL.md +2 -2
- package/skills/yad-discovery/references/discovery-schema.md +1 -1
- package/skills/yad-docs/SKILL.md +1 -1
- package/skills/yad-docs/references/data-mapping.md +1 -1
- package/skills/yad-docs/templates/app/package-lock.json +8 -8
- package/skills/yad-docs/templates/app/package.json +1 -1
- package/skills/yad-docs/templates/app/src/components/Canvas/FlowCanvas.tsx +2 -2
- package/skills/yad-docs-overview/references/pipeline-model.md +3 -3
- package/skills/yad-engineer-review/SKILL.md +2 -2
- package/skills/yad-engineer-review/references/ship-and-record.md +3 -3
- package/skills/yad-epic/SKILL.md +6 -4
- package/skills/yad-epic/references/state-schema.md +13 -12
- package/skills/yad-open-pr/SKILL.md +1 -1
- package/skills/yad-pair-review/SKILL.md +5 -5
- package/skills/yad-pair-review/references/session-state.md +2 -2
- package/skills/yad-pr-template/SKILL.md +14 -12
- package/skills/yad-pr-template/templates/checks/pr-template.sh +18 -16
- package/skills/yad-pr-template/templates/checks/pr-title.sh +15 -13
- package/skills/yad-pr-template/templates/{hub → product}/github/pull_request_template.md +2 -2
- package/skills/yad-pr-template/templates/{hub → product}/gitlab/merge_request_templates/Default.md +2 -2
- package/skills/{yad-hub-bridge → yad-product-bridge}/SKILL.md +15 -15
- package/skills/{yad-hub-bridge → yad-product-bridge}/references/bridge.md +15 -10
- package/skills/{yad-hub-bridge → yad-product-bridge}/references/login-roster.md +2 -2
- package/skills/{yad-hub-bridge/templates/checks/hub-route.sh → yad-product-bridge/templates/checks/product-route.sh} +2 -2
- package/skills/{yad-hub-bridge → yad-product-bridge}/templates/github/yad-gate-sync.yml +29 -16
- package/skills/{yad-hub-bridge → yad-product-bridge}/templates/gitlab/gitlab-ci.include-root.yml +1 -1
- package/skills/{yad-hub-bridge → yad-product-bridge}/templates/gitlab/yad-gate-sync.gitlab-ci.yml +5 -3
- package/skills/yad-report/SKILL.md +1 -1
- package/skills/yad-review-companion/SKILL.md +3 -3
- package/skills/yad-review-gate/SKILL.md +22 -23
- package/skills/yad-review-gate/references/gating.md +13 -13
- package/skills/yad-run/SKILL.md +2 -2
- package/skills/yad-run/references/run-loop.md +2 -2
- package/skills/yad-spec/SKILL.md +2 -1
- package/skills/yad-spec/references/spec-handoff.md +19 -10
- package/skills/yad-stories/SKILL.md +2 -2
- package/skills/yad-sync-repos/SKILL.md +19 -3
- package/skills/yad-test-cases/SKILL.md +2 -2
- package/skills/yad-ui/SKILL.md +2 -2
- package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +0 -91
- package/skills/yad-checks/templates/hooks/ledger-guard.sh +0 -100
- package/skills/yad-checks/templates/hooks/yad-capture.sh +0 -53
|
@@ -10,11 +10,35 @@
|
|
|
10
10
|
# best-effort: the commit PASSes with a note (spec-link already proved the story link).
|
|
11
11
|
set -euo pipefail
|
|
12
12
|
|
|
13
|
+
# --- shared Product settings file (byte-identical across the gates; they are standalone by design, so
|
|
14
|
+
# --- it is duplicated, not sourced) ---
|
|
15
|
+
# The Product's settings live under two names until v5: `.sdlc/product.json`, read first from 4.0, and
|
|
16
|
+
# `.sdlc/hub.json`, which an older yadflow wrote and its gates read. The environment may name the file
|
|
17
|
+
# instead: SDLC_PRODUCT_CONFIG, or the older SDLC_HUB_CONFIG. Two names that say different things are
|
|
18
|
+
# never settled by picking one — the CLI refuses the same way (YAD-STATE-008) — so the gate FAILS and
|
|
19
|
+
# names both. Said on stderr and returned as a failure: this runs inside `$(...)`, where an `exit` would
|
|
20
|
+
# only leave the subshell. `cmp -s` is byte for byte, like the CLI's own comparison.
|
|
21
|
+
product_config() {
|
|
22
|
+
if [ -n "${SDLC_PRODUCT_CONFIG:-}" ] && [ -n "${SDLC_HUB_CONFIG:-}" ] \
|
|
23
|
+
&& [ "$SDLC_PRODUCT_CONFIG" != "$SDLC_HUB_CONFIG" ] && ! cmp -s "$SDLC_PRODUCT_CONFIG" "$SDLC_HUB_CONFIG"; then
|
|
24
|
+
echo "FAIL [product-settings]: SDLC_PRODUCT_CONFIG (${SDLC_PRODUCT_CONFIG}) and SDLC_HUB_CONFIG (${SDLC_HUB_CONFIG}) name files that say different things. Set only SDLC_PRODUCT_CONFIG." >&2
|
|
25
|
+
return 1
|
|
26
|
+
fi
|
|
27
|
+
if [ -n "${SDLC_PRODUCT_CONFIG:-}" ]; then printf '%s' "$SDLC_PRODUCT_CONFIG"; return 0; fi
|
|
28
|
+
if [ -n "${SDLC_HUB_CONFIG:-}" ]; then printf '%s' "$SDLC_HUB_CONFIG"; return 0; fi
|
|
29
|
+
if [ -f .sdlc/product.json ] && [ -f .sdlc/hub.json ] && ! cmp -s .sdlc/product.json .sdlc/hub.json; then
|
|
30
|
+
echo "FAIL [product-settings]: .sdlc/product.json and .sdlc/hub.json say different things — they are one file under two names until v5. Run \`yad migrate\` in the Product to choose the copy to keep, and commit both." >&2
|
|
31
|
+
return 1
|
|
32
|
+
fi
|
|
33
|
+
if [ -f .sdlc/product.json ]; then printf '%s' .sdlc/product.json; else printf '%s' .sdlc/hub.json; fi
|
|
34
|
+
}
|
|
35
|
+
PRODUCT_CONFIG="$(product_config)" || exit 1
|
|
36
|
+
|
|
13
37
|
# --- shared base resolution (byte-identical across the gates; they are standalone by design, so it
|
|
14
38
|
# --- is duplicated, not sourced) ---
|
|
15
39
|
# With no explicit base, RESOLVE the trunk instead of assuming a hardcoded `origin/main` — on a repo
|
|
16
40
|
# whose trunk is `develop`/`master` that guess either fails closed or, where a stale `main` still
|
|
17
|
-
# exists, silently diffs the WRONG range (issue #161). Mirrors the CLI's own order (cli/
|
|
41
|
+
# exists, silently diffs the WRONG range (issue #161). Mirrors the CLI's own order (cli/productcommit.mjs,
|
|
18
42
|
# cli/repo.mjs): the CONFIGURED default_branch first, then the remote's published default
|
|
19
43
|
# (origin/HEAD), then origin/main. Each candidate must actually resolve before it is used, so a
|
|
20
44
|
# DANGLING origin/HEAD (trunk renamed, the old remote-tracking ref pruned) falls through to the next
|
|
@@ -23,7 +47,7 @@ set -euo pipefail
|
|
|
23
47
|
# command substitution in an assignment aborts the script.
|
|
24
48
|
resolve_base() {
|
|
25
49
|
# tr first: a key and its value may legally sit on separate lines, which a per-line match misses.
|
|
26
|
-
_cfg="$(tr -d '\n' < "$
|
|
50
|
+
_cfg="$({ tr -d '\n' < "$PRODUCT_CONFIG"; } 2>/dev/null | sed -nE 's/.*"default_branch"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p')" || _cfg=""
|
|
27
51
|
_head="$(git symbolic-ref --short --quiet refs/remotes/origin/HEAD 2>/dev/null)" || _head=""
|
|
28
52
|
for _c in "origin/${_cfg}" "${_head}" origin/main; do
|
|
29
53
|
case "$_c" in ''|origin/) continue ;; esac
|
|
@@ -46,8 +70,9 @@ EXEMPT='ci|chore|build|test'
|
|
|
46
70
|
# --- reconcile-debt-check; the gates are deliberately standalone, so it is duplicated, not sourced) ---
|
|
47
71
|
# Read one frontmatter value from the FIRST --- … --- block only. awk bounds to the first block (stops
|
|
48
72
|
# at the first closing fence), so a body `---` or an absent key can never leak a body line. Plain
|
|
49
|
-
# scalars only; trailing spaces/CR are stripped so they never become part of a path.
|
|
50
|
-
|
|
73
|
+
# scalars only; trailing spaces/CR are stripped so they never become part of a path. The CR goes BEFORE
|
|
74
|
+
# the fences are matched: a CRLF file's `---\r` is no fence, and every key in it read as empty (E117 r2).
|
|
75
|
+
fm_val() { awk -v k="$1" '{ sub(/\r$/, "") } NR==1 && /^---$/ {f=1; next} f && /^---$/ {exit} f && index($0, k":")==1 {sub("^" k ":[ \t]*", ""); print; exit}' "$2" 2>/dev/null | tr -d '\r' | sed -E 's/[[:space:]]+$//'; }
|
|
51
76
|
|
|
52
77
|
# Same, for a link.md field. yad-spec writes link.md WITH frontmatter, but code repos still carry
|
|
53
78
|
# pre-frontmatter ones that contract-check used to read with a whole-file scan — so fall back to that
|
|
@@ -59,6 +84,349 @@ link_val() {
|
|
|
59
84
|
printf '%s' "$_v"
|
|
60
85
|
}
|
|
61
86
|
|
|
87
|
+
# E117. WHERE the Product is read from must not be something the PR under check chose. link.md is a file
|
|
88
|
+
# the PR's own author writes, so two things are read around it:
|
|
89
|
+
# - `product-repo` comes from the BASE. Where the Product lives is a fact about the whole repo, not
|
|
90
|
+
# about one story, so: link.md as it stands on the base when that has a value; else a sibling
|
|
91
|
+
# `specs/<story>/link.md` on the base — one of the same epic first, and the first whose value reaches
|
|
92
|
+
# a folder here, so one stale link.md cannot hand every new story a deferral; else — the repo's
|
|
93
|
+
# first spec — this PR's value. Otherwise a PR could point it at a path that does not exist —
|
|
94
|
+
# "not reachable", a deferral, a PASS — or at a folder of its own. A value that differs is printed
|
|
95
|
+
# and counts once it merges. Only `product-repo` comes from the base: `contract-lock` is read as the
|
|
96
|
+
# PR leaves it — a re-spec PR updates the pin, and must.
|
|
97
|
+
# - a Product this repo KEEPS (a monorepo: the Product and the code in one git repo) is read as it
|
|
98
|
+
# stands on the base, never as the PR leaves it. In order: (1) the path's TEXT names a folder whose
|
|
99
|
+
# `.sdlc/product.json` or `hub.json` the base tracks (every Product commits one) — asked of the base before the disk,
|
|
100
|
+
# which is the PR's: a PR that deleted or moved that folder read as untracked, and deferred; (2) the
|
|
101
|
+
# path is walked on disk: a tracked symlink, submodule or file on the way is refused (what is behind
|
|
102
|
+
# it is the PR's to choose), and so is a tracked folder that is not a kept Product, so one force-added
|
|
103
|
+
# file cannot switch the gates to a partial Product; the walk is repeated from where the path really
|
|
104
|
+
# lands (an alias like /proc/self/cwd means another folder to each process); an untracked folder
|
|
105
|
+
# that is there is CI's checkout, and is read — unless it is inside the repo's own git folder,
|
|
106
|
+
# which branch names shape (review 7); (3) only when the path reaches NOTHING, a Product the base keeps that holds
|
|
107
|
+
# this story's epic is the one meant. "Inside the code repo" is NOT the test: CI can only check a
|
|
108
|
+
# second repo out INSIDE the workspace, and an untracked checkout there is not the PR's.
|
|
109
|
+
#
|
|
110
|
+
# E120. Before any of that, the repo's own record: `.sdlc/product-link.json`, written by `yad check --fix`
|
|
111
|
+
# / `yad update` and merged on its own. Its `path` (from the repo root; where CI checks the Product out)
|
|
112
|
+
# is read from the BASE and wins over every link.md — so no gate takes where the Product lives from the
|
|
113
|
+
# PR. A record the PR adds or changes counts once it merges (a note says so). A link.md whose own
|
|
114
|
+
# product-repo reaches a DIFFERENT folder here gets a note; one that reaches nothing (a path on its
|
|
115
|
+
# author's machine) is passed over in silence. When the record's path reaches NOTHING — a developer's
|
|
116
|
+
# own run, where nobody cloned into it — the base's link.md order below is used if IT reaches a folder
|
|
117
|
+
# (still the base's, never the PR's); otherwise the record stands, and the gate defers (review 1).
|
|
118
|
+
# With no record on the base, the order above is kept.
|
|
119
|
+
# Sets product_rel, prod (the folder to read), prod_note and prod_fail (text; the caller prints them).
|
|
120
|
+
_yad_tmp=""
|
|
121
|
+
_arch_key=""
|
|
122
|
+
_arch_dir=""
|
|
123
|
+
yad_tmp() { [ -n "$_yad_tmp" ] || { _yad_tmp="$(mktemp -d)"; trap 'rm -rf "$_yad_tmp"' EXIT; }; }
|
|
124
|
+
# `product-repo` as link.md $1 has it on the base (empty when it is not there or names none).
|
|
125
|
+
base_product_rel() {
|
|
126
|
+
git cat-file -e "${BASE}:$1" 2>/dev/null || return 0
|
|
127
|
+
yad_tmp
|
|
128
|
+
git show "${BASE}:$1" > "$_yad_tmp/base-link.md" 2>/dev/null || : > "$_yad_tmp/base-link.md"
|
|
129
|
+
link_val product-repo "$_yad_tmp/base-link.md"
|
|
130
|
+
}
|
|
131
|
+
# The `path` of `.sdlc/product-link.json` at $1 (a commit), or empty when there is none or it names none.
|
|
132
|
+
record_path() {
|
|
133
|
+
git cat-file -e "$1:.sdlc/product-link.json" 2>/dev/null || return 0
|
|
134
|
+
git show "$1:.sdlc/product-link.json" 2>/dev/null | tr -d '\n\r' | sed -nE 's/.*"path"[[:space:]]*:[[:space:]]*"([^"\\]*)".*/\1/p' || true
|
|
135
|
+
}
|
|
136
|
+
product_for() {
|
|
137
|
+
_link="specs/$1/link.md"
|
|
138
|
+
prod=""; prod_note=""; prod_fail=""
|
|
139
|
+
yad_tmp # here, in this shell: a $(…) below would set it, and its EXIT trap, in a subshell only
|
|
140
|
+
product_rel="$(link_val product-repo "$_link")"
|
|
141
|
+
_from="$_link"
|
|
142
|
+
_rec="$(record_path "$BASE")"
|
|
143
|
+
_rec_head="$(record_path HEAD)"
|
|
144
|
+
if [ "$_rec_head" != "$_rec" ]; then
|
|
145
|
+
if [ -n "$_rec" ]; then _w="changes in this PR"; else _w="is new in this PR"; fi
|
|
146
|
+
prod_note=".sdlc/product-link.json ${_w} — where the Product lives is read from ${BASE}, not from this PR; the new one counts once it merges."
|
|
147
|
+
fi
|
|
148
|
+
_base_rel="$(base_product_rel "$_link")"
|
|
149
|
+
if [ -z "$_base_rel" ]; then
|
|
150
|
+
# A sibling's value. Every link.md sits at specs/<story>/, so a relative value means the same there.
|
|
151
|
+
_sibs="$(git ls-tree -r -z --name-only "$BASE" -- specs 2>/dev/null | tr '\n\0' '?\n' | grep -E '^specs/[^/]+/link\.md$' || true)"
|
|
152
|
+
_first=""; _first_from=""
|
|
153
|
+
for _pass in same other; do
|
|
154
|
+
while IFS= read -r _sib; do
|
|
155
|
+
[ -n "$_sib" ] && [ "$_sib" != "$_link" ] || continue
|
|
156
|
+
_s="${_sib#specs/}"; _s="${_s%/link.md}"
|
|
157
|
+
story_ok "$_s" || continue
|
|
158
|
+
if [ "$(story_epic "$_s")" = "$(story_epic "$1")" ]; then [ "$_pass" = same ] || continue; else [ "$_pass" = other ] || continue; fi
|
|
159
|
+
_v="$(base_product_rel "$_sib")"
|
|
160
|
+
[ -n "$_v" ] || continue
|
|
161
|
+
if [ -z "$_first" ]; then _first="$_v"; _first_from="$_sib"; fi
|
|
162
|
+
if [ -d "$(resolve_product "$_v" "$1")" ]; then _base_rel="$_v"; _from="$_sib"; break 2; fi
|
|
163
|
+
done <<SIBLINGS
|
|
164
|
+
$_sibs
|
|
165
|
+
SIBLINGS
|
|
166
|
+
done
|
|
167
|
+
if [ -z "$_base_rel" ]; then _base_rel="$_first"; _from="$_first_from"; fi
|
|
168
|
+
fi
|
|
169
|
+
_use_rec=""
|
|
170
|
+
if [ -n "$_rec" ]; then
|
|
171
|
+
# Relative to the repo root; link.md values are relative to specs/<story>/, so join it that way.
|
|
172
|
+
case "$_rec" in /*) _rv="$_rec" ;; *) _rv="../../$_rec" ;; esac
|
|
173
|
+
if [ -d "$(resolve_product "$_rv" "$1")" ] || [ -z "$_base_rel" ] || [ ! -d "$(resolve_product "$_base_rel" "$1")" ]; then
|
|
174
|
+
_use_rec=1; _base_rel="$_rv"; _from=".sdlc/product-link.json"
|
|
175
|
+
fi
|
|
176
|
+
fi
|
|
177
|
+
if [ -n "$_use_rec" ]; then
|
|
178
|
+
# The record wins. Say so only when this link.md's own value reaches a different folder here.
|
|
179
|
+
_mine="$(resolve_product "$product_rel" "$1")"
|
|
180
|
+
_theirs="$(resolve_product "$_base_rel" "$1")"
|
|
181
|
+
if [ -n "$product_rel" ] && [ -d "$_mine" ] && ! [ "$_mine" -ef "$_theirs" ]; then
|
|
182
|
+
prod_note="${prod_note:+$prod_note
|
|
183
|
+
}${_link} names product-repo '${product_rel}', but .sdlc/product-link.json on ${BASE} says '${_rec}' — the record is read."
|
|
184
|
+
fi
|
|
185
|
+
product_rel="$_base_rel"
|
|
186
|
+
elif [ -n "$_base_rel" ] && [ "$_base_rel" != "$product_rel" ]; then
|
|
187
|
+
prod_note="${prod_note:+$prod_note
|
|
188
|
+
}${_link} names product-repo '${product_rel}', but ${_from} on ${BASE} says '${_base_rel}' — the Product is read from the base value; a new one counts once it merges."
|
|
189
|
+
product_rel="$_base_rel"
|
|
190
|
+
fi
|
|
191
|
+
# A Product this repo keeps, asked of the base first (see above). Both joins resolve_product may pick.
|
|
192
|
+
case "$product_rel" in
|
|
193
|
+
'') _cands="" ;;
|
|
194
|
+
/*|'~'*|'$'*) _cands="$product_rel" ;;
|
|
195
|
+
*) _cands="specs/$1/$product_rel
|
|
196
|
+
$product_rel" ;;
|
|
197
|
+
esac
|
|
198
|
+
while IFS= read -r _cand; do
|
|
199
|
+
[ -n "$_cand" ] || continue
|
|
200
|
+
_lr="$(lex_rel "$_cand")"
|
|
201
|
+
[ -n "$_lr" ] || continue
|
|
202
|
+
[ "$_lr" != . ] || _lr=""
|
|
203
|
+
if git cat-file -e "${BASE}:${_lr:+$_lr/}.sdlc/product.json" 2>/dev/null || git cat-file -e "${BASE}:${_lr:+$_lr/}.sdlc/hub.json" 2>/dev/null; then base_product "$_lr"; return 0; fi
|
|
204
|
+
done <<CANDS
|
|
205
|
+
$_cands
|
|
206
|
+
CANDS
|
|
207
|
+
if [ -n "$product_rel" ]; then
|
|
208
|
+
prod="$(resolve_product "$product_rel" "$1")"
|
|
209
|
+
! tracked_verdict "$(product_tracked "$prod")" || return 0
|
|
210
|
+
if [ -d "$prod" ]; then
|
|
211
|
+
# Walked once more, from where it really lands, resolved in ONE step from this shell (review 5).
|
|
212
|
+
# The walk above goes one part at a time in a subshell, and a Linux magic link such as
|
|
213
|
+
# /proc/self/cwd means a different folder to each process: there it pointed at the walk's own
|
|
214
|
+
# subshell and found nothing, while this shell read the PR's own copy of a kept Product through it.
|
|
215
|
+
# `./` before a relative path, so a value starting with `-` is not read by `cd` as an option
|
|
216
|
+
# (review 6); and a folder that is there but cannot be entered is refused, not read unchecked.
|
|
217
|
+
case "$prod" in /*) _pc="$prod" ;; *) _pc="./$prod" ;; esac
|
|
218
|
+
_phys="$(cd -P "$_pc" 2>/dev/null && pwd -P)" || _phys=""
|
|
219
|
+
if [ -z "$_phys" ]; then
|
|
220
|
+
prod_fail="product-repo resolves to '${prod}', a folder the gate cannot enter to check where it really is — so it is not read."
|
|
221
|
+
return 0
|
|
222
|
+
fi
|
|
223
|
+
# Not inside this repo's own git folder (review 7). git never lists `.git/` as tracked, yet a PR
|
|
224
|
+
# shapes it: a branch named `epics/EP-x/y` makes the folders `.git/refs/…/epics/EP-x/` when CI
|
|
225
|
+
# fetches, and that read as an untracked checkout holding an open epic with no lock.
|
|
226
|
+
# Compared as FOLDERS (`-ef`: the same one on disk), not as spellings: on a disk that ignores case
|
|
227
|
+
# (macOS, Windows) `.GIT/refs` is `.git/refs`, and `pwd -P` keeps the case it was typed in (review 8).
|
|
228
|
+
for _gd in "$(git rev-parse --absolute-git-dir 2>/dev/null)" "$(git rev-parse --git-common-dir 2>/dev/null)"; do
|
|
229
|
+
[ -n "$_gd" ] && [ -d "$_gd" ] || continue
|
|
230
|
+
_p="$_phys"
|
|
231
|
+
while :; do
|
|
232
|
+
if [ "$_p" -ef "$_gd" ]; then
|
|
233
|
+
prod_fail="product-repo resolves to '${prod}', inside this repo's own git folder — what is there is git's, shaped by branch names, not a Product."
|
|
234
|
+
return 0
|
|
235
|
+
fi
|
|
236
|
+
case "$_p" in /|'') break ;; esac
|
|
237
|
+
_p="${_p%/*}"; [ -n "$_p" ] || _p=/
|
|
238
|
+
done
|
|
239
|
+
done
|
|
240
|
+
! tracked_verdict "$(product_tracked "$_phys")" || return 0
|
|
241
|
+
# An untracked folder that is there is the Product CI checked out: a PR cannot make one. Read it.
|
|
242
|
+
return 0
|
|
243
|
+
fi
|
|
244
|
+
fi
|
|
245
|
+
# product-repo reaches NOTHING. Then a Product the base keeps that holds this story's epic is the one
|
|
246
|
+
# meant (E117 review 3): a monorepo value spelled past the repo's own folder name, another machine's
|
|
247
|
+
# absolute path, or a first spec pointing nowhere cannot be folded to it by text, and a PR that deleted
|
|
248
|
+
# or moved it leaves no disk to walk. Only when nothing is reached (review 4): a kept Product used to
|
|
249
|
+
# win over the real checkout, so a merged test fixture shaped like a Product was read instead of it.
|
|
250
|
+
# And only one holding the epic, so such a fixture is not read for stories it knows nothing about.
|
|
251
|
+
# (In a two-repo setup whose CI checks nothing out, a fixture that DOES hold the epic is read where
|
|
252
|
+
# the gate used to defer — no weaker than the deferral it replaces.)
|
|
253
|
+
kept_products
|
|
254
|
+
_epic_of="$(story_epic "$1")"
|
|
255
|
+
_kept_here=""
|
|
256
|
+
while IFS= read -r _k; do
|
|
257
|
+
[ -n "$_k" ] || continue
|
|
258
|
+
_kd="$_k"; [ "$_kd" != . ] || _kd=""
|
|
259
|
+
if [ "$(git cat-file -t "${BASE}:${_kd:+$_kd/}epics/${_epic_of}" 2>/dev/null)" = tree ]; then _kept_here="${_kept_here}${_k}
|
|
260
|
+
"; fi
|
|
261
|
+
done <<KEPT
|
|
262
|
+
$_kept
|
|
263
|
+
KEPT
|
|
264
|
+
[ -n "$_kept_here" ] || return 0
|
|
265
|
+
if [ -n "$product_rel" ]; then _what="product-repo '${product_rel}' reaches nothing"; else _what="link.md names no product-repo"; fi
|
|
266
|
+
if [ "$(printf '%s' "$_kept_here" | grep -c .)" -gt 1 ]; then
|
|
267
|
+
prod_fail="${_what}, and ${BASE} keeps more than one Product holding ${_epic_of} ($(printf '%s' "$_kept_here" | tr '\n' ' ' | sed -E 's/ +$//')) — the gate cannot tell which is meant."
|
|
268
|
+
return 0
|
|
269
|
+
fi
|
|
270
|
+
_k="$(printf '%s' "$_kept_here" | head -1)"
|
|
271
|
+
prod_note="${prod_note:+$prod_note
|
|
272
|
+
}${_what} here; the Product this repo keeps at '${_k}' on ${BASE} holds ${_epic_of}, so that one is read."
|
|
273
|
+
product_rel="$_k"
|
|
274
|
+
[ "$_k" != . ] || _k=""
|
|
275
|
+
base_product "$_k"
|
|
276
|
+
return 0
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
# Act on product_tracked's answer $1: a tracked entry on the way is refused; a tracked folder is read from
|
|
280
|
+
# the base when the base keeps a Product there, and refused otherwise. Returns 1 when there was nothing to
|
|
281
|
+
# act on (the path is not the repo's), so the caller goes on.
|
|
282
|
+
tracked_verdict() {
|
|
283
|
+
case "$1" in
|
|
284
|
+
entry:*)
|
|
285
|
+
prod_fail="product-repo reaches the Product through '${1#*:}', a symlink, submodule or file this repo tracks — what is behind it is this PR's to choose. Point product-repo at the Product itself."
|
|
286
|
+
return 0 ;;
|
|
287
|
+
tree:*)
|
|
288
|
+
_r="${1#*:}"
|
|
289
|
+
if git cat-file -e "${BASE}:${_r:+$_r/}.sdlc/product.json" 2>/dev/null || git cat-file -e "${BASE}:${_r:+$_r/}.sdlc/hub.json" 2>/dev/null; then base_product "$_r"; return 0; fi
|
|
290
|
+
prod_fail="product-repo resolves to '${_r:-.}', which holds files this repo tracks, but ${BASE} does not track '${_r:+$_r/}.sdlc/product.json' (or the older hub.json) there, so it is not a Product kept in this repo. Untrack those files (git rm -r --cached '${_r:-.}') or point product-repo at the Product."
|
|
291
|
+
return 0 ;;
|
|
292
|
+
esac
|
|
293
|
+
return 1
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
# Every Product the base keeps: a tracked `.sdlc/product.json` or `.sdlc/hub.json` (both, until v5) with
|
|
297
|
+
# an `epics/` folder beside it (a code repo may track one of its own; only a Product has epics). One line
|
|
298
|
+
# each, `.` for the root, the two names counted once; read once.
|
|
299
|
+
_kept=""
|
|
300
|
+
_kept_read=""
|
|
301
|
+
kept_products() {
|
|
302
|
+
[ -z "$_kept_read" ] || return 0
|
|
303
|
+
_kept_read=1
|
|
304
|
+
while IFS= read -r _h; do
|
|
305
|
+
[ -n "$_h" ] || continue
|
|
306
|
+
_d="${_h%.sdlc/*.json}"; _d="${_d%/}"
|
|
307
|
+
case "
|
|
308
|
+
${_kept}" in *"
|
|
309
|
+
${_d:-.}
|
|
310
|
+
"*) continue ;; esac
|
|
311
|
+
if [ "$(git cat-file -t "${BASE}:${_d:+$_d/}epics" 2>/dev/null)" = tree ]; then _kept="${_kept}${_d:-.}
|
|
312
|
+
"; fi
|
|
313
|
+
done <<KEPT
|
|
314
|
+
$(git ls-tree -r -z --name-only "$BASE" 2>/dev/null | tr '\n\0' '?\n' | grep -E '(^|/)\.sdlc/(product|hub)\.json$' || true)
|
|
315
|
+
KEPT
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
# Read the Product this repo keeps at $1 (from the repo root; "" is the root) as it stands on the base:
|
|
319
|
+
# its `epics/` written out under the temp folder. Sets prod, or prod_fail.
|
|
320
|
+
base_product() {
|
|
321
|
+
_r="$1"; _e="${_r:+$_r/}epics"
|
|
322
|
+
if [ "$(git cat-file -t "${BASE}:${_e}" 2>/dev/null)" != tree ]; then
|
|
323
|
+
prod_fail="product-repo resolves to '${_r:-.}', a Product this repo keeps, and ${BASE} has no '${_e}' folder — there is nothing on the base to read."
|
|
324
|
+
return 0
|
|
325
|
+
fi
|
|
326
|
+
# (awk reads to the end rather than stopping at the first hit: stopping left `tr` writing into a
|
|
327
|
+
# closed pipe on a large epics/, and under pipefail the gate died with no message — review 6.)
|
|
328
|
+
# A symlink or submodule inside it is refused by name, as under specs/ (E115): a link written out
|
|
329
|
+
# still points where it pointed — an absolute one into this PR's working tree — and a submodule comes
|
|
330
|
+
# out as an empty folder, which reads as an epic with no stories.
|
|
331
|
+
_bad="$(git ls-tree -r -z "${BASE}:${_e}" 2>/dev/null | tr '\n\0' '?\n' | awk '!f && ($1 == "120000" || $1 == "160000") { sub(/^[^\t]*\t/, ""); print; f = 1 }')"
|
|
332
|
+
if [ -n "$_bad" ]; then
|
|
333
|
+
prod_fail="'${_e}/${_bad}' on ${BASE} is a symlink or a submodule, so the Product this repo keeps cannot be read from the base. Replace it with the files themselves."
|
|
334
|
+
return 0
|
|
335
|
+
fi
|
|
336
|
+
# Keyed as "x<path>": the repo root is the path "", which an empty key would take as done.
|
|
337
|
+
if [ "$_arch_key" != "x$_r" ]; then
|
|
338
|
+
_arch_dir="$(mktemp -d "$_yad_tmp/product.XXXXXX")"
|
|
339
|
+
# A throwaway index holding the base's epics/, written out with the WORK TREE pointed at the empty
|
|
340
|
+
# temp folder. Not `git archive`: it drops whatever the base's .gitattributes marks `export-ignore`.
|
|
341
|
+
# And not with the real work tree: `git checkout-index` takes attributes from the work tree, which
|
|
342
|
+
# is the PR's — a `.gitattributes` of `*.md text eol=crlf` or `working-tree-encoding=UTF-16` in the
|
|
343
|
+
# PR wrote the base's files unreadable, and every gate passed. Pointed here, it finds none but the
|
|
344
|
+
# base's own.
|
|
345
|
+
# The index's own `.gitattributes` entries are dropped before it is written out, for the same reason
|
|
346
|
+
# (review 3): one merged into the base's epics/ applied `working-tree-encoding=UTF-16` to every file.
|
|
347
|
+
if ! { mkdir -p "${_arch_dir}/${_e}" &&
|
|
348
|
+
GIT_INDEX_FILE="${_arch_dir}.idx" git read-tree "${BASE}:${_e}" &&
|
|
349
|
+
GIT_INDEX_FILE="${_arch_dir}.idx" git ls-files -z -- ':(glob)**/.gitattributes' |
|
|
350
|
+
GIT_INDEX_FILE="${_arch_dir}.idx" xargs -0 git update-index --force-remove -- &&
|
|
351
|
+
GIT_WORK_TREE="${_arch_dir}/${_e}" GIT_INDEX_FILE="${_arch_dir}.idx" git checkout-index -a -f; } >/dev/null 2>&1; then
|
|
352
|
+
prod_fail="product-repo resolves to '${_r:-.}', a Product this repo keeps, and '${_e}' could not be read from ${BASE}."
|
|
353
|
+
return 0
|
|
354
|
+
fi
|
|
355
|
+
_arch_key="x$_r"
|
|
356
|
+
fi
|
|
357
|
+
prod="${_arch_dir}${_r:+/$_r}"
|
|
358
|
+
prod_note="${prod_note:+$prod_note
|
|
359
|
+
}the Product at '${_r:-.}' is kept in this repo, so it is read as it stands on ${BASE}, not as this PR leaves it."
|
|
360
|
+
return 0
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
# The path $1 from the repo root, `.` and `..` folded by its text alone — no disk. Prints `.` for the
|
|
364
|
+
# root and nothing when it leaves the repo. An absolute path counts when it starts with the repo's path
|
|
365
|
+
# as the shell spells it or as the disk does (`pwd`, `pwd -P`; the gates run from the root).
|
|
366
|
+
lex_rel() (
|
|
367
|
+
_p="$1"
|
|
368
|
+
case "$_p" in
|
|
369
|
+
/*)
|
|
370
|
+
_in=""
|
|
371
|
+
for _top in "$(pwd)" "$(pwd -P)"; do
|
|
372
|
+
case "$_p/" in "$_top"/*) _p="${_p#"$_top"}"; _in=1; break ;; esac
|
|
373
|
+
done
|
|
374
|
+
[ -n "$_in" ] || exit 0 ;;
|
|
375
|
+
esac
|
|
376
|
+
_out=""
|
|
377
|
+
set -f; IFS=/
|
|
378
|
+
for _c in $_p; do
|
|
379
|
+
case "$_c" in
|
|
380
|
+
''|.) ;;
|
|
381
|
+
..) [ -n "$_out" ] || exit 0
|
|
382
|
+
case "$_out" in */*) _out="${_out%/*}" ;; *) _out="" ;; esac ;;
|
|
383
|
+
*) _out="${_out:+$_out/}$_c" ;;
|
|
384
|
+
esac
|
|
385
|
+
done
|
|
386
|
+
printf '%s' "${_out:-.}"
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
# Is the path $1 — as the shell would walk it, `..` and links included — something this repo tracks?
|
|
390
|
+
# Prints `entry:<path>` when a part of the way is a tracked entry itself (a symlink, a submodule, a
|
|
391
|
+
# file), `tree:<path>` when it ends in a folder with tracked files at or under it, and nothing when
|
|
392
|
+
# it is the repo's to neither. <path> is from the repo root, as the disk spells it (`pwd -P`). The case
|
|
393
|
+
# is folded where git folds it (core.ignorecase — a Mac checkout), and in ASCII only.
|
|
394
|
+
product_tracked() (
|
|
395
|
+
_top="$(git rev-parse --show-toplevel 2>/dev/null)" && _top="$(cd -P "$_top" 2>/dev/null && pwd -P)" || exit 0
|
|
396
|
+
_ps='literal'; [ "$(git config --bool core.ignorecase 2>/dev/null)" = true ] && _ps='literal,icase'
|
|
397
|
+
_in() { case "$1/" in "$_top"/*) _r="${1#"$_top"}"; _r="${_r#/}"; return 0 ;; esac; return 1; }
|
|
398
|
+
_hit() { git -C "$_top" ls-files -z -- ":(${_ps})$1" 2>/dev/null | tr '\n\0' '?\n'; }
|
|
399
|
+
case "$1" in /*) cd / ;; esac
|
|
400
|
+
_rest="$1"
|
|
401
|
+
while [ -n "$_rest" ]; do
|
|
402
|
+
_c="${_rest%%/*}"
|
|
403
|
+
case "$_rest" in */*) _rest="${_rest#*/}" ;; *) _rest="" ;; esac
|
|
404
|
+
case "$_c" in ''|.) continue ;; esac
|
|
405
|
+
if [ "$_c" != .. ] && _in "$(pwd -P)/$_c" && [ -n "$_r" ] &&
|
|
406
|
+
_hit "$_r" | awk -v p="$_r" -v i="$_ps" '{ a = $0; b = p; if (i ~ /icase/) { a = tolower(a); b = tolower(b) } } a == b { f = 1 } END { exit !f }'; then
|
|
407
|
+
printf 'entry:%s' "$_r"; exit 0
|
|
408
|
+
fi
|
|
409
|
+
cd -P "./$_c" 2>/dev/null || exit 0 # ./ — a part named `-p` is not an option
|
|
410
|
+
done
|
|
411
|
+
_in "$(pwd -P)" || exit 0
|
|
412
|
+
if [ -n "$(_hit "${_r:-.}" | head -1)" ]; then printf 'tree:%s' "$_r"; fi
|
|
413
|
+
exit 0
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
# E118. A story's epic is its ID's prefix (EP-<slug>-S0N -> EP-<slug>): yad-spec always writes link.md's
|
|
417
|
+
# `epic:` so, and contract-check never read the field. The field is the PR's to write, so a gate that took
|
|
418
|
+
# it as it stands gated whichever epic the PR named — a story of a SEALED epic could name an open one and
|
|
419
|
+
# pass epic-open, or step off a thread frozen for hotfix debt. The gates that read `epic:` require it to
|
|
420
|
+
# agree with the story ID.
|
|
421
|
+
story_epic() { printf '%s' "$1" | sed -E 's/-S[0-9]+$//'; }
|
|
422
|
+
|
|
423
|
+
# One story ID shape for every gate (E117 review 1): EP-<slug>-S<n>, what yad-stories writes. Anything
|
|
424
|
+
# else is refused by name — a Task trailer of `EP-x-S01/.-T1` read `specs/EP-x-S01/./link.md`, a path
|
|
425
|
+
# the base read never found, and its `epic:` of `EP-x-S01/.` agreed with story_epic. The whole string
|
|
426
|
+
# must match (bash `=~`, not a line-wise grep), and the letters are spelled out so no locale widens them.
|
|
427
|
+
_story_re='^EP-[abcdefghijklmnopqrstuvwxyz0123456789-]+-S[0123456789]+$'
|
|
428
|
+
story_ok() { [[ $1 =~ $_story_re ]]; }
|
|
429
|
+
|
|
62
430
|
# Resolve link.md's `product-repo` to a path in THIS checkout. An ABSOLUTE value is used as-is. A
|
|
63
431
|
# RELATIVE value is written relative to the link.md's own directory (specs/<story>/) — the canonical
|
|
64
432
|
# form — but contract-check historically read it from the repo root, so a link.md authored against that
|
|
@@ -85,7 +453,7 @@ while IFS= read -r sha; do
|
|
|
85
453
|
[ -z "$sha" ] && continue
|
|
86
454
|
short="$(git log -1 --format=%h "$sha")"
|
|
87
455
|
subject="$(git log -1 --format=%s "$sha")"
|
|
88
|
-
task="$(git log -1 --format='%(trailers:key=Task,valueonly)' "$sha" |
|
|
456
|
+
task="$(git log -1 --format='%(trailers:key=Task,valueonly)' "$sha" | awk '!f && length($0) { print; f = 1 }')"
|
|
89
457
|
# The type exemption waives the REQUIREMENT for an owning epic, not the VALIDITY of one that is
|
|
90
458
|
# claimed — same rule spec-link applies. Exempting on the subject alone would let `chore(x): …` plus
|
|
91
459
|
# a Task trailer pointing at an orphaned/sealed epic bypass this gate entirely.
|
|
@@ -99,19 +467,35 @@ while IFS= read -r sha; do
|
|
|
99
467
|
continue
|
|
100
468
|
fi
|
|
101
469
|
story="$(printf '%s' "$task" | sed -E 's/-T[0-9]+$//')"
|
|
470
|
+
if ! story_ok "$story"; then
|
|
471
|
+
echo "FAIL [lineage-check]: ${short} ${task} — '${story}' is not a story ID (expected EP-<slug>-S<n>, the slug in lowercase letters, digits and dashes)."
|
|
472
|
+
rc=1
|
|
473
|
+
continue
|
|
474
|
+
fi
|
|
102
475
|
link="specs/${story}/link.md"
|
|
103
476
|
if [ ! -f "$link" ]; then
|
|
104
477
|
echo "note [lineage-check]: ${short} ${task} — specs/${story}/link.md missing (spec-link will FAIL)."
|
|
105
478
|
continue
|
|
106
479
|
fi
|
|
107
|
-
product_rel="$(link_val product-repo "$link")"
|
|
108
480
|
epic="$(link_val epic "$link")"
|
|
109
481
|
if [ -z "$epic" ]; then
|
|
110
482
|
echo "FAIL [lineage-check]: ${short} ${task} — link.md has no 'epic:' (cannot place it in a thread)."
|
|
111
483
|
rc=1
|
|
112
484
|
continue
|
|
113
485
|
fi
|
|
114
|
-
|
|
486
|
+
if [ "$epic" != "$(story_epic "$story")" ]; then
|
|
487
|
+
echo "FAIL [lineage-check]: ${short} ${task} — ${link} says epic: ${epic:-<none>}, but ${story} is a story of $(story_epic "$story")."
|
|
488
|
+
echo " A story's epic is the prefix of its ID; the gate will not read another. Fix epic: in ${link}."
|
|
489
|
+
rc=1
|
|
490
|
+
continue
|
|
491
|
+
fi
|
|
492
|
+
product_for "$story"
|
|
493
|
+
if [ -n "$prod_note" ]; then printf '%s\n' "$prod_note" | sed "s/^/note [lineage-check]: ${short} /"; fi
|
|
494
|
+
if [ -n "$prod_fail" ]; then
|
|
495
|
+
echo "FAIL [lineage-check]: ${short} ${task} — ${prod_fail}"
|
|
496
|
+
rc=1
|
|
497
|
+
continue
|
|
498
|
+
fi
|
|
115
499
|
epicmd="${prod}/epics/${epic}/epic.md"
|
|
116
500
|
# Defer ONLY when the product checkout itself is unreachable. A reachable Product whose epic is missing is
|
|
117
501
|
# an orphaned story link — FAIL, do not pass it off as "not reachable".
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Check the Product out where the code repo's record says (E120), so the Product-reading gates
|
|
3
|
+
# (contract-check, lineage-check, epic-open, reconcile-debt-check) can read it in CI.
|
|
4
|
+
#
|
|
5
|
+
# The record is `.sdlc/product-link.json` — `git_url`, `path` (from the repo root, inside the repo) and
|
|
6
|
+
# `default_branch` — written by `yad check --fix` / `yad update` and merged on its own. It is read from
|
|
7
|
+
# the BASE, never as the PR leaves it: a PR must not choose what CI clones, or where.
|
|
8
|
+
#
|
|
9
|
+
# Needs the YAD_PRODUCT_TOKEN secret (a token that can read the Product: a GitHub fine-grained token or
|
|
10
|
+
# App token, a GitLab project/group access token). Without it — the default, and every PR from a fork —
|
|
11
|
+
# nothing is cloned and the gates defer, as before. With it, a clone that fails FAILS the job: a broken
|
|
12
|
+
# setup must not read like a deferral.
|
|
13
|
+
set -euo pipefail
|
|
14
|
+
|
|
15
|
+
# --- shared Product settings file (byte-identical across the gates; they are standalone by design, so
|
|
16
|
+
# --- it is duplicated, not sourced) ---
|
|
17
|
+
# The Product's settings live under two names until v5: `.sdlc/product.json`, read first from 4.0, and
|
|
18
|
+
# `.sdlc/hub.json`, which an older yadflow wrote and its gates read. The environment may name the file
|
|
19
|
+
# instead: SDLC_PRODUCT_CONFIG, or the older SDLC_HUB_CONFIG. Two names that say different things are
|
|
20
|
+
# never settled by picking one — the CLI refuses the same way (YAD-STATE-008) — so the gate FAILS and
|
|
21
|
+
# names both. Said on stderr and returned as a failure: this runs inside `$(...)`, where an `exit` would
|
|
22
|
+
# only leave the subshell. `cmp -s` is byte for byte, like the CLI's own comparison.
|
|
23
|
+
product_config() {
|
|
24
|
+
if [ -n "${SDLC_PRODUCT_CONFIG:-}" ] && [ -n "${SDLC_HUB_CONFIG:-}" ] \
|
|
25
|
+
&& [ "$SDLC_PRODUCT_CONFIG" != "$SDLC_HUB_CONFIG" ] && ! cmp -s "$SDLC_PRODUCT_CONFIG" "$SDLC_HUB_CONFIG"; then
|
|
26
|
+
echo "FAIL [product-settings]: SDLC_PRODUCT_CONFIG (${SDLC_PRODUCT_CONFIG}) and SDLC_HUB_CONFIG (${SDLC_HUB_CONFIG}) name files that say different things. Set only SDLC_PRODUCT_CONFIG." >&2
|
|
27
|
+
return 1
|
|
28
|
+
fi
|
|
29
|
+
if [ -n "${SDLC_PRODUCT_CONFIG:-}" ]; then printf '%s' "$SDLC_PRODUCT_CONFIG"; return 0; fi
|
|
30
|
+
if [ -n "${SDLC_HUB_CONFIG:-}" ]; then printf '%s' "$SDLC_HUB_CONFIG"; return 0; fi
|
|
31
|
+
if [ -f .sdlc/product.json ] && [ -f .sdlc/hub.json ] && ! cmp -s .sdlc/product.json .sdlc/hub.json; then
|
|
32
|
+
echo "FAIL [product-settings]: .sdlc/product.json and .sdlc/hub.json say different things — they are one file under two names until v5. Run \`yad migrate\` in the Product to choose the copy to keep, and commit both." >&2
|
|
33
|
+
return 1
|
|
34
|
+
fi
|
|
35
|
+
if [ -f .sdlc/product.json ]; then printf '%s' .sdlc/product.json; else printf '%s' .sdlc/hub.json; fi
|
|
36
|
+
}
|
|
37
|
+
PRODUCT_CONFIG="$(product_config)" || exit 1
|
|
38
|
+
export LC_ALL=C
|
|
39
|
+
|
|
40
|
+
# --- shared base resolution (byte-identical across the gates; they are standalone by design, so it
|
|
41
|
+
# --- is duplicated, not sourced) ---
|
|
42
|
+
# With no explicit base, RESOLVE the trunk instead of assuming a hardcoded `origin/main` — on a repo
|
|
43
|
+
# whose trunk is `develop`/`master` that guess either fails closed or, where a stale `main` still
|
|
44
|
+
# exists, silently diffs the WRONG range (issue #161). Mirrors the CLI's own order (cli/productcommit.mjs,
|
|
45
|
+
# cli/repo.mjs): the CONFIGURED default_branch first, then the remote's published default
|
|
46
|
+
# (origin/HEAD), then origin/main. Each candidate must actually resolve before it is used, so a
|
|
47
|
+
# DANGLING origin/HEAD (trunk renamed, the old remote-tracking ref pruned) falls through to the next
|
|
48
|
+
# candidate instead of failing the gate on a fully-fetched repo. CI always passes the base explicitly,
|
|
49
|
+
# so this governs local runs only. The `|| _x=""` guards are load-bearing: under `set -e` a failing
|
|
50
|
+
# command substitution in an assignment aborts the script.
|
|
51
|
+
resolve_base() {
|
|
52
|
+
# tr first: a key and its value may legally sit on separate lines, which a per-line match misses.
|
|
53
|
+
_cfg="$({ tr -d '\n' < "$PRODUCT_CONFIG"; } 2>/dev/null | sed -nE 's/.*"default_branch"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p')" || _cfg=""
|
|
54
|
+
_head="$(git symbolic-ref --short --quiet refs/remotes/origin/HEAD 2>/dev/null)" || _head=""
|
|
55
|
+
for _c in "origin/${_cfg}" "${_head}" origin/main; do
|
|
56
|
+
case "$_c" in ''|origin/) continue ;; esac
|
|
57
|
+
if git rev-parse --verify --quiet "${_c}^{commit}" >/dev/null 2>&1; then printf '%s' "$_c"; return; fi
|
|
58
|
+
done
|
|
59
|
+
printf '%s' origin/main
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
BASE="${1:-${SDLC_BASE:-$(resolve_base)}}"
|
|
63
|
+
[ -n "${1:-}" ] || [ -n "${SDLC_BASE:-}" ] || echo "note [product-checkout]: no base given — reading the record from '${BASE}'."
|
|
64
|
+
if ! git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null; then
|
|
65
|
+
echo "FAIL [product-checkout]: base ref '${BASE}' not found — fetch full history / check the base branch."
|
|
66
|
+
exit 1
|
|
67
|
+
fi
|
|
68
|
+
|
|
69
|
+
rec=.sdlc/product-link.json
|
|
70
|
+
if ! git cat-file -e "${BASE}:${rec}" 2>/dev/null; then
|
|
71
|
+
echo "note [product-checkout]: no ${rec} on ${BASE} — nothing to check out; the gates read link.md as before."
|
|
72
|
+
exit 0
|
|
73
|
+
fi
|
|
74
|
+
# One string value of the record on the base (a flat JSON object written by yad; no escapes in values).
|
|
75
|
+
field() { git show "${BASE}:${rec}" 2>/dev/null | tr -d '\n\r' | sed -nE "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"([^\"\\\\]*)\".*/\\1/p" || true; }
|
|
76
|
+
url="$(field git_url)"
|
|
77
|
+
dest="$(field path)"
|
|
78
|
+
branch="$(field default_branch)"
|
|
79
|
+
|
|
80
|
+
if [ -z "${YAD_PRODUCT_TOKEN:-}" ]; then
|
|
81
|
+
echo "note [product-checkout]: no YAD_PRODUCT_TOKEN secret — the Product is not checked out, and the gates defer."
|
|
82
|
+
echo " To check it out here, add a token that can read the Product as the YAD_PRODUCT_TOKEN secret (CI variable)."
|
|
83
|
+
exit 0
|
|
84
|
+
fi
|
|
85
|
+
if [ -z "$url" ]; then
|
|
86
|
+
echo "note [product-checkout]: ${rec} on ${BASE} names no git_url — the Product is not checked out."
|
|
87
|
+
echo " Set the Product's git_url in its .sdlc/product.json (.sdlc/hub.json on a Product that has only that name; when both exist, then run \`yad migrate --apply --keep product\`), then run \`yad check --fix --push\` from the Product."
|
|
88
|
+
exit 0
|
|
89
|
+
fi
|
|
90
|
+
# Inside the repo, and not the repo itself: CI can only place a checkout in the workspace, and the gates
|
|
91
|
+
# read an UNTRACKED folder there as CI's checkout.
|
|
92
|
+
case "/${dest}/" in
|
|
93
|
+
//|/./|*/../*|//*|/.git/*|/.git)
|
|
94
|
+
echo "FAIL [product-checkout]: ${rec} on ${BASE} says path '${dest}' — it must be a folder inside this repo, such as .yad/product."
|
|
95
|
+
exit 1 ;;
|
|
96
|
+
esac
|
|
97
|
+
# And no symlink on the way (review 1): the text above is only text, and a PR can track `.yad` as a link
|
|
98
|
+
# to `../elsewhere` — git clone follows it, so the clone (with the token) would land where the PR chose.
|
|
99
|
+
_p=""
|
|
100
|
+
_rest="$dest"
|
|
101
|
+
while [ -n "$_rest" ]; do
|
|
102
|
+
_part="${_rest%%/*}"
|
|
103
|
+
[ "$_part" = "$_rest" ] && _rest="" || _rest="${_rest#*/}"
|
|
104
|
+
[ -n "$_part" ] || continue
|
|
105
|
+
_p="${_p:+$_p/}$_part"
|
|
106
|
+
if [ -L "$_p" ]; then
|
|
107
|
+
echo "FAIL [product-checkout]: '${_p}' is a symlink — the Product is not checked out through it. Point path in ${rec} at a real folder."
|
|
108
|
+
exit 1
|
|
109
|
+
fi
|
|
110
|
+
done
|
|
111
|
+
# Tracked first (review 2): a tracked folder is also "already there", and read that way it passed.
|
|
112
|
+
if [ -n "$(git ls-files -- "$dest" 2>/dev/null | head -1)" ]; then
|
|
113
|
+
echo "FAIL [product-checkout]: '${dest}' holds files this repo tracks — the Product cannot be checked out there."
|
|
114
|
+
exit 1
|
|
115
|
+
fi
|
|
116
|
+
if [ -e "$dest" ]; then
|
|
117
|
+
echo "note [product-checkout]: '${dest}' is already there — it is not replaced."
|
|
118
|
+
exit 0
|
|
119
|
+
fi
|
|
120
|
+
# https only: the token goes in as HTTP basic auth. An ssh form (git@host:org/repo.git or ssh://) is
|
|
121
|
+
# read as the same repo over https; a file:// url (a local test) is cloned as it is.
|
|
122
|
+
case "$url" in
|
|
123
|
+
git@*:*) _h="${url#git@}"; url="https://${_h%%:*}/${_h#*:}" ;;
|
|
124
|
+
ssh://git@*) _h="${url#ssh://git@}"; _hp="${_h%%/*}"; url="https://${_hp%%:*}/${_h#*/}" ;; # an ssh port is not the https one
|
|
125
|
+
https://*|file://*) ;;
|
|
126
|
+
http://*) echo "FAIL [product-checkout]: ${rec} on ${BASE} has an http:// git_url — the token is not sent over plain http. Use the https url."; exit 1 ;;
|
|
127
|
+
*) echo "FAIL [product-checkout]: ${rec} on ${BASE} has a git_url git cannot clone over https: '${url}'."; exit 1 ;;
|
|
128
|
+
esac
|
|
129
|
+
# The token never goes on a command line or into a URL, so no process list or log line shows it; git
|
|
130
|
+
# reads the header from its environment (GIT_CONFIG_COUNT, git 2.31+). GitHub is told to mask the
|
|
131
|
+
# encoded form too — it only masks the secret as written.
|
|
132
|
+
case "${GITLAB_CI:-}" in true) _user=oauth2 ;; *) _user=x-access-token ;; esac
|
|
133
|
+
_auth="$(printf '%s:%s' "$_user" "$YAD_PRODUCT_TOKEN" | base64 | tr -d '\n')"
|
|
134
|
+
[ "${GITHUB_ACTIONS:-}" != true ] || echo "::add-mask::${_auth}"
|
|
135
|
+
if ! GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader GIT_CONFIG_VALUE_0="Authorization: Basic ${_auth}" \
|
|
136
|
+
GIT_TERMINAL_PROMPT=0 git clone --quiet --depth 1 ${branch:+--branch "$branch"} -- "$url" "$dest"; then
|
|
137
|
+
echo "FAIL [product-checkout]: could not clone the Product from ${url}${branch:+ (${branch})} into '${dest}'."
|
|
138
|
+
echo " Check that YAD_PRODUCT_TOKEN can read that repo, and that git_url and default_branch in ${rec} are right."
|
|
139
|
+
exit 1
|
|
140
|
+
fi
|
|
141
|
+
echo "note [product-checkout]: the Product is checked out at '${dest}'${branch:+ (${branch})} — the gates read it there."
|