@metasession.co/devaudit-cli 0.3.9 → 0.3.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metasession.co/devaudit-cli",
3
- "version": "0.3.9",
3
+ "version": "0.3.10",
4
4
  "description": "DevAudit CLI — installs, syncs, and operates the Metasession SDLC across consumer projects.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,7 +33,7 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "@clack/prompts": "^0.8.2",
36
- "@metasession.co/devaudit-plugin-sdk": "^0.3.9",
36
+ "@metasession.co/devaudit-plugin-sdk": "^0.3.10",
37
37
  "ajv": "^8.20.0",
38
38
  "commander": "^12.1.0",
39
39
  "consola": "^3.2.3",
@@ -152,9 +152,10 @@ Two distinct credentials exist; conflating them is what causes the silent-CI-tok
152
152
  |---|---|---|---|---|
153
153
  | **Personal PAT** | `mctok_…` | `~/.config/devaudit/auth.json` (per developer) | You (the user) | Your local CLI commands |
154
154
  | **Project API key** | `dak_…` | Repo secret `DEVAUDIT_API_KEY` | The project | CI's `devaudit push` calls |
155
- | **Operator's PAT** | `mctok_…` | Repo secret `DEVAUDIT_USER_TOKEN` | The operator (singular) | CI's mutation calls (release register, approval submit) |
155
+ | **Operator's PAT** | `mctok_…` | Repo secret `DEVAUDIT_USER_TOKEN` | The operator (singular) | CI's DevAudit portal calls that need human attribution (release submit / approval flows) |
156
+ | **GitHub workflow token** | `${{ github.token }}` | Issued automatically per workflow run | The GitHub Actions workflow | CI's GitHub repo mutations (checkout, branch push, PR, issue, comment, label, check-run updates) |
156
157
 
157
- **Never paste your personal PAT into a repo secret.** Repo `DEVAUDIT_USER_TOKEN` is operator-owned. CI portal mutations are attributed to whoever's PAT is there — if it's yours, your name shows up against every release the team ships, and the moment your PAT expires CI breaks. Rotation belongs to the operator: `devaudit install --force-team-config` on their machine.
158
+ **Never paste your personal PAT into a repo secret.** Repo `DEVAUDIT_USER_TOKEN` is operator-owned and is not the default GitHub auth path for workflow repo mutations. CI portal mutations are attributed to whoever's PAT is there — if it's yours, your name shows up against every release the team ships, and the moment your PAT expires CI portal actions break. Rotation belongs to the operator: `devaudit install --force-team-config` on their machine.
158
159
 
159
160
  If `devaudit auth status` shows the wrong user, run `devaudit auth logout && devaudit auth login` and paste the right PAT.
160
161
 
@@ -181,10 +182,10 @@ The synced CI gates expect a specific environment. Here's what you need locally
181
182
 
182
183
  | Surface | CI | Local (your machine) |
183
184
  |---|---|---|
184
- | Personal identity | `secrets.DEVAUDIT_USER_TOKEN` (operator's) | `~/.config/devaudit/auth.json` (yours) — `devaudit auth login` |
185
+ | Personal identity | `secrets.DEVAUDIT_USER_TOKEN` (operator's, portal-only) | `~/.config/devaudit/auth.json` (yours) — `devaudit auth login` |
185
186
  | Project API key | `secrets.DEVAUDIT_API_KEY` | Usually unset locally — only needed if you're testing `devaudit push` against the live portal; ask the operator if you need to debug it |
186
187
  | Portal URL | `vars.DEVAUDIT_BASE_URL` | `~/.config/devaudit/auth.json` (set by `auth login`) or `$DEVAUDIT_BASE_URL` env |
187
- | GitHub auth | `${{ github.token }}` (auto) | `gh auth login` |
188
+ | GitHub auth | `${{ github.token }}` (auto, repo mutations) | `gh auth login` |
188
189
  | Node | matrix-pinned to project's `node_version` | nvm / volta / whatever — `devaudit doctor` checks ≥ 22 |
189
190
  | `jq` / `curl` | always present on GH runners | install via package manager — `devaudit doctor` flags absence |
190
191
 
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env bash
2
+ # close-out-contract.test.sh — Tests for the close-out marker helper.
3
+ #
4
+ # Usage:
5
+ # ./scripts/close-out-contract.test.sh
6
+
7
+ set -euo pipefail
8
+
9
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
10
+ # shellcheck source=/dev/null
11
+ source "$SCRIPT_DIR/close-out-contract.sh"
12
+
13
+ PASS=0
14
+ FAIL=0
15
+
16
+ assert_eq() {
17
+ local desc="$1" want="$2" got="$3"
18
+ if [ "$got" = "$want" ]; then
19
+ echo " PASS: $desc"
20
+ PASS=$((PASS + 1))
21
+ else
22
+ echo " FAIL: $desc"
23
+ echo " want: $want"
24
+ echo " got: $got"
25
+ FAIL=$((FAIL + 1))
26
+ fi
27
+ }
28
+
29
+ assert_true() {
30
+ local desc="$1"
31
+ shift
32
+ if "$@"; then
33
+ echo " PASS: $desc"
34
+ PASS=$((PASS + 1))
35
+ else
36
+ echo " FAIL: $desc"
37
+ FAIL=$((FAIL + 1))
38
+ fi
39
+ }
40
+
41
+ assert_false() {
42
+ local desc="$1"
43
+ shift
44
+ if "$@"; then
45
+ echo " FAIL: $desc"
46
+ FAIL=$((FAIL + 1))
47
+ else
48
+ echo " PASS: $desc"
49
+ PASS=$((PASS + 1))
50
+ fi
51
+ }
52
+
53
+ echo "=== close-out-contract.sh tests ==="
54
+
55
+ assert_eq "branch prefix constant" "chore/close-out-" "$CLOSEOUT_BRANCH_PREFIX"
56
+ assert_eq "marker prefix constant" "Release-Closeout:" "$CLOSEOUT_MARKER_PREFIX"
57
+ assert_eq "closeout_marker emits structured line" "Release-Closeout: REQ-090" "$(closeout_marker REQ-090)"
58
+
59
+ assert_true "is_closeout_marker accepts canonical marker" is_closeout_marker "Release-Closeout: REQ-090"
60
+ assert_true "is_closeout_marker accepts extra whitespace" is_closeout_marker "Release-Closeout: REQ-123"
61
+ assert_false "is_closeout_marker rejects wrong prefix" is_closeout_marker "Closeout: REQ-090"
62
+ assert_false "is_closeout_marker rejects short req ids" is_closeout_marker "Release-Closeout: REQ-9"
63
+ assert_false "is_closeout_marker rejects bare prose mention" is_closeout_marker "This closes REQ-090 after release."
64
+
65
+ assert_eq "closeout_marker_req extracts req id" "REQ-090" "$(closeout_marker_req "Release-Closeout: REQ-090")"
66
+ assert_eq "closeout_marker_req extracts first req id from mixed text" "REQ-111" "$(closeout_marker_req $'x\nRelease-Closeout: REQ-111\nmore')"
67
+ assert_eq "closeout_marker_req returns empty when marker absent" "" "$(closeout_marker_req "no marker here" || true)"
68
+
69
+ echo
70
+ echo "Result: $PASS passed, $FAIL failed"
71
+ [ "$FAIL" = "0" ]
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env bash
2
+ # prepare-release-pr.sh — ensure there is one truthful release PR.
3
+ #
4
+ # Usage:
5
+ # ./scripts/prepare-release-pr.sh [--apply] [--mode=update|recreate]
6
+ #
7
+ # Default mode is dry-run + update. Dry-run prints the action it would take.
8
+ # With --apply:
9
+ # - no existing PR: creates one
10
+ # - current PR: updates title/body only when needed
11
+ # - stale PR + mode=update: rewrites title/body and comments
12
+ # - stale PR + mode=recreate: closes old PR and opens a fresh one
13
+
14
+ set -euo pipefail
15
+
16
+ APPLY=false
17
+ MODE="update"
18
+ for arg in "$@"; do
19
+ case "$arg" in
20
+ --apply) APPLY=true ;;
21
+ --mode=update) MODE="update" ;;
22
+ --mode=recreate) MODE="recreate" ;;
23
+ *) echo "Unknown argument: $arg" >&2; exit 2 ;;
24
+ esac
25
+ done
26
+
27
+ INTEGRATION_BRANCH=$(jq -r '.integration_branch // "develop"' sdlc-config.json 2>/dev/null || echo "develop")
28
+ RELEASE_BRANCH=$(jq -r '.release_branch // "main"' sdlc-config.json 2>/dev/null || echo "main")
29
+
30
+ if [ ! -x scripts/derive-release-version.sh ]; then
31
+ chmod +x scripts/derive-release-version.sh 2>/dev/null || true
32
+ fi
33
+ CURRENT_RELEASE=$(./scripts/derive-release-version.sh)
34
+ if [ -z "$CURRENT_RELEASE" ]; then
35
+ echo "No active release version derived; close-out reconciliation appears in progress." >&2
36
+ exit 1
37
+ fi
38
+
39
+ TITLE="Release: ${CURRENT_RELEASE}"
40
+ BODY=$(cat <<EOF
41
+ ## Release
42
+
43
+ - Release: ${CURRENT_RELEASE}
44
+ - Base: ${RELEASE_BRANCH}
45
+ - Head: ${INTEGRATION_BRANCH}
46
+
47
+ ## DevAudit
48
+
49
+ - Release context is derived from the current ${INTEGRATION_BRANCH} head.
50
+ - If this PR replaced an older release PR, the older PR was superseded because it no longer matched the governing release context.
51
+ EOF
52
+ )
53
+
54
+ OPEN_PRS=$(gh pr list --base "$RELEASE_BRANCH" --head "$INTEGRATION_BRANCH" --state open --json number,title,body,url --limit 10)
55
+ COUNT=$(echo "$OPEN_PRS" | jq 'length')
56
+
57
+ if [ "$COUNT" -eq 0 ]; then
58
+ echo "No open ${INTEGRATION_BRANCH}->${RELEASE_BRANCH} PR found."
59
+ if [ "$APPLY" = "true" ]; then
60
+ gh pr create --base "$RELEASE_BRANCH" --head "$INTEGRATION_BRANCH" --title "$TITLE" --body "$BODY"
61
+ else
62
+ echo "Dry run: would create release PR titled '$TITLE'."
63
+ fi
64
+ exit 0
65
+ fi
66
+
67
+ if [ "$COUNT" -gt 1 ]; then
68
+ echo "ERROR: ${COUNT} open ${INTEGRATION_BRANCH}->${RELEASE_BRANCH} PRs found. Resolve duplicates manually." >&2
69
+ echo "$OPEN_PRS" | jq -r '.[] | " #\(.number) \(.title) \(.url)"' >&2
70
+ exit 1
71
+ fi
72
+
73
+ PR_NUMBER=$(echo "$OPEN_PRS" | jq -r '.[0].number')
74
+ PR_TITLE=$(echo "$OPEN_PRS" | jq -r '.[0].title // ""')
75
+ PR_BODY=$(echo "$OPEN_PRS" | jq -r '.[0].body // ""')
76
+ PR_URL=$(echo "$OPEN_PRS" | jq -r '.[0].url')
77
+
78
+ if printf '%s\n%s\n' "$PR_TITLE" "$PR_BODY" | grep -q "$CURRENT_RELEASE"; then
79
+ echo "Open release PR #${PR_NUMBER} already matches ${CURRENT_RELEASE}: ${PR_URL}"
80
+ exit 0
81
+ fi
82
+
83
+ echo "Open release PR #${PR_NUMBER} is stale for current release ${CURRENT_RELEASE}: ${PR_URL}"
84
+ echo "Current title: ${PR_TITLE}"
85
+
86
+ if [ "$APPLY" != "true" ]; then
87
+ echo "Dry run: would ${MODE} stale PR #${PR_NUMBER} for ${CURRENT_RELEASE}."
88
+ exit 0
89
+ fi
90
+
91
+ if [ "$MODE" = "recreate" ]; then
92
+ gh pr comment "$PR_NUMBER" --body "Superseded: current ${INTEGRATION_BRANCH} head now derives ${CURRENT_RELEASE}; this PR no longer matches the governing release context."
93
+ gh pr close "$PR_NUMBER" --comment "Closed as superseded by ${CURRENT_RELEASE} release context."
94
+ gh pr create --base "$RELEASE_BRANCH" --head "$INTEGRATION_BRANCH" --title "$TITLE" --body "$BODY"
95
+ else
96
+ gh pr edit "$PR_NUMBER" --title "$TITLE" --body "$BODY"
97
+ gh pr comment "$PR_NUMBER" --body "Release PR context refreshed: current ${INTEGRATION_BRANCH} head derives ${CURRENT_RELEASE}."
98
+ echo "Updated PR #${PR_NUMBER} to ${CURRENT_RELEASE}: ${PR_URL}"
99
+ fi
@@ -37,6 +37,23 @@ fi
37
37
  TOTAL=0
38
38
  FAILED=0
39
39
 
40
+ ACTIVE_RELEASE_REQS=""
41
+ if [ -d compliance/pending-releases ]; then
42
+ ACTIVE_RELEASE_REQS=$(find compliance/pending-releases -maxdepth 1 -type f \
43
+ -name 'RELEASE-TICKET-REQ-*.md' -print 2>/dev/null \
44
+ | sed -E 's|.*/RELEASE-TICKET-(REQ-[0-9]+)\.md$|\1|' | sort -u || true)
45
+ fi
46
+ if [ -z "$ACTIVE_RELEASE_REQS" ] && [ -f compliance/RTM.md ]; then
47
+ ACTIVE_RELEASE_REQS=$(sed 's/\\|/ /g' compliance/RTM.md 2>/dev/null \
48
+ | grep -E '\|[[:space:]]+IN PROGRESS|\|[[:space:]]+TESTED - PENDING SIGN-OFF' \
49
+ | grep -oE '^\|[[:space:]]*REQ-[0-9]+' \
50
+ | grep -oE 'REQ-[0-9]+' | sort -u || true)
51
+ fi
52
+ ACTIVE_RELEASE_REQ_COUNT=0
53
+ if [ -n "$ACTIVE_RELEASE_REQS" ]; then
54
+ ACTIVE_RELEASE_REQ_COUNT=$(printf '%s\n' "$ACTIVE_RELEASE_REQS" | grep -c . || true)
55
+ fi
56
+
40
57
  while IFS= read -r sha; do
41
58
  TOTAL=$((TOTAL + 1))
42
59
  SUBJECT=$(git log -1 --format='%s' "$sha")
@@ -66,13 +83,21 @@ while IFS= read -r sha; do
66
83
  feat|fix|refactor|perf)
67
84
  if ! echo "$SUBJECT" | grep -qP '\[REQ-\d{3,}\]' \
68
85
  && ! echo "$BODY" | grep -qiP 'Ref:\s*REQ-\d{3,}'; then
69
- echo "ERROR [$SHORT]: '$TYPE' is an implementation commit but cites no requirement."
70
- echo " Add [REQ-XXX] to the subject or a 'Ref: REQ-XXX' trailer. Start work"
71
- echo " from a requirement via the sdlc-implementer skill (it assigns the REQ"
72
- echo " from the originating issue in Phase 1)."
73
- FAILED=$((FAILED + 1))
74
- EXIT_CODE=1
75
- continue
86
+ if [ "$ACTIVE_RELEASE_REQ_COUNT" -eq 1 ]; then
87
+ ACTIVE_REQ=$(printf '%s\n' "$ACTIVE_RELEASE_REQS" | head -1)
88
+ echo "WARNING [$SHORT]: '$TYPE' cites no requirement, but the branch has one active tracked release (${ACTIVE_REQ})."
89
+ echo " Treating the release ticket / RTM context as authoritative for this already-merged history."
90
+ echo " Add a follow-up compliance note if this was not an intentional recovery path."
91
+ WARN_COUNT=$((WARN_COUNT + 1))
92
+ else
93
+ echo "ERROR [$SHORT]: '$TYPE' is an implementation commit but cites no requirement."
94
+ echo " Add [REQ-XXX] to the subject or a 'Ref: REQ-XXX' trailer. Start work"
95
+ echo " from a requirement via the sdlc-implementer skill (it assigns the REQ"
96
+ echo " from the originating issue in Phase 1)."
97
+ FAILED=$((FAILED + 1))
98
+ EXIT_CODE=1
99
+ continue
100
+ fi
76
101
  fi
77
102
 
78
103
  # RTM provenance check (devaudit-installer#226): verify the REQ-XXX
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env bash
2
+ # validate-commits.test.sh — Fixture-based tests for validate-commits.sh.
3
+ #
4
+ # Covers the relaxed "exactly one active tracked release" behavior:
5
+ # implementation commits without REQ tags downgrade to warnings only when
6
+ # release context is unambiguous; otherwise they remain hard errors.
7
+ #
8
+ # Usage:
9
+ # ./scripts/validate-commits.test.sh
10
+
11
+ set -euo pipefail
12
+
13
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
14
+ VALIDATOR="$SCRIPT_DIR/validate-commits.sh"
15
+ [ -x "$VALIDATOR" ] || chmod +x "$VALIDATOR"
16
+
17
+ PASS=0
18
+ FAIL=0
19
+
20
+ make_fixture() {
21
+ local dir="$1" subject="$2" body="${3:-}"
22
+ rm -rf "$dir"
23
+ mkdir -p "$dir"
24
+ cd "$dir"
25
+ git init -q --initial-branch=main
26
+ git config user.email "test@example.com"
27
+ git config user.name "test"
28
+ echo "base" > base.txt
29
+ git add base.txt
30
+ git commit -q -m "chore: base"
31
+
32
+ git checkout -q -b feature
33
+ echo "feature" > app.ts
34
+ git add app.ts
35
+ if [ -n "$body" ]; then
36
+ git commit -q -m "$subject" -m "$body"
37
+ else
38
+ git commit -q -m "$subject"
39
+ fi
40
+ }
41
+
42
+ run_validator() {
43
+ set +e
44
+ OUT_FILE=$(mktemp)
45
+ bash "$VALIDATOR" main > "$OUT_FILE" 2>&1
46
+ LAST_EXIT=$?
47
+ set -e
48
+ }
49
+
50
+ assert_exit() {
51
+ local desc="$1" want="$2"
52
+ if [ "$LAST_EXIT" = "$want" ]; then
53
+ echo " PASS: $desc (exit=$LAST_EXIT)"
54
+ PASS=$((PASS + 1))
55
+ else
56
+ echo " FAIL: $desc (want=$want, got=$LAST_EXIT)"
57
+ sed 's/^/ /' "$OUT_FILE"
58
+ FAIL=$((FAIL + 1))
59
+ fi
60
+ }
61
+
62
+ assert_grep() {
63
+ local desc="$1" pattern="$2" want_match="$3"
64
+ local found=0
65
+ if grep -qE "$pattern" "$OUT_FILE"; then
66
+ found=1
67
+ fi
68
+ if [ "$found" = "$want_match" ]; then
69
+ echo " PASS: $desc"
70
+ PASS=$((PASS + 1))
71
+ else
72
+ echo " FAIL: $desc (want match=$want_match, got=$found, pattern=$pattern)"
73
+ sed 's/^/ /' "$OUT_FILE"
74
+ FAIL=$((FAIL + 1))
75
+ fi
76
+ }
77
+
78
+ WORKDIR=$(mktemp -d -t validate-commits-test-XXXX)
79
+ trap 'rm -rf "$WORKDIR"' EXIT
80
+
81
+ echo "=== validate-commits.sh tests ==="
82
+
83
+ # Case 1: one active pending ticket makes a missing-REQ implementation
84
+ # commit a warning, not a hard failure.
85
+ echo "Case 1: single active pending release downgrades missing REQ to warning"
86
+ make_fixture "$WORKDIR/case1" "feat: implement kitchen workflow" "Co-Authored-By: Test <test@example.com>"
87
+ mkdir -p compliance/pending-releases
88
+ cat > compliance/pending-releases/RELEASE-TICKET-REQ-123.md <<'EOF'
89
+ # Release Ticket: REQ-123
90
+ EOF
91
+ git add compliance/pending-releases
92
+ git commit -q --amend --no-edit
93
+ run_validator
94
+ assert_exit "single active pending release exits 0" 0
95
+ assert_grep "warning emitted for missing REQ with one active release" 'WARNING .*one active tracked release \(REQ-123\)' 1
96
+ assert_grep "no hard error for missing requirement" "ERROR .*implementation commit but cites no requirement" 0
97
+
98
+ # Case 2: ambiguous pending release context keeps the missing-REQ path as
99
+ # a hard error.
100
+ echo "Case 2: two active pending releases keep missing REQ as hard error"
101
+ make_fixture "$WORKDIR/case2" "feat: implement kitchen workflow" "Co-Authored-By: Test <test@example.com>"
102
+ mkdir -p compliance/pending-releases
103
+ cat > compliance/pending-releases/RELEASE-TICKET-REQ-123.md <<'EOF'
104
+ # Release Ticket: REQ-123
105
+ EOF
106
+ cat > compliance/pending-releases/RELEASE-TICKET-REQ-124.md <<'EOF'
107
+ # Release Ticket: REQ-124
108
+ EOF
109
+ git add compliance/pending-releases
110
+ git commit -q --amend --no-edit
111
+ run_validator
112
+ assert_exit "ambiguous pending releases exits 1" 1
113
+ assert_grep "hard error emitted when active context is ambiguous" "ERROR .*implementation commit but cites no requirement" 1
114
+
115
+ # Case 3: no pending ticket, but exactly one active RTM row in the
116
+ # accepted statuses also downgrades to a warning.
117
+ echo "Case 3: RTM fallback with one active row downgrades missing REQ to warning"
118
+ make_fixture "$WORKDIR/case3" "fix: recover already-merged history" "Co-Authored-By: Test <test@example.com>"
119
+ mkdir -p compliance
120
+ cat > compliance/RTM.md <<'EOF'
121
+ # RTM
122
+ | REQ-ID | Title | Status |
123
+ | ------- | ----- | ------------------------- |
124
+ | REQ-200 | Done | APPROVED - DEPLOYED |
125
+ | REQ-201 | Live | TESTED - PENDING SIGN-OFF |
126
+ EOF
127
+ git add compliance/RTM.md
128
+ git commit -q --amend --no-edit
129
+ run_validator
130
+ assert_exit "single active RTM row exits 0" 0
131
+ assert_grep "warning emitted for RTM fallback context" 'WARNING .*one active tracked release \(REQ-201\)' 1
132
+ assert_grep "no hard error under single RTM fallback context" "ERROR .*implementation commit but cites no requirement" 0
133
+
134
+ # Case 4: zero active context remains a hard error.
135
+ echo "Case 4: no active release context keeps missing REQ as hard error"
136
+ make_fixture "$WORKDIR/case4" "perf: tune kitchen workflow" "Co-Authored-By: Test <test@example.com>"
137
+ run_validator
138
+ assert_exit "no active context exits 1" 1
139
+ assert_grep "hard error emitted without any active release context" "ERROR .*implementation commit but cites no requirement" 1
140
+
141
+ echo
142
+ echo "Result: $PASS passed, $FAIL failed"
143
+ [ "$FAIL" = "0" ]
@@ -553,7 +553,17 @@ Reached only on the **tracked** route from Phase 0 (the issue is already fetched
553
553
 
554
554
  ### Phase 4 — Submit for UAT review (SDLC stage 4)
555
555
 
556
- 1. **Open the release PR** — the PR that carries the UAT four-eyes approval gate (`check-release-approval.yml`), always into `$RELEASE_BRANCH`: `gh pr create --base "$RELEASE_BRANCH" --head "$INTEGRATION_BRANCH"` (e.g. `develop → main`). The implementation already landed on `$INTEGRATION_BRANCH` in Phase 2; this promotes it. (Note: if other work is also waiting on `$INTEGRATION_BRANCH`, this is a bundled release — every in-scope REQ keeps its own release record and Production approval.)
556
+ 1. **Prepare the truthful release PR** — the PR that carries the UAT four-eyes approval gate (`check-release-approval.yml`), always into `$RELEASE_BRANCH` from `$INTEGRATION_BRANCH` (e.g. `develop → main`). The implementation already landed on `$INTEGRATION_BRANCH` in Phase 2; this promotes it. (Note: if other work is also waiting on `$INTEGRATION_BRANCH`, this is a bundled release — every in-scope REQ keeps its own release record and Production approval.)
557
+
558
+ Before creating a PR manually, run the synced helper:
559
+
560
+ ```bash
561
+ ./scripts/prepare-release-pr.sh --apply --mode=update
562
+ ```
563
+
564
+ This is mandatory for devaudit-installer#312. It detects an existing open `$INTEGRATION_BRANCH → $RELEASE_BRANCH` PR, derives the current governing release from the current head, and ensures there is exactly one truthful release PR. If the existing PR is stale, the default `update` mode rewrites the title/body and comments with the refresh. Use `--mode=recreate` only when the operator explicitly wants the older PR closed and a new PR opened.
565
+
566
+ If the helper reports multiple open release PRs, halt with: "Multiple open release PRs for `$INTEGRATION_BRANCH → $RELEASE_BRANCH`; operator action — close duplicates or choose the authoritative PR, then resume REQ-XXX." Do not open another PR.
557
567
 
558
568
  PR body per the SDLC PR template (see [`.github/pull_request_template.md`](../../../../../.github/pull_request_template.md)):
559
569
  - Closes #N
@@ -571,8 +581,30 @@ Reached only on the **tracked** route from Phase 0 (the issue is already fetched
571
581
 
572
582
  3. **Apply labels** — `awaiting-uat-review`, `risk:<class>`.
573
583
  4. **Comment on the issue**: "Implementation complete. PR #M opened. Evidence on portal: <link>. UAT review requested. Resume with `resume REQ-XXX` once UAT approval is granted on the portal."
574
- 5. **Hard stop.** Phase 4 ends here. Do not proceed to merge; the human's next action is reviewing on the portal.
575
- 6. **Update SDLC status sticky** before halting: `bash scripts/update-sdlc-status.sh "$ISSUE_NUM" "Phase 4 release PR #<N> opened against $RELEASE_BRANCH; CI running" "Operator action — review PR #<N> + approve UAT release on the portal; sdlc-implementer halts until you ping resume REQ-XXX"`. This is a critical handoff — the sticky must reflect that the agent has stopped + the operator is on the hook.
584
+ 5. **Inspect PR blockers before handoff (devaudit-installer#304).** Run `gh pr view <N> --json mergeStateStatus,reviewDecision,statusCheckRollup,url` and categorize the state:
585
+ - `reviewDecision` not approved human approval blocker; hand off to the reviewer.
586
+ - Release Approval Gate failing while portal state is not `uat_approved` → portal approval blocker; hand off to portal UAT reviewer.
587
+ - Release Approval Gate failing while portal state is `uat_approved` → retry path below.
588
+ - Compliance validation / evidence completeness failing → real workflow or evidence defect; fix or create a follow-up PR before handoff.
589
+ - Checks pending → wait/poll until terminal before declaring the PR ready.
590
+ - Stale PR context detected by `prepare-release-pr.sh` → rerun the helper in `--apply` mode and repeat this blocker inspection.
591
+
592
+ **Executable loop (devaudit-installer#304).** Use the bundled watcher rather than a one-shot `gh` read when the PR is blocked or waiting:
593
+
594
+ ```bash
595
+ node SDLC/bin/devaudit-sdlc.js \
596
+ --watch-pr=<N> \
597
+ --repo <owner/name> \
598
+ --req XXX \
599
+ --release REQ-XXX \
600
+ --project-slug <project-slug> \
601
+ --base-url <devaudit-base-url>
602
+ ```
603
+
604
+ The watcher persists retry state in `.sdlc-pr-watch.json`, polls `gh pr view` + `gh pr checks`, re-runs likely flaky workflows automatically, and re-runs the Release Approval Gate when the portal is already approved but GitHub has not converged yet. Use `--once` when you only need a single classification pass.
605
+
606
+ 6. **Hard stop only after blocker classification.** Phase 4 ends here. Do not proceed to merge; the human's next action is reviewing on the portal or clearing the categorized blocker above.
607
+ 7. **Update SDLC status sticky** before halting: `bash scripts/update-sdlc-status.sh "$ISSUE_NUM" "Phase 4 — truthful release PR #<N> prepared against $RELEASE_BRANCH; blockers classified" "Operator action — review PR #<N> + approve UAT release on the portal; sdlc-implementer halts until you ping resume REQ-XXX"`. This is a critical handoff — the sticky must reflect that the agent has stopped + the operator is on the hook.
576
608
 
577
609
  **When an external gate hangs or fails for unrelated reasons.** A required gate may fail for reasons outside the change's scope — flaky infra, an unrelated regression test that hangs at hour-plus runtime with no log activity, a known-failing suite. When this happens:
578
610
 
@@ -685,6 +717,10 @@ The native agent must NOT continue to the next phase (Phase 3 → Phase 4, Phase
685
717
 
686
718
  **PR merged to main ≠ done.** After the release PR is merged to `main`, the native agent must re-invoke the skill for Phase 5 close-out (see Phase 5 step 0 below). The merge is not the end of the workflow — post-merge compliance steps are mandatory.
687
719
 
720
+ ### Commit-history hygiene recovery (devaudit-installer#315)
721
+
722
+ Commit-level `[REQ-XXX]` hygiene is preventive, not the strongest source of release truth after history has already merged to a protected integration branch. If CI reports a missing commit-level REQ after the branch has exactly one active tracked release ticket or one matching RTM row, treat the release ticket / RTM context as authoritative and add a follow-up compliance note instead of rewriting protected history. The generated `validate-commits.sh` implements this as a warning in the exactly-one-active-release case. If there are zero or multiple active tracked releases, the validation remains a hard error because the release context is ambiguous.
723
+
688
724
  ### Commit-scoping rule for SRS updates (devaudit-installer#226)
689
725
 
690
726
  When a commit is authored as part of REQ-XXX's SDLC flow, the commit subject must cite `[REQ-XXX]` — the **active REQ** driving the current SDLC flow — regardless of which other REQs the content touches. Other REQs may appear in the body for traceability, but never in the subject.
@@ -783,13 +783,13 @@ jobs:
783
783
  # "zero traceable evidence" even though well-written, REQ-tagged
784
784
  # specs exist on disk. If specs are found but weren't run in
785
785
  # this tier, emit a non-blocking warning instead of an error.
786
- SPECS_ON_DISK=$(grep -rl --include='*.spec.ts' -e "@requirement ${REQ}" -e "tagTest.*${REQ}" -e "'${REQ}'" e2e/ 2>/dev/null | wc -l)
786
+ SPECS_ON_DISK=$( { grep -rl --include='*.spec.ts' -e "@requirement ${REQ}" -e "tagTest.*${REQ}" -e "'${REQ}'" e2e/ 2>/dev/null || true; } | wc -l | tr -d ' ')
787
787
 
788
788
  # devaudit-installer#237 — also scan unit test files for
789
789
  # @requirement REQ-XXX annotations. A REQ with thorough unit
790
790
  # test coverage but no E2E specs (legitimate for API-only or
791
791
  # backend-only changes) should not be hard-blocked.
792
- UNIT_TESTS_ON_DISK=$(grep -rl --include='*.test.ts' --include='*.test.tsx' -e "@requirement ${REQ}" -e "'${REQ}'" e2e/ __tests__/ tests/ src/ 2>/dev/null | wc -l)
792
+ UNIT_TESTS_ON_DISK=$( { grep -rl --include='*.test.ts' --include='*.test.tsx' -e "@requirement ${REQ}" -e "'${REQ}'" e2e/ __tests__/ tests/ src/ 2>/dev/null || true; } | wc -l | tr -d ' ')
793
793
 
794
794
  # devaudit-installer#237 — check for test-execution-summary.md
795
795
  # as a secondary signal. This is a human-authored document and
@@ -638,6 +638,7 @@ jobs:
638
638
  permissions:
639
639
  contents: read
640
640
  actions: read
641
+ issues: write
641
642
  env:
642
643
  DEVAUDIT_BASE_URL_VAR: ${{ vars.DEVAUDIT_BASE_URL }}
643
644
  DEVAUDIT_API_KEY: ${{ secrets.DEVAUDIT_API_KEY }}
@@ -682,7 +683,7 @@ jobs:
682
683
  name: e2e-regression-report
683
684
  path: e2e-artifacts/
684
685
  run-id: ${{ github.event.workflow_run.id }}
685
- github-token: ${{ secrets.GITHUB_TOKEN }}
686
+ github-token: ${{ github.token }}
686
687
  continue-on-error: true
687
688
 
688
689
  - name: Derive release version
@@ -726,8 +727,9 @@ jobs:
726
727
  FLAGS="${FLAGS} --test-cycle ${{ github.event.workflow_run.id }}"
727
728
 
728
729
  # In-scope REQs from pending release tickets at the triggering
729
- # SHA. Fall back to `_compliance-docs` if no tickets present so
730
- # the artefact at least lands somewhere visible.
730
+ # SHA. If the ticket was created after the triggering E2E run,
731
+ # recover REQs from the Playwright JSON artifact before falling
732
+ # back to `_compliance-docs` (DevAudit-Installer#311).
731
733
  REQS=()
732
734
  if [ -d compliance/pending-releases ]; then
733
735
  for TICKET in compliance/pending-releases/RELEASE-TICKET-REQ-*.md; do
@@ -742,6 +744,47 @@ jobs:
742
744
  REQS+=("$REQ_ID")
743
745
  done
744
746
  fi
747
+
748
+ if [ "${#REQS[@]}" -eq 0 ] && [ -f e2e-artifacts/e2e-regression-results.json ]; then
749
+ while IFS= read -r REQ_ID; do
750
+ [ -n "$REQ_ID" ] || continue
751
+ REQS+=("$REQ_ID")
752
+ done < <(python3 - <<'PY'
753
+ import json
754
+ import re
755
+ import sys
756
+
757
+ try:
758
+ with open('e2e-artifacts/e2e-regression-results.json', 'r', encoding='utf-8') as handle:
759
+ data = json.load(handle)
760
+ except Exception:
761
+ sys.exit(0)
762
+
763
+ seen = set()
764
+
765
+ def walk(value):
766
+ if isinstance(value, str):
767
+ for req in re.findall(r'REQ-[0-9]+', value):
768
+ seen.add(req)
769
+ elif isinstance(value, dict):
770
+ for item in value.values():
771
+ walk(item)
772
+ elif isinstance(value, list):
773
+ for item in value:
774
+ walk(item)
775
+
776
+ walk(data)
777
+ for req in sorted(seen):
778
+ print(req)
779
+ PY
780
+ )
781
+ fi
782
+
783
+ if [ "${#REQS[@]}" -eq 1 ] && echo "$DERIVED_RELEASE" | grep -qE '^v[0-9]{4}\.[0-9]{2}\.[0-9]{2}([.][0-9]+)?$'; then
784
+ echo "E2E upload recovered tracked release ${REQS[0]} from artifact metadata; overriding bare-date release ${DERIVED_RELEASE}."
785
+ DERIVED_RELEASE="${REQS[0]}"
786
+ fi
787
+
745
788
  if [ "${#REQS[@]}" -eq 0 ]; then
746
789
  REQS=(_compliance-docs)
747
790
  fi
@@ -812,7 +855,9 @@ jobs:
812
855
  - name: Triage and file incidents on E2E Regression failure
813
856
  if: steps.resolve.outputs.skip != 'true' && github.event.workflow_run.conclusion == 'failure'
814
857
  env:
815
- GH_TOKEN: ${{ secrets.DEVAUDIT_USER_TOKEN || github.token }}
858
+ # GitHub issue/label mutations use the workflow token. DevAudit
859
+ # user tokens are portal PATs, not guaranteed GitHub PATs (#305).
860
+ GH_TOKEN: ${{ github.token }}
816
861
  CI_RUN_ID: ${{ github.event.workflow_run.id }}
817
862
  WORKFLOW_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }}
818
863
  GIT_SHA: ${{ github.event.workflow_run.head_sha }}
@@ -49,7 +49,9 @@ jobs:
49
49
  - uses: actions/checkout@v6
50
50
  with:
51
51
  fetch-depth: 0
52
- token: ${{ secrets.DEVAUDIT_USER_TOKEN || github.token }}
52
+ # GitHub repo mutations use the workflow token. The DevAudit
53
+ # user token is for portal attribution, not GitHub checkout/push.
54
+ token: ${{ github.token }}
53
55
 
54
56
  - name: Classify routing (Path A vs Path B)
55
57
  id: route
@@ -436,7 +438,8 @@ jobs:
436
438
  - name: Direct commit to develop (Path A)
437
439
  if: steps.route.outputs.path == 'A'
438
440
  env:
439
- GH_TOKEN: ${{ secrets.DEVAUDIT_USER_TOKEN || github.token }}
441
+ # GitHub push uses the workflow token plus job permissions.
442
+ GH_TOKEN: ${{ github.token }}
440
443
  OUT: ${{ steps.paths.outputs.out }}
441
444
  ISSUE_NUMBER: ${{ github.event.issue.number }}
442
445
  ISSUE_URL: ${{ github.event.issue.html_url }}
@@ -454,7 +457,8 @@ jobs:
454
457
  - name: Open export PR (Path B)
455
458
  if: steps.route.outputs.path == 'B'
456
459
  env:
457
- GH_TOKEN: ${{ secrets.DEVAUDIT_USER_TOKEN || github.token }}
460
+ # GitHub branch push + PR creation use the workflow token.
461
+ GH_TOKEN: ${{ github.token }}
458
462
  BRANCH: ${{ steps.paths.outputs.branch }}
459
463
  OUT: ${{ steps.paths.outputs.out }}
460
464
  ISSUE_NUMBER: ${{ github.event.issue.number }}
@@ -56,7 +56,9 @@ jobs:
56
56
  - name: Enforce label retention
57
57
  if: steps.opt.outputs.enabled == 'true'
58
58
  env:
59
- GH_TOKEN: ${{ secrets.DEVAUDIT_USER_TOKEN || github.token }}
59
+ # GitHub issue/comment mutations use the workflow token. The
60
+ # DevAudit user token is reserved for portal attribution flows.
61
+ GH_TOKEN: ${{ github.token }}
60
62
  EVENT_NAME: ${{ github.event_name }}
61
63
  EVENT_ACTION: ${{ github.event.action }}
62
64
  ISSUE_NUMBER: ${{ github.event.issue.number }}
@@ -37,8 +37,9 @@ jobs:
37
37
  - uses: actions/checkout@v6
38
38
  with:
39
39
  fetch-depth: 0
40
- # Need write access for the chore branch.
41
- token: ${{ secrets.DEVAUDIT_USER_TOKEN || github.token }}
40
+ # Need write access for the chore branch. GitHub repo mutations
41
+ # use the workflow token; the DevAudit user token is portal auth.
42
+ token: ${{ github.token }}
42
43
 
43
44
  - name: Compute review period
44
45
  id: period
@@ -188,7 +189,8 @@ jobs:
188
189
 
189
190
  - name: Open / update review PR
190
191
  env:
191
- GH_TOKEN: ${{ secrets.DEVAUDIT_USER_TOKEN || github.token }}
192
+ # GitHub branch push + PR creation use the workflow token.
193
+ GH_TOKEN: ${{ github.token }}
192
194
  BRANCH: ${{ steps.period.outputs.branch }}
193
195
  REVIEW_ID: ${{ steps.period.outputs.review_id }}
194
196
  run: |