@opengsd/gsd-core 1.19.0 → 1.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/agents/gsd-code-fixer.compact.md +5 -5
- package/agents/gsd-code-fixer.md +9 -4
- package/agents/gsd-code-reviewer.compact.md +49 -8
- package/agents/gsd-code-reviewer.md +53 -14
- package/agents/gsd-executor.md +8 -4
- package/bin/install.js +36 -40
- package/commands/gsd/new-milestone.md +2 -2
- package/commands/gsd/new-project.md +2 -2
- package/commands/gsd/update.md +5 -2
- package/gsd-core/bin/gsd-tools.cjs +177 -93
- package/gsd-core/bin/lib/capability-lifecycle.cjs +1 -12
- package/gsd-core/bin/lib/capability-registry.cjs +284 -93
- package/gsd-core/bin/lib/capability-validator.cjs +15 -0
- package/gsd-core/bin/lib/commands.cjs +46 -17
- package/gsd-core/bin/lib/config-loader.cjs +3 -3
- package/gsd-core/bin/lib/core-utils.cjs +11 -0
- package/gsd-core/bin/lib/frontmatter.cjs +21 -3
- package/gsd-core/bin/lib/gate-api-coverage-verify-pre.cjs +2 -2
- package/gsd-core/bin/lib/gate-context-drift.cjs +1 -1
- package/gsd-core/bin/lib/gate-phase-context.cjs +7 -8
- package/gsd-core/bin/lib/gate-schema-drift.cjs +1 -1
- package/gsd-core/bin/lib/git-info-exclude.cjs +1 -6
- package/gsd-core/bin/lib/gsd-tool-dir.cjs +134 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +13 -9
- package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +9 -7
- package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +3 -3
- package/gsd-core/bin/lib/init.cjs +181 -68
- package/gsd-core/bin/lib/install-engine.cjs +9 -9
- package/gsd-core/bin/lib/install-manifest-record.cjs +86 -0
- package/gsd-core/bin/lib/install-profiles.cjs +4 -2
- package/gsd-core/bin/lib/io.cjs +6 -0
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +34 -1
- package/gsd-core/bin/lib/markdown-table.cjs +23 -0
- package/gsd-core/bin/lib/mcp-server.cjs +8 -5
- package/gsd-core/bin/lib/milestone.cjs +18 -50
- package/gsd-core/bin/lib/phase-entry-grammar.cjs +119 -0
- package/gsd-core/bin/lib/phase-id-display.cjs +2 -0
- package/gsd-core/bin/lib/phase-id.cjs +627 -36
- package/gsd-core/bin/lib/phase-lifecycle.cjs +9 -74
- package/gsd-core/bin/lib/phase-locator.cjs +328 -19
- package/gsd-core/bin/lib/phase.cjs +3039 -666
- package/gsd-core/bin/lib/planning-document.cjs +26 -5
- package/gsd-core/bin/lib/planning-inspect.cjs +27 -35
- package/gsd-core/bin/lib/planning-snapshot.cjs +12 -7
- package/gsd-core/bin/lib/planning-workspace.cjs +3 -3
- package/gsd-core/bin/lib/read-only-git-env.cjs +23 -2
- package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +2 -2
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +22 -0
- package/gsd-core/bin/lib/review-lane-invocation.cjs +125 -18
- package/gsd-core/bin/lib/reviewer-step-dispatch.cjs +11 -9
- package/gsd-core/bin/lib/roadmap-parser.cjs +759 -144
- package/gsd-core/bin/lib/roadmap-progress.cjs +136 -0
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +8 -5
- package/gsd-core/bin/lib/roadmap.cjs +358 -106
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +310 -130
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +6 -0
- package/gsd-core/bin/lib/shell-command-projection.cjs +36 -9
- package/gsd-core/bin/lib/smart-entry.cjs +22 -11
- package/gsd-core/bin/lib/state-contract.cjs +11 -7
- package/gsd-core/bin/lib/state-document.cjs +332 -23
- package/gsd-core/bin/lib/state-transition.cjs +51 -15
- package/gsd-core/bin/lib/state.cjs +499 -180
- package/gsd-core/bin/lib/template.cjs +2 -2
- package/gsd-core/bin/lib/validate.cjs +3 -2
- package/gsd-core/bin/lib/vendor/re2js.cjs +863 -577
- package/gsd-core/bin/lib/vendor/re2js.d.cts +284 -219
- package/gsd-core/bin/lib/verify.cjs +2 -2
- package/gsd-core/bin/lib/workstream-inventory.cjs +27 -21
- package/gsd-core/bin/lib/worktree-base-ref.cjs +32 -2
- package/gsd-core/bin/verify-reapply-patches.cjs +411 -10
- package/gsd-core/references/cicd-review-patterns.md +45 -0
- package/gsd-core/references/necessity-review.md +48 -0
- package/gsd-core/workflows/code-review-fix.md +46 -5
- package/gsd-core/workflows/code-review.md +3 -0
- package/gsd-core/workflows/execute-phase.md +1 -1
- package/gsd-core/workflows/execute-plan.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +13 -9
- package/gsd-core/workflows/plan-phase.md +1 -1
- package/gsd-core/workflows/pr-branch.md +2 -0
- package/gsd-core/workflows/reapply-patches.md +104 -0
- package/gsd-core/workflows/review.md +54 -141
- package/gsd-core/workflows/secure-phase.md +1 -1
- package/gsd-core/workflows/settings-advanced.md +57 -9
- package/gsd-core/workflows/ship.md +12 -0
- package/hooks/dist/gsd-agent-isolation-guard.js +1 -1
- package/hooks/dist/gsd-cursor-subagent-start.js +1 -1
- package/hooks/dist/gsd-graphify-update.sh +6 -0
- package/hooks/dist/gsd-statusline.js +3 -2
- package/hooks/dist/gsd-windsurf-pre-write.js +4 -4
- package/hooks/dist/gsd-workflow-guard.js +2 -1
- package/hooks/dist/gsd-worktree-path-guard.js +4 -4
- package/hooks/dist/lib/git-probe.js +16 -0
- package/hooks/dist/lib/isolation-sentinel.js +77 -2
- package/hooks/gsd-agent-isolation-guard.js +1 -1
- package/hooks/gsd-cursor-subagent-start.js +1 -1
- package/hooks/gsd-graphify-update.sh +6 -0
- package/hooks/gsd-statusline.js +3 -2
- package/hooks/gsd-windsurf-pre-write.js +4 -4
- package/hooks/gsd-workflow-guard.js +2 -1
- package/hooks/gsd-worktree-path-guard.js +4 -4
- package/hooks/lib/git-probe.js +16 -0
- package/hooks/lib/isolation-sentinel.js +77 -2
- package/package.json +3 -3
- package/pi/package-agents/gsd-code-fixer.md +9 -4
- package/pi/package-agents/gsd-code-reviewer.md +53 -14
- package/pi/package-agents/gsd-executor.md +8 -4
- package/pi/package-agents/manifest.json +3 -3
- package/pi/package-skills/gsd-new-milestone/SKILL.md +1 -1
- package/pi/package-skills/gsd-new-project/SKILL.md +1 -1
- package/pi/package-skills/gsd-update/SKILL.md +4 -1
- package/scripts/changeset/lint.cjs +28 -3
- package/scripts/ci-next-health.cjs +246 -23
- package/scripts/docs-guard-registry.cjs +2 -0
- package/scripts/gen-features.cjs +14 -2
- package/scripts/lib/drift-scan.cjs +27 -0
- package/scripts/lib/macos-conformance-tier.generated.cjs +10 -0
- package/scripts/lib/platform-conformance-tier.generated.cjs +15 -0
- package/scripts/lint-phase-enumeration-drift.cjs +8 -4
- package/scripts/lint-roadmap-progress-drift.cjs +421 -0
- package/scripts/merge-commit-pr.cjs +186 -0
- package/scripts/mutation-matrix.cjs +23 -1
- package/scripts/release-ancestry.cjs +86 -0
- package/scripts/release-pipeline-pr.cjs +92 -0
- package/scripts/require-issue-link-policy.cjs +18 -9
- package/scripts/sync-next-version.cjs +48 -7
- package/skills/gsd-new-milestone/SKILL.md +1 -1
- package/skills/gsd-new-project/SKILL.md +1 -1
- package/skills/gsd-update/SKILL.md +5 -2
- package/vscode/package.json +1 -1
|
@@ -60,6 +60,8 @@ This ensures project-specific patterns, conventions, and best practices are appl
|
|
|
60
60
|
|
|
61
61
|
**3. Code Quality** — Dead code, unused imports/variables, poor naming conventions, missing error handling, inconsistent patterns, overly complex functions (high cyclomatic complexity), code duplication, magic numbers, commented-out code
|
|
62
62
|
|
|
63
|
+
**4. Necessity** (#4100) — reachable code whose guarded-against problem cannot occur in this stack: impossible-state guard, already-defended-elsewhere, cargo-culted pattern, phantom requirement, generality without instantiation. At `standard`/`deep`, Read `__GSD_PI_ENGINE_ROOT__/gsd-core/references/necessity-review.md` before classifying findings; if it cannot be read, say so in the Summary and omit `necessity:`. `quick` does not assess necessity. Warning when provable from the repo, Info when it rests on judgment, Never Critical; each finding cites the evidence that the state cannot occur and carries a `**Category:** necessity` line.
|
|
64
|
+
|
|
63
65
|
**Out of Scope (v1):** Performance issues (O(n²) algorithms, memory leaks, inefficient queries) are NOT in scope for v1. Focus on correctness, security, and maintainability.
|
|
64
66
|
|
|
65
67
|
</review_scope>
|
|
@@ -70,12 +72,17 @@ This ensures project-specific patterns, conventions, and best practices are appl
|
|
|
70
72
|
|
|
71
73
|
**quick** — Pattern-matching only. Use grep/regex to scan for common anti-patterns without reading full file contents. Target: under 2 minutes.
|
|
72
74
|
|
|
73
|
-
|
|
74
|
-
- Hardcoded secrets
|
|
75
|
-
- Dangerous functions
|
|
76
|
-
- Debug artifacts
|
|
77
|
-
- Empty catch blocks
|
|
78
|
-
- Commented-out code
|
|
75
|
+
Checks (commands in `review_by_depth`):
|
|
76
|
+
- Hardcoded secrets
|
|
77
|
+
- Dangerous functions
|
|
78
|
+
- Debug artifacts
|
|
79
|
+
- Empty catch blocks
|
|
80
|
+
- Commented-out code
|
|
81
|
+
- Unpinned actions
|
|
82
|
+
- Non-idempotent install
|
|
83
|
+
- Missing job timeout
|
|
84
|
+
|
|
85
|
+
The last three: GitHub Actions workflows only.
|
|
79
86
|
|
|
80
87
|
**standard** (default) — Read each changed file. Check for bugs, security issues, and quality problems in context. Cross-reference imports and exports. Target: 5-15 minutes.
|
|
81
88
|
|
|
@@ -85,6 +92,7 @@ Language-aware checks:
|
|
|
85
92
|
- **Go**: Unchecked error returns, goroutine leaks, context not passed, `defer` in loops, race conditions
|
|
86
93
|
- **C/C++**: Buffer overflow patterns, use-after-free indicators, null pointer dereferences, missing bounds checks, memory leaks
|
|
87
94
|
- **Shell**: Unquoted variables, `eval` usage, missing `set -e`, command injection via interpolation
|
|
95
|
+
- **CI/CD pipelines** (by path: `.github/workflows/*.yml`, `Dockerfile*`, `docker-compose*.yml`, deploy scripts): Read `__GSD_PI_ENGINE_ROOT__/gsd-core/references/cicd-review-patterns.md`; if it cannot be read, say so in the Summary and check: non-idempotent steps (`npm install` vs `npm ci`, unlocked `pip install`, unguarded `mkdir`/`rm`), no job `timeout-minutes`, deploy without `concurrency` + `cancel-in-progress`, per-environment rebuild (not build-once), no health check or rollback, load-bearing `continue-on-error`, static credentials where OIDC works, unpinned actions
|
|
88
96
|
|
|
89
97
|
**deep** — All of standard, plus cross-file analysis. Trace function call chains across imports. Target: 15-30 minutes.
|
|
90
98
|
|
|
@@ -173,6 +181,7 @@ NOTE: Do NOT exclude all `.md` files — commands, workflows, and agents are sou
|
|
|
173
181
|
- Go: `.go`
|
|
174
182
|
- C/C++: `.c`, `.cpp`, `.h`, `.hpp`
|
|
175
183
|
- Shell: `.sh`, `.bash`
|
|
184
|
+
- CI/CD (by path): `.github/workflows/*.yml`/`*.yaml`, `Dockerfile*`, `docker-compose*.yml`, `deploy*.sh` (also Shell)
|
|
176
185
|
- Other: Review generically
|
|
177
186
|
|
|
178
187
|
**3. Exit early if empty:** If no source files remain after filtering, create REVIEW.md with:
|
|
@@ -193,22 +202,48 @@ NOTE: `status: clean` means "reviewed and found no issues." `status: skipped` me
|
|
|
193
202
|
Branch on depth level:
|
|
194
203
|
|
|
195
204
|
**For depth=quick:**
|
|
196
|
-
Run
|
|
205
|
+
Run every check from `<depth_levels>` quick section:
|
|
197
206
|
```bash
|
|
207
|
+
# Set FILE per scoped path with: while IFS= read -r FILE (never paste a name into code).
|
|
208
|
+
|
|
198
209
|
# Hardcoded secrets
|
|
199
|
-
grep -n -E "(password|secret|api_key|token|apikey|api-key)\s*[=:]\s*['\"]\
|
|
210
|
+
grep -n -E -- "(password|secret|api_key|token|apikey|api-key)\s*[=:]\s*['\"][^'\"]+['\"]" "$FILE"
|
|
200
211
|
|
|
201
212
|
# Dangerous functions
|
|
202
|
-
grep -n -E "eval\(|innerHTML|dangerouslySetInnerHTML|exec\(|system\(|shell_exec"
|
|
213
|
+
grep -n -E -- "eval\(|innerHTML|dangerouslySetInnerHTML|exec\(|system\(|shell_exec|passthru" "$FILE"
|
|
203
214
|
|
|
204
215
|
# Debug artifacts
|
|
205
|
-
grep -n -E "console\.log|debugger;|TODO|FIXME|XXX|HACK"
|
|
206
|
-
|
|
207
|
-
# Empty catch
|
|
208
|
-
grep -n -E "catch\s*\([^)]*\)\s*\{\s*\}"
|
|
216
|
+
grep -n -E -- "console\.log|debugger;|TODO|FIXME|XXX|HACK" "$FILE"
|
|
217
|
+
|
|
218
|
+
# Empty catch blocks
|
|
219
|
+
grep -n -E -- "catch\s*\([^)]*\)\s*\{\s*\}" "$FILE"
|
|
220
|
+
|
|
221
|
+
# Commented-out code
|
|
222
|
+
grep -n -E -- "^\s*//.*[{};]|^\s*#[[:space:]]*(if|for|while|def|class|elif|else|try|except|with)[[:space:](].*:[[:space:]]*$|^\s*/\*[^*].*[{};]" "$FILE"
|
|
223
|
+
|
|
224
|
+
# Unpinned actions
|
|
225
|
+
grep -n -E -- "^[[:space:]]*(-[[:space:]]+)?uses:[[:space:]]*['\"]?[^[:space:]'\"]+@(main|master|latest)['\"]?([[:space:]]|$)" "$FILE"
|
|
226
|
+
|
|
227
|
+
# Non-idempotent install
|
|
228
|
+
[ -f package-lock.json ] && grep -n -E -- "(^|[^[:alnum:]_-])npm[[:space:]]+(install|i)([[:space:]]|$)" "$FILE" | grep -v -E "^[0-9]+:[[:space:]]*#|[[:space:]](-g|--global)([[:space:]]|$)"
|
|
229
|
+
|
|
230
|
+
# Missing job timeout
|
|
231
|
+
awk '
|
|
232
|
+
function flush() { if (job != "" && runs && !timed) print line ": " job ": no job-level timeout-minutes"; job = "" }
|
|
233
|
+
{ sub(/\r$/, "") }
|
|
234
|
+
/^[^[:space:]#]/ { flush(); injobs = ($0 ~ /^jobs:[[:space:]]*(#.*)?$/); jind = -1; next }
|
|
235
|
+
!injobs || /^[[:space:]]*(#.*)?$/ { next }
|
|
236
|
+
{ ind = match($0, /[^ ]/) - 1 }
|
|
237
|
+
jind < 0 { jind = ind }
|
|
238
|
+
ind == jind { flush(); job = $0; sub(/^ +/, "", job); sub(/:.*/, "", job); line = NR; kind = -1; runs = 0; timed = 0; next }
|
|
239
|
+
kind < 0 { kind = ind }
|
|
240
|
+
ind == kind && /^ *runs-on:/ { runs = 1 }
|
|
241
|
+
ind == kind && /^ *timeout-minutes:/ { timed = 1 }
|
|
242
|
+
END { flush() }
|
|
243
|
+
' "$FILE"
|
|
209
244
|
```
|
|
210
245
|
|
|
211
|
-
Record findings with severity: secrets/dangerous=Critical, debug=Info, empty catch=Warning
|
|
246
|
+
Record findings with severity: secrets/dangerous=Critical, debug and commented-out code=Info, empty catch and the three CI/CD checks=Warning
|
|
212
247
|
|
|
213
248
|
**For depth=standard:**
|
|
214
249
|
For each file:
|
|
@@ -252,6 +287,7 @@ For each finding, assign severity:
|
|
|
252
287
|
- Type coercion issues (`==` vs `===`)
|
|
253
288
|
- Unhandled promise rejections
|
|
254
289
|
- Dead code paths that indicate logic errors
|
|
290
|
+
- CI/CD pipeline findings: every check in the CI/CD entry
|
|
255
291
|
|
|
256
292
|
**Info** — Style issues, naming improvements, dead code, unused imports, suggestions:
|
|
257
293
|
- Unused imports/variables
|
|
@@ -286,10 +322,13 @@ findings:
|
|
|
286
322
|
warning: N
|
|
287
323
|
info: N
|
|
288
324
|
total: N
|
|
325
|
+
necessity: N
|
|
289
326
|
status: clean | issues_found
|
|
290
327
|
---
|
|
291
328
|
```
|
|
292
329
|
|
|
330
|
+
Emit `necessity:` only at standard/deep depth. It counts the necessity findings among `warning` + `info` and is never added to `total`.
|
|
331
|
+
|
|
293
332
|
**3. Body sections (required order):**
|
|
294
333
|
1) `## Structural Findings (fallow)` — only when structural findings were provided; list normalized items first.
|
|
295
334
|
2) `## Narrative Findings (AI reviewer)` — your adversarial findings from direct code review, including any external-reviewer claim you independently verified (`(external: {slug})`, see `load_context` step 5).
|
|
@@ -761,7 +761,14 @@ Do NOT skip. Do NOT proceed to state updates if self-check fails.
|
|
|
761
761
|
</self_check>
|
|
762
762
|
|
|
763
763
|
<state_updates>
|
|
764
|
-
After SUMMARY.md, update STATE.md
|
|
764
|
+
After SUMMARY.md, update ROADMAP.md progress FIRST, then STATE.md. The ROADMAP write is the step that can decline (`missing_phase_details`), and a decline stops the plan before any STATE.md write, so STATE.md never advances past a plan the ROADMAP did not record:
|
|
765
|
+
|
|
766
|
+
```bash
|
|
767
|
+
# Update ROADMAP.md progress for this phase (plan counts, status). A decline stops here, before STATE.md moves.
|
|
768
|
+
PROGRESS=$(gsd_run query roadmap.update-plan-progress "${PHASE_NUMBER}") && REASON=$(jq -er '.reason//""' <<<"$PROGRESS") && [ "$REASON" != missing_phase_details ] || { echo "ERROR: ROADMAP update-plan-progress did not complete for ${PHASE_NUMBER}-${PLAN} (missing_phase_details decline, failed query, or jq not installed). STOP: skip this plan's completion steps." >&2; exit 1; }
|
|
769
|
+
```
|
|
770
|
+
|
|
771
|
+
Then update STATE.md using `gsd-tools query` state handlers (named flags):
|
|
765
772
|
|
|
766
773
|
```bash
|
|
767
774
|
# Advance plan counter (handles edge cases automatically)
|
|
@@ -788,9 +795,6 @@ gsd_run query state.record-session \
|
|
|
788
795
|
```
|
|
789
796
|
|
|
790
797
|
```bash
|
|
791
|
-
# Update ROADMAP.md progress for this phase (plan counts, status)
|
|
792
|
-
gsd_run query roadmap.update-plan-progress "${PHASE_NUMBER}"
|
|
793
|
-
|
|
794
798
|
# Mark completed requirements from PLAN.md frontmatter
|
|
795
799
|
# Extract the `requirements` array from the plan's frontmatter, then mark each complete
|
|
796
800
|
gsd_run query requirements.mark-complete ${REQ_IDS}
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
"gsd-advisor-researcher.md": "6988b236b53568bbcea7886d11ca9f0badf9aedb3f650d42ca894211d91aafe4",
|
|
4
4
|
"gsd-ai-researcher.md": "bdc567357f7361d7e427a2730bdac17c0b28ecb1bcbbc687df68d573f0ee9f3e",
|
|
5
5
|
"gsd-assumptions-analyzer.md": "56b243f4c50403cbc133ad8c91ae40aa1aeceadd6624d0fd26406ebf8c113205",
|
|
6
|
-
"gsd-code-fixer.md": "
|
|
7
|
-
"gsd-code-reviewer.md": "
|
|
6
|
+
"gsd-code-fixer.md": "2d806b6bae3797a7757b4872a93790fe33a4f22e90f6e71f6344b52df3048d52",
|
|
7
|
+
"gsd-code-reviewer.md": "459b38676fd7e6b7bf147a9c3d88243630ff289366a359c7c489536d38b2bd21",
|
|
8
8
|
"gsd-codebase-mapper.md": "5901db6721a6f2365be52819a41ab1c1590a7cbbbdc6d992dce2a7b99f7d12fb",
|
|
9
9
|
"gsd-debug-session-manager.md": "7b68386a6ab0169eb4c1b44afa921b8196db1eac1af3a0dd07a87d5ae2b208ef",
|
|
10
10
|
"gsd-debugger.md": "7328920b7fbee1b76af7259a14b96290562544d97c6ced1291e1e1f3529e1598",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"gsd-domain-researcher.md": "f1b07d9c79dcb89c90e5af7f61adfa5dc73ebe8429783d8cb281d7ae2bdc682b",
|
|
17
17
|
"gsd-eval-auditor.md": "9d77e7329c52741e7e62185b566231eb872c6b34afa6c869960a922af573b3d5",
|
|
18
18
|
"gsd-eval-planner.md": "efc12a2822d77a5989db2e3efe83b7fda9f0509bb203910d7e26eb9369f89949",
|
|
19
|
-
"gsd-executor.md": "
|
|
19
|
+
"gsd-executor.md": "45e9540f6380b1883ad5b144411b21447eef20f310cbd0ab7c72c2bdaf5f8c47",
|
|
20
20
|
"gsd-framework-selector.md": "96d52931468372ee88c4f1d551c13c97d2be8079053b55d992e0ea1720d3d5c9",
|
|
21
21
|
"gsd-integration-checker.md": "a233ab8c5c97b6099c649d1e9a09e88ff1ed1bf7df7c1b13bc766c33d58e09b9",
|
|
22
22
|
"gsd-intel-updater.md": "26c70b81580cc5bf713959bdcc84a16a35569861d2e79c07c71258ccd05ba9bc",
|
|
@@ -26,7 +26,7 @@ Brownfield equivalent of new-project. Project exists, PROJECT.md has history. Ga
|
|
|
26
26
|
- `.planning/ROADMAP.md` — phase structure (continues numbering)
|
|
27
27
|
- `.planning/STATE.md` — reset for new milestone
|
|
28
28
|
|
|
29
|
-
**After:** `/skill:gsd-plan-phase [N]` to
|
|
29
|
+
**After:** Run `/skill:gsd-discuss-phase [N]` to clarify the approach (or `/skill:gsd-plan-phase [N]` to plan directly); then `/skill:gsd-execute-phase [N]` to execute.
|
|
30
30
|
</objective>
|
|
31
31
|
|
|
32
32
|
<execution_context>
|
|
@@ -32,7 +32,7 @@ Initialize a new project through unified flow: questioning → research (optiona
|
|
|
32
32
|
- `.planning/ROADMAP.md` — phase structure
|
|
33
33
|
- `.planning/STATE.md` — project memory
|
|
34
34
|
|
|
35
|
-
**After this command:** Run `/skill:gsd-plan-phase 1` to
|
|
35
|
+
**After this command:** Run `/skill:gsd-discuss-phase 1` to clarify the approach (or `/skill:gsd-plan-phase 1` to plan directly); then `/skill:gsd-execute-phase 1` to execute.
|
|
36
36
|
</objective>
|
|
37
37
|
|
|
38
38
|
<execution_context>
|
|
@@ -33,14 +33,17 @@ Routes to the update workflow which handles:
|
|
|
33
33
|
<flags>
|
|
34
34
|
- **--sync**: Sync managed GSD skills across runtime roots so multi-runtime users stay aligned after an update. Runs the sync-skills workflow (--from, --to, --dry-run, --apply flags supported).
|
|
35
35
|
- **--reapply**: Reapply local modifications after a GSD update. Uses three-way comparison (pristine baseline, user-modified backup, newly installed version) to merge user customizations back. Runs the reapply-patches workflow.
|
|
36
|
+
- **--interactive[=exceptions|all]** (with `--reapply` only): show each user edit next to the stock text with a recommended disposition and wait for a keep / retire / rewrite decision per edit. `all` shows every edit; `exceptions` (bare `--interactive`) shows only edits upstream adopted, partly adopted, reworded around, or orphaned, and keeps the rest. Without it, `--reapply` behaves exactly as before.
|
|
36
37
|
- **--next** (alias **--rc**): Target the `@next` RC dist-tag instead of `@latest` so you can install or refresh a release candidate (e.g. `1.4.0-rc.1`) through the normal update flow — scope/runtime detection, changelog preview, custom-file backup, and cache clearing all still apply. Omitting it keeps targeting `@latest` (no change). See ADR #660 for the RC channel.
|
|
37
38
|
- **(no flag)**: Standard update — check for new version, show changelog, install.
|
|
38
39
|
</flags>
|
|
39
40
|
|
|
40
41
|
<process>
|
|
42
|
+
If the arguments contain `--interactive` (bare or with a value) but not `--reapply`, STOP with: `--interactive applies only to --reapply (for example: /skill:gsd-update --reapply --interactive=all)`. Do nothing else.
|
|
43
|
+
|
|
41
44
|
Parse the first token of the `<arguments>` block:
|
|
42
45
|
- If it is `--sync`: strip the flag, execute the sync-skills workflow (passing remaining args for --from/--to/--dry-run/--apply).
|
|
43
|
-
- If it is `--reapply`: strip the flag, execute the reapply-patches workflow.
|
|
46
|
+
- If it is `--reapply`: strip the flag, execute the reapply-patches workflow, passing the remaining arguments (for example `--interactive=all`) to it.
|
|
44
47
|
- Otherwise (including `--next` / `--rc`): execute the update workflow end-to-end, passing the `<arguments>` block through so the workflow's parse_update_channel step can select the release channel.
|
|
45
48
|
|
|
46
49
|
</process>
|
|
@@ -20,6 +20,9 @@ const LINT_REASON = Object.freeze({
|
|
|
20
20
|
OK_FRAGMENT_PRESENT: 'ok_fragment_present',
|
|
21
21
|
OK_OPT_OUT_LABEL: 'ok_opt_out_label',
|
|
22
22
|
OK_NO_USER_FACING_CHANGES: 'ok_no_user_facing_changes',
|
|
23
|
+
// #5903: the release pipeline's own PRs consume every fragment into
|
|
24
|
+
// CHANGELOG.md, so they carry none by design.
|
|
25
|
+
OK_RELEASE_PIPELINE: 'ok_release_pipeline',
|
|
23
26
|
FAIL_MISSING_FRAGMENT: 'fail_missing_fragment',
|
|
24
27
|
FAIL_INVALID_FRAGMENT: 'fail_invalid_fragment',
|
|
25
28
|
FAIL_PR_FIELD_DRIFT: 'fail_pr_field_drift',
|
|
@@ -86,13 +89,18 @@ function findPrFieldDrift(prEntries, realPrNumber) {
|
|
|
86
89
|
return drift;
|
|
87
90
|
}
|
|
88
91
|
|
|
89
|
-
function evaluateLint({
|
|
92
|
+
function evaluateLint({
|
|
93
|
+
changedFiles, labels, fragmentFailures = [], prFieldDrift = [], releasePipelineKind = null,
|
|
94
|
+
}) {
|
|
90
95
|
if (fragmentFailures.length > 0) {
|
|
91
96
|
return { ok: false, reason: LINT_REASON.FAIL_INVALID_FRAGMENT, failures: fragmentFailures };
|
|
92
97
|
}
|
|
93
98
|
if (prFieldDrift.length > 0) {
|
|
94
99
|
return { ok: false, reason: LINT_REASON.FAIL_PR_FIELD_DRIFT, drift: prFieldDrift };
|
|
95
100
|
}
|
|
101
|
+
if (releasePipelineKind) {
|
|
102
|
+
return { ok: true, reason: LINT_REASON.OK_RELEASE_PIPELINE, releasePipelineKind };
|
|
103
|
+
}
|
|
96
104
|
if (changedFiles.some(isFragment)) {
|
|
97
105
|
return { ok: true, reason: LINT_REASON.OK_FRAGMENT_PRESENT };
|
|
98
106
|
}
|
|
@@ -107,6 +115,7 @@ function evaluateLint({ changedFiles, labels, fragmentFailures = [], prFieldDrif
|
|
|
107
115
|
|
|
108
116
|
const { ExitError, runMain } = require('../lib/cli-exit.cjs');
|
|
109
117
|
const { parseFragment } = require('./parse.cjs');
|
|
118
|
+
const { releasePipelinePrKind } = require('../release-pipeline-pr.cjs');
|
|
110
119
|
|
|
111
120
|
function main() {
|
|
112
121
|
const fs = require('node:fs');
|
|
@@ -119,6 +128,12 @@ function main() {
|
|
|
119
128
|
// `pull_request` in the payload, or no payload at all) — the drift check
|
|
120
129
|
// below is a no-op in that case, it never fails a push run.
|
|
121
130
|
let realPrNumber = null;
|
|
131
|
+
// #5903: head/base refs and whether the head lives in this repository, for
|
|
132
|
+
// the release-pipeline exemption. sameRepo is false when anything is missing
|
|
133
|
+
// (a deleted fork has head.repo === null).
|
|
134
|
+
let headRef;
|
|
135
|
+
let baseRef;
|
|
136
|
+
let sameRepo = false;
|
|
122
137
|
if (eventPath && fs.existsSync(eventPath)) {
|
|
123
138
|
try {
|
|
124
139
|
const event = JSON.parse(fs.readFileSync(eventPath, 'utf8'));
|
|
@@ -126,6 +141,12 @@ function main() {
|
|
|
126
141
|
if (event.pull_request && Number.isInteger(event.pull_request.number)) {
|
|
127
142
|
realPrNumber = event.pull_request.number;
|
|
128
143
|
}
|
|
144
|
+
headRef = event.pull_request?.head?.ref;
|
|
145
|
+
baseRef = event.pull_request?.base?.ref;
|
|
146
|
+
const headRepo = event.pull_request?.head?.repo?.full_name;
|
|
147
|
+
const thisRepo = process.env.GITHUB_REPOSITORY;
|
|
148
|
+
sameRepo = typeof headRepo === 'string' && typeof thisRepo === 'string'
|
|
149
|
+
&& headRepo !== '' && headRepo.toLowerCase() === thisRepo.toLowerCase();
|
|
129
150
|
} catch { /* fall through */ }
|
|
130
151
|
}
|
|
131
152
|
// #2988: local fallback must match the repo's integration branch (`next`),
|
|
@@ -176,11 +197,15 @@ function main() {
|
|
|
176
197
|
}
|
|
177
198
|
|
|
178
199
|
const prFieldDrift = findPrFieldDrift(prEntries, realPrNumber);
|
|
179
|
-
const
|
|
200
|
+
const releasePipelineKind = releasePipelinePrKind({ headRef, baseRef, sameRepo });
|
|
201
|
+
const verdict = evaluateLint({
|
|
202
|
+
changedFiles, labels, fragmentFailures, prFieldDrift, releasePipelineKind,
|
|
203
|
+
});
|
|
180
204
|
if (process.argv.includes('--json')) {
|
|
181
205
|
process.stdout.write(JSON.stringify({ ...verdict, changedFiles, labels }, null, 2) + '\n');
|
|
182
206
|
} else if (verdict.ok) {
|
|
183
|
-
|
|
207
|
+
const kind = verdict.releasePipelineKind ? ` (${verdict.releasePipelineKind})` : '';
|
|
208
|
+
process.stdout.write(`ok changeset-lint: ${verdict.reason}${kind}\n`);
|
|
184
209
|
} else if (verdict.reason === LINT_REASON.FAIL_INVALID_FRAGMENT) {
|
|
185
210
|
process.stderr.write(`\nERROR changeset-lint: ${verdict.reason}\n`);
|
|
186
211
|
process.stderr.write(`The following .changeset fragment(s) failed content validation:\n`);
|
|
@@ -59,14 +59,158 @@ function classifyRunConclusion(payload) {
|
|
|
59
59
|
return VERDICT.RED;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
// Run statuses that mean "an attempt is still going". A run object reports the
|
|
63
|
+
// state of its LATEST attempt only, so a re-run of a red run reads as
|
|
64
|
+
// in-flight with no trace of the failure it is re-running.
|
|
65
|
+
const IN_FLIGHT_STATUSES = new Set(['queued', 'in_progress', 'waiting', 'requested', 'pending']);
|
|
66
|
+
|
|
67
|
+
function isInFlight(run) {
|
|
68
|
+
return Boolean(run) && IN_FLIGHT_STATUSES.has(run.status);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Pure, total: the "list workflow runs" payload (newest first, ANY status) ->
|
|
73
|
+
* the verdict, the run it rests on, and why. Never throws.
|
|
74
|
+
*
|
|
75
|
+
* A completed newest run is judged by classifyRunConclusion. An in-flight
|
|
76
|
+
* newest run is never read as healthy on its own:
|
|
77
|
+
* - a re-run (`run_attempt` > 1) whose previous attempt did not succeed is RED
|
|
78
|
+
* — the fetcher attaches that attempt's conclusion as
|
|
79
|
+
* `previous_attempt_conclusion`, because the list endpoint cannot show it;
|
|
80
|
+
* - otherwise the newest SETTLED run decides (red stays RED, green is CLEAN).
|
|
81
|
+
* This is what stops a mid-flight re-run from hiding the failure it re-runs
|
|
82
|
+
* (2026-10-27: it let #5755 and #5750 through behind a red `next`).
|
|
83
|
+
*
|
|
84
|
+
* @returns {{verdict:string, run:(object|null), reason:string}}
|
|
85
|
+
*/
|
|
86
|
+
function classifyBaseRuns(payload) {
|
|
87
|
+
if (payload === null || typeof payload !== 'object' || !Array.isArray(payload.workflow_runs)) {
|
|
88
|
+
return { verdict: VERDICT.INDETERMINATE, run: null, reason: 'malformed-payload' };
|
|
89
|
+
}
|
|
90
|
+
const runs = payload.workflow_runs;
|
|
91
|
+
if (runs.length === 0) return { verdict: VERDICT.CLEAN, run: null, reason: 'resolved' };
|
|
92
|
+
|
|
93
|
+
const [newest] = runs;
|
|
94
|
+
if (!isInFlight(newest)) {
|
|
95
|
+
return {
|
|
96
|
+
verdict: classifyRunConclusion({ workflow_runs: [newest] }),
|
|
97
|
+
run: newest,
|
|
98
|
+
reason: 'resolved',
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const prior = newest.previous_attempt_conclusion;
|
|
103
|
+
if (typeof prior === 'string' && prior !== '' && prior !== 'success') {
|
|
104
|
+
return { verdict: VERDICT.RED, run: newest, reason: 'rerun-in-flight-after-failure' };
|
|
105
|
+
}
|
|
106
|
+
// A re-run of an attempt that succeeded: the newest evidence is green, so an
|
|
107
|
+
// older red run must not turn the gate red.
|
|
108
|
+
if (prior === 'success') {
|
|
109
|
+
return { verdict: VERDICT.CLEAN, run: newest, reason: 'rerun-in-flight-after-success' };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const settled = runs.find((run) => !isInFlight(run));
|
|
113
|
+
if (!settled) return { verdict: VERDICT.CLEAN, run: null, reason: 'newest-in-flight-no-history' };
|
|
114
|
+
return {
|
|
115
|
+
verdict: classifyRunConclusion({ workflow_runs: [settled] }),
|
|
116
|
+
run: settled,
|
|
117
|
+
reason: 'newest-in-flight',
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// merge_group heads are `gh-readonly-queue/<base>/pr-<N>-<40-hex sha>`; the
|
|
122
|
+
// name carries only the LAST PR of the group.
|
|
123
|
+
const QUEUE_REF_PATTERN = /^(?:refs\/heads\/)?gh-readonly-queue\/(.+)\/pr-(\d+)-[0-9a-f]{40}$/;
|
|
124
|
+
|
|
125
|
+
/** @returns {{base:string, prNumber:number}|null} */
|
|
126
|
+
function parseQueueRef(ref) {
|
|
127
|
+
if (typeof ref !== 'string') return null;
|
|
128
|
+
const match = QUEUE_REF_PATTERN.exec(ref.trim());
|
|
129
|
+
if (!match) return null;
|
|
130
|
+
return { base: match[1], prNumber: Number(match[2]) };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Repository permissions that may apply the bypass label. `maintain` is
|
|
134
|
+
// reported as `write` by this endpoint's legacy `permission` field; both are
|
|
135
|
+
// accepted so a role-name change cannot silently lock the bypass out.
|
|
136
|
+
const BYPASS_PERMISSIONS = new Set(['admin', 'maintain', 'write']);
|
|
137
|
+
const EVENT_PAGES_MAX = 10;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* merge_group has no label surface, so read it back from the API. The bypass
|
|
141
|
+
* is honoured only when ALL hold, and every failure is "not authorized" (RED),
|
|
142
|
+
* never fail-open:
|
|
143
|
+
* 1. the ref names a PR and the group holds exactly that one commit — a group
|
|
144
|
+
* of several PRs would let one labelled PR carry unlabelled ones;
|
|
145
|
+
* 2. the PR currently carries the label;
|
|
146
|
+
* 3. the actor of the label's latest `labeled` event has write or admin
|
|
147
|
+
* permission (triage-role users can apply labels).
|
|
148
|
+
*
|
|
149
|
+
* `ghGet(path)` returns parsed JSON or throws.
|
|
150
|
+
*
|
|
151
|
+
* @returns {Promise<{authorized:boolean, reason:string, actor?:string, prNumber?:number}>}
|
|
152
|
+
*/
|
|
153
|
+
async function evaluateQueueBypass({ headRef, baseRef, baseSha, headSha, repo, ghGet } = {}) {
|
|
154
|
+
const parsed = parseQueueRef(headRef);
|
|
155
|
+
if (!parsed) return { authorized: false, reason: 'queue-ref-unparseable' };
|
|
156
|
+
// The queue branch must be for the branch whose health is being judged.
|
|
157
|
+
if (typeof baseRef === 'string' && baseRef !== '' && parsed.base !== baseRef) {
|
|
158
|
+
return { authorized: false, reason: 'queue-base-mismatch', prNumber: parsed.prNumber };
|
|
159
|
+
}
|
|
160
|
+
const { prNumber } = parsed;
|
|
161
|
+
if (!baseSha || !headSha) return { authorized: false, reason: 'no-group-shas', prNumber };
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
const compare = await ghGet(`/repos/${repo}/compare/${baseSha}...${headSha}`);
|
|
165
|
+
if (!compare || compare.total_commits !== 1) {
|
|
166
|
+
return { authorized: false, reason: 'multi-pr-group', prNumber };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const labels = await ghGet(`/repos/${repo}/issues/${prNumber}/labels?per_page=100`);
|
|
170
|
+
const hasLabel = Array.isArray(labels) && labels.some((l) => l && l.name === BYPASS_LABEL);
|
|
171
|
+
if (!hasLabel) return { authorized: false, reason: 'no-label', prNumber };
|
|
172
|
+
|
|
173
|
+
let actor = null;
|
|
174
|
+
let exhausted = false;
|
|
175
|
+
for (let page = 1; page <= EVENT_PAGES_MAX; page += 1) {
|
|
176
|
+
const events = await ghGet(`/repos/${repo}/issues/${prNumber}/events?per_page=100&page=${page}`);
|
|
177
|
+
if (!Array.isArray(events)) break;
|
|
178
|
+
for (const event of events) {
|
|
179
|
+
if (event && event.event === 'labeled' && event.label && event.label.name === BYPASS_LABEL) {
|
|
180
|
+
actor = event.actor && typeof event.actor.login === 'string' ? event.actor.login : null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (events.length < 100) break;
|
|
184
|
+
if (page === EVENT_PAGES_MAX) exhausted = true;
|
|
185
|
+
}
|
|
186
|
+
// More events than we read: the latest `labeled` event may be unseen, so
|
|
187
|
+
// the applier is unknown. Fail closed.
|
|
188
|
+
if (exhausted) return { authorized: false, reason: 'label-applier-unknown', prNumber };
|
|
189
|
+
if (!actor) return { authorized: false, reason: 'label-applier-unknown', prNumber };
|
|
190
|
+
|
|
191
|
+
const perm = await ghGet(`/repos/${repo}/collaborators/${encodeURIComponent(actor)}/permission`);
|
|
192
|
+
const level = perm && typeof perm.permission === 'string' ? perm.permission : '';
|
|
193
|
+
if (!BYPASS_PERMISSIONS.has(level)) {
|
|
194
|
+
return { authorized: false, reason: 'label-applier-lacks-write', actor, prNumber };
|
|
195
|
+
}
|
|
196
|
+
return { authorized: true, reason: 'queue-label', actor, prNumber };
|
|
197
|
+
} catch (err) {
|
|
198
|
+
return { authorized: false, reason: `bypass-check-failed: ${err.message}`, prNumber };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
62
202
|
/**
|
|
63
203
|
* Dependency-injected orchestrator. `fetchLatestRun` is an async function
|
|
64
204
|
* taking the resolved base branch name and returning the parsed API payload
|
|
65
205
|
* (or throwing) — injected so tests never touch the network.
|
|
206
|
+
* `evaluateQueueBypass` (merge_group only) is an async function returning
|
|
207
|
+
* `{authorized, reason, actor}`; it is how a queued fix-forward is recognised.
|
|
66
208
|
*
|
|
67
209
|
* @returns {Promise<{verdict:string, payload:*, reason:string}>}
|
|
68
210
|
*/
|
|
69
|
-
async function resolveNextHealth({
|
|
211
|
+
async function resolveNextHealth({
|
|
212
|
+
fetchLatestRun, eventName, baseRef, labels, evaluateQueueBypass: queueBypass,
|
|
213
|
+
} = {}) {
|
|
70
214
|
if (!APPLICABLE_EVENTS.has(eventName)) {
|
|
71
215
|
return { verdict: VERDICT.SKIPPED_NOT_APPLICABLE, payload: null, reason: 'not-applicable-event' };
|
|
72
216
|
}
|
|
@@ -82,23 +226,46 @@ async function resolveNextHealth({ fetchLatestRun, eventName, baseRef, labels }
|
|
|
82
226
|
return { verdict: VERDICT.INDETERMINATE, payload: null, reason: `fetch-failed: ${err.message}` };
|
|
83
227
|
}
|
|
84
228
|
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
229
|
+
const assessed = classifyBaseRuns(payload);
|
|
230
|
+
// The run the verdict rests on, so annotations link the red run and not the
|
|
231
|
+
// in-flight re-run that sits in front of it.
|
|
232
|
+
const evidence = assessed.run ? { workflow_runs: [assessed.run] } : payload;
|
|
233
|
+
if (assessed.verdict !== VERDICT.RED) {
|
|
234
|
+
const reason = assessed.reason === 'resolved' ? 'resolved' : assessed.reason;
|
|
235
|
+
return { verdict: assessed.verdict, payload: evidence, reason };
|
|
88
236
|
}
|
|
89
237
|
|
|
90
|
-
// Escape hatch
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
// fix-forward as a direct pull_request merge (where the label IS readable)
|
|
94
|
-
// rather than through the merge queue, until GitHub exposes an equivalent
|
|
95
|
-
// signal for merge_group.
|
|
238
|
+
// Escape hatch for the PR that is itself the fix-forward. pull_request runs
|
|
239
|
+
// read the label from the event (`labels`). merge_group events carry no
|
|
240
|
+
// labels, so the queue run asks the API instead (evaluateQueueBypass).
|
|
96
241
|
const labelList = Array.isArray(labels) ? labels : [];
|
|
97
242
|
if (labelList.includes(BYPASS_LABEL)) {
|
|
98
|
-
return { verdict: VERDICT.BYPASSED, payload, reason: 'bypass-label' };
|
|
243
|
+
return { verdict: VERDICT.BYPASSED, payload: evidence, reason: 'bypass-label' };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (eventName === 'merge_group' && typeof queueBypass === 'function') {
|
|
247
|
+
let decision;
|
|
248
|
+
try {
|
|
249
|
+
decision = await queueBypass();
|
|
250
|
+
} catch (err) {
|
|
251
|
+
decision = { authorized: false, reason: `bypass-check-failed: ${err.message}` };
|
|
252
|
+
}
|
|
253
|
+
if (decision && decision.authorized) {
|
|
254
|
+
return {
|
|
255
|
+
verdict: VERDICT.BYPASSED,
|
|
256
|
+
payload: evidence,
|
|
257
|
+
reason: decision.reason || 'queue-label',
|
|
258
|
+
actor: decision.actor,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
verdict: VERDICT.RED,
|
|
263
|
+
payload: evidence,
|
|
264
|
+
reason: `${assessed.reason === 'resolved' ? 'red' : assessed.reason}; bypass: ${decision && decision.reason}`,
|
|
265
|
+
};
|
|
99
266
|
}
|
|
100
267
|
|
|
101
|
-
return { verdict: VERDICT.RED, payload, reason: 'red' };
|
|
268
|
+
return { verdict: VERDICT.RED, payload: evidence, reason: assessed.reason === 'resolved' ? 'red' : assessed.reason };
|
|
102
269
|
}
|
|
103
270
|
|
|
104
271
|
function usage() {
|
|
@@ -108,9 +275,15 @@ function usage() {
|
|
|
108
275
|
'',
|
|
109
276
|
'CI-only gate: checks whether the PR/merge-group\'s base branch\'s own last',
|
|
110
277
|
'push-triggered Tests run is red, and fails the job (exit 1) when it is —',
|
|
111
|
-
'unless the PR carries the "fix-next" bypass label.
|
|
112
|
-
'
|
|
113
|
-
'
|
|
278
|
+
'unless the PR carries the "fix-next" bypass label. A push run that is',
|
|
279
|
+
'still in flight (including a re-run of a failed run) never counts as',
|
|
280
|
+
'healthy: a re-run whose previous attempt failed is RED, otherwise the',
|
|
281
|
+
'newest settled run decides. Every other case (wrong event, no',
|
|
282
|
+
'resolvable base ref, an unreadable API read) fails open (exit 0).',
|
|
283
|
+
'',
|
|
284
|
+
'On merge_group the label is read back from the API: the queue ref must',
|
|
285
|
+
'name a PR, the group must hold exactly one commit, the PR must carry the',
|
|
286
|
+
'label, and whoever applied it must have write or admin permission.',
|
|
114
287
|
'',
|
|
115
288
|
'Environment variables read:',
|
|
116
289
|
' GITHUB_EVENT_NAME workflow trigger event; only "pull_request" and',
|
|
@@ -128,6 +301,12 @@ function usage() {
|
|
|
128
301
|
' the caller workflow must populate this from',
|
|
129
302
|
' github.event.pull_request.labels.*.name); checked',
|
|
130
303
|
' for the literal "fix-next" bypass label',
|
|
304
|
+
' MERGE_GROUP_HEAD_REF merge-group queue branch',
|
|
305
|
+
' (github.event.merge_group.head_ref; falls back to',
|
|
306
|
+
' GITHUB_REF)',
|
|
307
|
+
' MERGE_GROUP_BASE_SHA merge-group base sha (github.event.merge_group.base_sha)',
|
|
308
|
+
' MERGE_GROUP_HEAD_SHA merge-group head sha (github.event.merge_group.head_sha;',
|
|
309
|
+
' falls back to GITHUB_SHA)',
|
|
131
310
|
' GITHUB_OUTPUT path to append verdict= step output to',
|
|
132
311
|
' GITHUB_STEP_SUMMARY path to append a human-readable summary to',
|
|
133
312
|
].join('\n');
|
|
@@ -200,9 +379,10 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
200
379
|
const baseRef = resolveBaseRef(eventName);
|
|
201
380
|
const labels = parseLabels(process.env.PR_LABELS);
|
|
202
381
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
382
|
+
// JSON GET against the API. `path` is either absolute-from-root (starts with
|
|
383
|
+
// `/`) or a full URL. Returns parsed JSON (null if the body is not JSON).
|
|
384
|
+
const ghGet = async (pathOrUrl) => {
|
|
385
|
+
const url = pathOrUrl.startsWith('http') ? pathOrUrl : `${apiBase}${pathOrUrl}`;
|
|
206
386
|
const headers = {
|
|
207
387
|
accept: 'application/vnd.github+json',
|
|
208
388
|
'x-github-api-version': '2022-11-28',
|
|
@@ -221,26 +401,66 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
221
401
|
}
|
|
222
402
|
};
|
|
223
403
|
|
|
224
|
-
|
|
404
|
+
// No `status=completed` filter: an in-flight re-run must be visible so it
|
|
405
|
+
// cannot hide the failure it is re-running (see classifyBaseRuns).
|
|
406
|
+
const fetchLatestRun = async (branch) => {
|
|
407
|
+
const payload = await ghGet(
|
|
408
|
+
`/repos/${repo}/actions/workflows/${TESTS_WORKFLOW_FILE}/runs`
|
|
409
|
+
+ `?branch=${encodeURIComponent(branch)}&event=push&per_page=5`,
|
|
410
|
+
);
|
|
411
|
+
const newest = payload && Array.isArray(payload.workflow_runs) ? payload.workflow_runs[0] : undefined;
|
|
412
|
+
if (isInFlight(newest) && Number(newest.run_attempt) > 1) {
|
|
413
|
+
// The list shows only the latest attempt; the previous one holds the
|
|
414
|
+
// failure being re-run. An API error here propagates -> INDETERMINATE.
|
|
415
|
+
// If this read fails we cannot show the re-run is not hiding a failure,
|
|
416
|
+
// and failing open here would reopen the hole this closes: treat an
|
|
417
|
+
// unreadable previous attempt as not-success (RED).
|
|
418
|
+
try {
|
|
419
|
+
const previous = await ghGet(
|
|
420
|
+
`/repos/${repo}/actions/runs/${newest.id}/attempts/${Number(newest.run_attempt) - 1}`,
|
|
421
|
+
);
|
|
422
|
+
newest.previous_attempt_conclusion = previous && typeof previous.conclusion === 'string'
|
|
423
|
+
? previous.conclusion : 'unknown';
|
|
424
|
+
} catch {
|
|
425
|
+
newest.previous_attempt_conclusion = 'unknown';
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return payload;
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
const evaluateQueue = () => evaluateQueueBypass({
|
|
432
|
+
headRef: process.env.MERGE_GROUP_HEAD_REF || process.env.GITHUB_REF || '',
|
|
433
|
+
baseRef,
|
|
434
|
+
baseSha: process.env.MERGE_GROUP_BASE_SHA || '',
|
|
435
|
+
headSha: process.env.MERGE_GROUP_HEAD_SHA || process.env.GITHUB_SHA || '',
|
|
436
|
+
repo,
|
|
437
|
+
ghGet,
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
const result = await resolveNextHealth({
|
|
441
|
+
fetchLatestRun, eventName, baseRef, labels, evaluateQueueBypass: evaluateQueue,
|
|
442
|
+
});
|
|
225
443
|
|
|
226
444
|
writeOutput([`verdict=${result.verdict}`]);
|
|
227
|
-
writeSummary(`Base branch health: ${result.verdict}`);
|
|
445
|
+
writeSummary(`Base branch health: ${result.verdict}${result.reason ? ` (${result.reason})` : ''}`);
|
|
228
446
|
|
|
229
447
|
if (result.verdict === VERDICT.RED) {
|
|
230
448
|
const runUrl = runUrlOf(result.payload);
|
|
231
449
|
process.stderr.write(
|
|
232
|
-
`::error::the base branch "${baseRef}"'s own last Tests run is red: ${runUrl} — `
|
|
450
|
+
`::error::the base branch "${baseRef}"'s own last Tests run is red or still re-running after a failure: ${runUrl} (${result.reason}) — `
|
|
233
451
|
+ 'wait for a fix-forward merge, or if THIS pull request is the fix, ask a '
|
|
234
|
-
+ `maintainer to apply the "${BYPASS_LABEL}" label
|
|
452
|
+
+ `maintainer with write access to apply the "${BYPASS_LABEL}" label and re-queue it `
|
|
453
|
+
+ '(the queue run reads the label from the API; it must be the only PR in its merge group).\n',
|
|
235
454
|
);
|
|
236
455
|
return 1;
|
|
237
456
|
}
|
|
238
457
|
|
|
239
458
|
if (result.verdict === VERDICT.BYPASSED) {
|
|
240
459
|
const runUrl = runUrlOf(result.payload);
|
|
460
|
+
const by = result.actor ? ` (label applied by ${result.actor})` : '';
|
|
241
461
|
process.stderr.write(
|
|
242
462
|
`::warning::the base branch "${baseRef}"'s own last Tests run is red: ${runUrl} — `
|
|
243
|
-
+ `a human applied the "${BYPASS_LABEL}" label to explicitly override this gate.\n`,
|
|
463
|
+
+ `a human applied the "${BYPASS_LABEL}" label to explicitly override this gate${by}.\n`,
|
|
244
464
|
);
|
|
245
465
|
return 0;
|
|
246
466
|
}
|
|
@@ -266,6 +486,9 @@ module.exports = {
|
|
|
266
486
|
BYPASS_LABEL,
|
|
267
487
|
TESTS_WORKFLOW_FILE,
|
|
268
488
|
classifyRunConclusion,
|
|
489
|
+
classifyBaseRuns,
|
|
490
|
+
parseQueueRef,
|
|
491
|
+
evaluateQueueBypass,
|
|
269
492
|
resolveNextHealth,
|
|
270
493
|
main,
|
|
271
494
|
};
|