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: