Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot]
04fd3b8033 chore: bump version to 0.13.2 2026-07-21 17:47:26 +00:00
24 changed files with 37 additions and 1606 deletions

View File

@@ -1,115 +0,0 @@
"""Check that committed security audit requirements are up to date."""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
COMMITTED_REQUIREMENTS = REPO_ROOT / ".github" / "security-audit-requirements.txt"
DEPENDENCY_INPUTS = ("pyproject.toml", ".github/security-audit-requirements.txt")
def _dependency_diff_refs() -> tuple[str, str]:
base_ref = os.environ.get("DEPENDENCY_DIFF_BASE", "").strip()
head_ref = os.environ.get("DEPENDENCY_DIFF_HEAD", "").strip() or "HEAD"
if base_ref and not set(base_ref) <= {"0"}:
return base_ref, head_ref
# Fallback when no usable base is supplied (push with an all-zero
# ``github.event.before``, manual dispatch, etc.). ``HEAD^`` fails on a
# shallow checkout or a single-commit repo; that ``git diff`` error is
# caught by the caller and deliberately treated as "inputs changed" so the
# audit runs anyway — failing safe (audit) rather than skipping silently.
return "HEAD^", "HEAD"
def _dependency_inputs_changed() -> bool:
base_ref, head_ref = _dependency_diff_refs()
try:
result = subprocess.run(
[
"git",
"diff",
"--name-only",
base_ref,
head_ref,
"--",
*DEPENDENCY_INPUTS,
],
check=True,
cwd=REPO_ROOT,
stderr=subprocess.PIPE,
stdout=subprocess.PIPE,
text=True,
)
except subprocess.CalledProcessError as exc:
print(
"Could not determine changed dependency inputs; checking requirements.",
file=sys.stderr,
)
if exc.stderr:
print(exc.stderr.strip(), file=sys.stderr)
return True
changed_inputs = [line for line in result.stdout.splitlines() if line]
if not changed_inputs:
print("Dependency audit inputs unchanged; sync check skipped.")
return False
print(f"Dependency audit inputs changed: {', '.join(changed_inputs)}")
return True
def main() -> int:
if not _dependency_inputs_changed():
return 0
generated_requirements_env = os.environ.get("GENERATED_REQUIREMENTS", "").strip()
if not generated_requirements_env:
print(
"GENERATED_REQUIREMENTS must be set to the temporary output file path.",
file=sys.stderr,
)
return 1
generated_requirements = Path(generated_requirements_env)
generated_requirements.parent.mkdir(parents=True, exist_ok=True)
subprocess.run(
[
"uv",
"pip",
"compile",
"pyproject.toml",
"--extra",
"test",
"--universal",
"--upgrade",
"--generate-hashes",
"--quiet",
"--no-header",
"--output-file",
str(generated_requirements),
],
check=True,
cwd=REPO_ROOT,
)
committed = COMMITTED_REQUIREMENTS.read_text(encoding="utf-8")
generated = generated_requirements.read_text(encoding="utf-8")
if committed == generated:
return 0
print(
"Regenerate .github/security-audit-requirements.txt with the documented "
"uv pip compile command.",
file=sys.stderr,
)
return 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,253 +0,0 @@
annotated-doc==0.0.4 \
--hash=sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320 \
--hash=sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4
# via typer
click==8.4.2 \
--hash=sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6 \
--hash=sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76
# via specify-cli (pyproject.toml)
colorama==0.4.6 ; sys_platform == 'win32' \
--hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
--hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
# via
# click
# pytest
# typer
coverage==7.15.2 \
--hash=sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2 \
--hash=sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e \
--hash=sha256:094dd37f3ef7b2da8b068b583d1f4c40f91c65197e16c52a71962d5d537fc5db \
--hash=sha256:09f5c6ec5901f667bd97dd140b5b9a2586b10efec66f46fb1e6d8135f8b95bdf \
--hash=sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c \
--hash=sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8 \
--hash=sha256:1268ac8fb9ddcd783d3948dbabaf80a5d53bfdaa0575e873e2139a692f797443 \
--hash=sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a \
--hash=sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145 \
--hash=sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9 \
--hash=sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2 \
--hash=sha256:1d16e3a7104ea84f03e614611b3edbf6fb6892554b3ab0fe7fbb3f2b2ef04376 \
--hash=sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138 \
--hash=sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578 \
--hash=sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c \
--hash=sha256:29c052f7c83ccfcc5c577eaae025d2e4a9bb80daf03c0ac31c996e83b000ce88 \
--hash=sha256:2f1ec6f304b156669cfde653b4e9a953f5de87e247ea02ac599bce0ab2744036 \
--hash=sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c \
--hash=sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071 \
--hash=sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a \
--hash=sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d \
--hash=sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b \
--hash=sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a \
--hash=sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b \
--hash=sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050 \
--hash=sha256:44826758cfe73fcd0e6af5deb4ba6d5417cc1d13df3acb35c93484a11160f846 \
--hash=sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d \
--hash=sha256:48ccc6395958eda89093ecdc35644c86f23a8b23a7f4d44958812b721aad67c1 \
--hash=sha256:4d3361879d736f469f45723c11ea1a5bbdaf1f6928f0e632c940378b5aa9b660 \
--hash=sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40 \
--hash=sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026 \
--hash=sha256:67d7602480a47bdf5b675635403625553ebaa70d5a62a657c035149fd401cea0 \
--hash=sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b \
--hash=sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0 \
--hash=sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa \
--hash=sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d \
--hash=sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658 \
--hash=sha256:728a33676d4c3f0db977990a4bd421dcaa3be3e53b5b6273036fff6666008e89 \
--hash=sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072 \
--hash=sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199 \
--hash=sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446 \
--hash=sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743 \
--hash=sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7 \
--hash=sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1 \
--hash=sha256:7e8f27131dc7cd53de2c137dd207b3720919320b3c20d499dc30aa9ee6173287 \
--hash=sha256:81f382c5a94b434ec1f6da607edb904c76d7212e618cd4d1bc9f97bed4120ef5 \
--hash=sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be \
--hash=sha256:8bb9f4b4279187560796a4cdaca3b0a93dd97e48ee667df005f4ed9a97403688 \
--hash=sha256:8c726b232659cbd2ae57ade46509eb068c9bd7a06df9fcbff6fe484870006934 \
--hash=sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7 \
--hash=sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440 \
--hash=sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984 \
--hash=sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc \
--hash=sha256:9b5bd92ff1ec22e535eab0de75fa6db021992791f461a2aceb7822c625a1187d \
--hash=sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098 \
--hash=sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6 \
--hash=sha256:9f4432898c4bf2fba0435bbe35dd4437d7264565e5a88a21f5b49d8662a6b629 \
--hash=sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b \
--hash=sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee \
--hash=sha256:a29ec5305a7335aacee2d799e3422e91e1c8a12474986e2b3b07e315c91be82f \
--hash=sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1 \
--hash=sha256:a4c46b247b5d4b78f613bd89fea926d32b25c6cc61a50bd1e99ba310348f3dad \
--hash=sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3 \
--hash=sha256:a63b9e190711134d581c4d703df5df09851b1acf99792c7aacbbe9f41f0283c9 \
--hash=sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3 \
--hash=sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a \
--hash=sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296 \
--hash=sha256:affd532502d34c0472d0cdb181325c89f1d2c44992fef0c17e88e7b1576259a1 \
--hash=sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd \
--hash=sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73 \
--hash=sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f \
--hash=sha256:bbc808daf4f5cd567af8075ecc72d21c6dfef9a254709a621a84c217c935ebc0 \
--hash=sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6 \
--hash=sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5 \
--hash=sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1 \
--hash=sha256:c6a98d698f9e2c8008d0370ec7fc452ebfcc530002ae2d0061170d768b992589 \
--hash=sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688 \
--hash=sha256:cee0f89f4767a6057c8fbf168f8135f18be651300496086bd873e3189fed0487 \
--hash=sha256:d17d7512151fedfcc64c1821a8977fc9be0dbf495754669afcab7b57abc98ae9 \
--hash=sha256:d46e62cb35d91e6e2589fda6d28074426b0e276422b5d2ebef2c6b11dc60dbfd \
--hash=sha256:d50dd325e18ec25bfcc10cd7f99b04df1ab9ec76b0918c260e60817ad0643dee \
--hash=sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d \
--hash=sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d \
--hash=sha256:dfd3db045e95960ae3683059571e597fda7cc610106a8916f77c5839048c1deb \
--hash=sha256:e26ff680768b8095e8874aabe0e9d3a47a2a9f176a8340d05f8604c56457c23a \
--hash=sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328 \
--hash=sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635 \
--hash=sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188 \
--hash=sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c \
--hash=sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243 \
--hash=sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a
# via pytest-cov
iniconfig==2.3.0 \
--hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \
--hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12
# via pytest
json5==0.15.0 \
--hash=sha256:56636a30c0e8a4665fe2179c0212f32eae3796dea89ea6f649b9436ecdb39618 \
--hash=sha256:7424d1f1eb1d56da6e3d70643f53619862b4ce81440bdb8ecfd6f875e5ba4a71
# via specify-cli (pyproject.toml)
markdown-it-py==4.2.0 \
--hash=sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49 \
--hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a
# via rich
mdurl==0.1.2 \
--hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \
--hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba
# via markdown-it-py
packaging==26.2 \
--hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \
--hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661
# via
# specify-cli (pyproject.toml)
# pytest
pathspec==1.1.1 \
--hash=sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a \
--hash=sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189
# via specify-cli (pyproject.toml)
platformdirs==4.11.0 \
--hash=sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0 \
--hash=sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74
# via specify-cli (pyproject.toml)
pluggy==1.6.0 \
--hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \
--hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
# via
# pytest
# pytest-cov
pygments==2.20.0 \
--hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \
--hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176
# via
# pytest
# rich
pytest==9.1.1 \
--hash=sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313 \
--hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c
# via
# specify-cli (pyproject.toml)
# pytest-cov
pytest-cov==7.1.0 \
--hash=sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2 \
--hash=sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678
# via specify-cli (pyproject.toml)
pyyaml==6.0.3 \
--hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \
--hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \
--hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \
--hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \
--hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \
--hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \
--hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \
--hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \
--hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \
--hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \
--hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \
--hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \
--hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \
--hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \
--hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \
--hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \
--hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \
--hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \
--hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \
--hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \
--hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \
--hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \
--hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \
--hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \
--hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \
--hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \
--hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \
--hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \
--hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \
--hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \
--hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \
--hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \
--hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \
--hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \
--hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \
--hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \
--hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \
--hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \
--hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \
--hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \
--hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \
--hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \
--hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \
--hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \
--hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \
--hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \
--hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \
--hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \
--hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \
--hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \
--hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \
--hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \
--hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \
--hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \
--hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \
--hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \
--hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \
--hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \
--hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \
--hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \
--hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \
--hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \
--hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \
--hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \
--hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \
--hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \
--hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \
--hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \
--hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \
--hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \
--hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \
--hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \
--hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0
# via specify-cli (pyproject.toml)
readchar==4.2.2 \
--hash=sha256:92daf7e42c52b0787e6c75d01ecfb9a94f4ceff3764958b570c1dddedd47b200 \
--hash=sha256:e3b270fe16fc90c50ac79107700330a133dd4c63d22939f5b03b4f24564d5dd8
# via specify-cli (pyproject.toml)
rich==15.0.0 \
--hash=sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb \
--hash=sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36
# via
# specify-cli (pyproject.toml)
# typer
shellingham==1.5.4 \
--hash=sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686 \
--hash=sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de
# via typer
typer==0.27.0 \
--hash=sha256:629bd12ea5d13a17148125d9a264f949eb171fb3f120f9b04d85873cab054fa5 \
--hash=sha256:6f4b27631e47f077871b7dc30e933ec0131c1390fbe0e387ea5574b5bac9ccf1
# via specify-cli (pyproject.toml)

View File

@@ -1,78 +0,0 @@
name: Security Audit
permissions:
contents: read
on:
push:
branches: ["main"]
pull_request:
types: [opened, synchronize, reopened]
schedule:
- cron: "17 4 * * 1"
workflow_dispatch:
jobs:
dependency-audit:
name: Dependency audit
if: ${{ github.event_name != 'schedule' }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
- name: Install uv
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: "3.14"
- name: Check committed audit requirements are current
env:
DEPENDENCY_DIFF_BASE: ${{ github.event.pull_request.base.sha || github.event.before || '' }}
DEPENDENCY_DIFF_HEAD: ${{ github.sha }}
GENERATED_REQUIREMENTS: ${{ runner.temp }}/security-audit-requirements.txt
run: python .github/scripts/check_security_requirements.py
- name: Run pip-audit (committed requirements)
run: uvx --from pip-audit==2.10.0 pip-audit --disable-pip --require-hashes -r .github/security-audit-requirements.txt --progress-spinner off
dependency-audit-scheduled:
name: Dependency audit scheduled (${{ matrix.os }}, Python ${{ matrix.python-version }})
if: ${{ github.event_name == 'schedule' }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
python-version: ["3.11", "3.12", "3.13", "3.14"]
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install uv
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: ${{ matrix.python-version }}
# The committed .github/security-audit-requirements.txt is generated with
# --universal (resolves across all interpreters/platforms) and is what
# push/PR/workflow_dispatch runs audit. The scheduled job instead compiles
# per matrix entry with --python-version so it can surface advisories in
# wheels that only resolve on a specific interpreter (e.g. 3.11-only) —
# coverage the universal file may not exercise. This broadening is
# intentional; non-scheduled runs trade that depth for determinism against
# the committed snapshot.
- name: Compile scheduled audit requirements
run: |
uv pip compile pyproject.toml --extra test --python-version "${{ matrix.python-version }}" --upgrade --generate-hashes --quiet --output-file "${{ runner.temp }}/spec-kit-audit-requirements.txt"
- name: Run pip-audit (scheduled live resolution)
run: uvx --from pip-audit==2.10.0 pip-audit --disable-pip --require-hashes -r "${{ runner.temp }}/spec-kit-audit-requirements.txt" --progress-spinner off

View File

@@ -2,26 +2,6 @@
<!-- insert new changelog below this comment -->
## [0.13.3] - 2026-07-22
### Changed
- fix(integrations): escape Rich markup in --integration-options error messages (#3458)
- docs: document __SPECKIT_COMMAND_ token for portable cross-command references (#3503)
- [preset] Add Parallel Autonomous Run Governance preset to community catalog (#3614)
- docs(workflows): fix stale FanOutStep docstring claiming sequential-only execution (#3639)
- [bundle] Add SicarioSpec Security & Governance Bundle to community catalog (#3636)
- [preset] Update Autonomous Run Governance preset to v0.3.2 (#3615)
- fix(workflows): validate every redirect hop when fetching workflow/step catalogs (#3637)
- Add pipeline workflow to community catalog (#3338)
- [extension] Add Linear Weave extension to community catalog (#3609)
- docs: clarify hook priority validation semantics (#3594)
- fix(workflows): reject a non-string 'integration'/'model' in command & prompt steps (#3597)
- ci: add dependency audit workflow (#3138)
- Add Intake Review Governance preset to community catalog (#3613)
- fix(workflows): reject non-list input 'enum' instead of crashing (#3601)
- chore: release 0.13.2, begin 0.13.3.dev0 development (#3617)
## [0.13.2] - 2026-07-21
### Changed

View File

@@ -113,27 +113,6 @@ uv pip install -e ".[test]"
> `specify_cli` to this checkout's `src/`. This matches the gotcha documented in
> `AGENTS.md` (Common Pitfalls).
#### Security checks
```bash
uvx --from pip-audit==2.10.0 pip-audit --disable-pip --require-hashes -r .github/security-audit-requirements.txt --progress-spinner off
```
This command audits the committed hashed requirements snapshot. Pull request,
push, and manual CI runs use the same snapshot so their results stay
deterministic. If dependency metadata changes, refresh and commit the snapshot
before auditing it:
```bash
uv pip compile pyproject.toml --extra test --universal --upgrade --generate-hashes --quiet --no-header --output-file .github/security-audit-requirements.txt
```
The scheduled CI audit resolves the runtime and `test` extra dependency set
across the supported Python and OS matrix to catch newly published advisories.
Upstream package releases drift over time, so even an unrelated PR touching
`pyproject.toml` can fail the `dependency-audit` check until the committed file
is regenerated with the command above and re-committed.
#### Shell scripts
```bash

View File

@@ -1,35 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-07-22T00:00:00Z",
"updated_at": "2026-07-15T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/bundles/catalog.community.json",
"bundles": {
"sicario-spec": {
"name": "SicarioSpec Security & Governance Bundle",
"id": "sicario-spec",
"version": "0.5.1",
"role": "security-engineer",
"description": "Secure-by-default governance bundle for GitHub Spec Kit. Enforces data classification, threat modeling, and code-owned verification gates.",
"author": "SicarioSpec Contributors",
"license": "MIT",
"download_url": "https://github.com/dfirs1car1o/sicario-spec/releases/download/v0.5.1/sicario-spec-0.5.1.zip",
"repository": "https://github.com/dfirs1car1o/sicario-spec",
"requires": {
"speckit_version": ">=0.9.0"
},
"provides": {
"extensions": 1,
"presets": 11,
"steps": 0,
"workflows": 0
},
"tags": [
"security",
"governance",
"compliance",
"appsec",
"threat-modeling"
],
"verified": false
}
}
"bundles": {}
}

View File

@@ -9,7 +9,6 @@ Accepted community bundle entries are published in [`bundles/catalog.community.j
| Bundle | Purpose | Role or team | Provides | Required catalogs | URL |
|--------|---------|--------------|----------|-------------------|-----|
| SicarioSpec Security & Governance Bundle | Secure-by-default governance bundle for GitHub Spec Kit. Enforces data classification, threat modeling, and code-owned verification gates. | `security-engineer` | 1 extension, 11 presets | Documented | [sicario-spec](https://github.com/dfirs1car1o/sicario-spec) |
## What to Submit

View File

@@ -70,7 +70,6 @@ The following community-contributed extensions are available in [`catalog.commun
| Jira Integration (Sync Engine) | Idempotent, drift-aware, fail-closed reconcile engine mirroring spec-kit specs into Jira (Epic per repo, Story per spec, Subtask per phase) | `integration` | Read+Write | [spec-kit-jira-sync](https://github.com/ashbrener/spec-kit-jira-sync) |
| Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) |
| Linear Integration | Mirror spec-kit feature directories into Linear (filesystem → Linear, reconcile-based, unidirectional). | `integration` | Read+Write | [spec-kit-linear-sync](https://github.com/ashbrener/spec-kit-linear-sync) |
| Linear Weave | Weave Spec Kit into Linear: pull requirements, mirror tasks.md into sub-issues, sync statuses | `integration` | Read+Write | [spec-kit-linear-weave](https://github.com/tonydwoodhouse/spec-kit-linear-weave) |
| LLM Wiki | LLM-maintained compounding project wiki: source ingestion, cited answers, and consistency linting | `docs` | Read+Write | [spec-kit-wiki](https://github.com/formin/spec-kit-wiki) |
| Loop Engineering | Engineer safe autonomous agent loops for spec-driven development: a maker/checker split, externalized loop state, and stay-the-engineer guardrails against comprehension debt and cognitive surrender | `process` | Read+Write | [spec-kit-loop](https://github.com/formin/spec-kit-loop) |
| MAQA — Multi-Agent & Quality Assurance | Coordinator → feature → QA agent workflow with parallel worktree-based implementation. Language-agnostic. Auto-detects installed board plugins. Optional CI gate. | `process` | Read+Write | [spec-kit-maqa-ext](https://github.com/GenieRobot/spec-kit-maqa-ext) |

View File

@@ -11,7 +11,7 @@ The following community-contributed presets customize how Spec Kit behaves — o
| Agent Parity Governance | Adds shared-guidance parity, audit-ready Spec-Kit run evidence, and agent-neutral model-routing guidance across a project's declared AI-agent instruction surfaces so agent guidance does not drift. | 6 templates, 3 commands | — | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) |
| AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
| Architecture Governance | Adds secure software architecture, STRIDE+CAPEC threat modeling, arc42 security cross-cutting concepts, S-ADRs, Zero Trust applicability, OWASP SAMM governance, BSI C3A cloud autonomy, BSI C5 cloud compliance assurance, and audit-ready Spec Kit run evidence | 13 templates, 3 commands | — | [spec-kit-preset-architecture-governance](https://github.com/hindermath/spec-kit-preset-architecture-governance) |
| Autonomous Run Governance | Adds permission-bounded, evidence-first governance for complete autonomous Spec Kit delivery, including validated status, stop, explicit resume, exact-head proof, post-merge closeout, retrospective learning, and an optional policy-driven intake-review gate before feature creation. | 13 templates, 5 commands, 4 scripts | — | [spec-kit-preset-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-autonomous-run-governance) |
| Autonomous Run Governance | Adds permission-bounded, evidence-first governance for autonomous Spec Kit delivery with validated status, stop, resume, exact-head proof, closeout, and learner guidance. | 13 templates, 5 commands, 4 scripts | — | [spec-kit-preset-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-autonomous-run-governance) |
| Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) |
| Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | — | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) |
| Command Density | Compacts the nine core Spec Kit command prompts while preserving scripts, handoffs, placeholders, hook output blocks, and rule structure | 9 commands | — | [spec-kit-preset-command-density](https://github.com/Xopoko/spec-kit-preset-command-density) |
@@ -19,12 +19,10 @@ The following community-contributed presets customize how Spec Kit behaves — o
| Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) |
| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose principles. Supports interactive elements like brainstorming, interview, roleplay, and extras like statistics, cover builder, illustration builder, and bio command. Export with templates for KDP, D2D, etc. | 26 templates, 34 commands, 2 scripts | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
| Game Narrative Writing | Preset for game narrative design and interactive storytelling. It adapts the Spec-Driven Development workflow for game narratives: features become story mechanics, specs become narrative briefs, plans become story maps, and tasks become dialogue and scene-writing tasks. Supports branching narratives, player agency systems, state machines, and interactive dialogue trees. | 37 templates, 34 commands, 5 scripts | — | [speckit-preset-game-narrative-writing](https://github.com/adaumann/speckit-preset-game-narrative-writing) |
| Intake Review Governance | Adds hash-bound review, repair, and status gates for single, series, and campaign intake files before interactive, autonomous, or parallel Spec Kit execution. | 8 templates, 3 commands, 2 scripts | — | [spec-kit-preset-intake-review-governance](https://github.com/hindermath/spec-kit-preset-intake-review-governance) |
| iSAQB Architecture Governance | Adds general iSAQB/CPSA-F and arc42 software-architecture governance, including audit-ready Spec Kit run evidence for architecture goals, views, quality scenarios, ADRs, risks, and technical debt. | 13 templates, 3 commands | — | [spec-kit-preset-isaqb-architecture-governance](https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance) |
| Jira Issue Tracking | Overrides `speckit.taskstoissues` to create Jira epics, stories, and tasks instead of GitHub Issues via Atlassian MCP tools | 1 command | — | [spec-kit-preset-jira](https://github.com/luno/spec-kit-preset-jira) |
| Model Driven Engineering | Focuses on streamlined commands, app repository support, cross-spec support, and capability-aware project memory for model-driven engineering workflows | 6 templates, 11 commands | MDE extension | [spec-kit-preset-mde](https://github.com/AI-MDE/spec-kit-preset-mde) |
| Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) |
| Parallel Autonomous Run Governance | Coordinates isolated autonomous Spec Kit campaigns with bounded concurrency, mixed agents, resumable consolidation, governed post-merge closeout, schema 1.2, and an optional current intake-review gate before worker scheduling. | 9 templates, 5 commands, 2 scripts | autonomous-run-governance >=0.3.2; optional: intake-review-governance >=0.1.0 | [spec-kit-preset-parallel-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance) |
| Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
| Screenwriting | Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks. Export to Fountain, FTX, PDF | 26 templates, 32 commands, 1 script | — | [speckit-preset-screenwriting](https://github.com/adaumann/speckit-preset-screenwriting) |
| Security Governance | Adds memory-safe-language preference, language-specific secure coding profiles, audit-ready Spec-Kit run evidence, ASVS verification, SBOM/AI-SBOM supply-chain transparency, CRA awareness, and regulatory applicability screening for NIS2, CRA, EU AI Act, and DORA | 14 templates, 3 commands | — | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) |

View File

@@ -221,14 +221,12 @@ Each hook entry supports the following fields:
| `command` | Extension command associated with the hook. |
| `enabled` | Whether the hook is active. Hooks with `enabled: false` are skipped. |
| `optional` | Whether the hook is optional. If `true`, the hook is presented with its `prompt` and can be skipped; if `false`, the hook is emitted as an automatic hook (includes `EXECUTE_COMMAND` markers). |
| `priority` | Priority metadata for the hook. Registered hook entries use integer values >= 1; entries installed from manifests default to `10` when no priority is declared. Current command templates surface hooks in their configured YAML order and do not sort them by `priority`. |
| `priority` | Priority metadata for the hook. Values must be integers >= 1; invalid values fall back to the default priority `10`. Current command templates surface hooks in their configured YAML order and do not sort them by `priority`. |
| `prompt` | Message shown when asking whether to run an optional hook. |
| `description` | Human-readable explanation of what the hook does. |
| `condition` | Optional expression evaluated by `HookExecutor` (using `config.<path>` or `env.<VAR>` with `is set`, `==`, or `!=`). Current command templates do not evaluate conditions and skip hooks with a non-empty condition. |
Hook event names identify when a hook is invoked. They generally use `before_<command>` or `after_<command>`, such as `before_implement`, `after_implement`, `before_tasks`, and `after_tasks`.
Extension manifests reject invalid hook priorities during installation. For existing `.specify/extensions.yml` entries, `HookExecutor.get_hooks_for_event()` sorts with `normalize_priority()`: missing values, booleans, non-numeric values rejected by `int()`, and values less than `1` fall back to `10`; numeric strings and finite floats are coerced with `int()`, while non-finite floats are unsupported and may fail instead of falling back.
`HookExecutor.get_hooks_for_event()` returns hooks ordered by `priority`, with lower values first. However, current command templates read hook lists directly and surface them in their configured YAML order rather than using priority ordering.
## FAQ

View File

@@ -252,7 +252,6 @@ Use standard Markdown with special placeholders:
- `$ARGUMENTS`: User-provided arguments
- `{SCRIPT}`: Replaced with script path during registration
- `__SPECKIT_COMMAND_<NAME>__`: Replaced with the invocation of another command, rendered using the active integration's separator (see [Referencing other commands](#referencing-other-commands))
**Example**:
@@ -268,40 +267,6 @@ echo "Running with args: $args"
```
````
### Referencing other commands
A command body is a *template* that Spec Kit renders once per agent. Different agents invoke commands with different surface syntax — for example `/speckit.plan` (dot separator) or `/speckit-plan` (hyphen separator). Some agents also use different prefixes in skills mode (e.g. Kimi `/skill:speckit-plan`, Codex/ZCode `$speckit-plan`). So when you reference a sibling command from a body, **do not hard-code a literal invocation** like `/speckit.my-ext.prepare`. A literal is correct for exactly one agent and breaks on the rest.
Instead use the agent-neutral token `__SPECKIT_COMMAND_<NAME>__`. Spec Kit resolves it to a `/speckit<separator>...` invocation using the active integration's `invoke_separator` (and integrations may post-process that further in skills output).
Encode the command name in upper case, dropping the `speckit.` prefix and turning each dotted segment separator into an underscore:
| Command file | Token |
| --- | --- |
| `speckit.plan.md` | `__SPECKIT_COMMAND_PLAN__` |
| `speckit.bug.fix.md` | `__SPECKIT_COMMAND_BUG_FIX__` |
| `speckit.git.commit.md` | `__SPECKIT_COMMAND_GIT_COMMIT__` |
The resolver maps each underscore back to the active agent's separator, so use tokens to reference commands whose name segments are single words. (Command names are dotted segments like `git.commit`; the token scheme rebuilds those dots and does not carry hyphens within a segment.)
**Example** — a command body that points the user at the next step:
```markdown
Once the assessment exists, the next step is `__SPECKIT_COMMAND_BUG_FIX__ slug=<slug>`.
```
This renders as `/speckit.bug.fix slug=<slug>` for a slash-based agent, `/speckit-bug-fix slug=<slug>` for a skills-based agent, and so on — the author writes it once and it stays portable. The first-party `bug` and `git` extensions use this token exclusively; see `extensions/bug/commands/` for working examples.
> **Current limitation — skills mode.** Token resolution runs in the
> command-rendering path (`CommandRegistrar`), so it applies when an extension
> installs *command files*. It does **not** yet run when an extension is
> registered as *skills* for a skills-based agent: `_register_extension_skills`
> resolves placeholders and post-processes content but never calls
> `resolve_command_refs`, so a `__SPECKIT_COMMAND_<NAME>__` token reaches
> agents such as Codex, ZCode, and Kimi verbatim in that mode. Until that
> rendering step lands, prefer the token for command-file extensions and avoid
> relying on it inside skill bodies destined for skills-based agents.
### Script Path Rewriting
Extension commands use relative paths that get rewritten during registration:

View File

@@ -2029,40 +2029,6 @@
"created_at": "2026-06-01T00:00:00Z",
"updated_at": "2026-06-22T00:00:00Z"
},
"linear-weave": {
"name": "Linear Weave",
"id": "linear-weave",
"description": "Weave Spec Kit into Linear: pull requirements, mirror tasks.md into sub-issues, sync statuses.",
"author": "Tony Woodhouse",
"version": "1.0.0",
"download_url": "https://github.com/tonydwoodhouse/spec-kit-linear-weave/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/tonydwoodhouse/spec-kit-linear-weave",
"homepage": "https://github.com/tonydwoodhouse/spec-kit-linear-weave",
"documentation": "https://github.com/tonydwoodhouse/spec-kit-linear-weave#readme",
"changelog": "https://github.com/tonydwoodhouse/spec-kit-linear-weave/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "integration",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.13.0,<1.0.0",
"tools": [{ "name": "linear-mcp", "required": true }]
},
"provides": {
"commands": 5,
"hooks": 5
},
"tags": [
"linear",
"issue-tracking",
"integration",
"workflow"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-21T00:00:00Z",
"updated_at": "2026-07-21T00:00:00Z"
},
"loop": {
"name": "Loop Engineering",
"id": "loop",

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-07-22T00:00:00Z",
"updated_at": "2026-07-17T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
"presets": {
"a11y-governance": {
@@ -69,7 +69,7 @@
"name": "AIDE In-Place Migration",
"id": "aide-in-place",
"version": "1.0.0",
"description": "Adapts the AIDE workflow for in-place technology migrations (X \u2192 Y pattern). Overrides vision, roadmap, progress, and work item commands with migration-specific guidance.",
"description": "Adapts the AIDE workflow for in-place technology migrations (X Y pattern). Overrides vision, roadmap, progress, and work item commands with migration-specific guidance.",
"author": "mnriem",
"repository": "https://github.com/mnriem/spec-kit-presets",
"download_url": "https://github.com/mnriem/spec-kit-presets/releases/download/aide-in-place-v1.0.0/aide-in-place.zip",
@@ -134,13 +134,13 @@
"autonomous-run-governance": {
"name": "Autonomous Run Governance",
"id": "autonomous-run-governance",
"version": "0.3.2",
"description": "Adds permission-bounded, evidence-first governance for complete autonomous Spec Kit delivery, including validated status, stop, explicit resume, exact-head proof, post-merge closeout, retrospective learning, and an optional policy-driven intake-review gate before feature creation.",
"version": "0.2.2",
"description": "Adds permission-bounded, evidence-first governance for autonomous Spec Kit delivery with validated status, stop, resume, exact-head proof, closeout, and learner guidance.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/archive/refs/tags/v0.3.2.zip",
"download_url": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/archive/refs/tags/v0.2.2.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/blob/v0.3.2/README.md",
"documentation": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/blob/v0.2.2/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.3"
@@ -155,11 +155,10 @@
"governance",
"evidence",
"permissions",
"resume",
"intake-review"
"resume"
],
"created_at": "2026-07-13T00:00:00Z",
"updated_at": "2026-07-21T00:00:00Z"
"updated_at": "2026-07-17T00:00:00Z"
},
"canon-core": {
"name": "Canon Core",
@@ -364,35 +363,6 @@
"created_at": "2026-05-05T08:00:00Z",
"updated_at": "2026-06-22T00:00:00Z"
},
"intake-review-governance": {
"name": "Intake Review Governance",
"id": "intake-review-governance",
"version": "0.1.0",
"description": "Adds hash-bound review, repair, and status gates for single, series, and campaign intake files before interactive, autonomous, or parallel Spec Kit execution.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-intake-review-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-intake-review-governance/archive/refs/tags/v0.1.0.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-intake-review-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-intake-review-governance/blob/v0.1.0/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.3"
},
"provides": {
"templates": 8,
"commands": 3,
"scripts": 2
},
"tags": [
"intake",
"review",
"governance",
"quality-gate",
"autonomous"
],
"created_at": "2026-07-21T00:00:00Z",
"updated_at": "2026-07-21T00:00:00Z"
},
"isaqb-architecture-governance": {
"name": "iSAQB Architecture Governance",
"id": "isaqb-architecture-governance",
@@ -510,36 +480,6 @@
"created_at": "2026-04-09T00:00:00Z",
"updated_at": "2026-04-09T00:00:00Z"
},
"parallel-autonomous-run-governance": {
"name": "Parallel Autonomous Run Governance",
"id": "parallel-autonomous-run-governance",
"version": "0.2.3",
"description": "Coordinates isolated autonomous Spec Kit campaigns with bounded concurrency, mixed agents, resumable consolidation, governed post-merge closeout, schema 1.2, and an optional current intake-review gate before worker scheduling.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance/archive/refs/tags/v0.2.3.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance/blob/v0.2.3/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.3"
},
"provides": {
"templates": 9,
"commands": 5,
"scripts": 2
},
"tags": [
"parallel",
"autonomous",
"governance",
"orchestration",
"resume",
"intake-review"
],
"created_at": "2026-07-22T00:00:00Z",
"updated_at": "2026-07-22T00:00:00Z"
},
"pirate": {
"name": "Pirate Speak (Full)",
"id": "pirate",
@@ -569,7 +509,7 @@
"name": "Screenwriting",
"id": "screenwriting",
"version": "1.0.0",
"description": "Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft \u2014 slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents replace prose fiction conventions. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks.",
"description": "Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents replace prose fiction conventions. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks.",
"author": "Andreas Daumann",
"repository": "https://github.com/adaumann/speckit-preset-screenwriting",
"download_url": "https://github.com/adaumann/speckit-preset-screenwriting/archive/refs/tags/v1.0.0.zip",
@@ -684,7 +624,7 @@
"name": "Spec2Cloud",
"id": "spec2cloud",
"version": "1.1.0",
"description": "Spec-driven workflow tuned for shipping to Azure: spec \u2192 plan \u2192 tasks \u2192 implement \u2192 deploy.",
"description": "Spec-driven workflow tuned for shipping to Azure: spec → plan → tasks implement deploy.",
"author": "Azure Samples",
"repository": "https://github.com/Azure-Samples/Spec2Cloud",
"download_url": "https://github.com/Azure-Samples/Spec2Cloud/releases/download/spec-kit-spec2cloud-v1.1.0/preset.zip",
@@ -712,7 +652,7 @@
"id": "test-first-governance",
"version": "1.3.0",
"description": "Governs TDD with coverage-complete BDD/ATDD Gherkin scenarios, explicit suite ownership, professional test reports, traceability, and risk-based quality gates.",
"author": "Zolt\u00e1n Katona, PhD",
"author": "Zoltán Katona, PhD",
"repository": "https://github.com/ka-zo/spec-kit-preset-test-first-governance",
"download_url": "https://github.com/ka-zo/spec-kit-preset-test-first-governance/archive/refs/tags/1.3.0.zip",
"homepage": "https://github.com/ka-zo/spec-kit-preset-test-first-governance",

View File

@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.13.3"
version = "0.13.2"
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
readme = "README.md"
requires-python = ">=3.11"

View File

@@ -6,7 +6,6 @@ from pathlib import Path
from typing import Any, Callable
import typer
from rich.markup import escape
from .._agent_config import SCRIPT_TYPE_CHOICES
from .._console import console
@@ -207,7 +206,7 @@ def _parse_integration_options(integration: Any, raw_options: str) -> dict[str,
while i < len(tokens):
token = tokens[i]
if not token.startswith("-"):
console.print(f"[red]Error:[/red] Unexpected integration option value '{escape(token)}'.")
console.print(f"[red]Error:[/red] Unexpected integration option value '{token}'.")
if allowed:
console.print(f"Allowed options: {allowed}")
raise typer.Exit(1)
@@ -218,7 +217,7 @@ def _parse_integration_options(integration: Any, raw_options: str) -> dict[str,
name, value = name.split("=", 1)
opt = declared.get(name)
if not opt:
console.print(f"[red]Error:[/red] Unknown integration option '{escape(token)}'.")
console.print(f"[red]Error:[/red] Unknown integration option '{token}'.")
if allowed:
console.print(f"Allowed options: {allowed}")
raise typer.Exit(1)

View File

@@ -523,20 +523,8 @@ class WorkflowCatalog:
_validate_catalog_url(entry.url)
# Validate EVERY redirect hop, not just the final URL: _open_url follows
# redirects, so an https:// entry that 30x-redirects through http:// (or
# to a non-HTTPS host mid-chain) could otherwise let a network attacker
# rewrite the next hop and slip a payload past a final-URL-only check.
# redirect_validator runs before each hop; the geturl() check below is
# retained as a defense-in-depth backstop. Mirrors the presets/extensions
# catalog fix (#3523 / #3524).
def _validate_redirect(_old_url: str, new_url: str) -> None:
_validate_catalog_url(new_url)
try:
with _open_url(
entry.url, timeout=30, redirect_validator=_validate_redirect
) as resp:
with _open_url(entry.url, timeout=30) as resp:
_validate_catalog_url(resp.geturl())
data = json.loads(resp.read().decode("utf-8"))
except Exception as exc:
@@ -1192,20 +1180,8 @@ class StepCatalog:
_validate_url(entry.url)
# Validate EVERY redirect hop, not just the final URL: _open_url follows
# redirects, so an https:// entry that 30x-redirects through http:// (or
# to a non-HTTPS host mid-chain) could otherwise let a network attacker
# rewrite the next hop and slip a payload past a final-URL-only check.
# redirect_validator runs before each hop; the geturl() check below is
# retained as a defense-in-depth backstop. Mirrors the presets/extensions
# catalog fix (#3523 / #3524).
def _validate_redirect(_old_url: str, new_url: str) -> None:
_validate_url(new_url)
try:
with _open_url(
entry.url, timeout=30, redirect_validator=_validate_redirect
) as resp:
with _open_url(entry.url, timeout=30) as resp:
_validate_url(resp.geturl())
data = json.loads(resp.read().decode("utf-8"))
except Exception as exc:

View File

@@ -201,20 +201,6 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]:
f"Must be 'string', 'number', or 'boolean'."
)
# ``enum`` must be a list. Checked here — not only via the
# ``_coerce_input`` call below — because that call is reached only
# when a ``default`` is present, and the ``integration: auto`` case
# strips ``enum`` before coercing; a scalar/string ``enum`` on an
# input with no default (or the auto-integration default) would
# otherwise slip through here and then crash ``_resolve_inputs`` with
# a raw ``TypeError`` at run time. ``None`` means "no enum".
enum_values = input_def.get("enum")
if enum_values is not None and not isinstance(enum_values, list):
errors.append(
f"Input {input_name!r} has invalid 'enum': must be a list, "
f"got {type(enum_values).__name__}."
)
# Validate the default eagerly so authoring mistakes (e.g. a
# default not in the declared enum, or a non-numeric default for
# a number input) surface at install/validation time instead of
@@ -223,28 +209,13 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]:
# enum-membership check is exempted for that exact case — the
# declared type is still enforced (e.g. ``type: number`` paired
# with ``default: "auto"`` is still rejected).
enum_is_valid = enum_values is None or isinstance(enum_values, list)
if "default" in input_def:
default_value = input_def["default"]
is_auto_integration = (
input_name == "integration" and default_value == "auto"
)
# Strip ``enum`` from the definition handed to ``_coerce_input``
# when either:
# * this is the auto-integration sentinel (enum-membership is
# a runtime concern, exempted for ``"auto"``), or
# * the ``enum`` is malformed (non-list) and already reported
# above — leaving it in would make ``_coerce_input`` re-raise
# the same enum-shape error re-framed as an "invalid default"
# (a confusing duplicate).
# Removing *only* ``enum`` (rather than skipping the check
# entirely) preserves the default's type validation: a
# ``type: string`` input with ``default: 5, enum: 5`` still
# reports the wrong-typed default alongside the enum error,
# instead of hiding it.
strip_enum = is_auto_integration or not enum_is_valid
validation_input_def: dict[str, Any] = input_def
if strip_enum and "enum" in input_def:
if is_auto_integration and "enum" in input_def:
validation_input_def = {
key: value
for key, value in input_def.items()
@@ -1429,18 +1400,11 @@ class WorkflowEngine:
# definition (``string`` rejects non-strings, ``number`` rejects
# bools and uncoercible values, ``boolean`` rejects non-bools),
# so ill-typed values still fail fast here.
#
# ``execute()`` accepts unvalidated definitions, so a malformed
# (non-list) ``enum`` can reach here. Only strip a *list* ``enum``:
# a scalar/string ``enum`` must stay in the definition so
# ``_coerce_input`` raises the clean shape ``ValueError`` instead of
# being silently exempted by the ``auto`` membership skip (which
# would otherwise let ``enum: 5`` resolve successfully).
coerce_input_def = input_def
if (
name == "integration"
and value == "auto"
and isinstance(input_def.get("enum"), list)
and "enum" in input_def
):
coerce_input_def = {
key: val
@@ -1486,22 +1450,6 @@ class WorkflowEngine:
input_type = input_def.get("type", "string")
enum_values = input_def.get("enum")
# ``enum`` must be a list. A scalar (``enum: 5``, ``enum: true``) makes
# the ``value not in enum_values`` membership test below raise a raw
# ``TypeError`` ("argument of type 'int' is not ... iterable"), which
# escapes ``validate_workflow``'s ``except ValueError`` and breaks its
# "return errors, never raise" contract — and crashes ``_resolve_inputs``
# outright at run time. A bare string is just as wrong: ``value in "abc"``
# is a silent substring/character test, not enum membership. Require a
# list so both forms fail fast with a clear message. ``None`` means "no
# enum" and is left alone.
if enum_values is not None and not isinstance(enum_values, list):
msg = (
f"Input {name!r} has invalid 'enum': must be a list, got "
f"{type(enum_values).__name__}."
)
raise ValueError(msg)
if input_type == "number":
# Reject bools explicitly: ``bool`` is a subclass of ``int`` so
# ``float(True)`` succeeds and would silently coerce a YAML

View File

@@ -66,52 +66,16 @@ class CommandStep(StepBase):
for key, value in input_data.items():
resolved_input[key] = evaluate_expression(value, context)
# Resolve integration (step → workflow default → project default).
# Fall back to the workflow default ONLY for a genuinely-unset value
# (missing / YAML-null / empty string). A ``config.get(...) or ...``
# would also swallow a falsey *non-string* ([], {}, 0, False), coercing
# it to the default before the guard below runs — so on an unvalidated
# execute() such a step would silently dispatch with the configured
# default instead of failing. Fall through instead, so every non-string
# reaches the type guard.
integration = config.get("integration")
if integration is None or integration == "":
integration = context.default_integration
# Resolve integration (step → workflow default → project default)
integration = config.get("integration") or context.default_integration
if integration and isinstance(integration, str) and "{{" in integration:
integration = evaluate_expression(integration, context)
# Resolve model (same fallback rationale as 'integration' above).
model = config.get("model")
if model is None or model == "":
model = context.default_model
# Resolve model
model = config.get("model") or context.default_model
if model and isinstance(model, str) and "{{" in model:
model = evaluate_expression(model, context)
# A non-string integration/model — a literal list/dict/number that
# skipped validation, an unvalidated workflow-level default, or an
# expression that resolved to one — crashes downstream: get_integration()
# uses the value as a dict key (raw TypeError on an unhashable list/dict,
# even on a *validated* run) and build_exec_args() feeds model into the
# CLI argv. Fail the step with the contract error rather than taking down
# the whole run, mirroring the 'input'/'options' guards above. ``None``
# stays valid — it means "unset" and falls back to dispatch-not-possible.
if integration is not None and not isinstance(integration, str):
return StepResult(
status=StepStatus.FAILED,
error=(
f"Command step {config.get('id', '?')!r}: 'integration' must "
f"be a string, got {type(integration).__name__}."
),
)
if model is not None and not isinstance(model, str):
return StepResult(
status=StepStatus.FAILED,
error=(
f"Command step {config.get('id', '?')!r}: 'model' must be a "
f"string, got {type(model).__name__}."
),
)
# Merge options (workflow defaults ← step overrides)
options = dict(context.default_options)
step_options = config.get("options", {})
@@ -253,23 +217,4 @@ class CommandStep(StepBase):
errors.append(
f"Command step {config.get('id', '?')!r}: 'options' must be a mapping."
)
# execute() passes 'integration' to get_integration(), which uses it as a
# dict key — a non-string (list/dict) raises a raw TypeError (unhashable),
# even on a validated run — and feeds 'model' into the CLI argv. Reject a
# literal non-string here, mirroring the sibling type checks. ``None``
# (an explicit ``integration:``/``model:`` YAML null) means "inherit the
# workflow default" and stays valid; an expression like "{{ ... }}" is
# still a str, so it stays valid too.
integration = config.get("integration")
if integration is not None and not isinstance(integration, str):
errors.append(
f"Command step {config.get('id', '?')!r}: 'integration' must be a "
f"string, got {type(integration).__name__}."
)
model = config.get("model")
if model is not None and not isinstance(model, str):
errors.append(
f"Command step {config.get('id', '?')!r}: 'model' must be a "
f"string, got {type(model).__name__}."
)
return errors

View File

@@ -12,10 +12,9 @@ class FanOutStep(StepBase):
"""Dispatch a step template for each item in a collection.
The engine executes the nested ``step:`` template once per item,
setting ``context.item`` for each iteration. ``max_concurrency``
controls parallelism: ``<= 1`` (the default) runs items
sequentially, while ``> 1`` runs up to that many items concurrently
on a bounded thread pool (see ``WorkflowEngine._run_fan_out``).
setting ``context.item`` for each iteration. Execution is
currently sequential; ``max_concurrency`` is accepted but not
enforced.
"""
type_key = "fan-out"

View File

@@ -42,52 +42,16 @@ class PromptStep(StepBase):
if not isinstance(prompt, str):
prompt = str(prompt)
# Resolve integration (step → workflow default).
# Fall back to the workflow default ONLY for a genuinely-unset value
# (missing / YAML-null / empty string). A ``config.get(...) or ...``
# would also swallow a falsey *non-string* ([], {}, 0, False), coercing
# it to the default before the guard below runs — so on an unvalidated
# execute() such a step would silently dispatch with the configured
# default instead of failing. Fall through instead, so every non-string
# reaches the type guard.
integration = config.get("integration")
if integration is None or integration == "":
integration = context.default_integration
# Resolve integration (step → workflow default)
integration = config.get("integration") or context.default_integration
if integration and isinstance(integration, str) and "{{" in integration:
integration = evaluate_expression(integration, context)
# Resolve model (same fallback rationale as 'integration' above).
model = config.get("model")
if model is None or model == "":
model = context.default_model
# Resolve model
model = config.get("model") or context.default_model
if model and isinstance(model, str) and "{{" in model:
model = evaluate_expression(model, context)
# A non-string integration/model — a literal list/dict/number that
# skipped validation, an unvalidated workflow-level default, or an
# expression that resolved to one — crashes downstream: get_integration()
# uses the value as a dict key (raw TypeError on an unhashable list/dict,
# even on a *validated* run) and build_exec_args() feeds model into the
# CLI argv. Fail the step with the contract error rather than taking down
# the whole run. ``None`` stays valid — it means "unset" and falls back
# to dispatch-not-possible.
if integration is not None and not isinstance(integration, str):
return StepResult(
status=StepStatus.FAILED,
error=(
f"Prompt step {config.get('id', '?')!r}: 'integration' must "
f"be a string, got {type(integration).__name__}."
),
)
if model is not None and not isinstance(model, str):
return StepResult(
status=StepStatus.FAILED,
error=(
f"Prompt step {config.get('id', '?')!r}: 'model' must be a "
f"string, got {type(model).__name__}."
),
)
# Attempt CLI dispatch
dispatch_result = self._try_dispatch(
prompt, integration, model, context
@@ -208,23 +172,4 @@ class PromptStep(StepBase):
f"Prompt step {config.get('id', '?')!r}: 'prompt' must be a "
f"string, got {type(config['prompt']).__name__}."
)
# execute() passes 'integration' to get_integration(), which uses it as a
# dict key — a non-string (list/dict) raises a raw TypeError (unhashable),
# even on a validated run — and feeds 'model' into the CLI argv. Reject a
# literal non-string here, mirroring the 'prompt' check above. ``None``
# (an explicit ``integration:``/``model:`` YAML null) means "inherit the
# workflow default" and stays valid; an expression like "{{ ... }}" is
# still a str, so it stays valid too.
integration = config.get("integration")
if integration is not None and not isinstance(integration, str):
errors.append(
f"Prompt step {config.get('id', '?')!r}: 'integration' must be a "
f"string, got {type(integration).__name__}."
)
model = config.get("model")
if model is not None and not isinstance(model, str):
errors.append(
f"Prompt step {config.get('id', '?')!r}: 'model' must be a "
f"string, got {type(model).__name__}."
)
return errors

View File

@@ -3119,30 +3119,6 @@ class TestParseIntegrationOptionsEqualsForm:
assert excinfo.value.exit_code == 1
assert "Error: Could not parse integration options: No closing quotation." in capsys.readouterr().out
def test_bad_option_token_with_rich_markup_exits_cleanly(self):
"""A bad option token carrying Rich markup must exit cleanly, not crash.
The token is user-controlled and gets interpolated into console.print.
A value like '[/red]foo' parses fine through shlex but is an unexpected
value / unknown option — and an unbalanced Rich tag would raise
rich.errors.MarkupError inside console.print, leaking a traceback
instead of the intended typer.Exit(1). The token must be escaped."""
import typer
from specify_cli.integrations._commands import _parse_integration_options
from specify_cli.integrations import get_integration
integration = get_integration("generic")
assert integration is not None
# Unexpected value token carrying markup.
with pytest.raises(typer.Exit):
_parse_integration_options(integration, "[/red]foo")
# Unknown option token carrying markup.
with pytest.raises(typer.Exit):
_parse_integration_options(integration, "--[/red]bad")
class TestUninstallNoManifestClearsInitOptions:
def test_init_options_cleared_on_no_manifest_uninstall(self, tmp_path):

View File

@@ -1,349 +0,0 @@
"""Static checks for the dependency-audit security workflow."""
from __future__ import annotations
import importlib.util
import re
import subprocess
from pathlib import Path
import yaml
REPO_ROOT = Path(__file__).resolve().parent.parent
SECURITY_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "security.yml"
CONTRIBUTING = REPO_ROOT / "CONTRIBUTING.md"
SECURITY_REQUIREMENTS = REPO_ROOT / ".github" / "security-audit-requirements.txt"
SECURITY_REQUIREMENTS_SYNC_SCRIPT = (
REPO_ROOT / ".github" / "scripts" / "check_security_requirements.py"
)
WORKFLOW_LIVE_AUDIT_REQUIREMENTS = '"${{ runner.temp }}/spec-kit-audit-requirements.txt"'
COMMITTED_AUDIT_REQUIREMENTS = ".github/security-audit-requirements.txt"
WORKFLOW_COMPILE_SCHEDULED_TEST_EXTRA_DEPS = (
"uv pip compile pyproject.toml --extra test "
'--python-version "${{ matrix.python-version }}" --upgrade --generate-hashes --quiet '
f"--output-file {WORKFLOW_LIVE_AUDIT_REQUIREMENTS}"
)
LOCAL_REFRESH_TEST_EXTRA_DEPS = (
"uv pip compile pyproject.toml --extra test --universal --upgrade --generate-hashes "
f"--quiet --no-header --output-file {COMMITTED_AUDIT_REQUIREMENTS}"
)
WORKFLOW_SYNC_COMPILE_TEST_EXTRA_DEPS = (
"uv pip compile pyproject.toml --extra test --universal --upgrade --generate-hashes "
"--quiet --no-header --output-file"
)
WORKFLOW_SYNC_SCRIPT = "python .github/scripts/check_security_requirements.py"
WORKFLOW_LIVE_PIP_AUDIT = (
"uvx --from pip-audit==2.10.0 pip-audit --disable-pip --require-hashes "
f"-r {WORKFLOW_LIVE_AUDIT_REQUIREMENTS} --progress-spinner off"
)
LOCAL_PIP_AUDIT = (
"uvx --from pip-audit==2.10.0 pip-audit --disable-pip --require-hashes "
f"-r {COMMITTED_AUDIT_REQUIREMENTS} --progress-spinner off"
)
def _load_security_workflow() -> dict:
return yaml.safe_load(SECURITY_WORKFLOW.read_text(encoding="utf-8"))
def _workflow_triggers() -> dict:
workflow = _load_security_workflow()
return workflow.get("on") or workflow[True]
def _step(job_name: str, step_name: str) -> dict:
workflow = _load_security_workflow()
for step in workflow["jobs"][job_name]["steps"]:
if step.get("name") == step_name:
return step
raise AssertionError(f"Step {step_name!r} not found in job {job_name!r}.")
def _job_run_text(*job_names: str) -> str:
workflow = _load_security_workflow()
return "\n".join(
step.get("run", "")
for job_name in job_names
for step in workflow["jobs"][job_name]["steps"]
)
def _load_sync_script():
spec = importlib.util.spec_from_file_location(
"check_security_requirements",
SECURITY_REQUIREMENTS_SYNC_SCRIPT,
)
assert spec is not None
assert spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class TestDependencyAuditWorkflow:
"""Guard the dependency-audit security workflow."""
def test_dependency_audit_uses_committed_requirements_for_prs_and_pushes(self):
workflow = _load_security_workflow()
job = workflow["jobs"]["dependency-audit"]
committed_audit = _step("dependency-audit", "Run pip-audit (committed requirements)")
sync_check = _step("dependency-audit", "Check committed audit requirements are current")
setup_python = _step("dependency-audit", "Set up Python")
assert job["if"] == "${{ github.event_name != 'schedule' }}"
assert job["runs-on"] == "ubuntu-latest"
assert "strategy" not in job
assert setup_python["with"]["python-version"] == "3.14"
assert sync_check["env"]["DEPENDENCY_DIFF_BASE"] == (
"${{ github.event.pull_request.base.sha || github.event.before || '' }}"
)
assert sync_check["env"]["DEPENDENCY_DIFF_HEAD"] == "${{ github.sha }}"
assert sync_check["run"] == WORKFLOW_SYNC_SCRIPT
assert committed_audit["run"] == LOCAL_PIP_AUDIT
dependency_job_text = _job_run_text(
"dependency-audit",
"dependency-audit-scheduled",
)
protection_text = (
dependency_job_text
+ "\n"
+ SECURITY_REQUIREMENTS_SYNC_SCRIPT.read_text(encoding="utf-8")
)
assert "--generate-hashes" in protection_text
assert "--no-header" in protection_text
assert "--require-hashes" in protection_text
assert "--disable-pip" in protection_text
assert WORKFLOW_LIVE_AUDIT_REQUIREMENTS in dependency_job_text
assert COMMITTED_AUDIT_REQUIREMENTS in protection_text
assert "uv export" not in protection_text
assert "--frozen" not in protection_text
assert "--locked" not in protection_text
assert "uv.lock" not in protection_text
assert "/tmp/" not in protection_text
def test_dependency_audit_checkout_fetches_full_history_for_diff_base(self):
checkout = _step("dependency-audit", "Checkout")
assert checkout["with"]["fetch-depth"] == 0
def test_security_workflow_triggers(self):
triggers = _workflow_triggers()
assert triggers["push"]["branches"] == ["main"]
# Asserted by inclusion so later PRs (e.g. baseline-growth gates) can add
# labeled/unlabeled without rewriting this test.
assert {"opened", "synchronize", "reopened"} <= set(
triggers["pull_request"]["types"]
)
assert "workflow_dispatch" in triggers
assert triggers["schedule"] == [{"cron": "17 4 * * 1"}]
def test_scheduled_dependency_audit_runs_supported_python_os_matrix(self):
workflow = _load_security_workflow()
job = workflow["jobs"]["dependency-audit-scheduled"]
matrix = job["strategy"]["matrix"]
scheduled_compile = _step(
"dependency-audit-scheduled",
"Compile scheduled audit requirements",
)
scheduled_audit = _step(
"dependency-audit-scheduled",
"Run pip-audit (scheduled live resolution)",
)
assert job["if"] == "${{ github.event_name == 'schedule' }}"
assert matrix["os"] == ["ubuntu-latest", "windows-latest"]
assert matrix["python-version"] == ["3.11", "3.12", "3.13", "3.14"]
assert job["runs-on"] == "${{ matrix.os }}"
assert WORKFLOW_COMPILE_SCHEDULED_TEST_EXTRA_DEPS in scheduled_compile["run"]
assert scheduled_audit["run"] == WORKFLOW_LIVE_PIP_AUDIT
def test_pip_audit_is_pinned(self):
workflow_text = SECURITY_WORKFLOW.read_text(encoding="utf-8")
assert WORKFLOW_LIVE_PIP_AUDIT in workflow_text
assert LOCAL_PIP_AUDIT in workflow_text
assert re.search(r"\buvx\s+pip-audit\b", workflow_text) is None
def test_actions_are_pinned_to_full_commit_shas(self):
workflow = _load_security_workflow()
uses_refs = [
step["uses"]
for job in workflow["jobs"].values()
for step in job["steps"]
if "uses" in step
]
assert uses_refs
for uses_ref in uses_refs:
assert re.search(r"@[0-9a-f]{40}$", uses_ref), uses_ref
assert re.search(r"@v\d+", uses_ref) is None
def test_setup_python_pin_matches_repo_standard(self):
workflow = _load_security_workflow()
security_refs = {
step["uses"]
for job in workflow["jobs"].values()
for step in job["steps"]
if step.get("uses", "").startswith("actions/setup-python@")
}
repo_standard_refs = set()
for workflow_path in (
REPO_ROOT / ".github" / "workflows" / "test.yml",
REPO_ROOT / ".github" / "workflows" / "publish-pypi.yml",
):
workflow_data = yaml.safe_load(workflow_path.read_text(encoding="utf-8"))
repo_standard_refs.update(
step["uses"]
for job in workflow_data["jobs"].values()
for step in job["steps"]
if step.get("uses", "").startswith("actions/setup-python@")
)
assert len(repo_standard_refs) == 1
assert security_refs == repo_standard_refs
def test_setup_uv_pin_matches_repo_standard(self):
workflow = _load_security_workflow()
security_refs = {
step["uses"]
for job in workflow["jobs"].values()
for step in job["steps"]
if step.get("uses", "").startswith("astral-sh/setup-uv@")
}
test_workflow = yaml.safe_load(
(REPO_ROOT / ".github" / "workflows" / "test.yml").read_text(
encoding="utf-8"
)
)
repo_standard_refs = {
step["uses"]
for job in test_workflow["jobs"].values()
for step in job["steps"]
if step.get("uses", "").startswith("astral-sh/setup-uv@")
}
assert len(repo_standard_refs) == 1
assert security_refs == repo_standard_refs
def test_committed_audit_requirements_are_hashed(self):
requirements = SECURITY_REQUIREMENTS.read_text(encoding="utf-8")
assert "--hash=sha256:" in requirements
assert not requirements.startswith("#")
assert "pytest==" in requirements
assert "pytest-cov==" in requirements
def test_sync_script_skips_when_dependency_inputs_are_unchanged(self, monkeypatch, capsys):
sync_script = _load_sync_script()
def fake_run(command, **kwargs):
assert command == [
"git", "diff", "--name-only", "HEAD^", "HEAD", "--",
"pyproject.toml", ".github/security-audit-requirements.txt",
]
assert kwargs["check"] is True
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
monkeypatch.setattr(sync_script.subprocess, "run", fake_run)
assert sync_script.main() == 0
assert "sync check skipped" in capsys.readouterr().out
def test_sync_script_uses_github_diff_refs_when_available(self, monkeypatch):
sync_script = _load_sync_script()
monkeypatch.setenv("DEPENDENCY_DIFF_BASE", "abc123")
monkeypatch.setenv("DEPENDENCY_DIFF_HEAD", "def456")
def fake_run(command, **_kwargs):
assert command == [
"git", "diff", "--name-only", "abc123", "def456", "--",
"pyproject.toml", ".github/security-audit-requirements.txt",
]
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
monkeypatch.setattr(sync_script.subprocess, "run", fake_run)
assert sync_script._dependency_inputs_changed() is False
def test_sync_script_compiles_and_compares_when_dependency_inputs_changed(
self, monkeypatch, tmp_path
):
sync_script = _load_sync_script()
committed_requirements = tmp_path / ".github" / "security-audit-requirements.txt"
generated_requirements = tmp_path / "generated-requirements.txt"
committed_requirements.parent.mkdir()
committed_requirements.write_text("pytest==1\n", encoding="utf-8")
compile_commands = []
monkeypatch.setattr(sync_script, "REPO_ROOT", tmp_path)
monkeypatch.setattr(sync_script, "COMMITTED_REQUIREMENTS", committed_requirements)
monkeypatch.setenv("GENERATED_REQUIREMENTS", str(generated_requirements))
def fake_run(command, **kwargs):
if command[0] == "git":
return subprocess.CompletedProcess(command, 0, stdout="pyproject.toml\n", stderr="")
compile_commands.append(command)
assert kwargs["check"] is True
generated_requirements.write_text("pytest==1\n", encoding="utf-8")
return subprocess.CompletedProcess(command, 0)
monkeypatch.setattr(sync_script.subprocess, "run", fake_run)
assert sync_script.main() == 0
assert len(compile_commands) == 1
compile_command = " ".join(compile_commands[0])
assert WORKFLOW_SYNC_COMPILE_TEST_EXTRA_DEPS in compile_command
assert "--output-file" in compile_commands[0]
assert str(generated_requirements) in compile_commands[0]
def test_sync_script_reports_missing_generated_requirements_env(
self, monkeypatch, capsys
):
sync_script = _load_sync_script()
monkeypatch.delenv("GENERATED_REQUIREMENTS", raising=False)
def fake_run(command, **_kwargs):
if command[0] == "git":
return subprocess.CompletedProcess(command, 0, stdout="pyproject.toml\n", stderr="")
raise AssertionError("compile should not run without GENERATED_REQUIREMENTS")
monkeypatch.setattr(sync_script.subprocess, "run", fake_run)
assert sync_script.main() == 1
assert "GENERATED_REQUIREMENTS must be set" in capsys.readouterr().err
def test_sync_script_fails_when_generated_requirements_differ(
self, monkeypatch, tmp_path, capsys
):
sync_script = _load_sync_script()
committed_requirements = tmp_path / ".github" / "security-audit-requirements.txt"
generated_requirements = tmp_path / "generated-requirements.txt"
committed_requirements.parent.mkdir()
committed_requirements.write_text("pytest==1\n", encoding="utf-8")
monkeypatch.setattr(sync_script, "REPO_ROOT", tmp_path)
monkeypatch.setattr(sync_script, "COMMITTED_REQUIREMENTS", committed_requirements)
monkeypatch.setenv("GENERATED_REQUIREMENTS", str(generated_requirements))
def fake_run(command, **_kwargs):
if command[0] == "git":
return subprocess.CompletedProcess(command, 0, stdout="pyproject.toml\n", stderr="")
generated_requirements.write_text("pytest==2\n", encoding="utf-8")
return subprocess.CompletedProcess(command, 0)
monkeypatch.setattr(sync_script.subprocess, "run", fake_run)
assert sync_script.main() == 1
assert "Regenerate .github/security-audit-requirements.txt" in capsys.readouterr().err
def test_contributing_documents_security_commands(self):
contributing_text = CONTRIBUTING.read_text(encoding="utf-8")
assert LOCAL_REFRESH_TEST_EXTRA_DEPS in contributing_text
assert LOCAL_PIP_AUDIT in contributing_text
assert "/tmp/" not in contributing_text
assert "uv export" not in contributing_text

View File

@@ -1026,41 +1026,6 @@ class TestCommandStep:
assert res_opt.status is StepStatus.FAILED
assert "'options' must be a mapping" in (res_opt.error or "")
@pytest.mark.parametrize("bad", [["claude"], {"a": 1}, 5, True])
def test_validate_rejects_non_string_integration_and_model(self, bad):
"""A non-string 'integration'/'model' must be rejected at validation.
execute() passes 'integration' to get_integration(), which uses it as a
dict key — an unhashable list/dict raises a raw TypeError there, even on
a validated run — and feeds 'model' into the CLI argv. Mirrors the
'command'/'input'/'options' type checks.
"""
from specify_cli.workflows.steps.command import CommandStep
step = CommandStep()
errs = step.validate({"id": "c", "command": "/x", "integration": bad})
assert any("'integration' must be a string" in e for e in errs), bad
errs = step.validate({"id": "c", "command": "/x", "model": bad})
assert any("'model' must be a string" in e for e in errs), bad
def test_validate_accepts_none_and_expression_integration_model(self):
"""An explicit YAML-null (inherit default) or a '{{ ... }}' expression
integration/model stays valid — only literal non-strings are rejected."""
from specify_cli.workflows.steps.command import CommandStep
step = CommandStep()
assert step.validate(
{"id": "c", "command": "/x", "integration": None, "model": None}
) == []
assert step.validate(
{
"id": "c",
"command": "/x",
"integration": "{{ inputs.agent }}",
"model": "{{ inputs.model }}",
}
) == []
def test_validate_rejects_non_string_command(self):
from specify_cli.workflows.steps.command import CommandStep
@@ -1096,55 +1061,6 @@ class TestCommandStep:
assert result.status is StepStatus.FAILED, bad
assert "'command' must be a string" in (result.error or ""), bad
def test_execute_non_string_integration_fails_loudly(self):
"""On an unvalidated run, an unhashable 'integration' would crash
get_integration() (dict.get on a list) with a raw TypeError. execute()
must fail the step with the contract error instead."""
from specify_cli.workflows.steps.command import CommandStep
from specify_cli.workflows.base import StepContext, StepStatus
step = CommandStep()
res = step.execute(
{"id": "c", "command": "speckit.specify", "integration": ["claude"]},
StepContext(),
)
assert res.status is StepStatus.FAILED
assert "'integration' must be a string" in (res.error or "")
# non-string model likewise fails before build_exec_args
res = step.execute(
{"id": "c", "command": "speckit.specify", "integration": "claude", "model": ["m"]},
StepContext(),
)
assert res.status is StepStatus.FAILED
assert "'model' must be a string" in (res.error or "")
@pytest.mark.parametrize("falsey", [[], {}, 0, False])
def test_execute_falsey_non_string_integration_fails_loudly(self, falsey):
"""A *falsey* non-string ([], {}, 0, False) must fail the step, not be
swallowed by an ``or``-fallback to the workflow default.
A ``config.get('integration') or context.default_integration`` coerces a
falsey non-string to the default *before* the type guard runs, so with a
configured default the step would silently dispatch using the wrong
integration instead of surfacing the contract error. The default is set
here so a regression dispatches rather than fails-not-possible."""
from specify_cli.workflows.steps.command import CommandStep
from specify_cli.workflows.base import StepContext, StepStatus
step = CommandStep()
ctx = StepContext(default_integration="claude", default_model="sonnet")
res = step.execute(
{"id": "c", "command": "speckit.specify", "integration": falsey}, ctx
)
assert res.status is StepStatus.FAILED, falsey
assert "'integration' must be a string" in (res.error or ""), falsey
# a falsey non-string model likewise reaches the guard
res = step.execute(
{"id": "c", "command": "speckit.specify", "model": falsey}, ctx
)
assert res.status is StepStatus.FAILED, falsey
assert "'model' must be a string" in (res.error or ""), falsey
def test_step_override_integration(self):
from unittest.mock import patch
from specify_cli.workflows.steps.command import CommandStep
@@ -1532,82 +1448,6 @@ class TestPromptStep:
)
assert errors == []
@pytest.mark.parametrize("bad", [["claude"], {"a": 1}, 5, True])
def test_validate_rejects_non_string_integration_and_model(self, bad):
"""A non-string 'integration'/'model' must be rejected at validation.
execute() passes 'integration' to get_integration(), which uses it as a
dict key — an unhashable list/dict raises a raw TypeError there, even on
a validated run — and feeds 'model' into the CLI argv."""
from specify_cli.workflows.steps.prompt import PromptStep
step = PromptStep()
errs = step.validate({"id": "p", "prompt": "hi", "integration": bad})
assert any("'integration' must be a string" in e for e in errs), bad
errs = step.validate({"id": "p", "prompt": "hi", "model": bad})
assert any("'model' must be a string" in e for e in errs), bad
def test_validate_accepts_none_and_expression_integration_model(self):
"""An explicit YAML-null (inherit default) or a '{{ ... }}' expression
integration/model stays valid — only literal non-strings are rejected."""
from specify_cli.workflows.steps.prompt import PromptStep
step = PromptStep()
assert step.validate(
{"id": "p", "prompt": "hi", "integration": None, "model": None}
) == []
assert step.validate(
{
"id": "p",
"prompt": "hi",
"integration": "{{ inputs.agent }}",
"model": "{{ inputs.model }}",
}
) == []
def test_execute_non_string_integration_fails_loudly(self):
"""On an unvalidated run, an unhashable 'integration' would crash
get_integration() (dict.get on a dict) with a raw TypeError. execute()
must fail the step with the contract error instead."""
from specify_cli.workflows.steps.prompt import PromptStep
from specify_cli.workflows.base import StepContext, StepStatus
step = PromptStep()
res = step.execute(
{"id": "p", "prompt": "hi", "integration": {"a": 1}}, StepContext()
)
assert res.status is StepStatus.FAILED
assert "'integration' must be a string" in (res.error or "")
res = step.execute(
{"id": "p", "prompt": "hi", "integration": "claude", "model": ["m"]},
StepContext(),
)
assert res.status is StepStatus.FAILED
assert "'model' must be a string" in (res.error or "")
@pytest.mark.parametrize("falsey", [[], {}, 0, False])
def test_execute_falsey_non_string_integration_fails_loudly(self, falsey):
"""A *falsey* non-string ([], {}, 0, False) must fail the step, not be
swallowed by an ``or``-fallback to the workflow default.
A ``config.get('integration') or context.default_integration`` coerces a
falsey non-string to the default *before* the type guard runs, so with a
configured default the step would silently dispatch using the wrong
integration instead of surfacing the contract error. The default is set
here so a regression dispatches rather than fails-not-possible."""
from specify_cli.workflows.steps.prompt import PromptStep
from specify_cli.workflows.base import StepContext, StepStatus
step = PromptStep()
ctx = StepContext(default_integration="claude", default_model="sonnet")
res = step.execute({"id": "p", "prompt": "hi", "integration": falsey}, ctx)
assert res.status is StepStatus.FAILED, falsey
assert "'integration' must be a string" in (res.error or ""), falsey
# a falsey non-string model likewise reaches the guard
res = step.execute({"id": "p", "prompt": "hi", "model": falsey}, ctx)
assert res.status is StepStatus.FAILED, falsey
assert "'model' must be a string" in (res.error or ""), falsey
class TestShellStep:
"""Test the shell step type."""
@@ -4509,108 +4349,6 @@ steps:
assert WorkflowEngine._coerce_input("count", 5.0, {"type": "number"}) == 5
assert WorkflowEngine._coerce_input("count", 3.5, {"type": "number"}) == 3.5
def test_coerce_input_rejects_non_list_enum_cleanly(self):
"""A non-list ``enum`` (scalar or string) must raise a clean ValueError,
not the raw ``TypeError`` from the ``value not in enum`` membership test.
A scalar (``enum: 5``) makes ``value not in 5`` raise
``TypeError: argument of type 'int' is not iterable``. A bare string
(``enum: "abc"``) is silently wrong instead — ``value in "abc"`` is a
substring test, not enum membership — so it must be rejected too.
"""
from specify_cli.workflows.engine import WorkflowEngine
for bad_enum in (5, True, "abc", {"a": 1}):
with pytest.raises(ValueError, match="invalid 'enum': must be a list"):
WorkflowEngine._coerce_input(
"scope", "x", {"type": "string", "enum": bad_enum}
)
# A valid list ``enum`` still works, and ``None`` means "no enum".
assert (
WorkflowEngine._coerce_input(
"scope", "a", {"type": "string", "enum": ["a", "b"]}
)
== "a"
)
assert (
WorkflowEngine._coerce_input("scope", "x", {"type": "string"}) == "x"
)
def test_validate_workflow_rejects_non_list_enum(self):
"""A non-list ``enum`` must be reported as an error, not crash
``validate_workflow``. The membership test would raise ``TypeError``,
which escapes its ``except ValueError`` and breaks the "return a list of
errors, never raise" contract. This must surface even with no ``default``
present (the coercion path that would otherwise catch it is only reached
when a default exists).
"""
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
definition = WorkflowDefinition.from_string("""
schema_version: "1.0"
workflow:
id: "bad-enum"
name: "Bad Enum"
version: "1.0.0"
inputs:
scope:
type: string
enum: 5
steps:
- id: noop
type: gate
message: "noop"
options: [approve]
""")
errors = validate_workflow(definition)
assert any("invalid 'enum': must be a list" in e for e in errors), errors
def test_resolve_inputs_rejects_non_list_enum_at_runtime(self, project_dir):
"""``execute()`` accepts unvalidated definitions, so a non-list ``enum``
can reach ``_resolve_inputs`` at run time. It must fail with a clean
ValueError rather than the raw ``TypeError`` from the membership test.
"""
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
definition = WorkflowDefinition.from_string("""
schema_version: "1.0"
workflow:
id: "runtime-bad-enum"
name: "Runtime Bad Enum"
version: "1.0.0"
inputs:
scope:
type: string
enum: 5
""")
engine = WorkflowEngine(project_dir)
with pytest.raises(ValueError, match="invalid 'enum': must be a list"):
engine._resolve_inputs(definition, {"scope": "x"})
def test_non_list_enum_on_integration_auto_still_rejected(self, project_dir):
"""The ``integration: auto`` sentinel strips a *list* ``enum`` before
coercion (enum-membership is a runtime concern for ``auto``). A non-list
``enum`` must NOT be silently stripped by that path — it is still an
authoring error and must fail with the clean shape ValueError.
"""
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
definition = WorkflowDefinition.from_string("""
schema_version: "1.0"
workflow:
id: "auto-bad-enum"
name: "Auto Bad Enum"
version: "1.0.0"
inputs:
integration:
type: string
default: "auto"
enum: 5
""")
engine = WorkflowEngine(project_dir)
with pytest.raises(ValueError, match="invalid 'enum': must be a list"):
engine._resolve_inputs(definition, {})
def test_validate_workflow_rejects_infinite_default_for_number_type(self):
"""``type: number`` with an infinite default (YAML ``.inf``) must be
reported as an error, not raise. ``int(inf)`` raises OverflowError during
@@ -6321,9 +6059,7 @@ class TestWorkflowCatalog:
return "https://[::1"
monkeypatch.setattr(
auth_http,
"open_url",
lambda url, timeout=30, redirect_validator=None: _FakeResponse(),
auth_http, "open_url", lambda url, timeout=30: _FakeResponse()
)
catalog = WorkflowCatalog(project_dir)
@@ -6338,41 +6074,6 @@ class TestWorkflowCatalog:
with pytest.raises(WorkflowCatalogError, match="malformed"):
catalog._fetch_single_catalog(entry, force_refresh=True)
def test_fetch_validates_every_redirect_hop(self, project_dir, monkeypatch):
"""A redirect_validator is passed to open_url and rejects a non-HTTPS
INTERMEDIATE hop — closing the https -> http -> attacker-https chain a
terminal-URL-only check would miss. Mirrors presets/extensions
(#3523 / #3524)."""
from specify_cli.workflows.catalog import (
WorkflowCatalog,
WorkflowCatalogEntry,
WorkflowCatalogError,
)
from specify_cli.authentication import http as auth_http
captured = {}
def fake_open(url, timeout=30, redirect_validator=None):
captured["rv"] = redirect_validator
# Simulate the hop urllib validates before following the redirect.
redirect_validator(
"https://good.example/catalog.json", "http://evil.test/hop"
)
raise AssertionError("redirect_validator should have raised")
monkeypatch.setattr(auth_http, "open_url", fake_open)
catalog = WorkflowCatalog(project_dir)
entry = WorkflowCatalogEntry(
url="https://good.example/catalog.json",
name="test",
priority=1,
install_allowed=True,
)
with pytest.raises(WorkflowCatalogError, match="HTTPS"):
catalog._fetch_single_catalog(entry, force_refresh=True)
assert captured["rv"] is not None
def test_add_catalog(self, project_dir):
from specify_cli.workflows.catalog import WorkflowCatalog
@@ -6917,9 +6618,7 @@ class TestStepCatalog:
return "https://[not-an-ip]/x"
monkeypatch.setattr(
auth_http,
"open_url",
lambda url, timeout=30, redirect_validator=None: _FakeResponse(),
auth_http, "open_url", lambda url, timeout=30: _FakeResponse()
)
catalog = StepCatalog(project_dir)
@@ -6934,41 +6633,6 @@ class TestStepCatalog:
with pytest.raises(StepCatalogError, match="malformed"):
catalog._fetch_single_catalog(entry, force_refresh=True)
def test_fetch_validates_every_redirect_hop(self, project_dir, monkeypatch):
"""A redirect_validator is passed to open_url and rejects a non-HTTPS
INTERMEDIATE hop — closing the https -> http -> attacker-https chain a
terminal-URL-only check would miss. Mirrors presets/extensions
(#3523 / #3524)."""
from specify_cli.workflows.catalog import (
StepCatalog,
StepCatalogEntry,
StepCatalogError,
)
from specify_cli.authentication import http as auth_http
captured = {}
def fake_open(url, timeout=30, redirect_validator=None):
captured["rv"] = redirect_validator
# Simulate the hop urllib validates before following the redirect.
redirect_validator(
"https://good.example/steps.json", "http://evil.test/hop"
)
raise AssertionError("redirect_validator should have raised")
monkeypatch.setattr(auth_http, "open_url", fake_open)
catalog = StepCatalog(project_dir)
entry = StepCatalogEntry(
url="https://good.example/steps.json",
name="test",
priority=1,
install_allowed=True,
)
with pytest.raises(StepCatalogError, match="HTTPS"):
catalog._fetch_single_catalog(entry, force_refresh=True)
assert captured["rv"] is not None
def test_add_catalog(self, project_dir):
from specify_cli.workflows.catalog import StepCatalog

View File

@@ -1,27 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-07-22T00:00:00Z",
"updated_at": "2026-04-10T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/workflows/catalog.community.json",
"workflows": {
"pipeline": {
"id": "pipeline",
"name": "Guided SDD Pipeline",
"description": "Chains specify, clarify, plan, tasks, analyze, implement, and converge into one guided run with a single clarify gate and a post-implement convergence loop",
"author": "domattioli",
"version": "1.1.0",
"url": "https://raw.githubusercontent.com/domattioli/spec-kit-workflow-pipeline/v1.1.0/workflow.yml",
"repository": "https://github.com/domattioli/spec-kit-workflow-pipeline",
"license": "MIT",
"requires": {
"speckit_version": ">=0.11.2"
},
"tags": [
"sdd",
"pipeline",
"automation"
],
"created_at": "2026-07-10T00:00:00Z",
"updated_at": "2026-07-21T00:00:00Z"
}
}
"workflows": {}
}