@metasession.co/devaudit-cli 0.3.7 → 0.3.8

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.
@@ -179,6 +179,12 @@ make_fixture "$D6" "REQ-006" << 'EOF'
179
179
  | ---------------- | ------------ | ------------------------------------------ |
180
180
  | E2E Tests | SKIPPED | API-only change, no UI surface (operator-approved) |
181
181
 
182
+ ## Accepted skips
183
+
184
+ | Spec | Test | REQ/AC | Classification | Resolution attempted | Approved by | Rationale |
185
+ |------|------|--------|---------------|---------------------|-------------|----------|
186
+ | e2e/foo.spec.ts | "AC1: bar dialog" | REQ-006/AC1 | intentional_non_applicability | N/A — API-only change | operator | API-only change, no UI surface |
187
+
182
188
  **Final assessment:** All gates passed. E2E skipped — API-only change.
183
189
  EOF
184
190
  EXIT6=$(run_validator "$D6")
@@ -259,6 +265,87 @@ EOF
259
265
  EXIT11=$(run_validator "$D11")
260
266
  assert_exit "summary with NOT_NEEDED but no rationale — should fail" 1 "$EXIT11"
261
267
 
268
+ # --- Case 12: SKIPPED with ## Accepted skips section and valid rows — should pass (devaudit-installer#279) ---
269
+ D12="$WORK/case12"
270
+ make_fixture "$D12" "REQ-012" << 'EOF'
271
+ # Test Execution Summary — REQ-012
272
+
273
+ ## Gate Results
274
+
275
+ | Gate | Result | Details |
276
+ | ---------------- | ------------ | ------------------------------------------ |
277
+ | E2E Tests | SKIPPED | API-only change, no UI surface (operator-approved) |
278
+
279
+ ## Accepted skips
280
+
281
+ | Spec | Test | REQ/AC | Classification | Resolution attempted | Approved by | Rationale |
282
+ |------|------|--------|---------------|---------------------|-------------|----------|
283
+ | e2e/foo.spec.ts | "AC3: bar dialog" | REQ-012/AC3 | intentional_non_applicability | N/A — API-only change | operator | API-only change, no UI surface |
284
+
285
+ **Final assessment:** All gates passed. E2E skipped — API-only change.
286
+ EOF
287
+ EXIT12=$(run_validator "$D12")
288
+ assert_exit "SKIPPED with ## Accepted skips section and valid rows — should pass" 0 "$EXIT12"
289
+
290
+ # --- Case 13: SKIPPED without ## Accepted skips section — should fail (devaudit-installer#279) ---
291
+ D13="$WORK/case13"
292
+ make_fixture "$D13" "REQ-013" << 'EOF'
293
+ # Test Execution Summary — REQ-013
294
+
295
+ ## Gate Results
296
+
297
+ | Gate | Result | Details |
298
+ | ---------------- | ------------ | ------------------------------------------ |
299
+ | E2E Tests | SKIPPED | API-only change, no UI surface (operator-approved) |
300
+
301
+ **Final assessment:** All gates passed. E2E skipped — API-only change.
302
+ EOF
303
+ EXIT13=$(run_validator "$D13")
304
+ assert_exit "SKIPPED without ## Accepted skips section — should fail" 1 "$EXIT13"
305
+
306
+ # --- Case 14: SKIPPED with ## Accepted skips but no table rows — should fail (devaudit-installer#279) ---
307
+ D14="$WORK/case14"
308
+ make_fixture "$D14" "REQ-014" << 'EOF'
309
+ # Test Execution Summary — REQ-014
310
+
311
+ ## Gate Results
312
+
313
+ | Gate | Result | Details |
314
+ | ---------------- | ------------ | ------------------------------------------ |
315
+ | E2E Tests | SKIPPED | API-only change, no UI surface (operator-approved) |
316
+
317
+ ## Accepted skips
318
+
319
+ | Spec | Test | REQ/AC | Classification | Resolution attempted | Approved by | Rationale |
320
+ |------|------|--------|---------------|---------------------|-------------|----------|
321
+
322
+ **Final assessment:** All gates passed. E2E skipped — API-only change.
323
+ EOF
324
+ EXIT14=$(run_validator "$D14")
325
+ assert_exit "SKIPPED with ## Accepted skips but no table rows — should fail" 1 "$EXIT14"
326
+
327
+ # --- Case 15: SKIPPED with accepted-skip rows missing Approved by — should fail (devaudit-installer#279) ---
328
+ D15="$WORK/case15"
329
+ make_fixture "$D15" "REQ-015" << 'EOF'
330
+ # Test Execution Summary — REQ-015
331
+
332
+ ## Gate Results
333
+
334
+ | Gate | Result | Details |
335
+ | ---------------- | ------------ | ------------------------------------------ |
336
+ | E2E Tests | SKIPPED | API-only change, no UI surface (operator-approved) |
337
+
338
+ ## Accepted skips
339
+
340
+ | Spec | Test | REQ/AC | Classification | Resolution attempted | Approved by | Rationale |
341
+ |------|------|--------|---------------|---------------------|-------------|----------|
342
+ | e2e/foo.spec.ts | "AC3: bar dialog" | REQ-015/AC3 | intentional_non_applicability | N/A — API-only change | | API-only change, no UI surface |
343
+
344
+ **Final assessment:** All gates passed. E2E skipped — API-only change.
345
+ EOF
346
+ EXIT15=$(run_validator "$D15")
347
+ assert_exit "SKIPPED with accepted-skip rows missing Approved by — should fail" 1 "$EXIT15"
348
+
262
349
  echo ""
263
350
  echo "=== Summary: $PASS pass / $FAIL fail ==="
264
351
 
@@ -300,6 +300,21 @@ Then bucket each failure:
300
300
 
301
301
  The classification is: "Is the AC correct but untested/buggy? → defect. Is the AC itself wrong/missing? → requirements gap."
302
302
 
303
+ **Then triage skipped tests (devaudit-installer#279).** After failure triage and missed-requirements check, inspect all skipped tests from the machine-readable Playwright reporter output (JSON reporter). For each skipped test:
304
+
305
+ 1. **Extract the best available reason** from: `test.skipReason`, annotations, `test.skip(...)` / `testInfo.skip(...)` descriptions, setup-project failures, or helper/guard messages. Do not hard-code to one field — use the richest available source.
306
+ 2. **Classify each skip into one of:**
307
+ - `environment_gap` — browser not installed, database down, port conflict, missing system dependency
308
+ - `precondition_gap` — seed data missing, auth fixture expired, test account locked
309
+ - `intentional_non_applicability` — API-only change with no UI surface, feature flag disabled for this environment
310
+ - `obsolete_or_quarantined` — test references removed functionality, spec is stale
311
+ 3. **Fix `environment_gap` and `precondition_gap` first** — these are never acceptable skip reasons. Resolve the underlying issue, rerun the focused tests, then rerun the full suite.
312
+ 4. **Only remaining `intentional_non_applicability` or unresolved skips** may be surfaced to the operator for approval. Each accepted skip must have an operator-approved rationale.
313
+ 5. **A skipped test never proves an acceptance criterion.** If a skipped test is the only coverage for an in-scope AC, that AC is unverified until a passing test proves it.
314
+ 6. **Do not write a `PASSED` E2E sentinel while unresolved or unapproved skips remain.**
315
+
316
+ Record all accepted skips in the `## Accepted skips` section of `test-execution-summary.md` (see Stage 3 blueprint).
317
+
303
318
  ### Phase 7 — Regression-pack handoff
304
319
 
305
320
  After Phase 6 succeeds — green run, all ACs proved, defects filed for anything missing — the new spec(s) you authored move into the project's regression pack. There is **no separate graduation step**. The pack is defined as:
@@ -422,6 +437,7 @@ Wrap up with a summary the user can drop into the PR or ticket:
422
437
  - Tests added — count, with a list.
423
438
  - Tests updated — count.
424
439
  - Tests deleted — count, with rationale.
440
+ - Tests skipped — count, classification, resolution status, and operator-approved rationale for any accepted skip (devaudit-installer#279).
425
441
  - Suite result — passing, failing, flaky.
426
442
  - Defects filed — count, with links.
427
443
  - Requirements gap reports — count, with details (devaudit-installer#212 Gap 4). These are ACs classified as "impossible to test" or "missing AC" — returned to `sdlc-implementer` for the requirements gap flow, not filed as defects.
@@ -209,7 +209,7 @@ NEXT: Done — close issue + retire feature branch (sdlc-implementer halts)
209
209
  Lead every substantive turn with a driver tag on the **first line**, then the two-line LAST/NEXT shape so the operator can `Ctrl-F NEXT:` in the chat transcript to find the current pointer without re-reading:
210
210
 
211
211
  ```
212
- [Agent driving] — or — [Operator driving] — or — [Blocked]
212
+ [Skill driving] — or — [Native agent driving] — or — [Operator driving] — or — [Blocked]
213
213
 
214
214
  **LAST:** <one sentence>
215
215
  **NEXT:** <one sentence with actor>
@@ -217,9 +217,10 @@ Lead every substantive turn with a driver tag on the **first line**, then the tw
217
217
 
218
218
  The driver tag is mandatory and comes **before** the LAST/NEXT lines:
219
219
 
220
- - **`[Agent driving]`** — the skill is auto-continuing; no human action needed right now. The operator can look away.
221
- - **`[Operator driving]`** — the skill has halted; the human must do something (review, approve, merge, answer a question). The NEXT line states the specific action needed.
222
- - **`[Blocked]`** — something failed and the skill cannot proceed. State the blocker and the operator action needed to unblock.
220
+ - **`[Skill driving]`** — a skill (e.g. `sdlc-implementer`) is auto-continuing; no human action needed right now. The operator can look away.
221
+ - **`[Native agent driving]`** — the native AI agent (Cursor, Windsurf, Claude Code, Gemini CLI, etc.) is executing steps without a skill invocation. The operator can look away but should monitor more closely since no skill protocol is governing phase transitions.
222
+ - **`[Operator driving]`** — the skill/agent has halted; the human must do something (review, approve, merge, answer a question). The NEXT line states the specific action needed.
223
+ - **`[Blocked]`** — something failed and the skill/agent cannot proceed. State the blocker and the operator action needed to unblock.
223
224
 
224
225
  The tag reflects the **final** state of the response — if the skill was driving but hits a halt mid-turn, the tag is `[Operator driving]` or `[Blocked]`.
225
226
 
@@ -587,6 +588,8 @@ Reached only on the **tracked** route from Phase 0 (the issue is already fetched
587
588
 
588
589
  **Release Approval Gate retry (devaudit-installer#211 Gap 17).** If the Release Approval Gate check fails on the PR and the portal approval was already given (API sync delay, stale cache), retry logic: re-run the Release Approval Gate workflow up to 3 times with 30-second intervals (`gh workflow run check-release-approval.yml` or trigger via `workflow_dispatch`). If it still fails after 3 retries: halt — "Release Approval Gate still failing after 3 retries. Portal may show approval but the gate can't verify it. Operator action — check the portal release status manually, verify the API key is valid, and re-run the workflow from GitHub Actions if needed."
589
590
 
591
+ **Auto-refresh on UAT approval (devaudit#562, devaudit-installer#283).** When the portal's UAT approval is granted, the portal automatically sends a `repository_dispatch('release-approved')` event to the consuming project's repo. This triggers `check-release-approval.yml` to re-run without manual intervention. The retry logic above is still needed for edge cases (API sync delays, network issues), but the common path is now fully automated. If the auto-refresh doesn't fire (e.g. portal GitHub trigger misconfigured), the manual retry sequence above remains the fallback.
592
+
590
593
  ### Phase 5 — Finalise or change-request loop (SDLC stage 5)
591
594
 
592
595
  Invoked separately by the user after UAT activity on the portal. Trigger: "resume REQ-XXX", "REQ-XXX UAT done", or just re-firing the skill on the same issue.
@@ -22,6 +22,14 @@ on:
22
22
  pull_request:
23
23
  branches: [main]
24
24
  workflow_dispatch:
25
+ repository_dispatch:
26
+ types: [release-approved]
27
+
28
+ permissions:
29
+ contents: read
30
+ checks: write
31
+ pull-requests: write
32
+ issues: write
25
33
 
26
34
  jobs:
27
35
  check-approval:
@@ -43,7 +51,11 @@ jobs:
43
51
  # produces the same release identity here (#81 — release-identity
44
52
  # coherence; was hardcoded to `v$(date +%Y.%m.%d)` which never
45
53
  # matched the `REQ-XXX` records the uploaders wrote).
46
- ref: ${{ github.event.pull_request.head.sha }}
54
+ #
55
+ # devaudit#283 — repository_dispatch events don't have a
56
+ # pull_request payload. Fall back to the develop branch ref so
57
+ # the gate can resolve the release and post a status check.
58
+ ref: ${{ github.event.pull_request.head.sha || 'develop' }}
47
59
  fetch-depth: 0
48
60
 
49
61
  - name: Resolve DevAudit base URL
@@ -149,13 +161,22 @@ jobs:
149
161
  id: release
150
162
  if: env.BOOTSTRAP_MODE != 'true'
151
163
  run: |
152
- # Derive the same release prefix the uploaders write to. The script
153
- # priority is: subject `[REQ-XXX]` body `Ref: REQ-XXX` → body
154
- # `[REQ-XXX]` (merge-commit case) bare-date fallback. Reusing it
155
- # here makes the gate and the uploaders agree on release identity
156
- # (#81). The 8 scenarios are covered by derive-release-version.test.sh.
157
- chmod +x scripts/derive-release-version.sh 2>/dev/null || true
158
- LOOKUP_PREFIX=$(bash scripts/derive-release-version.sh)
164
+ # devaudit#283 repository_dispatch('release-approved') events
165
+ # carry the release version in client_payload. Use it directly
166
+ # when available to avoid re-deriving from git history.
167
+ if [ "${{ github.event_name }}" = "repository_dispatch" ] && [ -n "${{ github.event.client_payload.release }}" ]; then
168
+ LOOKUP_PREFIX="${{ github.event.client_payload.release }}"
169
+ echo "Using release version from repository_dispatch payload: ${LOOKUP_PREFIX}"
170
+ else
171
+ # Derive the same release prefix the uploaders write to. The script
172
+ # priority is: subject `[REQ-XXX]` → body `Ref: REQ-XXX` → body
173
+ # `[REQ-XXX]` (merge-commit case) → bare-date fallback. Reusing it
174
+ # here makes the gate and the uploaders agree on release identity
175
+ # (#81). The 8 scenarios are covered by derive-release-version.test.sh.
176
+ chmod +x scripts/derive-release-version.sh 2>/dev/null || true
177
+ LOOKUP_PREFIX=$(bash scripts/derive-release-version.sh)
178
+ echo "Derived release prefix from git history: ${LOOKUP_PREFIX}"
179
+ fi
159
180
  echo "Resolving release for prefix: ${LOOKUP_PREFIX}"
160
181
  RESOLVE_URL="${BASE}/api/ci/releases/resolve?projectSlug=${PROJECT_SLUG}&versionPrefix=${LOOKUP_PREFIX}"
161
182
  # Retry: register-release in ci.yml may still be racing with us.
@@ -234,21 +255,59 @@ jobs:
234
255
  echo "::warning::Approved SHA (${APPROVED_SHA:0:8}) differs from PR HEAD (${PR_HEAD_SHA:0:8}). Code may have changed after approval."
235
256
  fi
236
257
 
237
- - name: Update PR status (workflow_dispatch re-trigger)
238
- if: env.BOOTSTRAP_MODE != 'true' && github.event_name == 'workflow_dispatch'
258
+ - name: Update PR check run (workflow_dispatch / repository_dispatch re-trigger)
259
+ if: env.BOOTSTRAP_MODE != 'true' && (github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch')
239
260
  env:
240
261
  GH_TOKEN: ${{ github.token }}
241
262
  run: |
242
- PR_DATA=$(gh pr list --base main --head develop --json headRefOid --limit 1 2>/dev/null || echo '[]')
243
- PR_SHA=$(echo "$PR_DATA" | jq -r '.[0].headRefOid // empty' 2>/dev/null || true)
244
- if [ -n "$PR_SHA" ]; then
245
- gh api "repos/${{ github.repository }}/statuses/${PR_SHA}" \
246
- -X POST \
247
- -f state=success \
248
- -f context="Release Approval Gate" \
249
- -f description="DevAudit release approval confirmed" \
250
- -f target_url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
251
- || echo "Warning: Failed to post commit status"
263
+ TARGET_SHA="${{ github.event.client_payload.approved_sha || '' }}"
264
+ if [ -z "$TARGET_SHA" ]; then
265
+ TARGET_SHA="${{ steps.release.outputs.approved_sha }}"
266
+ fi
267
+
268
+ RELEASE_PR="${{ github.event.client_payload.release_pr || '' }}"
269
+ if [ -z "$TARGET_SHA" ] && [ -n "$RELEASE_PR" ] && [ "$RELEASE_PR" != "null" ]; then
270
+ TARGET_SHA=$(gh pr view "$RELEASE_PR" --json headRefOid --jq '.headRefOid // empty' 2>/dev/null || true)
271
+ fi
272
+
273
+ if [ -z "$TARGET_SHA" ]; then
274
+ PR_DATA=$(gh pr list --base main --json number,headRefOid,updatedAt --limit 20 2>/dev/null || echo '[]')
275
+ TARGET_SHA=$(echo "$PR_DATA" | jq -r 'sort_by(.updatedAt) | reverse | .[0].headRefOid // empty' 2>/dev/null || true)
276
+ fi
277
+
278
+ if [ -z "$TARGET_SHA" ]; then
279
+ echo "::error::Unable to resolve PR head SHA for DevAudit Release Approval check refresh. Portal dispatch should include approved_sha."
280
+ exit 1
281
+ fi
282
+
283
+ CHECK_NAME="DevAudit Release Approval"
284
+ DETAILS_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
285
+ COMPLETED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
286
+ EXISTING_ID=$(gh api \
287
+ "/repos/${{ github.repository }}/commits/${TARGET_SHA}/check-runs?name=DevAudit%20Release%20Approval" \
288
+ --jq '.check_runs[] | select(.name == "DevAudit Release Approval") | .id' \
289
+ | head -1 || true)
290
+
291
+ if [ -n "$EXISTING_ID" ]; then
292
+ gh api "/repos/${{ github.repository }}/check-runs/${EXISTING_ID}" \
293
+ -X PATCH \
294
+ -f status=completed \
295
+ -f conclusion=success \
296
+ -f completed_at="$COMPLETED_AT" \
297
+ -f details_url="$DETAILS_URL" \
298
+ -f "output[title]=DevAudit release approval confirmed" \
299
+ -f "output[summary]=Release ${{ steps.release.outputs.version }} is approved in DevAudit."
300
+ echo "Updated ${CHECK_NAME} check run ${EXISTING_ID} on ${TARGET_SHA}."
252
301
  else
253
- echo "No open PR from develop to main found — skipping status update"
302
+ gh api "/repos/${{ github.repository }}/check-runs" \
303
+ -X POST \
304
+ -f name="$CHECK_NAME" \
305
+ -f head_sha="$TARGET_SHA" \
306
+ -f status=completed \
307
+ -f conclusion=success \
308
+ -f completed_at="$COMPLETED_AT" \
309
+ -f details_url="$DETAILS_URL" \
310
+ -f "output[title]=DevAudit release approval confirmed" \
311
+ -f "output[summary]=Release ${{ steps.release.outputs.version }} is approved in DevAudit."
312
+ echo "Created ${CHECK_NAME} check run on ${TARGET_SHA}."
254
313
  fi
@@ -13,6 +13,9 @@
13
13
  # `Quality Gates` status. Branch protection is satisfied without running the heavy gates
14
14
  # on a commit that has no code in it.
15
15
  #
16
+ # PRs to main are actively verified by quality-gates-provenance.yml, which checks
17
+ # for prior develop-side Quality Gates success on the same head SHA.
18
+ #
16
19
  # Mixed commits (some docs + some code) trigger BOTH workflows. Same status name, both
17
20
  # green = branch protection happy.
18
21
 
@@ -6,8 +6,8 @@
6
6
  # Single consolidated job — on a self-hosted runner, parallel jobs run
7
7
  # sequentially anyway. One checkout + one cached npm ci = fast.
8
8
  #
9
- # PRs to develop run Quality Gates before integration. PRs to main inherit
10
- # commit status via branch protection.
9
+ # PRs to develop run Quality Gates before integration. PRs to main are verified
10
+ # by quality-gates-provenance.yml (same-SHA provenance check, not heavy CI rerun).
11
11
  # Compliance validation runs separately on PRs (compliance-validation.yml).
12
12
 
13
13
  name: CI Pipeline
@@ -287,11 +287,16 @@ jobs:
287
287
  # feature converges on one release record. See DevAudit #310.
288
288
  chmod +x scripts/derive-release-version.sh 2>/dev/null || true
289
289
  VERSION=$(./scripts/derive-release-version.sh)
290
+ if [ -z "$VERSION" ]; then
291
+ echo "version=skip" >> "$GITHUB_OUTPUT"
292
+ echo "Release registration skipped: close-out reconciliation detected."
293
+ exit 0
294
+ fi
290
295
  echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
291
296
  echo "Release version: ${VERSION}"
292
297
 
293
298
  - name: Check release exists (no creation — deferred to upload-evidence)
294
- if: env.DEVAUDIT_BASE_URL != ''
299
+ if: env.DEVAUDIT_BASE_URL != '' && steps.version.outputs.version != 'skip'
295
300
  run: |
296
301
  # Release creation is deferred to the upload-evidence job (which
297
302
  # runs after gates pass) so the portal never shows a release from
@@ -306,7 +311,7 @@ jobs:
306
311
  --git-sha ${{ github.sha }} --branch ${{ github.ref_name }} || true
307
312
 
308
313
  - name: Generate and upload bundled changes (REQ releases only)
309
- if: env.DEVAUDIT_BASE_URL != ''
314
+ if: env.DEVAUDIT_BASE_URL != '' && steps.version.outputs.version != 'skip'
310
315
  run: |
311
316
  VERSION="${{ steps.version.outputs.version }}"
312
317
  # Only generate bundled changes for REQ-tagged releases —
@@ -349,32 +354,20 @@ jobs:
349
354
  run: |
350
355
  if [ ! -f "compliance/RTM.md" ]; then exit 0; fi
351
356
 
352
- # Per REQ row in RTM we look up: title (release-ticket H1, else the
353
- # linked issue's title via `gh`), and risk_class (the first LOW /
354
- # MEDIUM / HIGH / CRITICAL token in the row). Both are optional —
355
- # the portal renders gracefully with nulls. The portal endpoint
356
- # accepts either a bare string[] (legacy) or rich rows.
357
+ # Per REQ row in RTM we look up: title (shared release metadata
358
+ # helper: **Requirement:** line, issue title, then H1 fallback), and
359
+ # risk_class (the first LOW / MEDIUM / HIGH / CRITICAL token in the
360
+ # row). Both are optional — the portal renders gracefully with nulls.
361
+ # The portal endpoint accepts either a bare string[] (legacy) or rich
362
+ # rows.
357
363
  REQS_JSON='[]'
358
364
  while IFS= read -r REQ; do
359
365
  [ -z "$REQ" ] && continue
360
366
  ROW=$(grep -m1 -E "^\| ${REQ} " compliance/RTM.md || true)
361
367
  TITLE=""
362
- # Title: release-ticket H1 first (pending then approved)
363
- for FILE in "compliance/pending-releases/RELEASE-TICKET-${REQ}.md" \
364
- "compliance/approved-releases/RELEASE-TICKET-${REQ}.md"; do
365
- if [ -f "$FILE" ]; then
366
- TITLE=$(grep -m1 '^# ' "$FILE" \
367
- | sed -E 's/^# *(REQ-[0-9]+)?[[:space:]]*[—:-]?[[:space:]]*//')
368
- break
369
- fi
370
- done
371
- # Title fallback: first issue # found anywhere in the row
372
- if [ -z "$TITLE" ] && [ -n "$ROW" ]; then
373
- ISSUE_NUM=$(echo "$ROW" | grep -oE '#[0-9]+' | head -1 | tr -d '#')
374
- if [ -n "$ISSUE_NUM" ]; then
375
- TITLE=$(gh issue view "$ISSUE_NUM" --json title --jq .title 2>/dev/null || true)
376
- fi
377
- fi
368
+ source scripts/extract-release-metadata.sh 2>/dev/null || true
369
+ extract_release_metadata "$REQ" 2>/dev/null || true
370
+ TITLE="$RELEASE_TITLE"
378
371
  # Risk: first LOW/MEDIUM/HIGH/CRITICAL token in the row
379
372
  RISK=""
380
373
  if [ -n "$ROW" ]; then
@@ -680,18 +673,16 @@ jobs:
680
673
  SHOT_TMP="$(mktemp -d)"
681
674
  for REQ in "${SHOT_REQS[@]}"; do
682
675
  # Per-REQ release metadata for the portal (no-clobbered on existing rows):
683
- # release title from the pending ticket's H1; change type from the
684
- # latest commit that references the REQ.
685
- REQ_TITLE=""
686
- if [ -f "compliance/pending-releases/RELEASE-TICKET-${REQ}.md" ]; then
687
- REQ_TITLE=$(grep -m1 '^# ' "compliance/pending-releases/RELEASE-TICKET-${REQ}.md" \
688
- | sed -E 's/^# *(REQ-[0-9]+)?[[:space:]]*[—:-]?[[:space:]]*//')
689
- fi
676
+ # Uses the shared extraction helper so both workflow paths produce
677
+ # the same title/summary for the same REQ. DevAudit-Installer#285.
678
+ source scripts/extract-release-metadata.sh 2>/dev/null || true
679
+ extract_release_metadata "$REQ" 2>/dev/null || true
690
680
  REQ_CT=$(git log --grep "\[${REQ}\]\|Ref: ${REQ}" --pretty=%s -1 2>/dev/null \
691
681
  | grep -oE '^(feat|fix|refactor|perf|chore|docs|ci|build|test|compliance|revert)' \
692
682
  | head -1 || true)
693
683
  REQ_META=()
694
- [ -n "$REQ_TITLE" ] && REQ_META+=(--release-title "$REQ_TITLE")
684
+ [ -n "$RELEASE_TITLE" ] && REQ_META+=(--release-title "$RELEASE_TITLE")
685
+ [ -n "$RELEASE_SUMMARY" ] && REQ_META+=(--release-summary "$RELEASE_SUMMARY")
695
686
  [ -n "$REQ_CT" ] && REQ_META+=(--change-type "$REQ_CT")
696
687
  # devaudit-installer#147 — per-REQ scoped glob. ONLY this REQ's
697
688
  # own screenshots/ directory; legacy folders for REQs not in
@@ -1,5 +1,5 @@
1
- # Release close-out — reconcile the local compliance tree after a release is
2
- # marked `released` on the DevAudit portal.
1
+ # Release close-out — reconcile the local compliance tree and sync main back to
2
+ # develop after a release is marked `released` on the DevAudit portal.
3
3
  #
4
4
  # Generated by `devaudit install` / `devaudit update` from sdlc-config.json.
5
5
  # Do not edit manually — re-run the CLI (`devaudit update`) to regenerate.
@@ -10,9 +10,11 @@
10
10
  # release_pr }.
11
11
  # - workflow_dispatch — manual catch-up for an already-released requirement.
12
12
  #
13
- # It runs scripts/close-out-release.sh for the requirement (flip ticket +
14
- # RTM, move ticket to approved-releases/), then opens a PR to develop. The
15
- # script is idempotent, so a no-op produces no PR.
13
+ # It merges origin/main into a branch from develop (devaudit#281), runs
14
+ # scripts/close-out-release.sh for the requirement (flip ticket + RTM, move
15
+ # ticket to approved-releases/), then opens one PR to develop containing both
16
+ # the main→develop sync and the compliance-tree close-out. The script is
17
+ # idempotent, so a no-op produces no PR.
16
18
 
17
19
  name: Release Close-out
18
20
 
@@ -60,6 +62,25 @@ jobs:
60
62
  BASE=$(jq -r '.devaudit.base_url // empty' sdlc-config.json 2>/dev/null || true)
61
63
  echo "DEVAUDIT_BASE_URL=${BASE%/}" >> "$GITHUB_ENV"
62
64
 
65
+ - name: Merge main into develop branch
66
+ id: merge
67
+ run: |
68
+ # devaudit#281 — merge origin/main back into develop so the
69
+ # reconciliation PR carries both the released code and the
70
+ # compliance-tree close-out. Uses a non-interactive merge.
71
+ git config user.name "devaudit-bot"
72
+ git config user.email "devaudit-bot@users.noreply.github.com"
73
+ git fetch origin main
74
+ # Check if develop already contains main (nothing to merge)
75
+ if git merge-base --is-ancestor origin/main develop; then
76
+ echo "develop already contains origin/main — no merge needed."
77
+ echo "merged=false" >> "$GITHUB_OUTPUT"
78
+ else
79
+ git merge origin/main --no-edit --no-ff
80
+ echo "merged=true" >> "$GITHUB_OUTPUT"
81
+ echo "Merged origin/main into develop branch."
82
+ fi
83
+
63
84
  - name: Run close-out
64
85
  id: closeout
65
86
  run: |
@@ -74,23 +95,58 @@ jobs:
74
95
  echo "Nothing to close out (idempotent no-op) — no PR opened."
75
96
  fi
76
97
 
98
+ - name: Close existing sync PRs for same REQ
99
+ if: steps.closeout.outputs.changed == 'true' || steps.merge.outputs.merged == 'true'
100
+ run: |
101
+ # devaudit#281 — if an older same-REQ sync PR is already open,
102
+ # close it as superseded instead of leaving duplicate sync PRs.
103
+ REQ="${{ steps.in.outputs.req }}"
104
+ # Look for open PRs targeting develop that mention this REQ in the title
105
+ EXISTING_PRS=$(gh pr list --base develop --state open --json number,title --jq '.[] | select(.title | test("'"${REQ}"'")) | .number' 2>/dev/null || true)
106
+ for PR_NUM in $EXISTING_PRS; do
107
+ echo "Closing existing PR #${PR_NUM} as superseded by automated close-out for ${REQ}"
108
+ gh pr close ${PR_NUM} --comment "Superseded by automated release close-out for ${REQ}. This PR is no longer needed — the reconciliation is handled by the Release Close-out workflow." 2>/dev/null || true
109
+ done
110
+
77
111
  - name: Open close-out PR
78
- if: steps.closeout.outputs.changed == 'true'
112
+ if: steps.closeout.outputs.changed == 'true' || steps.merge.outputs.merged == 'true'
79
113
  run: |
80
114
  REQ="${{ steps.in.outputs.req }}"
81
- BRANCH="chore/close-out-${REQ}"
115
+ # Source the shared close-out contract for the branch prefix and marker
116
+ source scripts/close-out-contract.sh 2>/dev/null || true
117
+ BRANCH="${CLOSEOUT_BRANCH_PREFIX}${REQ}"
118
+ # Fallback if contract script not available
119
+ [ -z "$BRANCH" ] && BRANCH="chore/close-out-${REQ}"
82
120
  git config user.name "devaudit-bot"
83
121
  git config user.email "devaudit-bot@users.noreply.github.com"
84
122
  git checkout -b "$BRANCH"
85
123
  git add -A
86
- git commit -m "docs(compliance): close out ${REQ} release ticket (RELEASED)
124
+ # Build the close-out marker
125
+ MARKER=$(closeout_marker "${REQ}" 2>/dev/null || echo "Release-Closeout: ${REQ}")
126
+ git commit -m "docs(compliance): close out ${REQ} release — sync main + reconcile (RELEASED)
127
+
128
+ ${MARKER}
87
129
 
88
130
  Automated reconciliation after the DevAudit portal marked ${REQ} released.
89
- Ticket Status -> RELEASED + moved to approved-releases/; RTM row -> RELEASED.
131
+ Includes:
132
+ - main → develop sync
133
+ - release ticket moved to approved-releases/
134
+ - release ticket status → RELEASED
135
+ - RTM row → RELEASED
90
136
 
91
137
  Ref: ${REQ}"
92
138
  git push -u origin "$BRANCH" --force-with-lease
139
+ PR_BODY="Automated close-out — the DevAudit portal marked **${REQ}** \`released\`.
140
+
141
+ ${MARKER}
142
+
143
+ This PR includes:
144
+ - **main → develop reconciliation** — merges released code back into develop
145
+ - **release ticket** moved to \`approved-releases/\`, status \`RELEASED\`
146
+ - **RTM row** flipped to \`RELEASED\`
147
+
148
+ Review + merge to develop."
93
149
  gh pr create --base develop --head "$BRANCH" \
94
- --title "docs(compliance): close out ${REQ} release ticket (RELEASED)" \
95
- --body "Automated close-out — the DevAudit portal marked **${REQ}** \`released\`. This moves the ticket to \`approved-releases/\`, flips its Status and the RTM row to \`RELEASED\`. Review + merge to develop, then promote to main." \
150
+ --title "docs(compliance): close out ${REQ} sync main + reconcile (RELEASED)" \
151
+ --body "$PR_BODY" \
96
152
  || echo "::warning::PR may already exist for ${BRANCH}."
@@ -129,6 +129,11 @@ jobs:
129
129
  # code or docs. See DevAudit #310.
130
130
  chmod +x scripts/derive-release-version.sh 2>/dev/null || true
131
131
  VERSION=$(./scripts/derive-release-version.sh)
132
+ if [ -z "$VERSION" ]; then
133
+ echo "version=skip" >> "$GITHUB_OUTPUT"
134
+ echo "Compliance evidence upload skipped: close-out reconciliation detected."
135
+ exit 0
136
+ fi
132
137
  echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
133
138
  echo "Release version: ${VERSION}"
134
139
 
@@ -143,10 +148,17 @@ jobs:
143
148
  # merged, the next compliance-evidence.yml run uploads them as
144
149
  # evidence and the matrix flips both items to ✓.
145
150
  #
151
+ # devaudit#284 — release reconciliation merges (from close-out-release.yml)
152
+ # carry a `Release-Closeout: REQ-XXX` marker in the commit body.
153
+ # derive-release-version.sh detects this marker and emits an empty
154
+ # version, which this workflow converts to the explicit `skip` sentinel.
155
+ # Reconciliation pushes therefore never create housekeeping stubs and
156
+ # never attach fresh evidence to an already released tracked record.
157
+ #
146
158
  # Mirrors the `incident-export.yml` pattern: auto-PR, operator fills
147
159
  # in sign-off, merge to land.
148
160
  - name: Auto-generate housekeeping stubs (if needed)
149
- if: steps.resolve.outputs.skip != 'true'
161
+ if: steps.resolve.outputs.skip != 'true' && steps.version.outputs.version != 'skip'
150
162
  env:
151
163
  # Use the workflow's GITHUB_TOKEN directly. The `permissions:`
152
164
  # block on the job grants `contents: write` + `pull-requests:
@@ -226,7 +238,7 @@ jobs:
226
238
  fi
227
239
 
228
240
  - name: Upload compliance documents
229
- if: steps.resolve.outputs.skip != 'true'
241
+ if: steps.resolve.outputs.skip != 'true' && steps.version.outputs.version != 'skip'
230
242
  run: |
231
243
  chmod +x scripts/upload-evidence.sh 2>/dev/null || true
232
244
  # Common flags WITHOUT --release. Each upload appends its OWN
@@ -428,19 +440,17 @@ jobs:
428
440
  fi
429
441
  rm -f "$AUDIT_LOG_FILE"
430
442
 
431
- # Helper: emit a `--release-title …` `--change-type …` pair for a given
432
- # REQ, derived from its pending release-ticket H1 and the most recent
433
- # commit attributed to that REQ. Empty pair when neither is available.
443
+ # Helper: emit `--release-title …` `--release-summary …` `--change-type …`
444
+ # args for a given REQ, derived from its release-ticket metadata and
445
+ # the most recent commit attributed to that REQ. Empty pair when
446
+ # neither is available. Uses the shared extraction helper so both
447
+ # workflow paths produce the same title/summary for the same REQ.
448
+ # DevAudit-Installer#285.
434
449
  req_meta_args() {
435
- local REQ="$1"; local TITLE=""; local CT=""
436
- for FILE in "compliance/pending-releases/RELEASE-TICKET-${REQ}.md" \
437
- "compliance/approved-releases/RELEASE-TICKET-${REQ}.md"; do
438
- if [ -f "$FILE" ]; then
439
- TITLE=$(grep -m1 '^# ' "$FILE" \
440
- | sed -E 's/^# *(REQ-[0-9]+)?[[:space:]]*[—:-]?[[:space:]]*//')
441
- break
442
- fi
443
- done
450
+ local REQ="$1"; local CT=""
451
+ # Source the shared helper and extract canonical title + summary
452
+ source scripts/extract-release-metadata.sh 2>/dev/null || true
453
+ extract_release_metadata "$REQ" 2>/dev/null || true
444
454
  CT=$(git log --grep "\[${REQ}\]\|Ref: ${REQ}" --pretty=%s -1 2>/dev/null \
445
455
  | grep -oE '^(feat|fix|refactor|perf|chore|docs|ci|build|test|compliance|revert)' \
446
456
  | head -1 || true)
@@ -448,7 +458,8 @@ jobs:
448
458
  # trailing `[ … ] && cmd` whose test fails returns 1, and the
449
459
  # function inherits that exit code; calling via $(req_meta_args …)
450
460
  # then aborts the step silently (DevAudit-Installer#77).
451
- if [ -n "$TITLE" ]; then printf -- '--release-title %q ' "$TITLE"; fi
461
+ if [ -n "$RELEASE_TITLE" ]; then printf -- '--release-title %q ' "$RELEASE_TITLE"; fi
462
+ if [ -n "$RELEASE_SUMMARY" ]; then printf -- '--release-summary %q ' "$RELEASE_SUMMARY"; fi
452
463
  if [ -n "$CT" ]; then printf -- '--change-type %q ' "$CT"; fi
453
464
  }
454
465
 
@@ -680,11 +691,16 @@ jobs:
680
691
  run: |
681
692
  chmod +x scripts/derive-release-version.sh 2>/dev/null || true
682
693
  VERSION=$(./scripts/derive-release-version.sh)
694
+ if [ -z "$VERSION" ]; then
695
+ echo "version=skip" >> "$GITHUB_OUTPUT"
696
+ echo "E2E evidence upload skipped: close-out reconciliation detected."
697
+ exit 0
698
+ fi
683
699
  echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
684
700
  echo "Resolved release version: ${VERSION}"
685
701
 
686
702
  - name: Upload E2E Regression evidence to DevAudit
687
- if: steps.resolve.outputs.skip != 'true'
703
+ if: steps.resolve.outputs.skip != 'true' && steps.version.outputs.version != 'skip'
688
704
  run: |
689
705
  set -euo pipefail
690
706
  DERIVED_RELEASE="${{ steps.version.outputs.version }}"