Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 52 additions & 22 deletions .github/workflows/qsl_exception_lifecycle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ jobs:
fi
done < qsl-bundle-repos.tsv

- name: Prepare CN research dependency identity
- name: Prepare optional AI dependency identity
id: research_dependency
if: steps.workspace.outputs.scope == 'current'
env:
Expand All @@ -101,28 +101,47 @@ jobs:
import subprocess
import tomllib
from pathlib import Path
from scripts.check_qsl_compat import (
GitRef, _extract_git_refs, _gather_declared_current_refs,
_is_research_extra_only_dependency, _load_qsl_config,
)

workspace = Path(os.environ["RUNNER_TEMP"]) / "qsl-workspace"
manifest = workspace / "CnEquityStrategies" / "pyproject.toml"
if not manifest.exists():
raise SystemExit(0)
project = tomllib.loads(manifest.read_text(encoding="utf-8")).get("project", {})
research = project.get("optional-dependencies", {}).get("research", [])
pattern = re.compile(
r"^ai-gateway-client\s*@\s*git\+https://github\.com/"
r"QuantStrategyLab/(?:AIAuditBridge|QuantAIService)\.git@([0-9a-f]{40})$"
r"QuantStrategyLab/(AIAuditBridge|QuantAIService)\.git@([0-9a-f]{40})$"
)
matches = [pattern.fullmatch(item.strip()) for item in research if isinstance(item, str)]
matches = [match for match in matches if match]
if not matches:
refs = set()
for consumer, extra in (("CnEquityStrategies", "research"), ("QuantStrategyPlugins", "ai")):
repo_root = workspace / consumer
manifest = repo_root / "pyproject.toml"
if not manifest.exists():
continue
project = tomllib.loads(manifest.read_text(encoding="utf-8")).get("project", {})
requirements = project.get("optional-dependencies", {}).get(extra, [])
candidates = [item.strip() for item in requirements if isinstance(item, str)
and re.match(r"^ai[-_.]gateway[-_.]client\b", item.strip(), re.IGNORECASE)]
if not candidates:
continue
if len(candidates) != 1 or not (match := pattern.fullmatch(candidates[0])):
raise SystemExit(f"{consumer} optional {extra} AI identity is unapproved or ambiguous")
repository, ref = match.groups()
pin = GitRef(repo=repository, ref=ref, source="pyproject.toml", line_no=None)
declared = {item.ref for item in _gather_declared_current_refs(_load_qsl_config(repo_root))
if item.repo == repository}
locked = {item.ref for item in _extract_git_refs(repo_root / "uv.lock") if item.repo == repository}
if declared != {ref} or locked != {ref} or not _is_research_extra_only_dependency(repo_root, pin):
raise SystemExit(f"{consumer} AI identity lacks matching optional manifest/lock/declaration proof")
refs.add(ref)
if not refs:
raise SystemExit(0)
if len(matches) != 1:
raise SystemExit("CN research AIAuditBridge dependency is missing or ambiguous")
ref = matches[0].group(1)
destination = Path(os.environ["GITHUB_WORKSPACE"]).parent / "AIAuditBridge"
if len(refs) != 1:
raise SystemExit("optional AI consumers declare conflicting identity pins")
ref = refs.pop()
destination = Path(os.environ["GITHUB_WORKSPACE"]).parent / "QuantAIService"
if destination.exists() or destination.is_symlink():
raise SystemExit(f"refusing to overwrite existing dependency identity checkout: {destination}")
Path(os.environ["RUNNER_TEMP"], "qsl-aiauditbridge-identity-created").write_text(
Path(os.environ["RUNNER_TEMP"], "qsl-quantaiservice-identity-created").write_text(
str(destination), encoding="utf-8"
)
subprocess.run(
Expand All @@ -132,8 +151,19 @@ jobs:
subprocess.run(["git", "-C", str(destination), "checkout", "--detach", ref], check=True)
actual = subprocess.check_output(["git", "-C", str(destination), "rev-parse", "HEAD"], text=True).strip()
if actual != ref:
raise SystemExit(f"AIAuditBridge identity checkout mismatch: expected {ref}, got {actual}")
Path("qsl-aiauditbridge-identity.tsv").write_text(f"AIAuditBridge\t{actual}\n", encoding="utf-8")
raise SystemExit(f"QuantAIService identity checkout mismatch: expected {ref}, got {actual}")
origin = subprocess.check_output(
["git", "-C", str(destination), "remote", "get-url", "origin"], text=True
).strip().rstrip("/").removesuffix(".git")
if origin not in {"https://github.com/QuantStrategyLab/QuantAIService",
"git@github.com:QuantStrategyLab/QuantAIService",
"ssh://git@github.com/QuantStrategyLab/QuantAIService"}:
raise SystemExit("QuantAIService identity checkout has an unexpected origin")
identity = tomllib.loads((destination / "qsl.toml").read_text(encoding="utf-8"))
if (identity.get("tier") != "ops/tooling" or identity.get("upgrade_ring") != "ring_e"
or identity.get("runtime_dependency") is not False):
raise SystemExit("QuantAIService identity must remain non-runtime ops/tooling")
Path("qsl-quantaiservice-identity.tsv").write_text(f"QuantAIService\t{actual}\n", encoding="utf-8")
PY

- name: Generate QSL compatibility report
Expand Down Expand Up @@ -183,10 +213,10 @@ jobs:
repo, sha = line.split("\t", 1)
handle.write(f"- `{repo}`: `{sha}`\n")

identity_refs = Path("qsl-aiauditbridge-identity.tsv")
identity_refs = Path("qsl-quantaiservice-identity.tsv")
if identity_refs.exists():
with Path(os.environ["GITHUB_STEP_SUMMARY"]).open("a", encoding="utf-8") as handle:
handle.write("\n### Research dependency identity checkout\n")
handle.write("\n### Optional AI dependency identity checkout\n")
for line in identity_refs.read_text(encoding="utf-8").splitlines():
repo, sha = line.split("\t", 1)
handle.write(f"- `{repo}`: `{sha}` (identity-only)\n")
Expand All @@ -198,14 +228,14 @@ jobs:
QSL_BUNDLE: ${{ steps.workspace.outputs.bundle }}
QSL_WORKSPACE: ${{ steps.workspace.outputs.workspace }}

- name: Clean up research dependency identity
- name: Clean up optional AI dependency identity
if: always()
run: |
set -euo pipefail
marker="${RUNNER_TEMP}/qsl-aiauditbridge-identity-created"
marker="${RUNNER_TEMP}/qsl-quantaiservice-identity-created"
if [ -f "$marker" ]; then
destination="$(realpath -m "$(cat "$marker")")"
expected="$(realpath -m "${GITHUB_WORKSPACE}/../AIAuditBridge")"
expected="$(realpath -m "${GITHUB_WORKSPACE}/../QuantAIService")"
if [ "$destination" = "$expected" ]; then
rm -rf "$destination" "$marker"
else
Expand Down
59 changes: 59 additions & 0 deletions python/tests/test_qsl_compat_checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import importlib.util
import sys
import subprocess
import tempfile
import unittest
from pathlib import Path
Expand All @@ -16,6 +17,64 @@


class QSLCompatCheckerTest(unittest.TestCase):
def test_canonical_ai_workspace_identity_preserves_optional_and_managed_boundaries(self):
ref = "65c8bcf432f578f657c12d5dab0e16ebec0bef5a"
cases = [
# Original failure: four unmanaged declarations even if the
# canonical remote has been cloned into the old alias directory.
("AIAuditBridge", "AIAuditBridge", "QuantStrategyLab/QuantAIService", ref, False, False, "unmanaged"),
("QuantAIService", None, None, ref, False, False, "unmanaged"),
("QuantAIService", "AIAuditBridge", "QuantStrategyLab/QuantAIService", ref, False, False, "unmanaged"),
("QuantAIService", "QuantAIService", "Pigbibi/QuantAIService", ref, False, False, "unmanaged"),
("UnknownAI", "QuantAIService", "QuantStrategyLab/QuantAIService", ref, False, False, "unmanaged"),
("QuantAIService", "QuantAIService", "QuantStrategyLab/QuantAIService", ref, False, True, None),
("QuantAIService", "QuantAIService", "QuantStrategyLab/QuantAIService", ref, True, False, "forbidden dependency direction"),
("QuantAIService", "QuantAIService", "QuantStrategyLab/QuantAIService", "main", False, False, "forbidden ref"),
("QuantAIService", "QuantAIService", "QuantStrategyLab/QuantAIService", ref[:12], False, False, "forbidden short/invalid ref"),
]
for repository, dirname, remote, pin, base, allowed, failure in cases:
with self.subTest(repository=repository, dirname=dirname, remote=remote, pin=pin, base=base), \
tempfile.TemporaryDirectory() as directory:
root = Path(directory)
consumer = root / "QuantStrategyPlugins"
consumer.mkdir()
requirement = f"ai-gateway-client @ git+https://github.com/QuantStrategyLab/{repository}.git@{pin}"
(consumer / "qsl.toml").write_text(
'tier = "strategy-lib"\nupgrade_ring = "ring_b"\n[compat]\nbundle = "2026.07.1"\n'
f'requires = ["pandas>=2.0", "yfinance>=0.2.40", "{requirement}"]\n', encoding="utf-8",
)
(consumer / "pyproject.toml").write_text(
'[project]\nname = "quant-strategy-plugins"\ndependencies = '
+ repr([requirement] if base else []) + '\n[project.optional-dependencies]\nai = '
+ repr([requirement]) + '\n', encoding="utf-8",
)
url = f"https://github.com/QuantStrategyLab/{repository}.git?rev={pin}"
(consumer / "uv.lock").write_text(
'version = 1\n[[package]]\nname = "ai-gateway-client"\n'
f'source = {{ git = "{url}#{pin}" }}\n[[package]]\n'
'name = "quant-strategy-plugins"\nsource = { editable = "." }\n'
'[package.metadata]\nrequires-dist = [{ name = "ai-gateway-client", '
f'marker = "extra == \'ai\'", git = "{url}" }}]\n', encoding="utf-8",
)
if dirname is not None:
identity = root / dirname
subprocess.run(["git", "init", "-q", str(identity)], check=True)
subprocess.run(["git", "-C", str(identity), "remote", "add", "origin",
f"https://github.com/{remote}.git"], check=True)
(identity / "qsl.toml").write_text(
'tier = "ops/tooling"\nupgrade_ring = "ring_e"\nruntime_dependency = false\n'
'[compat]\nbundle = "2026.07.0"\n', encoding="utf-8",
)
result = check_qsl_compat._check(repo_root=consumer, compat_root=ROOT, scope="current")
self.assertEqual(result[0], allowed, result[1])
if failure:
self.assertTrue(any(failure in issue for issue in result[1]), result[1])
else:
self.assertEqual((result[1], result[2]), ([], []))
if repository == "AIAuditBridge":
self.assertEqual(len(result[1]), 4, result[1])
self.assertTrue(all("unmanaged" in issue for issue in result[1]))

def _make_repo_root(self, qsl_toml: str, pyproject: str) -> Path:
root = Path(tempfile.mkdtemp())
(root / "qsl.toml").write_text(qsl_toml, encoding="utf-8")
Expand Down
Loading
Loading