Skip to content

Commit fc2fd39

Browse files
authored
Merge branch 'github:main' into fix/4443-yaml-runtime-fallback
2 parents dd0114a + 67ab049 commit fc2fd39

528 files changed

Lines changed: 62029 additions & 37578 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/ISSUE_TEMPLATE/agent_request.yml‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,3 +139,16 @@ body:
139139
label: Additional Context
140140
description: Any other relevant information about this agent
141141
placeholder: Screenshots, community links, comparison to existing agents, etc.
142+
143+
- type: textarea
144+
id: ai-disclosure
145+
attributes:
146+
label: AI Disclosure
147+
description: >-
148+
Per our [Contributing guidelines](https://github.com/github/spec-kit/blob/main/CONTRIBUTING.md#ai-contributions-in-spec-kit),
149+
any AI assistance used to create this issue must be disclosed. If you used AI, name the agent/tool, model(s),
150+
settings/mode (reasoning effort; autonomous vs. human-supervised), and extent. Otherwise leave this as "N/A".
151+
This does not change how your issue is handled — it just gives maintainers visibility into model/agent usage.
152+
value: "N/A"
153+
validations:
154+
required: true

‎.github/ISSUE_TEMPLATE/bug_report.yml‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,3 +138,16 @@ body:
138138
label: Additional Context
139139
description: Add any other context about the problem
140140
placeholder: Screenshots, related issues, workarounds attempted, etc.
141+
142+
- type: textarea
143+
id: ai-disclosure
144+
attributes:
145+
label: AI Disclosure
146+
description: >-
147+
Per our [Contributing guidelines](https://github.com/github/spec-kit/blob/main/CONTRIBUTING.md#ai-contributions-in-spec-kit),
148+
any AI assistance used to create this issue must be disclosed. If you used AI, name the agent/tool, model(s),
149+
settings/mode (reasoning effort; autonomous vs. human-supervised), and extent. Otherwise leave this as "N/A".
150+
This does not change how your issue is handled — it just gives maintainers visibility into model/agent usage.
151+
value: "N/A"
152+
validations:
153+
required: true

‎.github/ISSUE_TEMPLATE/feature_request.yml‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,3 +124,16 @@ body:
124124
label: Additional Context
125125
description: Add any other context, screenshots, or examples
126126
placeholder: Links to similar features, mockups, related discussions, etc.
127+
128+
- type: textarea
129+
id: ai-disclosure
130+
attributes:
131+
label: AI Disclosure
132+
description: >-
133+
Per our [Contributing guidelines](https://github.com/github/spec-kit/blob/main/CONTRIBUTING.md#ai-contributions-in-spec-kit),
134+
any AI assistance used to create this issue must be disclosed. If you used AI, name the agent/tool, model(s),
135+
settings/mode (reasoning effort; autonomous vs. human-supervised), and extent. Otherwise leave this as "N/A".
136+
This does not change how your issue is handled — it just gives maintainers visibility into model/agent usage.
137+
value: "N/A"
138+
validations:
139+
required: true

‎.github/PULL_REQUEST_TEMPLATE.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
<!-- See: https://github.com/github/spec-kit/blob/main/CONTRIBUTING.md#ai-contributions-in-spec-kit -->
1717

1818
- [ ] I **did not** use AI assistance for this contribution
19-
- [ ] I **did** use AI assistance (describe below)
19+
- [ ] I **did** use AI assistance (fill in the disclosure below)
2020

21-
<!-- If you used AI, briefly describe how (e.g., "Code generated by Copilot", "Consulted ChatGPT for approach"): -->
21+
<!-- If you used AI assistance, replace "N/A" below with a free-form disclosure that names the agent/tool, model(s), settings/mode (reasoning effort; autonomous vs. human-supervised), and extent (docs vs. code generation). This does not change how much scrutiny your PR receives — the same evidence bar applies to everyone. It just gives maintainers visibility into model/agent usage. -->
22+
23+
**AI disclosure:** N/A

‎.github/aw/actions-lock.json‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,20 +25,25 @@
2525
"version": "v7.0.0",
2626
"sha": "5fda3b95a4ea91299a34e894583c3862153e4b97"
2727
},
28+
"actions/upload-artifact@v7.0.1": {
29+
"repo": "actions/upload-artifact",
30+
"version": "v7.0.1",
31+
"sha": "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a"
32+
},
2833
"astral-sh/setup-uv@v10.0.1": {
2934
"repo": "astral-sh/setup-uv",
3035
"version": "v10.0.1",
3136
"sha": "20cfd1bf945f4377ade1205e4dbc17946fc9a30d"
3237
},
33-
"actions/upload-artifact@v7.0.1": {
34-
"repo": "actions/upload-artifact",
35-
"version": "v7.0.1",
36-
"sha": "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a"
38+
"github/gh-aw-actions/setup-cli@v0.88.7": {
39+
"repo": "github/gh-aw-actions/setup-cli",
40+
"version": "v0.88.7",
41+
"sha": "5e508589e03a7757a7e05b26e834292f5445bfb6"
3742
},
38-
"github/gh-aw-actions/setup@v0.79.8": {
43+
"github/gh-aw-actions/setup@v0.88.7": {
3944
"repo": "github/gh-aw-actions/setup",
40-
"version": "v0.79.8",
41-
"sha": "c0338fef4749d08c21f8f975fb0e37efa17dda47"
45+
"version": "v0.88.7",
46+
"sha": "5e508589e03a7757a7e05b26e834292f5445bfb6"
4247
}
4348
}
4449
}
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
#!/usr/bin/env python3
2+
"""Fail a PR that changes bundled extension content without a version bump.
3+
4+
Update offers from `specify extension update` are version-driven: an
5+
extension is offered (and installed) only when the semver in
6+
`extensions/catalog.json` exceeds the installed copy's registered
7+
version. A content change shipped without a version bump is therefore
8+
never delivered automatically (#4345) — a bump is what makes a change
9+
actually reach existing installs, and this guard is what makes the bump
10+
non-optional.
11+
12+
This check enforces two invariants on the extensions listed in
13+
`extensions/catalog.json`:
14+
15+
1. Any change to a file under `extensions/<id>/` must increase the
16+
`version:` in that extension's `extension.yml` (PEP 440 comparison,
17+
the same semantics `extension update` uses), and the resulting
18+
version must itself parse as PEP 440 — including for a brand-new
19+
extension, since the CLI rejects a manifest whose version it cannot
20+
parse.
21+
2. Every `version` in `extensions/catalog.json` must parse as PEP 440
22+
(`extension update` skips entries it cannot parse), and for entries
23+
with an in-repo directory it must equal a likewise-valid
24+
`extension.version` in the manifest (the catalog is what update
25+
checks compare against, and the update preflight rejects a manifest
26+
whose version differs from the catalog's). This runs over every
27+
catalog entry, so catalog-only (hosted) entries and catalog-only
28+
promotions of existing directories are covered too.
29+
30+
Usage:
31+
check_extension_version_bump.py BASE_REF [HEAD_REF]
32+
33+
BASE_REF is a git ref/SHA for the PR base (must be fetchable with
34+
`git show`). HEAD_REF defaults to the working tree's HEAD. Exits 0 when
35+
all invariants hold, 1 otherwise, printing one line per violation.
36+
37+
Extensions under `extensions/` that are not in the catalog (the
38+
`selftest` fixture and the `template` scaffold) are exempt: no update
39+
flow is driven by their versions.
40+
"""
41+
42+
from __future__ import annotations
43+
44+
import json
45+
import subprocess
46+
import sys
47+
from pathlib import PurePosixPath
48+
49+
import yaml
50+
from packaging.version import InvalidVersion, Version
51+
52+
EXTENSIONS_ROOT = "extensions"
53+
CATALOG_PATH = f"{EXTENSIONS_ROOT}/catalog.json"
54+
55+
56+
def _changed_paths(base_ref: str, head_ref: str) -> list[str]:
57+
"""Paths under extensions/ that differ between *base_ref* and *head_ref*.
58+
59+
Uses NUL-delimited output (``-z``): without it git C-quotes any path
60+
containing non-ASCII or control characters (``"extensions/x/caf\\303\\251"``,
61+
quotes included), so the leading component would no longer equal
62+
``extensions`` and that change would silently escape the guard. Paths
63+
are decoded with surrogateescape so an undecodable byte can never crash
64+
the check; only the ASCII ``extensions/<id>/`` prefix is interpreted.
65+
"""
66+
raw = subprocess.run(
67+
[
68+
"git", "diff", "--name-only", "-z", "--no-renames",
69+
base_ref, head_ref, "--", EXTENSIONS_ROOT,
70+
],
71+
check=True,
72+
capture_output=True,
73+
).stdout
74+
return [
75+
chunk.decode("utf-8", errors="surrogateescape")
76+
for chunk in raw.split(b"\0")
77+
if chunk
78+
]
79+
80+
81+
def _show(ref: str, path: str) -> str | None:
82+
"""Return the file's content at *ref*, or None when absent there."""
83+
result = subprocess.run(
84+
["git", "show", f"{ref}:{path}"], capture_output=True, text=True
85+
)
86+
return result.stdout if result.returncode == 0 else None
87+
88+
89+
def _manifest_version(manifest_text: str, origin: str) -> str:
90+
data = yaml.safe_load(manifest_text)
91+
if not isinstance(data, dict) or not isinstance(data.get("extension"), dict):
92+
raise ValueError(f"{origin}: manifest is not a mapping with an 'extension' block")
93+
version = data["extension"].get("version")
94+
if not isinstance(version, str) or not version.strip():
95+
raise ValueError(f"{origin}: extension.version is missing or not a string")
96+
return version.strip()
97+
98+
99+
def main(argv: list[str]) -> int:
100+
if len(argv) < 2 or len(argv) > 3:
101+
print(__doc__, file=sys.stderr)
102+
return 2
103+
base_ref = argv[1]
104+
head_ref = argv[2] if len(argv) == 3 else "HEAD"
105+
106+
catalog_text = _show(head_ref, CATALOG_PATH)
107+
if catalog_text is None:
108+
print(f"::error::{CATALOG_PATH} is missing at {head_ref}")
109+
return 1
110+
catalog = json.loads(catalog_text)
111+
catalog_entries = catalog.get("extensions", {})
112+
113+
errors: list[str] = []
114+
115+
# -- Invariant 1: content change requires a version bump ---------------
116+
changed_ids = {
117+
parts[1]
118+
for path in _changed_paths(base_ref, head_ref)
119+
if len(parts := PurePosixPath(path).parts) >= 3 and parts[0] == EXTENSIONS_ROOT
120+
}
121+
122+
for ext_id in sorted(changed_ids):
123+
if ext_id not in catalog_entries:
124+
continue # not driven by `extension update` (selftest, template)
125+
manifest_path = f"{EXTENSIONS_ROOT}/{ext_id}/extension.yml"
126+
head_manifest = _show(head_ref, manifest_path)
127+
if head_manifest is None:
128+
continue # extension removed in this PR
129+
try:
130+
head_version = _manifest_version(head_manifest, f"{head_ref}:{manifest_path}")
131+
except ValueError as exc:
132+
errors.append(str(exc))
133+
continue
134+
135+
# Parse the head version before the new-extension early return: the
136+
# CLI's ExtensionManifest rejects a version packaging cannot parse and
137+
# `extension update` skips catalog entries whose version is invalid,
138+
# so a new extension shipped with e.g. "not-a-version" in both places
139+
# would be uninstallable even though the catalog check below (plain
140+
# string equality) passes. Same PEP 440 semantics as the CLI, so
141+
# prereleases and other accepted forms are handled identically.
142+
try:
143+
head_parsed = Version(head_version)
144+
except InvalidVersion as exc:
145+
errors.append(
146+
f"{manifest_path}: extension.version {head_version!r} is not a valid "
147+
f"PEP 440 version ({exc}); the CLI rejects this manifest."
148+
)
149+
continue
150+
151+
base_manifest = _show(base_ref, manifest_path)
152+
if base_manifest is None:
153+
continue # new extension; any valid initial version is fine
154+
try:
155+
base_version = _manifest_version(base_manifest, f"{base_ref}:{manifest_path}")
156+
except ValueError as exc:
157+
errors.append(str(exc))
158+
continue
159+
160+
# Compare with the same PEP 440 semantics the extension update and
161+
# install code use, so prereleases and other accepted forms cannot
162+
# bypass the guard (e.g. 2.0.0 -> 1.0.0rc1 is a downgrade). An
163+
# unparseable base version fails closed.
164+
try:
165+
base_parsed = Version(base_version)
166+
except InvalidVersion as exc:
167+
errors.append(
168+
f"{manifest_path}: could not compare versions "
169+
f"{base_version!r} -> {head_version!r}: {exc}"
170+
)
171+
continue
172+
if head_parsed <= base_parsed:
173+
errors.append(
174+
f"{manifest_path}: files under {EXTENSIONS_ROOT}/{ext_id}/ changed but "
175+
f"extension.version did not increase ({base_version} -> {head_version}). "
176+
f"Installed copies only receive changes when the version is bumped."
177+
)
178+
179+
# -- Invariant 2: catalog versions are valid and match the manifests ----
180+
# Runs over every catalog entry, changed or not: a catalog-only entry
181+
# (hosted elsewhere) never has files under extensions/<id>/, and a
182+
# catalog-only promotion of an existing uncataloged directory never
183+
# enters Invariant 1, so neither would otherwise have its version parsed.
184+
for ext_id, entry in sorted(catalog_entries.items()):
185+
catalog_version = entry.get("version") if isinstance(entry, dict) else None
186+
if not isinstance(catalog_version, str) or not catalog_version.strip():
187+
errors.append(f"{CATALOG_PATH}: entry '{ext_id}' has no string 'version'")
188+
continue
189+
# `extension update` skips a catalog entry whose version packaging
190+
# cannot parse, so an invalid catalog version is never offered.
191+
try:
192+
Version(catalog_version)
193+
except InvalidVersion as exc:
194+
errors.append(
195+
f"{CATALOG_PATH}: entry '{ext_id}' version {catalog_version!r} is not a "
196+
f"valid PEP 440 version ({exc}); `extension update` skips such entries."
197+
)
198+
continue
199+
200+
manifest_path = f"{EXTENSIONS_ROOT}/{ext_id}/extension.yml"
201+
head_manifest = _show(head_ref, manifest_path)
202+
if head_manifest is None:
203+
continue # catalog-only entry (e.g. hosted elsewhere); version checked above
204+
try:
205+
manifest_version = _manifest_version(head_manifest, f"{head_ref}:{manifest_path}")
206+
except ValueError as exc:
207+
errors.append(str(exc))
208+
continue
209+
try:
210+
Version(manifest_version)
211+
except InvalidVersion as exc:
212+
errors.append(
213+
f"{manifest_path}: extension.version {manifest_version!r} is not a valid "
214+
f"PEP 440 version ({exc}); the CLI rejects this manifest."
215+
)
216+
continue
217+
if catalog_version != manifest_version:
218+
errors.append(
219+
f"{CATALOG_PATH}: entry '{ext_id}' has version {catalog_version!r} but "
220+
f"{manifest_path} declares {manifest_version!r}. `extension update` "
221+
f"compares against the catalog, so the two must move together."
222+
)
223+
224+
for error in errors:
225+
print(f"::error::{error}")
226+
if not errors:
227+
print("Extension version guard: all invariants hold.")
228+
return 1 if errors else 0
229+
230+
231+
if __name__ == "__main__":
232+
sys.exit(main(sys.argv))
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
name: code-review
3+
description: Reviews Spec Kit code changes for positive and negative test coverage, regression evidence for bug fixes, and consistent repository terminology. Use when reviewing a diff or pull request. Do not use for implementing changes or posting GitHub review actions.
4+
argument-hint: 'Diff or pull request to review'
5+
---
6+
7+
# Code Review
8+
9+
1. Ensure each code change has test cases that verify what the code should do and what it should prevent.
10+
2. Ensure bug-fix pull requests include a regression test that demonstrates the bug was reproducible before the change and is fixed afterward; if the reviewer cannot run the comparison, use available evidence and state that limitation.
11+
3. Ensure wording changes use repository-consistent terms; exclude community-authored catalog content and its generated documentation from this check.

0 commit comments

Comments
 (0)