From 07d5c29587b71b2dabb7f743fa91341ba2f61480 Mon Sep 17 00:00:00 2001
From: ice-black-tea <30665081+ice-black-tea@users.noreply.github.com>
Date: Tue, 22 Sep 2026 07:29:44 +0800
Subject: [PATCH 01/22] fix(ai): preserve invalid workspace config failures
---
.../src/linktools/commands/ai/_common.py | 7 +---
tests/ai/test_debug_cli.py | 38 +++++++++++++++++++
2 files changed, 39 insertions(+), 6 deletions(-)
diff --git a/linktools-ai/src/linktools/commands/ai/_common.py b/linktools-ai/src/linktools/commands/ai/_common.py
index aff05aa7a..6ec1223e1 100644
--- a/linktools-ai/src/linktools/commands/ai/_common.py
+++ b/linktools-ai/src/linktools/commands/ai/_common.py
@@ -49,12 +49,7 @@ def _add_local_runtime_arguments(parser: "CommandParser") -> None:
def _load_workspace(root: "Path | None" = None) -> Workspace:
start = Path.cwd()
- try:
- return Workspace.discover(start) if root is None else Workspace.discover(start, root=root)
- except AIError as error:
- if error.code is not ErrorCode.WORKSPACE_CONFIG_INVALID:
- raise
- return Workspace.initialize(start if root is None else root)
+ return Workspace.discover(start) if root is None else Workspace.discover(start, root=root)
def _local_runtime_root(workspace: Workspace) -> Path:
diff --git a/tests/ai/test_debug_cli.py b/tests/ai/test_debug_cli.py
index 51be1eaa7..9afc8e5b9 100644
--- a/tests/ai/test_debug_cli.py
+++ b/tests/ai/test_debug_cli.py
@@ -328,6 +328,44 @@ def test_workspace_discovery_does_not_walk_up_without_configuration(
assert not (nested / ".linktools").exists()
+def test_workspace_loader_preserves_configured_ancestor_and_explicit_root(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ config_dir = tmp_path / ".linktools"
+ config_dir.mkdir()
+ (config_dir / "config.yaml").write_text("model: configured\n", encoding="utf-8")
+ nested = tmp_path / "src" / "package"
+ nested.mkdir(parents=True)
+ monkeypatch.chdir(nested)
+
+ discovered = _load_workspace()
+ explicit = _load_workspace(tmp_path)
+
+ assert discovered.root == tmp_path
+ assert explicit.root == tmp_path
+ assert discovered.config == {"model": "configured"}
+ assert explicit.config == {"model": "configured"}
+
+
+def test_invalid_ancestor_workspace_config_fails_without_creating_fallback(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ config_dir = tmp_path / ".linktools"
+ config_dir.mkdir()
+ (config_dir / "config.yaml").write_text("invalid: [\n", encoding="utf-8")
+ nested = tmp_path / "src" / "package"
+ nested.mkdir(parents=True)
+ monkeypatch.chdir(nested)
+
+ with pytest.raises(AIError) as error:
+ StatusCommand().run(SimpleNamespace())
+
+ assert error.value.code is ErrorCode.WORKSPACE_CONFIG_INVALID
+ assert not (nested / ".linktools").exists()
+
+
@pytest.mark.asyncio
async def test_local_debug_storage_uses_separate_runtime_and_metrics_databases(
tmp_path: Path,
From 1283546f48e4ced34820f43a692f3c7add9653cf Mon Sep 17 00:00:00 2001
From: ice-black-tea <30665081+ice-black-tea@users.noreply.github.com>
Date: Tue, 22 Sep 2026 07:33:49 +0800
Subject: [PATCH 02/22] fix(release): verify candidate artifacts before
publishing
---
.github/workflows/python-publish.yml | 14 +-
scripts/verify.py | 325 +++++++++++++++++++++++----
tests/core/test_release_verify.py | 54 +++++
3 files changed, 351 insertions(+), 42 deletions(-)
create mode 100644 tests/core/test_release_verify.py
diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml
index a3ab6616b..782423bca 100644
--- a/.github/workflows/python-publish.yml
+++ b/.github/workflows/python-publish.yml
@@ -25,15 +25,23 @@ env:
jobs:
validate:
runs-on: ubuntu-latest
+ outputs:
+ prerelease: ${{ steps.version.outputs.prerelease }}
steps:
- uses: actions/checkout@v7
- name: Validate version
+ id: version
run: |
VERSION="${{ inputs.version }}"
if ! [[ "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(rc[0-9]+)?$ ]]; then
echo "::error::Invalid version format: $VERSION (expected vX.Y.Z or vX.Y.ZrcN, e.g. v1.2.3 or v1.2.3rc0)"
exit 1
fi
+ if [[ "$VERSION" =~ rc[0-9]+$ ]]; then
+ echo "prerelease=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "prerelease=false" >> "$GITHUB_OUTPUT"
+ fi
if git ls-remote --exit-code --tags origin "refs/tags/$VERSION" >/dev/null 2>&1; then
echo "::error::Tag $VERSION already exists"
exit 1
@@ -124,4 +132,8 @@ jobs:
run: |
git tag ${{ inputs.version }}
git push origin ${{ inputs.version }}
- gh release create ${{ inputs.version }} --generate-notes dist/*
+ if [[ "${{ needs.validate.outputs.prerelease }}" == "true" ]]; then
+ gh release create ${{ inputs.version }} --generate-notes --prerelease --latest=false dist/*
+ else
+ gh release create ${{ inputs.version }} --generate-notes dist/*
+ fi
diff --git a/scripts/verify.py b/scripts/verify.py
index 0e4fed908..c998e99bb 100644
--- a/scripts/verify.py
+++ b/scripts/verify.py
@@ -27,15 +27,36 @@
"linktools-mobile": "linktools/assets/tools/linktools-mobile.json",
}
+_IMPORT_MODULES = {
+ "linktools": "linktools",
+ "linktools-common": "linktools.commands.common",
+ "linktools-mobile": "linktools.mobile",
+ "linktools-cntr": "linktools.cntr",
+ "linktools-ai": "linktools.ai",
+}
+_CLI_COMMANDS = {
+ "linktools": "lt",
+ "linktools-common": "ct-env",
+ "linktools-mobile": "at-adb",
+ "linktools-cntr": "ct-cntr",
+ "linktools-ai": "ai-run",
+}
+_CLI_EXTRAS = {
+ "linktools": "cli",
+ "linktools-ai": "sqlite",
+}
+_REQUIREMENT_NAME_PATTERN = re.compile(r"^\s*([A-Za-z0-9_.-]+)")
+
class _Artifact:
- def __init__(self, path, kind, name, version, requires_python):
+ def __init__(self, path, kind, name, version, requires_python, requires_dist=()):
self.path = path
self.kind = kind
self.name = name
self.normalized_name = _normalize_name(name)
self.version = version
self.requires_python = requires_python
+ self.requires_dist = tuple(requires_dist)
def _normalize_name(value):
@@ -94,7 +115,12 @@ def _read_artifact(path):
requires_python = ""
if not isinstance(requires_python, str):
requires_python = str(requires_python)
- return _Artifact(path, kind, name, version, requires_python)
+ requires_dist = getattr(metadata, "requires_dist", None) or ()
+ if isinstance(requires_dist, str):
+ requires_dist = (requires_dist,)
+ else:
+ requires_dist = tuple(str(value) for value in requires_dist)
+ return _Artifact(path, kind, name, version, requires_python, requires_dist)
def _load_artifacts(known_projects):
@@ -250,44 +276,45 @@ def _validate_sdist_inputs(pairs):
return roots
-def _validate_sdist_rebuild(pairs, roots):
- with tempfile.TemporaryDirectory(prefix="linktools-sdist-rebuild-") as temporary:
- temporary_root = Path(temporary)
- for project, pair in pairs.items():
- wheel, sdist = pair
- project_root = temporary_root / project
- project_root.mkdir()
- with tarfile.open(str(sdist.path), "r:gz") as archive:
- _safe_sdist_members(archive)
- archive.extractall(str(project_root))
- source = project_root / roots[project]
- output = project_root / "wheel"
- output.mkdir()
- environment = dict(os.environ)
- environment["RELEASE"] = "true"
- subprocess.check_call(
- [
- sys.executable,
- "-m",
- "build",
- "--wheel",
- "--outdir",
- str(output),
- str(source),
- ],
- cwd=str(_REPO_ROOT),
- env=environment,
- )
- rebuilt_paths = list(output.glob("*.whl"))
- if len(rebuilt_paths) != 1:
- raise ValueError("%s sdist rebuild produced %d wheel(s)" % (project, len(rebuilt_paths)))
- rebuilt = _read_artifact(rebuilt_paths[0])
- if rebuilt.name != wheel.name:
- raise ValueError("%s rebuilt wheel Name mismatch" % project)
- if rebuilt.version != wheel.version:
- raise ValueError("%s rebuilt wheel Version mismatch" % project)
- if rebuilt.requires_python != wheel.requires_python:
- raise ValueError("%s rebuilt wheel Requires-Python mismatch" % project)
+def _validate_sdist_rebuild(pairs, roots, output_root):
+ rebuilt_artifacts = {}
+ for project, pair in pairs.items():
+ wheel, sdist = pair
+ project_root = output_root / project
+ project_root.mkdir()
+ with tarfile.open(str(sdist.path), "r:gz") as archive:
+ _safe_sdist_members(archive)
+ archive.extractall(str(project_root))
+ source = project_root / roots[project]
+ output = project_root / "wheel"
+ output.mkdir()
+ environment = dict(os.environ)
+ environment["RELEASE"] = "true"
+ subprocess.check_call(
+ [
+ sys.executable,
+ "-m",
+ "build",
+ "--wheel",
+ "--outdir",
+ str(output),
+ str(source),
+ ],
+ cwd=str(_REPO_ROOT),
+ env=environment,
+ )
+ rebuilt_paths = list(output.glob("*.whl"))
+ if len(rebuilt_paths) != 1:
+ raise ValueError("%s sdist rebuild produced %d wheel(s)" % (project, len(rebuilt_paths)))
+ rebuilt = _read_artifact(rebuilt_paths[0])
+ if rebuilt.name != wheel.name:
+ raise ValueError("%s rebuilt wheel Name mismatch" % project)
+ if rebuilt.version != wheel.version:
+ raise ValueError("%s rebuilt wheel Version mismatch" % project)
+ if rebuilt.requires_python != wheel.requires_python:
+ raise ValueError("%s rebuilt wheel Requires-Python mismatch" % project)
+ rebuilt_artifacts[project] = rebuilt
+ return rebuilt_artifacts
def _wheel_resource(wheel, resource):
@@ -389,6 +416,210 @@ def _validate_install_isolation(pairs, resources):
raise ValueError("%s resource changed after mobile uninstall" % project)
+def _requirement_name(requirement):
+ match = _REQUIREMENT_NAME_PATTERN.match(requirement)
+ return _normalize_name(match.group(1)) if match else None
+
+
+def _candidate_install_order(project, artifacts, known_projects):
+ visiting = set()
+ visited = set()
+ order = []
+
+ def visit(name):
+ if name in visited:
+ return
+ if name in visiting:
+ raise ValueError("candidate dependency cycle while verifying %s: %s" % (project, name))
+ artifact = artifacts.get(name)
+ if artifact is None:
+ raise ValueError("missing candidate artifact while verifying %s: %s" % (project, name))
+ visiting.add(name)
+ for requirement in artifact.requires_dist:
+ dependency = _requirement_name(requirement)
+ if dependency not in known_projects:
+ continue
+ if dependency not in artifacts:
+ raise ValueError(
+ "%s requires repository candidate %s, but it is not selected for verification"
+ % (name, dependency)
+ )
+ visit(dependency)
+ visiting.remove(name)
+ visited.add(name)
+ order.append(name)
+
+ visit(project)
+ return tuple(order)
+
+
+def _isolated_environment():
+ environment = dict(os.environ)
+ environment.pop("PYTHONHOME", None)
+ environment.pop("PYTHONPATH", None)
+ environment["PIP_DISABLE_PIP_VERSION_CHECK"] = "1"
+ return environment
+
+
+def _venv_command(environment, name):
+ if os.name == "nt":
+ return environment / "Scripts" / ("%s.exe" % name)
+ return environment / "bin" / name
+
+
+_IMPORT_CHECK_SCRIPT = """
+import importlib
+import importlib.metadata as metadata
+import pathlib
+import site
+import sys
+
+distribution_name, module_name, expected_version = sys.argv[1:4]
+distribution = metadata.distribution(distribution_name)
+if distribution.version != expected_version:
+ raise SystemExit(
+ "distribution version mismatch: %s != %s"
+ % (distribution.version, expected_version)
+ )
+module = importlib.import_module(module_name)
+origin = getattr(module, "__file__", None)
+if not origin:
+ raise SystemExit("import has no file origin: %s" % module_name)
+module_path = pathlib.Path(origin).resolve()
+site_roots = tuple(pathlib.Path(value).resolve() for value in site.getsitepackages())
+if not any(root == module_path or root in module_path.parents for root in site_roots):
+ raise SystemExit("import escaped isolated site-packages: %s" % module_path)
+"""
+
+
+_SQLITE_CHECK_SCRIPT = """
+import asyncio
+import pathlib
+import sys
+
+from linktools.ai.runtime import RuntimeState
+
+async def main():
+ database = pathlib.Path(sys.argv[1]) / "runtime.db"
+ state = RuntimeState.sqlite(database)
+ await state.initialize(namespace="release-verify", tenant_id="release-verify")
+ await state.close()
+ reopened = RuntimeState.sqlite(database)
+ await reopened.initialize(
+ namespace="release-verify",
+ tenant_id="release-verify",
+ read_only=True,
+ )
+ await reopened.close()
+
+asyncio.run(main())
+"""
+
+
+def _pip_check(python, *, cwd, environment):
+ subprocess.check_call(
+ [str(python), "-m", "pip", "check"],
+ cwd=str(cwd),
+ env=environment,
+ )
+
+
+def _validate_candidate_imports(python, order, artifacts, *, cwd, environment):
+ for project in order:
+ artifact = artifacts[project]
+ subprocess.check_call(
+ [
+ str(python),
+ "-c",
+ _IMPORT_CHECK_SCRIPT,
+ artifact.name,
+ _IMPORT_MODULES[project],
+ artifact.version,
+ ],
+ cwd=str(cwd),
+ env=environment,
+ )
+
+
+def _validate_candidate_cli(project, environment, *, cwd, subprocess_environment):
+ command = _venv_command(environment, _CLI_COMMANDS[project])
+ if not command.is_file():
+ raise ValueError("%s CLI entry point is missing: %s" % (project, command))
+ subprocess.check_call(
+ [str(command), "--help"],
+ cwd=str(cwd),
+ env=subprocess_environment,
+ )
+
+
+def _validate_ai_sqlite(python, *, cwd, environment):
+ database_root = cwd / "sqlite-smoke"
+ database_root.mkdir()
+ subprocess.check_call(
+ [str(python), "-c", _SQLITE_CHECK_SCRIPT, str(database_root)],
+ cwd=str(cwd),
+ env=environment,
+ )
+
+
+def _validate_candidate_install(project, artifacts, known_projects, root):
+ order = _candidate_install_order(project, artifacts, known_projects)
+ target = artifacts[project]
+ environment = root / project
+ venv.create(str(environment), with_pip=True, system_site_packages=False)
+ python = _venv_python(environment)
+ subprocess_environment = _isolated_environment()
+ install_requirements = [str(artifacts[name].path) for name in order]
+ subprocess.check_call(
+ [str(python), "-m", "pip", "install"] + install_requirements,
+ cwd=str(root),
+ env=subprocess_environment,
+ )
+ _pip_check(python, cwd=root, environment=subprocess_environment)
+ _validate_candidate_imports(
+ python,
+ order,
+ artifacts,
+ cwd=root,
+ environment=subprocess_environment,
+ )
+
+ extra = _CLI_EXTRAS.get(project)
+ if extra is not None:
+ subprocess.check_call(
+ [str(python), "-m", "pip", "install", "%s[%s]" % (target.path, extra)],
+ cwd=str(root),
+ env=subprocess_environment,
+ )
+ _pip_check(python, cwd=root, environment=subprocess_environment)
+
+ _validate_candidate_cli(
+ project,
+ environment,
+ cwd=root,
+ subprocess_environment=subprocess_environment,
+ )
+ if project == "linktools-ai":
+ _validate_ai_sqlite(
+ python,
+ cwd=root,
+ environment=subprocess_environment,
+ )
+
+
+def _validate_candidate_installs(label, artifacts, known_projects):
+ revision = os.environ.get("GITHUB_SHA", "local")
+ with tempfile.TemporaryDirectory(prefix="linktools-%s-install-" % label) as temporary:
+ root = Path(temporary)
+ for project in sorted(artifacts):
+ artifact = artifacts[project]
+ print(
+ "[+] %s candidate install: %s %s source=%s revision=%s"
+ % (label, artifact.name, artifact.version, artifact.path.name, revision)
+ )
+ _validate_candidate_install(project, artifacts, known_projects, root)
+
+
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("projects", nargs="*", help="registered projects to verify")
@@ -410,9 +641,21 @@ def main() -> int:
pairs = _selected_pairs(artifacts, selected, project_paths)
_validate_version(pairs, project_paths)
roots = _validate_sdist_inputs(pairs)
- _validate_sdist_rebuild(pairs, roots)
resources = _validate_capability_resources(pairs)
_validate_install_isolation(pairs, resources)
+ original_wheels = {project: pair[0] for project, pair in pairs.items()}
+ with tempfile.TemporaryDirectory(prefix="linktools-sdist-rebuild-") as temporary:
+ rebuilt_wheels = _validate_sdist_rebuild(
+ pairs,
+ roots,
+ Path(temporary),
+ )
+ _validate_candidate_installs("wheel", original_wheels, project_paths)
+ _validate_candidate_installs(
+ "sdist-rebuilt-wheel",
+ rebuilt_wheels,
+ project_paths,
+ )
except (OSError, ValueError, subprocess.CalledProcessError, tarfile.TarError, zipfile.BadZipFile) as error:
print("[-] Artifact verification failed: %s" % error, file=sys.stderr)
return 1
diff --git a/tests/core/test_release_verify.py b/tests/core/test_release_verify.py
new file mode 100644
index 000000000..55bae25ce
--- /dev/null
+++ b/tests/core/test_release_verify.py
@@ -0,0 +1,54 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+from pathlib import Path
+
+import pytest
+
+from scripts import verify as release_verify
+
+
+def _artifact(name: str, *requires_dist: str) -> release_verify._Artifact:
+ return release_verify._Artifact(
+ Path("/tmp/%s.whl" % name),
+ "wheel",
+ name,
+ "0.10.0",
+ ">=3.6",
+ requires_dist,
+ )
+
+
+def test_candidate_install_order_uses_repository_dependencies_only() -> None:
+ artifacts = {
+ "linktools": _artifact("linktools", "filelock>=3.4.0"),
+ "linktools-common": _artifact(
+ "linktools-common",
+ "linktools[cli]>=0.10.0",
+ "lief>0.10.1; extra == 'lief'",
+ ),
+ }
+
+ order = release_verify._candidate_install_order(
+ "linktools-common",
+ artifacts,
+ {"linktools", "linktools-common", "linktools-mobile"},
+ )
+
+ assert order == ("linktools", "linktools-common")
+
+
+def test_candidate_install_order_rejects_missing_repository_dependency() -> None:
+ artifacts = {
+ "linktools-common": _artifact(
+ "linktools-common",
+ "linktools[cli]>=0.10.0",
+ ),
+ }
+
+ with pytest.raises(ValueError, match="requires repository candidate linktools"):
+ release_verify._candidate_install_order(
+ "linktools-common",
+ artifacts,
+ {"linktools", "linktools-common"},
+ )
From 83d9a825f1ae71d82352d801eaefc77e8a9349ab Mon Sep 17 00:00:00 2001
From: ice-black-tea <30665081+ice-black-tea@users.noreply.github.com>
Date: Tue, 22 Sep 2026 07:34:50 +0800
Subject: [PATCH 03/22] fix(ai): declare cli runtime dependency
---
README.md | 6 +++---
linktools-ai/README.md | 3 ++-
linktools-ai/linktools.yml | 1 +
3 files changed, 6 insertions(+), 4 deletions(-)
diff --git a/README.md b/README.md
index 81f02a39d..93dcd89ca 100644
--- a/README.md
+++ b/README.md
@@ -10,18 +10,18 @@ Linktools 是一套面向移动安全研究、逆向分析、合规检测工具
| [linktools-common](linktools-common/) | 通用工具:`ct-env`、`ct-grep`、`ct-tools` | [README](linktools-common/README.md) |
| [linktools-mobile](linktools-mobile/) | 移动设备:Android(`at-*`)和 iOS(`it-*`)工具 | [README](linktools-mobile/README.md) |
| [linktools-cntr](linktools-cntr/) | 容器管理:Docker/Compose 部署工具(`ct-cntr`) | [README](linktools-cntr/README.md) |
-| [linktools-ai](linktools-ai/) | AI agent 运行时:session/execution/swarm,基于 pydantic-ai(纯库,无 CLI) | [README](linktools-ai/README.md) |
+| [linktools-ai](linktools-ai/) | AI agent 运行时:session/execution/swarm,基于 pydantic-ai,并提供本地调试 CLI | [README](linktools-ai/README.md) |
## 快速开始
### 依赖项
-Python & pip(3.6 及以上):
+Python & pip:除 `linktools-ai` 外的子包支持 Python 3.6 及以上;`linktools-ai` 需要 Python 3.10 及以上。
### 安装
```bash
-# 安装方式一:安装所有包
+# 安装方式一:安装 linktools 的全部可选能力(包含 common/mobile/cntr,不包含独立的 linktools-ai)
python3 -m pip install -U "linktools[all]"
# 安装方式二:按需安装子包
diff --git a/linktools-ai/README.md b/linktools-ai/README.md
index ccf84eae4..f6e9304b5 100644
--- a/linktools-ai/README.md
+++ b/linktools-ai/README.md
@@ -42,11 +42,12 @@ python3 -m linktools ai run "review this change" --project /workspace/project --
Useful options:
- `--base-url`, `--api-key`, and `--model` also read `OPENAI_BASE_URL`, `OPENAI_API_KEY`, and `OPENAI_MODEL`.
-- `--storage filesystem|sqlite` selects Runtime state storage.
- `--planning` enables planning for the execution.
- `--thinking` requests model thinking when supported.
- `--json` emits one terminal JSON result.
+The local CLI stores Runtime state under `/.linktools/runtime` through `RuntimeState.from_root()`; storage is not selected with a CLI flag.
+
### Python
```python
diff --git a/linktools-ai/linktools.yml b/linktools-ai/linktools.yml
index e74b77570..ef77145c8 100644
--- a/linktools-ai/linktools.yml
+++ b/linktools-ai/linktools.yml
@@ -3,6 +3,7 @@ version: "0.10.0"
dependencies:
- filelock>=3.4.0
+ - rich
- jsonschema>=4.23.0,<5.0.0
- pydantic-ai-slim[mcp,openai]>=2.40.0,<3.0.0
- pydantic-ai-harness>=0.29.0
From f586b7e97e310877c96c147faea8762c3196daea Mon Sep 17 00:00:00 2001
From: ice-black-tea <30665081+ice-black-tea@users.noreply.github.com>
Date: Tue, 22 Sep 2026 10:23:15 +0800
Subject: [PATCH 04/22] refactor(release): keep artifact smoke verification
minimal
---
scripts/verify.py | 360 ++++++++++++++++------------------------------
1 file changed, 124 insertions(+), 236 deletions(-)
diff --git a/scripts/verify.py b/scripts/verify.py
index c998e99bb..2c557d63e 100644
--- a/scripts/verify.py
+++ b/scripts/verify.py
@@ -26,7 +26,6 @@
"linktools-common": "linktools/assets/tools/linktools-common.json",
"linktools-mobile": "linktools/assets/tools/linktools-mobile.json",
}
-
_IMPORT_MODULES = {
"linktools": "linktools",
"linktools-common": "linktools.commands.common",
@@ -45,7 +44,7 @@
"linktools": "cli",
"linktools-ai": "sqlite",
}
-_REQUIREMENT_NAME_PATTERN = re.compile(r"^\s*([A-Za-z0-9_.-]+)")
+_REQUIREMENT_NAME = re.compile(r"^\\s*([A-Za-z0-9_.-]+)")
class _Artifact:
@@ -118,8 +117,6 @@ def _read_artifact(path):
requires_dist = getattr(metadata, "requires_dist", None) or ()
if isinstance(requires_dist, str):
requires_dist = (requires_dist,)
- else:
- requires_dist = tuple(str(value) for value in requires_dist)
return _Artifact(path, kind, name, version, requires_python, requires_dist)
@@ -276,45 +273,46 @@ def _validate_sdist_inputs(pairs):
return roots
-def _validate_sdist_rebuild(pairs, roots, output_root):
- rebuilt_artifacts = {}
- for project, pair in pairs.items():
- wheel, sdist = pair
- project_root = output_root / project
- project_root.mkdir()
- with tarfile.open(str(sdist.path), "r:gz") as archive:
- _safe_sdist_members(archive)
- archive.extractall(str(project_root))
- source = project_root / roots[project]
- output = project_root / "wheel"
- output.mkdir()
- environment = dict(os.environ)
- environment["RELEASE"] = "true"
- subprocess.check_call(
- [
- sys.executable,
- "-m",
- "build",
- "--wheel",
- "--outdir",
- str(output),
- str(source),
- ],
- cwd=str(_REPO_ROOT),
- env=environment,
- )
- rebuilt_paths = list(output.glob("*.whl"))
- if len(rebuilt_paths) != 1:
- raise ValueError("%s sdist rebuild produced %d wheel(s)" % (project, len(rebuilt_paths)))
- rebuilt = _read_artifact(rebuilt_paths[0])
- if rebuilt.name != wheel.name:
- raise ValueError("%s rebuilt wheel Name mismatch" % project)
- if rebuilt.version != wheel.version:
- raise ValueError("%s rebuilt wheel Version mismatch" % project)
- if rebuilt.requires_python != wheel.requires_python:
- raise ValueError("%s rebuilt wheel Requires-Python mismatch" % project)
- rebuilt_artifacts[project] = rebuilt
- return rebuilt_artifacts
+def _validate_sdist_rebuild(pairs, roots):
+ with tempfile.TemporaryDirectory(prefix="linktools-sdist-rebuild-") as temporary:
+ temporary_root = Path(temporary)
+ for project, pair in pairs.items():
+ wheel, sdist = pair
+ project_root = temporary_root / project
+ project_root.mkdir()
+ with tarfile.open(str(sdist.path), "r:gz") as archive:
+ _safe_sdist_members(archive)
+ archive.extractall(str(project_root))
+ source = project_root / roots[project]
+ output = project_root / "wheel"
+ output.mkdir()
+ environment = dict(os.environ)
+ environment["RELEASE"] = "true"
+ subprocess.check_call(
+ [
+ sys.executable,
+ "-m",
+ "build",
+ "--wheel",
+ "--outdir",
+ str(output),
+ str(source),
+ ],
+ cwd=str(_REPO_ROOT),
+ env=environment,
+ )
+ rebuilt_paths = list(output.glob("*.whl"))
+ if len(rebuilt_paths) != 1:
+ raise ValueError("%s sdist rebuild produced %d wheel(s)" % (project, len(rebuilt_paths)))
+ rebuilt = _read_artifact(rebuilt_paths[0])
+ if rebuilt.name != wheel.name:
+ raise ValueError("%s rebuilt wheel Name mismatch" % project)
+ if rebuilt.version != wheel.version:
+ raise ValueError("%s rebuilt wheel Version mismatch" % project)
+ if rebuilt.requires_python != wheel.requires_python:
+ raise ValueError("%s rebuilt wheel Requires-Python mismatch" % project)
+ if rebuilt.requires_dist != wheel.requires_dist:
+ raise ValueError("%s rebuilt wheel Requires-Dist mismatch" % project)
def _wheel_resource(wheel, resource):
@@ -416,12 +414,7 @@ def _validate_install_isolation(pairs, resources):
raise ValueError("%s resource changed after mobile uninstall" % project)
-def _requirement_name(requirement):
- match = _REQUIREMENT_NAME_PATTERN.match(requirement)
- return _normalize_name(match.group(1)) if match else None
-
-
-def _candidate_install_order(project, artifacts, known_projects):
+def _candidate_install_order(project, wheels, known_projects):
visiting = set()
visited = set()
order = []
@@ -430,21 +423,17 @@ def visit(name):
if name in visited:
return
if name in visiting:
- raise ValueError("candidate dependency cycle while verifying %s: %s" % (project, name))
- artifact = artifacts.get(name)
- if artifact is None:
- raise ValueError("missing candidate artifact while verifying %s: %s" % (project, name))
+ raise ValueError("candidate dependency cycle: %s" % name)
visiting.add(name)
- for requirement in artifact.requires_dist:
- dependency = _requirement_name(requirement)
- if dependency not in known_projects:
- continue
- if dependency not in artifacts:
- raise ValueError(
- "%s requires repository candidate %s, but it is not selected for verification"
- % (name, dependency)
- )
- visit(dependency)
+ for requirement in wheels[name].requires_dist:
+ match = _REQUIREMENT_NAME.match(requirement)
+ dependency = _normalize_name(match.group(1)) if match else None
+ if dependency in known_projects:
+ if dependency not in wheels:
+ raise ValueError(
+ "%s requires unselected candidate %s" % (name, dependency)
+ )
+ visit(dependency)
visiting.remove(name)
visited.add(name)
order.append(name)
@@ -453,171 +442,81 @@ def visit(name):
return tuple(order)
-def _isolated_environment():
- environment = dict(os.environ)
- environment.pop("PYTHONHOME", None)
- environment.pop("PYTHONPATH", None)
- environment["PIP_DISABLE_PIP_VERSION_CHECK"] = "1"
- return environment
-
-
def _venv_command(environment, name):
- if os.name == "nt":
- return environment / "Scripts" / ("%s.exe" % name)
- return environment / "bin" / name
+ directory = "Scripts" if os.name == "nt" else "bin"
+ suffix = ".exe" if os.name == "nt" else ""
+ return environment / directory / (name + suffix)
-_IMPORT_CHECK_SCRIPT = """
-import importlib
-import importlib.metadata as metadata
-import pathlib
-import site
-import sys
-
-distribution_name, module_name, expected_version = sys.argv[1:4]
-distribution = metadata.distribution(distribution_name)
-if distribution.version != expected_version:
- raise SystemExit(
- "distribution version mismatch: %s != %s"
- % (distribution.version, expected_version)
- )
-module = importlib.import_module(module_name)
-origin = getattr(module, "__file__", None)
-if not origin:
- raise SystemExit("import has no file origin: %s" % module_name)
-module_path = pathlib.Path(origin).resolve()
-site_roots = tuple(pathlib.Path(value).resolve() for value in site.getsitepackages())
-if not any(root == module_path or root in module_path.parents for root in site_roots):
- raise SystemExit("import escaped isolated site-packages: %s" % module_path)
-"""
-
-
-_SQLITE_CHECK_SCRIPT = """
-import asyncio
-import pathlib
-import sys
-
-from linktools.ai.runtime import RuntimeState
-
-async def main():
- database = pathlib.Path(sys.argv[1]) / "runtime.db"
- state = RuntimeState.sqlite(database)
- await state.initialize(namespace="release-verify", tenant_id="release-verify")
- await state.close()
- reopened = RuntimeState.sqlite(database)
- await reopened.initialize(
- namespace="release-verify",
- tenant_id="release-verify",
- read_only=True,
- )
- await reopened.close()
-
-asyncio.run(main())
-"""
-
-
-def _pip_check(python, *, cwd, environment):
- subprocess.check_call(
- [str(python), "-m", "pip", "check"],
- cwd=str(cwd),
- env=environment,
- )
-
-
-def _validate_candidate_imports(python, order, artifacts, *, cwd, environment):
- for project in order:
- artifact = artifacts[project]
- subprocess.check_call(
- [
- str(python),
- "-c",
- _IMPORT_CHECK_SCRIPT,
- artifact.name,
- _IMPORT_MODULES[project],
- artifact.version,
- ],
- cwd=str(cwd),
- env=environment,
- )
-
-
-def _validate_candidate_cli(project, environment, *, cwd, subprocess_environment):
- command = _venv_command(environment, _CLI_COMMANDS[project])
- if not command.is_file():
- raise ValueError("%s CLI entry point is missing: %s" % (project, command))
- subprocess.check_call(
- [str(command), "--help"],
- cwd=str(cwd),
- env=subprocess_environment,
- )
-
-
-def _validate_ai_sqlite(python, *, cwd, environment):
- database_root = cwd / "sqlite-smoke"
- database_root.mkdir()
- subprocess.check_call(
- [str(python), "-c", _SQLITE_CHECK_SCRIPT, str(database_root)],
- cwd=str(cwd),
- env=environment,
- )
-
-
-def _validate_candidate_install(project, artifacts, known_projects, root):
- order = _candidate_install_order(project, artifacts, known_projects)
- target = artifacts[project]
- environment = root / project
- venv.create(str(environment), with_pip=True, system_site_packages=False)
- python = _venv_python(environment)
- subprocess_environment = _isolated_environment()
- install_requirements = [str(artifacts[name].path) for name in order]
- subprocess.check_call(
- [str(python), "-m", "pip", "install"] + install_requirements,
- cwd=str(root),
- env=subprocess_environment,
- )
- _pip_check(python, cwd=root, environment=subprocess_environment)
- _validate_candidate_imports(
- python,
- order,
- artifacts,
- cwd=root,
- environment=subprocess_environment,
- )
-
- extra = _CLI_EXTRAS.get(project)
- if extra is not None:
- subprocess.check_call(
- [str(python), "-m", "pip", "install", "%s[%s]" % (target.path, extra)],
- cwd=str(root),
- env=subprocess_environment,
- )
- _pip_check(python, cwd=root, environment=subprocess_environment)
-
- _validate_candidate_cli(
- project,
- environment,
- cwd=root,
- subprocess_environment=subprocess_environment,
- )
- if project == "linktools-ai":
- _validate_ai_sqlite(
- python,
- cwd=root,
- environment=subprocess_environment,
- )
-
-
-def _validate_candidate_installs(label, artifacts, known_projects):
- revision = os.environ.get("GITHUB_SHA", "local")
- with tempfile.TemporaryDirectory(prefix="linktools-%s-install-" % label) as temporary:
+def _validate_candidate_installs(pairs, known_projects):
+ wheels = {project: pair[0] for project, pair in pairs.items()}
+ with tempfile.TemporaryDirectory(prefix="linktools-candidate-install-") as temporary:
root = Path(temporary)
- for project in sorted(artifacts):
- artifact = artifacts[project]
- print(
- "[+] %s candidate install: %s %s source=%s revision=%s"
- % (label, artifact.name, artifact.version, artifact.path.name, revision)
+ for project in sorted(wheels):
+ environment = root / project
+ venv.create(str(environment), with_pip=True, system_site_packages=False)
+ python = _venv_python(environment)
+ process_environment = dict(os.environ)
+ process_environment.pop("PYTHONPATH", None)
+ order = _candidate_install_order(project, wheels, known_projects)
+ requirements = []
+ for name in order:
+ requirement = str(wheels[name].path)
+ if name == project and project in _CLI_EXTRAS:
+ requirement += "[%s]" % _CLI_EXTRAS[project]
+ requirements.append(requirement)
+ subprocess.check_call(
+ [str(python), "-m", "pip", "install"] + requirements,
+ cwd=str(root),
+ env=process_environment,
)
- _validate_candidate_install(project, artifacts, known_projects, root)
+ subprocess.check_call(
+ [str(python), "-m", "pip", "check"],
+ cwd=str(root),
+ env=process_environment,
+ )
+ subprocess.check_call(
+ [
+ str(python),
+ "-I",
+ "-c",
+ "import importlib; importlib.import_module(%r)" % _IMPORT_MODULES[project],
+ ],
+ cwd=str(root),
+ env=process_environment,
+ )
+ command = _venv_command(environment, _CLI_COMMANDS[project])
+ if not command.is_file():
+ raise ValueError("%s CLI entry point is missing" % project)
+ subprocess.check_call(
+ [str(command), "--help"],
+ cwd=str(root),
+ env=process_environment,
+ )
+ if project == "linktools-ai":
+ subprocess.check_call(
+ [
+ str(python),
+ "-I",
+ "-c",
+ (
+ "import asyncio,pathlib,sys;"
+ "from linktools.ai.runtime import RuntimeState;"
+ "p=pathlib.Path(sys.argv[1]);"
+ "async def f():\n"
+ " s=RuntimeState.sqlite(p);"
+ " await s.initialize(namespace='verify',tenant_id='verify');"
+ " await s.close();"
+ " r=RuntimeState.sqlite(p);"
+ " await r.initialize(namespace='verify',tenant_id='verify',read_only=True);"
+ " await r.close()\n"
+ "asyncio.run(f())"
+ ),
+ str(root / "runtime.db"),
+ ],
+ cwd=str(root),
+ env=process_environment,
+ )
def main() -> int:
@@ -641,21 +540,10 @@ def main() -> int:
pairs = _selected_pairs(artifacts, selected, project_paths)
_validate_version(pairs, project_paths)
roots = _validate_sdist_inputs(pairs)
+ _validate_sdist_rebuild(pairs, roots)
resources = _validate_capability_resources(pairs)
_validate_install_isolation(pairs, resources)
- original_wheels = {project: pair[0] for project, pair in pairs.items()}
- with tempfile.TemporaryDirectory(prefix="linktools-sdist-rebuild-") as temporary:
- rebuilt_wheels = _validate_sdist_rebuild(
- pairs,
- roots,
- Path(temporary),
- )
- _validate_candidate_installs("wheel", original_wheels, project_paths)
- _validate_candidate_installs(
- "sdist-rebuilt-wheel",
- rebuilt_wheels,
- project_paths,
- )
+ _validate_candidate_installs(pairs, project_paths)
except (OSError, ValueError, subprocess.CalledProcessError, tarfile.TarError, zipfile.BadZipFile) as error:
print("[-] Artifact verification failed: %s" % error, file=sys.stderr)
return 1
From b9e650f79c96a81818ed30b44b5b817c61c311b0 Mon Sep 17 00:00:00 2001
From: ice-black-tea <30665081+ice-black-tea@users.noreply.github.com>
Date: Tue, 22 Sep 2026 10:23:52 +0800
Subject: [PATCH 05/22] fix(release): make artifact smoke checks executable
---
scripts/verify.py | 51 ++++++++++++++++++++++++++++++++---------------
1 file changed, 35 insertions(+), 16 deletions(-)
diff --git a/scripts/verify.py b/scripts/verify.py
index 2c557d63e..f334e1eeb 100644
--- a/scripts/verify.py
+++ b/scripts/verify.py
@@ -45,6 +45,28 @@
"linktools-ai": "sqlite",
}
_REQUIREMENT_NAME = re.compile(r"^\\s*([A-Za-z0-9_.-]+)")
+_AI_SQLITE_SMOKE = """
+import asyncio
+import pathlib
+import sys
+
+from linktools.ai.runtime import RuntimeState
+
+async def main():
+ path = pathlib.Path(sys.argv[1])
+ state = RuntimeState.sqlite(path)
+ await state.initialize(namespace="verify", tenant_id="verify")
+ await state.close()
+ reopened = RuntimeState.sqlite(path)
+ await reopened.initialize(
+ namespace="verify",
+ tenant_id="verify",
+ read_only=True,
+ )
+ await reopened.close()
+
+asyncio.run(main())
+"""
class _Artifact:
@@ -449,6 +471,8 @@ def _venv_command(environment, name):
def _validate_candidate_installs(pairs, known_projects):
+ if set(pairs) != set(known_projects):
+ return
wheels = {project: pair[0] for project, pair in pairs.items()}
with tempfile.TemporaryDirectory(prefix="linktools-candidate-install-") as temporary:
root = Path(temporary)
@@ -461,10 +485,17 @@ def _validate_candidate_installs(pairs, known_projects):
order = _candidate_install_order(project, wheels, known_projects)
requirements = []
for name in order:
- requirement = str(wheels[name].path)
if name == project and project in _CLI_EXTRAS:
- requirement += "[%s]" % _CLI_EXTRAS[project]
- requirements.append(requirement)
+ requirements.append(
+ "%s[%s] @ %s"
+ % (
+ wheels[name].name,
+ _CLI_EXTRAS[project],
+ wheels[name].path.as_uri(),
+ )
+ )
+ else:
+ requirements.append(str(wheels[name].path))
subprocess.check_call(
[str(python), "-m", "pip", "install"] + requirements,
cwd=str(root),
@@ -499,19 +530,7 @@ def _validate_candidate_installs(pairs, known_projects):
str(python),
"-I",
"-c",
- (
- "import asyncio,pathlib,sys;"
- "from linktools.ai.runtime import RuntimeState;"
- "p=pathlib.Path(sys.argv[1]);"
- "async def f():\n"
- " s=RuntimeState.sqlite(p);"
- " await s.initialize(namespace='verify',tenant_id='verify');"
- " await s.close();"
- " r=RuntimeState.sqlite(p);"
- " await r.initialize(namespace='verify',tenant_id='verify',read_only=True);"
- " await r.close()\n"
- "asyncio.run(f())"
- ),
+ _AI_SQLITE_SMOKE,
str(root / "runtime.db"),
],
cwd=str(root),
From 10f37d9f8206174284cf434d5c15c05b8d80e03c Mon Sep 17 00:00:00 2001
From: ice-black-tea <30665081+ice-black-tea@users.noreply.github.com>
Date: Tue, 22 Sep 2026 10:25:32 +0800
Subject: [PATCH 06/22] fix(release): parse candidate dependency names
---
scripts/verify.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/scripts/verify.py b/scripts/verify.py
index f334e1eeb..0b032858b 100644
--- a/scripts/verify.py
+++ b/scripts/verify.py
@@ -44,7 +44,7 @@
"linktools": "cli",
"linktools-ai": "sqlite",
}
-_REQUIREMENT_NAME = re.compile(r"^\\s*([A-Za-z0-9_.-]+)")
+_REQUIREMENT_NAME = re.compile(r"^\s*([A-Za-z0-9_.-]+)")
_AI_SQLITE_SMOKE = """
import asyncio
import pathlib
From 1bb6fb3d922bf8181994343d831f88c475c1a7e2 Mon Sep 17 00:00:00 2001
From: ice-black-tea <30665081+ice-black-tea@users.noreply.github.com>
Date: Tue, 22 Sep 2026 10:26:47 +0800
Subject: [PATCH 07/22] refactor(release): let pip resolve candidate
dependencies
---
scripts/verify.py | 71 ++++++++++++-------------------
tests/core/test_release_verify.py | 43 ++++++-------------
2 files changed, 40 insertions(+), 74 deletions(-)
diff --git a/scripts/verify.py b/scripts/verify.py
index 0b032858b..455ca9fb6 100644
--- a/scripts/verify.py
+++ b/scripts/verify.py
@@ -44,7 +44,6 @@
"linktools": "cli",
"linktools-ai": "sqlite",
}
-_REQUIREMENT_NAME = re.compile(r"^\s*([A-Za-z0-9_.-]+)")
_AI_SQLITE_SMOKE = """
import asyncio
import pathlib
@@ -333,7 +332,7 @@ def _validate_sdist_rebuild(pairs, roots):
raise ValueError("%s rebuilt wheel Version mismatch" % project)
if rebuilt.requires_python != wheel.requires_python:
raise ValueError("%s rebuilt wheel Requires-Python mismatch" % project)
- if rebuilt.requires_dist != wheel.requires_dist:
+ if sorted(rebuilt.requires_dist) != sorted(wheel.requires_dist):
raise ValueError("%s rebuilt wheel Requires-Dist mismatch" % project)
@@ -436,32 +435,12 @@ def _validate_install_isolation(pairs, resources):
raise ValueError("%s resource changed after mobile uninstall" % project)
-def _candidate_install_order(project, wheels, known_projects):
- visiting = set()
- visited = set()
- order = []
-
- def visit(name):
- if name in visited:
- return
- if name in visiting:
- raise ValueError("candidate dependency cycle: %s" % name)
- visiting.add(name)
- for requirement in wheels[name].requires_dist:
- match = _REQUIREMENT_NAME.match(requirement)
- dependency = _normalize_name(match.group(1)) if match else None
- if dependency in known_projects:
- if dependency not in wheels:
- raise ValueError(
- "%s requires unselected candidate %s" % (name, dependency)
- )
- visit(dependency)
- visiting.remove(name)
- visited.add(name)
- order.append(name)
-
- visit(project)
- return tuple(order)
+def _candidate_constraints(wheels, *, exclude=None):
+ return tuple(
+ "%s @ %s" % (wheels[name].name, wheels[name].path.as_uri())
+ for name in sorted(wheels)
+ if name != exclude
+ )
def _venv_command(environment, name):
@@ -482,22 +461,28 @@ def _validate_candidate_installs(pairs, known_projects):
python = _venv_python(environment)
process_environment = dict(os.environ)
process_environment.pop("PYTHONPATH", None)
- order = _candidate_install_order(project, wheels, known_projects)
- requirements = []
- for name in order:
- if name == project and project in _CLI_EXTRAS:
- requirements.append(
- "%s[%s] @ %s"
- % (
- wheels[name].name,
- _CLI_EXTRAS[project],
- wheels[name].path.as_uri(),
- )
- )
- else:
- requirements.append(str(wheels[name].path))
+ constraints = root / ("%s-constraints.txt" % project)
+ constraints.write_text(
+ "\n".join(_candidate_constraints(wheels, exclude=project)) + "\n",
+ encoding="utf-8",
+ )
+ target = "%s @ %s" % (wheels[project].name, wheels[project].path.as_uri())
+ if project in _CLI_EXTRAS:
+ target = "%s[%s] @ %s" % (
+ wheels[project].name,
+ _CLI_EXTRAS[project],
+ wheels[project].path.as_uri(),
+ )
subprocess.check_call(
- [str(python), "-m", "pip", "install"] + requirements,
+ [
+ str(python),
+ "-m",
+ "pip",
+ "install",
+ "--constraint",
+ str(constraints),
+ target,
+ ],
cwd=str(root),
env=process_environment,
)
diff --git a/tests/core/test_release_verify.py b/tests/core/test_release_verify.py
index 55bae25ce..165db4006 100644
--- a/tests/core/test_release_verify.py
+++ b/tests/core/test_release_verify.py
@@ -3,52 +3,33 @@
from pathlib import Path
-import pytest
-
from scripts import verify as release_verify
-def _artifact(name: str, *requires_dist: str) -> release_verify._Artifact:
+def _artifact(path: Path, name: str) -> release_verify._Artifact:
return release_verify._Artifact(
- Path("/tmp/%s.whl" % name),
+ path,
"wheel",
name,
"0.10.0",
">=3.6",
- requires_dist,
)
-def test_candidate_install_order_uses_repository_dependencies_only() -> None:
- artifacts = {
- "linktools": _artifact("linktools", "filelock>=3.4.0"),
+def test_candidate_constraints_pin_other_repository_wheels(tmp_path: Path) -> None:
+ wheels = {
+ "linktools": _artifact(tmp_path / "linktools.whl", "linktools"),
"linktools-common": _artifact(
+ tmp_path / "linktools_common.whl",
"linktools-common",
- "linktools[cli]>=0.10.0",
- "lief>0.10.1; extra == 'lief'",
),
}
- order = release_verify._candidate_install_order(
- "linktools-common",
- artifacts,
- {"linktools", "linktools-common", "linktools-mobile"},
+ constraints = release_verify._candidate_constraints(
+ wheels,
+ exclude="linktools-common",
)
- assert order == ("linktools", "linktools-common")
-
-
-def test_candidate_install_order_rejects_missing_repository_dependency() -> None:
- artifacts = {
- "linktools-common": _artifact(
- "linktools-common",
- "linktools[cli]>=0.10.0",
- ),
- }
-
- with pytest.raises(ValueError, match="requires repository candidate linktools"):
- release_verify._candidate_install_order(
- "linktools-common",
- artifacts,
- {"linktools", "linktools-common"},
- )
+ assert constraints == (
+ "linktools @ %s" % (tmp_path / "linktools.whl").as_uri(),
+ )
From cf1989f2d2beb0606720d62fdf630b5f318b8da1 Mon Sep 17 00:00:00 2001
From: ice-black-tea <30665081+ice-black-tea@users.noreply.github.com>
Date: Tue, 22 Sep 2026 10:30:25 +0800
Subject: [PATCH 08/22] fix(ai): declare yaml runtime dependency
---
linktools-ai/linktools.yml | 1 +
1 file changed, 1 insertion(+)
diff --git a/linktools-ai/linktools.yml b/linktools-ai/linktools.yml
index ef77145c8..8b4137805 100644
--- a/linktools-ai/linktools.yml
+++ b/linktools-ai/linktools.yml
@@ -3,6 +3,7 @@ version: "0.10.0"
dependencies:
- filelock>=3.4.0
+ - pyyaml
- rich
- jsonschema>=4.23.0,<5.0.0
- pydantic-ai-slim[mcp,openai]>=2.40.0,<3.0.0
From b6b4cc19bdfe1b9ee8c133fb03da9068a19a155a Mon Sep 17 00:00:00 2001
From: ice-black-tea <30665081+ice-black-tea@users.noreply.github.com>
Date: Tue, 22 Sep 2026 15:36:59 +0800
Subject: [PATCH 09/22] fix(release): freeze refs before publication
---
.github/workflows/python-publish.yml | 163 +++++++++++++++++++++------
1 file changed, 128 insertions(+), 35 deletions(-)
diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml
index 782423bca..1eeda7e03 100644
--- a/.github/workflows/python-publish.yml
+++ b/.github/workflows/python-publish.yml
@@ -27,64 +27,98 @@ jobs:
runs-on: ubuntu-latest
outputs:
prerelease: ${{ steps.version.outputs.prerelease }}
+ release_ref: ${{ steps.version.outputs.release_ref }}
+ resume: ${{ steps.version.outputs.resume }}
steps:
- uses: actions/checkout@v7
- - name: Validate version
+ with:
+ fetch-depth: 0
+ - name: Validate release state
id: version
+ env:
+ VERSION: ${{ inputs.version }}
run: |
- VERSION="${{ inputs.version }}"
if ! [[ "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(rc[0-9]+)?$ ]]; then
echo "::error::Invalid version format: $VERSION (expected vX.Y.Z or vX.Y.ZrcN, e.g. v1.2.3 or v1.2.3rc0)"
exit 1
fi
+ if [[ "$GITHUB_REF" != "refs/heads/master" ]]; then
+ echo "::error::Release workflow must be dispatched from master"
+ exit 1
+ fi
if [[ "$VERSION" =~ rc[0-9]+$ ]]; then
echo "prerelease=true" >> "$GITHUB_OUTPUT"
else
echo "prerelease=false" >> "$GITHUB_OUTPUT"
fi
- if git ls-remote --exit-code --tags origin "refs/tags/$VERSION" >/dev/null 2>&1; then
- echo "::error::Tag $VERSION already exists"
- exit 1
+
+ git fetch origin master --tags
+
+ if git show-ref --verify --quiet "refs/tags/$VERSION"; then
+ RELEASE_COMMIT="$(git rev-list -n 1 "$VERSION")"
+ RELEASE_SUBJECT="$(git log -1 --format=%s "$RELEASE_COMMIT")"
+ if [[ "$RELEASE_SUBJECT" != "build(release): prepare $VERSION" ]]; then
+ echo "::error::Existing tag $VERSION is not owned by the release workflow"
+ exit 1
+ fi
+ if ! git merge-base --is-ancestor "$RELEASE_COMMIT" origin/master; then
+ echo "::error::Existing tag $VERSION is not reachable from master"
+ exit 1
+ fi
+ echo "resume=true" >> "$GITHUB_OUTPUT"
+ echo "release_ref=$VERSION" >> "$GITHUB_OUTPUT"
+ else
+ if [[ "$(git rev-parse origin/master)" != "$GITHUB_SHA" ]]; then
+ echo "::error::master moved after this release run was dispatched; start a new run"
+ exit 1
+ fi
+ echo "resume=false" >> "$GITHUB_OUTPUT"
+ echo "release_ref=$GITHUB_SHA" >> "$GITHUB_OUTPUT"
fi
checks:
needs: validate
+ if: ${{ needs.validate.outputs.resume != 'true' }}
uses: ./.github/workflows/python-check.yml
deploy:
needs:
- validate
- checks
+ if: ${{ always() && needs.validate.result == 'success' && (needs.validate.outputs.resume == 'true' || needs.checks.result == 'success') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
+ with:
+ ref: ${{ needs.validate.outputs.release_ref }}
+ fetch-depth: 0
- # build frida.min.js
+ # build frida.min.js for a new release commit
- name: Set up Node
- if: ${{ !env.ACT }}
+ if: ${{ needs.validate.outputs.resume != 'true' && !env.ACT }}
uses: actions/setup-node@v7
with:
node-version: 20
- name: Build frida scripts
- if: ${{ !env.ACT }}
+ if: ${{ needs.validate.outputs.resume != 'true' && !env.ACT }}
run: cd linktools-mobile/agents/frida && npm install && npm run build
- # build android-tools.apk
+ # build android-tools.apk for a new release commit
- name: Set up JDK 17
- if: ${{ !env.ACT }}
+ if: ${{ needs.validate.outputs.resume != 'true' && !env.ACT }}
uses: actions/setup-java@v6
with:
distribution: temurin
java-version: 17
- name: Setup Android SDK
- if: ${{ !env.ACT }}
+ if: ${{ needs.validate.outputs.resume != 'true' && !env.ACT }}
uses: android-actions/setup-android@v4
- name: Build android tools
- if: ${{ !env.ACT }}
+ if: ${{ needs.validate.outputs.resume != 'true' && !env.ACT }}
run: cd linktools-mobile/agents/android && ./gradlew --no-daemon :tools:buildTools
- # build python package
+ # build and verify the exact candidate package set
- name: Set up Python
uses: actions/setup-python@v7
with:
@@ -98,42 +132,101 @@ jobs:
- name: Clean package
run: python manage.py clean
- name: Build package
- run: VERSION=${{ inputs.version }} RELEASE=true python manage.py build
+ env:
+ RELEASE: "true"
+ VERSION: ${{ needs.validate.outputs.resume != 'true' && inputs.version || '' }}
+ run: |
+ if [[ -n "$VERSION" ]]; then
+ VERSION="$VERSION" RELEASE="$RELEASE" python manage.py build
+ else
+ RELEASE="$RELEASE" python manage.py build
+ fi
- name: Verify package
run: VERSION=${{ inputs.version }} RELEASE=true python manage.py verify
- # publish python package
- - name: Publish package
- if: ${{ !env.ACT }}
- uses: pypa/gh-action-pypi-publish@release/v1
- with:
- user: __token__
- password: ${{ secrets.PYPI_API_TOKEN }}
- packages_dir: dist/
-
- # commit and push artifacts (fast-forward, no tag yet)
- - name: Commit files
- if: ${{ !env.ACT }}
+ # freeze source and tag before any external package publication
+ - name: Create release commit and tag
+ if: ${{ needs.validate.outputs.resume != 'true' && !env.ACT }}
+ env:
+ VERSION: ${{ inputs.version }}
run: |
git config user.name github-actions[bot]
git config user.email github-actions[bot]@users.noreply.github.com
+
git add linktools*/linktools.yml \
linktools-mobile/src/linktools/assets/frida.js \
linktools-mobile/src/linktools/assets/frida-*.js \
linktools-mobile/src/linktools/assets/android-tools.*
- git commit -m "Auto commit artifacts (${{ inputs.version }})"
- git push origin HEAD:master
- # tag and release only after artifacts are pushed, so they match exactly
- - name: Create tag and release
+ if ! git diff --quiet; then
+ echo "::error::Release preparation left unstaged tracked changes"
+ git status --short
+ exit 1
+ fi
+
+ git commit --allow-empty -m "build(release): prepare $VERSION"
+ git tag "$VERSION"
+
+ git fetch origin master
+ if [[ "$(git rev-parse origin/master)" != "$GITHUB_SHA" ]]; then
+ echo "::error::master moved while preparing $VERSION; no release refs were pushed"
+ exit 1
+ fi
+
+ git push --atomic origin HEAD:master "refs/tags/$VERSION"
+ git checkout --detach "$VERSION"
+
+ - name: Verify fixed release ref
+ if: ${{ !env.ACT }}
+ env:
+ VERSION: ${{ inputs.version }}
+ run: |
+ TAG_COMMIT="$(git rev-list -n 1 "$VERSION")"
+ if [[ "$(git rev-parse HEAD)" != "$TAG_COMMIT" ]]; then
+ echo "::error::Release build is not on the fixed tag $VERSION"
+ exit 1
+ fi
+ if ! git diff --quiet || ! git diff --cached --quiet; then
+ echo "::error::Release build changed tracked source after the tag was fixed"
+ git status --short
+ exit 1
+ fi
+
+ # retries consume the same tag; PyPI may already contain files from an interrupted prior run
+ - name: Publish package
+ if: ${{ !env.ACT }}
+ uses: pypa/gh-action-pypi-publish@release/v1
+ with:
+ user: __token__
+ password: ${{ secrets.PYPI_API_TOKEN }}
+ packages_dir: dist/
+ skip-existing: ${{ needs.validate.outputs.resume }}
+
+ # create as a draft, upload all assets, then publish; an interrupted draft is resumable
+ - name: Publish GitHub release
if: ${{ !env.ACT }}
env:
GH_TOKEN: ${{ github.token }}
+ PRERELEASE: ${{ needs.validate.outputs.prerelease }}
+ VERSION: ${{ inputs.version }}
run: |
- git tag ${{ inputs.version }}
- git push origin ${{ inputs.version }}
- if [[ "${{ needs.validate.outputs.prerelease }}" == "true" ]]; then
- gh release create ${{ inputs.version }} --generate-notes --prerelease --latest=false dist/*
+ if IS_DRAFT="$(gh release view "$VERSION" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
+ if [[ "$IS_DRAFT" != "true" ]]; then
+ echo "GitHub release $VERSION is already published"
+ exit 0
+ fi
+ else
+ if [[ "$PRERELEASE" == "true" ]]; then
+ gh release create "$VERSION" --draft --generate-notes --prerelease --latest=false
+ else
+ gh release create "$VERSION" --draft --generate-notes
+ fi
+ fi
+
+ gh release upload "$VERSION" dist/* --clobber
+
+ if [[ "$PRERELEASE" == "true" ]]; then
+ gh release edit "$VERSION" --draft=false --prerelease --latest=false
else
- gh release create ${{ inputs.version }} --generate-notes dist/*
+ gh release edit "$VERSION" --draft=false --prerelease=false
fi
From c552be16e94f79cb23cce694e04d131ec9984847 Mon Sep 17 00:00:00 2001
From: ice-black-tea <30665081+ice-black-tea@users.noreply.github.com>
Date: Tue, 22 Sep 2026 15:39:20 +0800
Subject: [PATCH 10/22] fix(release): verify resumed package uploads
---
.github/workflows/python-publish.yml | 87 ++++++++++++++++++++++++++--
1 file changed, 81 insertions(+), 6 deletions(-)
diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml
index 1eeda7e03..f6d1e0e71 100644
--- a/.github/workflows/python-publish.yml
+++ b/.github/workflows/python-publish.yml
@@ -133,13 +133,13 @@ jobs:
run: python manage.py clean
- name: Build package
env:
- RELEASE: "true"
- VERSION: ${{ needs.validate.outputs.resume != 'true' && inputs.version || '' }}
+ RESUME: ${{ needs.validate.outputs.resume }}
+ VERSION: ${{ inputs.version }}
run: |
- if [[ -n "$VERSION" ]]; then
- VERSION="$VERSION" RELEASE="$RELEASE" python manage.py build
+ if [[ "$RESUME" == "true" ]]; then
+ RELEASE=true python manage.py build
else
- RELEASE="$RELEASE" python manage.py build
+ VERSION="$VERSION" RELEASE=true python manage.py build
fi
- name: Verify package
run: VERSION=${{ inputs.version }} RELEASE=true python manage.py verify
@@ -192,7 +192,82 @@ jobs:
exit 1
fi
- # retries consume the same tag; PyPI may already contain files from an interrupted prior run
+ # retries consume the same tag; any existing PyPI files must match the candidate exactly
+ - name: Verify resumable PyPI state
+ if: ${{ needs.validate.outputs.resume == 'true' && !env.ACT }}
+ env:
+ VERSION: ${{ inputs.version }}
+ run: |
+ PYPI_VERSION="${VERSION#v}"
+ for PROJECT in linktools linktools-ai linktools-cntr linktools-common linktools-mobile; do
+ RESPONSE="$(mktemp)"
+ STATUS="$(curl --silent --show-error --location \
+ --output "$RESPONSE" --write-out '%{http_code}' \
+ "https://pypi.org/pypi/$PROJECT/$PYPI_VERSION/json")"
+ if [[ "$STATUS" == "404" ]]; then
+ rm -f "$RESPONSE"
+ continue
+ fi
+ if [[ "$STATUS" != "200" ]]; then
+ echo "::error::PyPI metadata request failed for $PROJECT $PYPI_VERSION: HTTP $STATUS"
+ rm -f "$RESPONSE"
+ exit 1
+ fi
+
+ while IFS= if: ${{ !env.ACT }}
+ uses: pypa/gh-action-pypi-publish@release/v1
+ with:
+ user: __token__
+ password: ${{ secrets.PYPI_API_TOKEN }}
+ packages_dir: dist/
+ skip-existing: ${{ needs.validate.outputs.resume }}
+
+ # create as a draft, upload all assets, then publish; an interrupted draft is resumable
+ - name: Publish GitHub release
+ if: ${{ !env.ACT }}
+ env:
+ GH_TOKEN: ${{ github.token }}
+ PRERELEASE: ${{ needs.validate.outputs.prerelease }}
+ VERSION: ${{ inputs.version }}
+ run: |
+ if IS_DRAFT="$(gh release view "$VERSION" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
+ if [[ "$IS_DRAFT" != "true" ]]; then
+ echo "GitHub release $VERSION is already published"
+ exit 0
+ fi
+ else
+ if [[ "$PRERELEASE" == "true" ]]; then
+ gh release create "$VERSION" --draft --generate-notes --prerelease --latest=false
+ else
+ gh release create "$VERSION" --draft --generate-notes
+ fi
+ fi
+
+ gh release upload "$VERSION" dist/* --clobber
+
+ if [[ "$PRERELEASE" == "true" ]]; then
+ gh release edit "$VERSION" --draft=false --prerelease --latest=false
+ else
+ gh release edit "$VERSION" --draft=false --prerelease=false
+ fi
+\t' read -r FILENAME EXPECTED_SHA256; do
+ [[ -n "$FILENAME" ]] || continue
+ LOCAL_PATH="dist/$FILENAME"
+ if [[ ! -f "$LOCAL_PATH" ]]; then
+ echo "::error::PyPI contains unexpected artifact $PROJECT/$PYPI_VERSION/$FILENAME"
+ rm -f "$RESPONSE"
+ exit 1
+ fi
+ ACTUAL_SHA256="$(sha256sum "$LOCAL_PATH" | cut -d' ' -f1)"
+ if [[ "$ACTUAL_SHA256" != "$EXPECTED_SHA256" ]]; then
+ echo "::error::PyPI artifact differs from fixed candidate: $FILENAME"
+ rm -f "$RESPONSE"
+ exit 1
+ fi
+ done < <(jq -r '.urls[] | [.filename, .digests.sha256] | @tsv' "$RESPONSE")
+ rm -f "$RESPONSE"
+ done
+
- name: Publish package
if: ${{ !env.ACT }}
uses: pypa/gh-action-pypi-publish@release/v1
From ab9cecb05df25004751ea825953b8dce4a379039 Mon Sep 17 00:00:00 2001
From: ice-black-tea <30665081+ice-black-tea@users.noreply.github.com>
Date: Tue, 22 Sep 2026 15:39:39 +0800
Subject: [PATCH 11/22] refactor(release): discover published projects
---
.github/workflows/python-publish.yml | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml
index f6d1e0e71..856adca24 100644
--- a/.github/workflows/python-publish.yml
+++ b/.github/workflows/python-publish.yml
@@ -199,7 +199,8 @@ jobs:
VERSION: ${{ inputs.version }}
run: |
PYPI_VERSION="${VERSION#v}"
- for PROJECT in linktools linktools-ai linktools-cntr linktools-common linktools-mobile; do
+ mapfile -t PROJECTS < <(python manage.py modules | jq -r '.[]')
+ for PROJECT in "${PROJECTS[@]}"; do
RESPONSE="$(mktemp)"
STATUS="$(curl --silent --show-error --location \
--output "$RESPONSE" --write-out '%{http_code}' \
From dad467d9516b3c63fb67a556f1d9813cde402e07 Mon Sep 17 00:00:00 2001
From: ice-black-tea <30665081+ice-black-tea@users.noreply.github.com>
Date: Tue, 22 Sep 2026 15:40:32 +0800
Subject: [PATCH 12/22] fix(release): restore publish workflow integrity
---
.github/workflows/python-publish.yml | 40 ++--------------------------
1 file changed, 2 insertions(+), 38 deletions(-)
diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml
index 856adca24..c1ed5ea0c 100644
--- a/.github/workflows/python-publish.yml
+++ b/.github/workflows/python-publish.yml
@@ -215,43 +215,7 @@ jobs:
exit 1
fi
- while IFS= if: ${{ !env.ACT }}
- uses: pypa/gh-action-pypi-publish@release/v1
- with:
- user: __token__
- password: ${{ secrets.PYPI_API_TOKEN }}
- packages_dir: dist/
- skip-existing: ${{ needs.validate.outputs.resume }}
-
- # create as a draft, upload all assets, then publish; an interrupted draft is resumable
- - name: Publish GitHub release
- if: ${{ !env.ACT }}
- env:
- GH_TOKEN: ${{ github.token }}
- PRERELEASE: ${{ needs.validate.outputs.prerelease }}
- VERSION: ${{ inputs.version }}
- run: |
- if IS_DRAFT="$(gh release view "$VERSION" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
- if [[ "$IS_DRAFT" != "true" ]]; then
- echo "GitHub release $VERSION is already published"
- exit 0
- fi
- else
- if [[ "$PRERELEASE" == "true" ]]; then
- gh release create "$VERSION" --draft --generate-notes --prerelease --latest=false
- else
- gh release create "$VERSION" --draft --generate-notes
- fi
- fi
-
- gh release upload "$VERSION" dist/* --clobber
-
- if [[ "$PRERELEASE" == "true" ]]; then
- gh release edit "$VERSION" --draft=false --prerelease --latest=false
- else
- gh release edit "$VERSION" --draft=false --prerelease=false
- fi
-\t' read -r FILENAME EXPECTED_SHA256; do
+ while IFS='|' read -r FILENAME EXPECTED_SHA256; do
[[ -n "$FILENAME" ]] || continue
LOCAL_PATH="dist/$FILENAME"
if [[ ! -f "$LOCAL_PATH" ]]; then
@@ -265,7 +229,7 @@ jobs:
rm -f "$RESPONSE"
exit 1
fi
- done < <(jq -r '.urls[] | [.filename, .digests.sha256] | @tsv' "$RESPONSE")
+ done < <(jq -r '.urls[] | "\(.filename)|\(.digests.sha256)"' "$RESPONSE")
rm -f "$RESPONSE"
done
From 8fbf3c0ade822e586a35471635dc60ebdf203693 Mon Sep 17 00:00:00 2001
From: ice-black-tea <30665081+ice-black-tea@users.noreply.github.com>
Date: Tue, 22 Sep 2026 15:50:56 +0800
Subject: [PATCH 13/22] fix(release): archive exact retry artifacts
---
.github/workflows/python-publish.yml | 88 ++++++++++++++++++++++------
1 file changed, 69 insertions(+), 19 deletions(-)
diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml
index c1ed5ea0c..5bc5d0129 100644
--- a/.github/workflows/python-publish.yml
+++ b/.github/workflows/python-publish.yml
@@ -94,7 +94,7 @@ jobs:
ref: ${{ needs.validate.outputs.release_ref }}
fetch-depth: 0
- # build frida.min.js for a new release commit
+ # build generated source only while preparing a new release commit
- name: Set up Node
if: ${{ needs.validate.outputs.resume != 'true' && !env.ACT }}
uses: actions/setup-node@v7
@@ -104,7 +104,6 @@ jobs:
if: ${{ needs.validate.outputs.resume != 'true' && !env.ACT }}
run: cd linktools-mobile/agents/frida && npm install && npm run build
- # build android-tools.apk for a new release commit
- name: Set up JDK 17
if: ${{ needs.validate.outputs.resume != 'true' && !env.ACT }}
uses: actions/setup-java@v6
@@ -118,7 +117,6 @@ jobs:
if: ${{ needs.validate.outputs.resume != 'true' && !env.ACT }}
run: cd linktools-mobile/agents/android && ./gradlew --no-daemon :tools:buildTools
- # build and verify the exact candidate package set
- name: Set up Python
uses: actions/setup-python@v7
with:
@@ -131,7 +129,40 @@ jobs:
run: python manage.py install --editable --quiet
- name: Clean package
run: python manage.py clean
+
+ # a completed draft archive is the exact recovery source for interrupted uploads
+ - name: Restore archived release artifacts
+ id: restore
+ if: ${{ needs.validate.outputs.resume == 'true' && !env.ACT }}
+ env:
+ GH_TOKEN: ${{ github.token }}
+ VERSION: ${{ inputs.version }}
+ run: |
+ echo "restored=false" >> "$GITHUB_OUTPUT"
+ echo "published=false" >> "$GITHUB_OUTPUT"
+ rm -f release-sha256sums.txt
+
+ if ! IS_DRAFT="$(gh release view "$VERSION" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
+ exit 0
+ fi
+ if [[ "$IS_DRAFT" != "true" ]]; then
+ echo "published=true" >> "$GITHUB_OUTPUT"
+ fi
+
+ if ! gh release download "$VERSION" --pattern 'release-sha256sums.txt' --output release-sha256sums.txt >/dev/null 2>&1; then
+ exit 0
+ fi
+
+ mkdir -p dist
+ gh release download "$VERSION" --pattern '*.whl' --pattern '*.tar.gz' --dir dist
+ (
+ cd dist
+ sha256sum --check ../release-sha256sums.txt
+ )
+ echo "restored=true" >> "$GITHUB_OUTPUT"
+
- name: Build package
+ if: ${{ needs.validate.outputs.resume != 'true' || steps.restore.outputs.restored != 'true' }}
env:
RESUME: ${{ needs.validate.outputs.resume }}
VERSION: ${{ inputs.version }}
@@ -141,6 +172,7 @@ jobs:
else
VERSION="$VERSION" RELEASE=true python manage.py build
fi
+
- name: Verify package
run: VERSION=${{ inputs.version }} RELEASE=true python manage.py verify
@@ -192,7 +224,35 @@ jobs:
exit 1
fi
- # retries consume the same tag; any existing PyPI files must match the candidate exactly
+ # archive the verified bytes before PyPI so retries never depend on reproducible builds
+ - name: Archive release artifacts
+ if: ${{ steps.restore.outputs.restored != 'true' && !env.ACT }}
+ env:
+ GH_TOKEN: ${{ github.token }}
+ PRERELEASE: ${{ needs.validate.outputs.prerelease }}
+ VERSION: ${{ inputs.version }}
+ run: |
+ if IS_DRAFT="$(gh release view "$VERSION" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
+ if [[ "$IS_DRAFT" != "true" ]]; then
+ echo "::error::Published release $VERSION has no complete recovery archive"
+ exit 1
+ fi
+ else
+ if [[ "$PRERELEASE" == "true" ]]; then
+ gh release create "$VERSION" --draft --generate-notes --prerelease --latest=false
+ else
+ gh release create "$VERSION" --draft --generate-notes
+ fi
+ fi
+
+ (
+ cd dist
+ sha256sum * | sort > ../release-sha256sums.txt
+ )
+ gh release upload "$VERSION" dist/* --clobber
+ gh release upload "$VERSION" release-sha256sums.txt --clobber
+
+ # any PyPI files from an interrupted upload must be byte-identical to the archived candidate
- name: Verify resumable PyPI state
if: ${{ needs.validate.outputs.resume == 'true' && !env.ACT }}
env:
@@ -225,7 +285,7 @@ jobs:
fi
ACTUAL_SHA256="$(sha256sum "$LOCAL_PATH" | cut -d' ' -f1)"
if [[ "$ACTUAL_SHA256" != "$EXPECTED_SHA256" ]]; then
- echo "::error::PyPI artifact differs from fixed candidate: $FILENAME"
+ echo "::error::PyPI artifact differs from archived candidate: $FILENAME"
rm -f "$RESPONSE"
exit 1
fi
@@ -242,7 +302,6 @@ jobs:
packages_dir: dist/
skip-existing: ${{ needs.validate.outputs.resume }}
- # create as a draft, upload all assets, then publish; an interrupted draft is resumable
- name: Publish GitHub release
if: ${{ !env.ACT }}
env:
@@ -250,21 +309,12 @@ jobs:
PRERELEASE: ${{ needs.validate.outputs.prerelease }}
VERSION: ${{ inputs.version }}
run: |
- if IS_DRAFT="$(gh release view "$VERSION" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
- if [[ "$IS_DRAFT" != "true" ]]; then
- echo "GitHub release $VERSION is already published"
- exit 0
- fi
- else
- if [[ "$PRERELEASE" == "true" ]]; then
- gh release create "$VERSION" --draft --generate-notes --prerelease --latest=false
- else
- gh release create "$VERSION" --draft --generate-notes
- fi
+ IS_DRAFT="$(gh release view "$VERSION" --json isDraft --jq '.isDraft')"
+ if [[ "$IS_DRAFT" != "true" ]]; then
+ echo "GitHub release $VERSION is already published"
+ exit 0
fi
- gh release upload "$VERSION" dist/* --clobber
-
if [[ "$PRERELEASE" == "true" ]]; then
gh release edit "$VERSION" --draft=false --prerelease --latest=false
else
From 190005c919228feb8c8b95bfe93edbb446323bc4 Mon Sep 17 00:00:00 2001
From: ice-black-tea <30665081+ice-black-tea@users.noreply.github.com>
Date: Tue, 22 Sep 2026 15:58:18 +0800
Subject: [PATCH 14/22] fix(release): serialize version publication
---
.github/workflows/python-publish.yml | 9 ++++-----
1 file changed, 4 insertions(+), 5 deletions(-)
diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml
index 5bc5d0129..da78b7853 100644
--- a/.github/workflows/python-publish.yml
+++ b/.github/workflows/python-publish.yml
@@ -22,6 +22,10 @@ env:
PIP_DISABLE_PIP_VERSION_CHECK: "1"
PYTHONUNBUFFERED: "1"
+concurrency:
+ group: python-publish-${{ inputs.version }}
+ cancel-in-progress: false
+
jobs:
validate:
runs-on: ubuntu-latest
@@ -139,16 +143,11 @@ jobs:
VERSION: ${{ inputs.version }}
run: |
echo "restored=false" >> "$GITHUB_OUTPUT"
- echo "published=false" >> "$GITHUB_OUTPUT"
rm -f release-sha256sums.txt
if ! IS_DRAFT="$(gh release view "$VERSION" --json isDraft --jq '.isDraft' 2>/dev/null)"; then
exit 0
fi
- if [[ "$IS_DRAFT" != "true" ]]; then
- echo "published=true" >> "$GITHUB_OUTPUT"
- fi
-
if ! gh release download "$VERSION" --pattern 'release-sha256sums.txt' --output release-sha256sums.txt >/dev/null 2>&1; then
exit 0
fi
From 19015513d658d801922683d30a377781a6fb6cdf Mon Sep 17 00:00:00 2001
From: ice-black-tea <30665081+ice-black-tea@users.noreply.github.com>
Date: Tue, 22 Sep 2026 18:51:56 +0800
Subject: [PATCH 15/22] docs: refresh root release usage
---
README.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index 93dcd89ca..f61fba332 100644
--- a/README.md
+++ b/README.md
@@ -36,7 +36,7 @@ python3 -m pip install --ignore-installed \
"linktools-common@ git+https://github.com/linktools-toolkit/linktools.git@master#subdirectory=linktools-common" \
"linktools-mobile@ git+https://github.com/linktools-toolkit/linktools.git@master#subdirectory=linktools-mobile" \
"linktools-cntr@ git+https://github.com/linktools-toolkit/linktools.git@master#subdirectory=linktools-cntr" \
- "linktools-ai@ git+https://github.com/linktools-toolkit/linktools.git@master#subdirectory=linktools-ai"
+ "linktools-ai[sqlite] @ git+https://github.com/linktools-toolkit/linktools.git@master#subdirectory=linktools-ai"
```
### 配置 alias(推荐)
@@ -66,7 +66,7 @@ alias jadx="ct-tools --set version=1.5.0 jadx-gui"
$ python3 -m linktools
___ __ __ __
/ (_)___ / /__/ /_____ ____ / /____
- / / / __ \/ //_/ __/ __ \/ __ \/ / ___/ linktools toolkit (v0.9.0)
+ / / / __ \/ //_/ __/ __ \/ __ \/ / ___/ linktools toolkit (v0.10.0)
/ / / / / / ,< / /_/ /_/ / /_/ / (__ ) by: Hu Ji <669898595@qq.com>
/_/_/_/ /_/_/|_|\__/\____/\____/_/____/
📎 All commands
From 217416065b4cf51371923cd188eb7d60c8a4c76a Mon Sep 17 00:00:00 2001
From: ice-black-tea <30665081+ice-black-tea@users.noreply.github.com>
Date: Tue, 22 Sep 2026 18:51:59 +0800
Subject: [PATCH 16/22] docs(core): refresh package usage
---
linktools/README.md | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/linktools/README.md b/linktools/README.md
index ce0c2ab24..7e70eea40 100644
--- a/linktools/README.md
+++ b/linktools/README.md
@@ -1,6 +1,6 @@
# linktools
-Linktools 核心框架,提供命令行工具基础设施、环境管理及通用工具集。
+Linktools 核心框架,提供命令行基础设施、环境/配置、工具管理和子包能力发现。
## 开始使用
@@ -14,7 +14,7 @@ Python & pip(3.6 及以上):
# 安装核心包
python3 -m pip install -U linktools
-# 安装完整功能(包含所有可选依赖)
+# 安装 core 的全部可选能力(包含 common/mobile/cntr 等,不包含独立的 linktools-ai)
python3 -m pip install -U "linktools[all]"
# 安装 GitHub 最新开发版
@@ -87,6 +87,7 @@ linktools 通过 Python entry points 机制加载各子包命令,安装对应
| `linktools-common` | `ct-` | 通用工具命令 |
| `linktools-mobile` | `at-` / `it-` | Android / iOS 设备命令 |
| `linktools-cntr` | `ct-cntr` | 容器管理命令 |
+| `linktools-ai` | `ai-` | AI Runtime 本地运行与调试命令 |
## Python API
From 2f37a5401c89e3a26c9691d301edfcd3ec1d669f Mon Sep 17 00:00:00 2001
From: ice-black-tea <30665081+ice-black-tea@users.noreply.github.com>
Date: Tue, 22 Sep 2026 18:52:02 +0800
Subject: [PATCH 17/22] docs(common): document runtime requirement
---
linktools-common/README.md | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/linktools-common/README.md b/linktools-common/README.md
index 1e7ff38d0..602a3b479 100644
--- a/linktools-common/README.md
+++ b/linktools-common/README.md
@@ -4,6 +4,10 @@ Linktools 通用工具包,提供环境管理、文件搜索及远程工具下
## 开始使用
+### 依赖项
+
+Python & pip(3.6 及以上):
+
### 安装
```bash
From b02ad46e72dcd0513daaf435e1a1b1e1dae13016 Mon Sep 17 00:00:00 2001
From: ice-black-tea <30665081+ice-black-tea@users.noreply.github.com>
Date: Tue, 22 Sep 2026 18:52:05 +0800
Subject: [PATCH 18/22] docs(mobile): refresh install and aliases
---
linktools-mobile/README.md | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/linktools-mobile/README.md b/linktools-mobile/README.md
index a6e3b7b29..eb346504c 100644
--- a/linktools-mobile/README.md
+++ b/linktools-mobile/README.md
@@ -4,6 +4,10 @@ Linktools 移动设备工具包,提供 Android 和 iOS 设备管理、动态
## 开始使用
+### 依赖项
+
+Python & pip(3.6 及以上):
+
### 安装
```bash
@@ -22,7 +26,7 @@ python3 -m pip install --ignore-installed \
```bash
alias adb="at-adb"
-alias sib="it-sib"
+alias sib="it-ios"
alias pidcat="at-pidcat"
```
From 22a78b84668dc7b7604208fb2ba652a7413a296a Mon Sep 17 00:00:00 2001
From: ice-black-tea <30665081+ice-black-tea@users.noreply.github.com>
Date: Tue, 22 Sep 2026 18:52:08 +0800
Subject: [PATCH 19/22] docs(cntr): refresh runtime requirements
---
linktools-cntr/README.md | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/linktools-cntr/README.md b/linktools-cntr/README.md
index a59599224..c218e20bd 100644
--- a/linktools-cntr/README.md
+++ b/linktools-cntr/README.md
@@ -4,6 +4,8 @@ Docker 容器部署和管理工具,为 homelab 及服务器环境提供统一
## 开始使用
+`linktools-cntr` 需要 Python 3.6 及以上,并仅支持 Docker / Docker Compose(不支持 Podman)。
+
以基于 Debian 的系统为例,先安装运行环境:
```bash
@@ -16,7 +18,7 @@ sudo apt-get install -y python3 python3-pip git docker-compose-plugin
安装 linktools-cntr:
```bash
-python3 -m pip install -U linktools linktools-cntr
+python3 -m pip install -U linktools-cntr
# 安装 GitHub 最新开发版
python3 -m pip install --ignore-installed \
From 3938230eeff1d1bbeaaaa3c40b6d46d0aec3d2ea Mon Sep 17 00:00:00 2001
From: ice-black-tea <30665081+ice-black-tea@users.noreply.github.com>
Date: Tue, 22 Sep 2026 18:52:11 +0800
Subject: [PATCH 20/22] docs(ai): document install and cli surface
---
linktools-ai/README.md | 23 ++++++++++++++++++++++-
1 file changed, 22 insertions(+), 1 deletion(-)
diff --git a/linktools-ai/README.md b/linktools-ai/README.md
index f6e9304b5..8925f1d40 100644
--- a/linktools-ai/README.md
+++ b/linktools-ai/README.md
@@ -30,6 +30,25 @@ The main ownership rules are:
- `AgentBinding` is created per execution and pins the exact durable semantics, including the output contract.
- `Session` is bound to `AgentSpec.id`; retry/recovery remain pinned to the exact historical execution binding.
+## Installation
+
+Python 3.10 or newer is required.
+
+```bash
+# Runtime library
+python3 -m pip install -U linktools-ai
+
+# Recommended for the local CLI and durable SQLite Runtime state
+python3 -m pip install -U "linktools-ai[sqlite]"
+
+# Latest development version
+python3 -m pip install --ignore-installed \
+ "linktools@ git+https://github.com/linktools-toolkit/linktools.git@master#subdirectory=linktools" \
+ "linktools-ai[sqlite] @ git+https://github.com/linktools-toolkit/linktools.git@master#subdirectory=linktools-ai"
+```
+
+Optional extras are deliberately scoped: `sqlite` adds the built-in SQLite dependencies, `sqlalchemy` adds the generic SQLAlchemy runtime dependency, and `evaluation` adds evaluation support.
+
## 1. Run a workspace
### Command line
@@ -39,6 +58,8 @@ ai-run "review this change" --project /workspace/project --model gpt-4o-mini
python3 -m linktools ai run "review this change" --project /workspace/project --model gpt-4o-mini
```
+Local CLI entry points include `ai-run`, `ai-status`, `ai-session`, `ai-history`, `ai-metrics`, `ai-trace`, and `ai-acp`.
+
Useful options:
- `--base-url`, `--api-key`, and `--model` also read `OPENAI_BASE_URL`, `OPENAI_API_KEY`, and `OPENAI_MODEL`.
@@ -46,7 +67,7 @@ Useful options:
- `--thinking` requests model thinking when supported.
- `--json` emits one terminal JSON result.
-The local CLI stores Runtime state under `/.linktools/runtime` through `RuntimeState.from_root()`; storage is not selected with a CLI flag.
+The local CLI stores Runtime state under `/.linktools/runtime` through `RuntimeState.from_root()`; storage is not selected with a CLI flag. Install `linktools-ai[sqlite]` when using this durable local Runtime state.
### Python
From 3a65cfc6d9fc88df58f6f51d211deab57ec023e4 Mon Sep 17 00:00:00 2001
From: ice-black-tea <30665081+ice-black-tea@users.noreply.github.com>
Date: Tue, 22 Sep 2026 18:52:42 +0800
Subject: [PATCH 21/22] docs: fix ios command alias
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index f61fba332..f5dd63e84 100644
--- a/README.md
+++ b/README.md
@@ -52,7 +52,7 @@ eval "$(ct-env --silent java 17.0.11 --shell bash)"
# 常用 alias
alias adb="at-adb"
-alias sib="it-sib"
+alias sib="it-ios"
alias pidcat="at-pidcat"
alias apktool="ct-tools apktool"
From 61fea89f4c31100b4257cab73d09696cbe3684ce Mon Sep 17 00:00:00 2001
From: ice-black-tea <30665081+ice-black-tea@users.noreply.github.com>
Date: Tue, 22 Sep 2026 18:54:38 +0800
Subject: [PATCH 22/22] docs(ai): clarify acp dependency
---
linktools-ai/README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/linktools-ai/README.md b/linktools-ai/README.md
index 8925f1d40..664acea40 100644
--- a/linktools-ai/README.md
+++ b/linktools-ai/README.md
@@ -58,7 +58,7 @@ ai-run "review this change" --project /workspace/project --model gpt-4o-mini
python3 -m linktools ai run "review this change" --project /workspace/project --model gpt-4o-mini
```
-Local CLI entry points include `ai-run`, `ai-status`, `ai-session`, `ai-history`, `ai-metrics`, `ai-trace`, and `ai-acp`.
+Local CLI entry points include `ai-run`, `ai-status`, `ai-session`, `ai-history`, `ai-metrics`, and `ai-trace`. `ai-acp` is also available when the separate `agent-client-protocol` dependency is installed.
Useful options: