txa 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. txa-1.0.0/PKG-INFO +34 -0
  2. txa-1.0.0/README.md +15 -0
  3. txa-1.0.0/pyproject.toml +43 -0
  4. txa-1.0.0/setup.cfg +4 -0
  5. txa-1.0.0/src/txa/__init__.py +3 -0
  6. txa-1.0.0/src/txa/ci/__init__.py +1 -0
  7. txa-1.0.0/src/txa/ci/api.py +78 -0
  8. txa-1.0.0/src/txa/ci/cli.py +190 -0
  9. txa-1.0.0/src/txa/ci/context.py +259 -0
  10. txa-1.0.0/src/txa/ci/gate.py +235 -0
  11. txa-1.0.0/src/txa/ci/report.py +115 -0
  12. txa-1.0.0/src/txa/ci/steps.py +765 -0
  13. txa-1.0.0/src/txa/constants.py +41 -0
  14. txa-1.0.0/src/txa/core/__init__.py +1 -0
  15. txa-1.0.0/src/txa/core/config.py +71 -0
  16. txa-1.0.0/src/txa/core/console.py +42 -0
  17. txa-1.0.0/src/txa/core/credentials.py +104 -0
  18. txa-1.0.0/src/txa/core/engines.py +242 -0
  19. txa-1.0.0/src/txa/core/github.py +110 -0
  20. txa-1.0.0/src/txa/core/manifest.py +264 -0
  21. txa-1.0.0/src/txa/core/net.py +340 -0
  22. txa-1.0.0/src/txa/core/pe.py +215 -0
  23. txa-1.0.0/src/txa/core/semver.py +40 -0
  24. txa-1.0.0/src/txa/core/signing.py +121 -0
  25. txa-1.0.0/src/txa/core/winos.py +89 -0
  26. txa-1.0.0/src/txa/create/__init__.py +1 -0
  27. txa-1.0.0/src/txa/create/cli.py +450 -0
  28. txa-1.0.0/src/txa/create/draft.py +131 -0
  29. txa-1.0.0/src/txa/create/submit.py +90 -0
  30. txa-1.0.0/src/txa/pm/__init__.py +1 -0
  31. txa-1.0.0/src/txa/pm/cli.py +297 -0
  32. txa-1.0.0/src/txa/pm/installer.py +174 -0
  33. txa-1.0.0/src/txa/pm/source.py +125 -0
  34. txa-1.0.0/src/txa/pm/uninstall.py +91 -0
  35. txa-1.0.0/src/txa/schemas/about.schema.json +33 -0
  36. txa-1.0.0/src/txa/schemas/build.schema.json +77 -0
  37. txa-1.0.0/src/txa/schemas/core.schema.json +50 -0
  38. txa-1.0.0/src/txa.egg-info/PKG-INFO +34 -0
  39. txa-1.0.0/src/txa.egg-info/SOURCES.txt +44 -0
  40. txa-1.0.0/src/txa.egg-info/dependency_links.txt +1 -0
  41. txa-1.0.0/src/txa.egg-info/entry_points.txt +4 -0
  42. txa-1.0.0/src/txa.egg-info/requires.txt +10 -0
  43. txa-1.0.0/src/txa.egg-info/top_level.txt +1 -0
  44. txa-1.0.0/tests/test_core.py +139 -0
  45. txa-1.0.0/tests/test_gate.py +144 -0
  46. txa-1.0.0/tests/test_pipeline.py +149 -0
txa-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,34 @@
1
+ Metadata-Version: 2.4
2
+ Name: txa
3
+ Version: 1.0.0
4
+ Summary: TXA package manager toolchain: txapm (install), txacreate (publish), txaci (16-step validator)
5
+ Author: TXA Software Dev
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/TXAVL/txa-repo
8
+ Project-URL: Issues, https://github.com/TXAVL/txa-repo/issues
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: PyYAML>=6.0
12
+ Requires-Dist: requests>=2.31
13
+ Requires-Dist: jsonschema>=4.20
14
+ Requires-Dist: olefile>=0.46
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest>=8; extra == "dev"
17
+ Provides-Extra: build
18
+ Requires-Dist: pyinstaller>=6.6; extra == "build"
19
+
20
+ # txa
21
+
22
+ Command line tools for the TXA Windows package repository.
23
+
24
+ ```
25
+ pipx install txa
26
+ ```
27
+
28
+ | Command | Purpose |
29
+ |---|---|
30
+ | `txapm` | install, upgrade, uninstall, list, search, export/import TXA packages |
31
+ | `txacreate` | sign in to GitHub and publish installers as pull requests (`new`, `update`, `hash`) |
32
+ | `txaci` | the 16-step manifest validator used by the repository bot |
33
+
34
+ Source, manifest format and docs: https://github.com/TXAVL/txa-repo
txa-1.0.0/README.md ADDED
@@ -0,0 +1,15 @@
1
+ # txa
2
+
3
+ Command line tools for the TXA Windows package repository.
4
+
5
+ ```
6
+ pipx install txa
7
+ ```
8
+
9
+ | Command | Purpose |
10
+ |---|---|
11
+ | `txapm` | install, upgrade, uninstall, list, search, export/import TXA packages |
12
+ | `txacreate` | sign in to GitHub and publish installers as pull requests (`new`, `update`, `hash`) |
13
+ | `txaci` | the 16-step manifest validator used by the repository bot |
14
+
15
+ Source, manifest format and docs: https://github.com/TXAVL/txa-repo
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "txa"
7
+ dynamic = ["version"]
8
+ description = "TXA package manager toolchain: txapm (install), txacreate (publish), txaci (16-step validator)"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "TXA Software Dev" }]
13
+ dependencies = [
14
+ "PyYAML>=6.0",
15
+ "requests>=2.31",
16
+ "jsonschema>=4.20",
17
+ "olefile>=0.46",
18
+ ]
19
+
20
+ [project.urls]
21
+ Homepage = "https://github.com/TXAVL/txa-repo"
22
+ Issues = "https://github.com/TXAVL/txa-repo/issues"
23
+
24
+ [project.optional-dependencies]
25
+ dev = ["pytest>=8"]
26
+ build = ["pyinstaller>=6.6"]
27
+
28
+ [project.scripts]
29
+ txapm = "txa.pm.cli:main"
30
+ txacreate = "txa.create.cli:main"
31
+ txaci = "txa.ci.cli:main"
32
+
33
+ [tool.setuptools.dynamic]
34
+ version = { attr = "txa.__version__" }
35
+
36
+ [tool.setuptools.packages.find]
37
+ where = ["src"]
38
+
39
+ [tool.setuptools.package-data]
40
+ txa = ["schemas/*.json"]
41
+
42
+ [tool.pytest.ini_options]
43
+ testpaths = ["tests"]
txa-1.0.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """TXA package manager toolchain."""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1 @@
1
+ """txaci - the 16-step TXA pull request validator and merge gate."""
@@ -0,0 +1,78 @@
1
+ """Static JSON API (namespace txa.v1) published to GitHub Pages from txa-repo.
2
+
3
+ Layout under ``<out>/api/txa/v1/``::
4
+
5
+ index.json every package, newest version summary
6
+ packages/<Id>.json every version of one package, all three cards
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+
14
+ from txa.constants import API_NAMESPACE
15
+ from txa.core import semver
16
+ from txa.core.manifest import Bundle, assemble, kind_of, schema_errors
17
+
18
+
19
+ def _valid(bundle: Bundle) -> list[str]:
20
+ problems = [f"{name}: {p}" for name, items in bundle.problems.items() for p in items]
21
+ for name, card in bundle.cards.items():
22
+ problems += [f"{name}: {e}" for e in schema_errors(kind_of(name), card)]
23
+ if not bundle.core or not bundle.build or not bundle.default_about():
24
+ problems.append("incomplete release (core, build and default about cards are required)")
25
+ return problems
26
+
27
+
28
+ def build_api(repo_root: Path, out: Path) -> dict:
29
+ packages: dict[str, dict[str, dict]] = {}
30
+ skipped = []
31
+ for release in sorted((repo_root / "manifests").glob("*/*/*/*")):
32
+ if not release.is_dir() or not semver.is_valid(release.name):
33
+ continue
34
+ bundle = Bundle.read(release)
35
+ problems = _valid(bundle)
36
+ if problems:
37
+ skipped.append({"path": release.relative_to(repo_root).as_posix(), "problems": problems[:5]})
38
+ continue
39
+ packages.setdefault(bundle.package_id, {})[bundle.version] = assemble(bundle)
40
+
41
+ api = out / API_NAMESPACE
42
+ (api / "packages").mkdir(parents=True, exist_ok=True)
43
+ generated = datetime.now(timezone.utc).isoformat(timespec="seconds")
44
+ index = []
45
+ for package_id in sorted(packages, key=str.lower):
46
+ versions = packages[package_id]
47
+ ordered = sorted(versions, key=semver.parse, reverse=True)
48
+ latest = versions[ordered[0]]
49
+ about = latest["about"].get(latest["core"]["locale"]["default"]) or next(iter(latest["about"].values()))
50
+ document = {"api": "txa.v1", "id": package_id, "latest": ordered[0], "versions": {v: versions[v] for v in ordered}}
51
+ (api / "packages" / f"{package_id}.json").write_text(json.dumps(document, ensure_ascii=False, indent=1), "utf-8")
52
+ index.append({
53
+ "id": package_id,
54
+ "alias": latest["core"]["package"].get("alias"),
55
+ "latest": ordered[0],
56
+ "versions": ordered,
57
+ "name": about["name"],
58
+ "publisher": about["publisher"],
59
+ "summary": about["summary"],
60
+ "tags": latest["core"].get("tags", []),
61
+ "arches": [t["arch"] for t in latest["build"]["targets"]],
62
+ })
63
+ manifest = {"api": "txa.v1", "generated": generated, "count": len(index), "packages": index}
64
+ (api / "index.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=1), "utf-8")
65
+ (out / ".nojekyll").write_text("", "utf-8")
66
+ rows = "".join(
67
+ f"<tr><td><code>{p['id']}</code></td><td>{p['latest']}</td><td>{p['summary']}</td></tr>" for p in index
68
+ )
69
+ (out / "index.html").write_text(
70
+ "<!doctype html><meta charset=utf-8><title>TXA package index</title>"
71
+ "<style>body{font:15px system-ui;margin:2rem auto;max-width:60rem;padding:0 1rem}"
72
+ "td,th{padding:.3rem .6rem;border-bottom:1px solid #8884;text-align:left}</style>"
73
+ f"<h1>TXA package index</h1><p>{len(index)} package(s) · generated {generated} · "
74
+ f"<a href='{API_NAMESPACE}/index.json'>index.json</a></p>"
75
+ f"<table><tr><th>Id</th><th>Latest</th><th>Summary</th></tr>{rows}</table>",
76
+ "utf-8",
77
+ )
78
+ return {"packages": len(index), "skipped": skipped, "out": str(api)}
@@ -0,0 +1,190 @@
1
+ """txaci - command line entry for the TXA validation pipeline.
2
+
3
+ CI usage (one GitHub Actions step per command)::
4
+
5
+ txaci init --base <sha> --head <sha> --pr <n>
6
+ txaci step 1 … txaci step 14
7
+ txaci verdict
8
+ txaci gate triage --report report.json --head-sha <sha> # STEP 15
9
+ txaci gate merge --report report.json # STEP 16
10
+
11
+ Local usage::
12
+
13
+ txaci validate manifests/t/TXAVL/HelloTool/1.4.2 [--offline]
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import os
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ from txa import __version__
24
+ from txa.ci import steps
25
+ from txa.ci.context import Context, init_ci, init_local
26
+ from txa.ci.report import build_report, render_console, render_markdown
27
+ from txa.core import console
28
+
29
+ LAST_VALIDATION_STEP = 14
30
+
31
+
32
+ def _escape(text: str) -> str:
33
+ return text.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
34
+
35
+
36
+ def _emit(number: int, results) -> None:
37
+ in_actions = os.environ.get("GITHUB_ACTIONS") == "true"
38
+ for target, outcome in results:
39
+ print(f"{target['path']} → {outcome.status}")
40
+ for message in outcome.messages:
41
+ level, text = message["level"], message["text"]
42
+ print(f" [{level}] {text}")
43
+ if in_actions and level in ("fail", "warn"):
44
+ kind = "error" if level == "fail" else "warning"
45
+ print(f"::{kind} title=STEP {number:02d} · {steps.TITLES[number]}::{_escape(target['path'] + ': ' + text)}")
46
+
47
+
48
+ def cmd_init(args) -> int:
49
+ pr = int(args.pr) if args.pr else None
50
+ ctx = init_ci(Path(args.repo_root), args.base, args.head, pr, Path(args.state))
51
+ print(f"txaci {__version__}: {len(ctx.targets)} release(s), {len(ctx.outside)} file(s) outside manifests/")
52
+ for target in ctx.targets:
53
+ print(f" {target['mode']:<12} {target['path']}")
54
+ for path in ctx.outside:
55
+ print(f" outside {path}")
56
+ return 0
57
+
58
+
59
+ def cmd_step(args) -> int:
60
+ number = int(args.number)
61
+ if number not in steps.REGISTRY:
62
+ raise SystemExit(f"txaci: step {number} is not a validation step (1-{LAST_VALIDATION_STEP})")
63
+ ctx = Context.load(Path(args.state))
64
+ print(f"STEP {number:02d} · {steps.TITLES[number]}")
65
+ if not ctx.targets:
66
+ print(" no manifest releases in this change set - skipped")
67
+ return 0
68
+ results = steps.run_step(ctx, number)
69
+ ctx.save()
70
+ _emit(number, results)
71
+ return 1 if any(o.status == "FAIL" for _, o in results) else 0
72
+
73
+
74
+ def cmd_verdict(args) -> int:
75
+ ctx = Context.load(Path(args.state))
76
+ report = build_report(ctx)
77
+ path = Path(args.report) if args.report else Path(args.state) / "report.json"
78
+ path.parent.mkdir(parents=True, exist_ok=True)
79
+ path.write_text(json.dumps(report, indent=2), "utf-8")
80
+ summary = os.environ.get("GITHUB_STEP_SUMMARY")
81
+ if summary:
82
+ with open(summary, "a", encoding="utf-8") as fh:
83
+ fh.write(render_markdown(report))
84
+ print(render_console(report))
85
+ print(f"report written to {path}")
86
+ return 1 if report["verdict"] == "fail" else 0
87
+
88
+
89
+ def cmd_validate(args) -> int:
90
+ ctx = init_local([Path(p) for p in args.paths], offline=args.offline)
91
+ numbers = sorted(steps.REGISTRY) if not args.only else [int(n) for n in args.only.split(",")]
92
+ for number in numbers:
93
+ steps.run_step(ctx, number)
94
+ report = build_report(ctx)
95
+ if args.json:
96
+ Path(args.json).write_text(json.dumps(report, indent=2), "utf-8")
97
+ print(render_console(report))
98
+ return 1 if report["verdict"] == "fail" else 0
99
+
100
+
101
+ def cmd_gate(args) -> int:
102
+ from txa.ci import gate
103
+ from txa.core.github import GitHub
104
+
105
+ token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")
106
+ if not token:
107
+ raise SystemExit("txaci gate: GH_TOKEN is required")
108
+ repo = args.repo or os.environ.get("GITHUB_REPOSITORY")
109
+ if not repo:
110
+ raise SystemExit("txaci gate: --repo or GITHUB_REPOSITORY is required")
111
+ state = Path(args.state) / "gate.json"
112
+ report = Path(args.report) if args.report else None
113
+ gh = GitHub(token)
114
+ if args.action == "triage":
115
+ if not args.head_sha:
116
+ raise SystemExit("txaci gate triage: --head-sha is required")
117
+ return gate.triage(gh, repo, report, args.head_sha, state, args.run_url)
118
+ merger = GitHub(os.environ.get("TXA_MERGE_TOKEN") or token)
119
+ return gate.merge_gate(gh, merger, repo, state, report, args.run_url)
120
+
121
+
122
+ def cmd_build_api(args) -> int:
123
+ from txa.ci.api import build_api
124
+
125
+ result = build_api(Path(args.root), Path(args.out))
126
+ print(f"published {result['packages']} package(s) to {result['out']}")
127
+ for item in result["skipped"]:
128
+ print(f"::warning title=Skipped release::{item['path']}: {'; '.join(item['problems'])}")
129
+ return 0
130
+
131
+
132
+ def cmd_schema(args) -> int:
133
+ from txa.core.manifest import schema
134
+
135
+ print(json.dumps(schema(args.kind), indent=2))
136
+ return 0
137
+
138
+
139
+ def main(argv: list[str] | None = None) -> int:
140
+ console.setup()
141
+ parser = argparse.ArgumentParser(prog="txaci", description="TXA 16-step manifest validator")
142
+ parser.add_argument("--version", action="version", version=f"txaci {__version__}")
143
+ parser.add_argument("--state", default=str(Context.default_state_dir()), help="pipeline state directory")
144
+ sub = parser.add_subparsers(dest="command", required=True)
145
+
146
+ p = sub.add_parser("init", help="collect the change set of a pull request")
147
+ p.add_argument("--base", required=True)
148
+ p.add_argument("--head", required=True)
149
+ p.add_argument("--pr")
150
+ p.add_argument("--repo-root", default=".")
151
+ p.set_defaults(func=cmd_init)
152
+
153
+ p = sub.add_parser("step", help="run one validation step (1-14)")
154
+ p.add_argument("number")
155
+ p.set_defaults(func=cmd_step)
156
+
157
+ p = sub.add_parser("verdict", help="write report.json and fail when any step failed")
158
+ p.add_argument("--report")
159
+ p.set_defaults(func=cmd_verdict)
160
+
161
+ p = sub.add_parser("validate", help="run steps 01-14 against release directories on disk")
162
+ p.add_argument("paths", nargs="+")
163
+ p.add_argument("--offline", action="store_true", help="skip network steps 06-09")
164
+ p.add_argument("--only", help="comma separated step numbers, e.g. 1,2,3")
165
+ p.add_argument("--json", help="also write the report to this file")
166
+ p.set_defaults(func=cmd_validate)
167
+
168
+ p = sub.add_parser("gate", help="STEP 15 triage / STEP 16 merge gate (trusted workflow only)")
169
+ p.add_argument("action", choices=["triage", "merge"])
170
+ p.add_argument("--report")
171
+ p.add_argument("--head-sha")
172
+ p.add_argument("--repo")
173
+ p.add_argument("--run-url")
174
+ p.set_defaults(func=cmd_gate)
175
+
176
+ p = sub.add_parser("build-api", help="generate the static txa.v1 JSON API")
177
+ p.add_argument("--root", default=".")
178
+ p.add_argument("--out", default="site")
179
+ p.set_defaults(func=cmd_build_api)
180
+
181
+ p = sub.add_parser("schema", help="print the JSON schema of a card kind")
182
+ p.add_argument("kind", choices=["core", "build", "about"])
183
+ p.set_defaults(func=cmd_schema)
184
+
185
+ args = parser.parse_args(argv)
186
+ return args.func(args)
187
+
188
+
189
+ if __name__ == "__main__":
190
+ sys.exit(main())
@@ -0,0 +1,259 @@
1
+ """Pipeline state shared between steps.
2
+
3
+ In CI every step runs as its own process (one GitHub Actions step each), so the
4
+ state lives in ``$RUNNER_TEMP/txa-ci/state.json``. Locally everything stays in memory.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import copy
9
+ import json
10
+ import os
11
+ import subprocess
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path, PurePosixPath
14
+
15
+ import yaml
16
+
17
+ from txa import __version__
18
+ from txa.core import semver
19
+ from txa.core.manifest import Bundle, find_repo_root
20
+
21
+ POLICY_FILE = "policy.txa.yaml"
22
+
23
+ DEFAULT_POLICY: dict = {
24
+ "namespaces": ["TXAVL"],
25
+ "limits": {
26
+ "max_payload_mb": 2048,
27
+ "max_redirects": 8,
28
+ "http_timeout_s": 30,
29
+ "max_line_length": 400,
30
+ },
31
+ "network": {
32
+ "min_regions_ok": 3,
33
+ # EDNS Client Subnet hints: the resolver answers as if the client sat in that network.
34
+ "regions": {
35
+ "vn-hanoi": "113.160.0.0/24",
36
+ "sg-singapore": "203.116.0.0/24",
37
+ "jp-tokyo": "126.0.0.0/24",
38
+ "au-sydney": "1.120.0.0/24",
39
+ "de-frankfurt": "79.192.0.0/24",
40
+ "us-east": "73.0.0.0/24",
41
+ },
42
+ },
43
+ "signature": {"require": False},
44
+ "os": {"floor": "10.0.17763"},
45
+ }
46
+
47
+
48
+ def _merge(base: dict, override: dict) -> dict:
49
+ out = copy.deepcopy(base)
50
+ for key, value in (override or {}).items():
51
+ if isinstance(value, dict) and isinstance(out.get(key), dict):
52
+ out[key] = _merge(out[key], value)
53
+ else:
54
+ out[key] = value
55
+ return out
56
+
57
+
58
+ def parse_policy(text: str | None) -> dict:
59
+ if not text:
60
+ return copy.deepcopy(DEFAULT_POLICY)
61
+ data = yaml.safe_load(text) or {}
62
+ data.pop("txa", None)
63
+ data.pop("kind", None)
64
+ return _merge(DEFAULT_POLICY, data)
65
+
66
+
67
+ # --------------------------------------------------------------------------- git
68
+
69
+
70
+ def git(repo: Path, *args: str, check: bool = True) -> str:
71
+ proc = subprocess.run(["git", *args], cwd=repo, capture_output=True, text=True, encoding="utf-8")
72
+ if check and proc.returncode != 0:
73
+ raise RuntimeError(f"git {' '.join(args)} failed: {proc.stderr.strip()}")
74
+ return proc.stdout if proc.returncode == 0 else ""
75
+
76
+
77
+ def changed_files(repo: Path, base: str, head: str) -> list[tuple[str, str]]:
78
+ out = git(repo, "diff", "--name-status", "--no-renames", "-z", f"{base}...{head}")
79
+ fields = [f for f in out.split("\0") if f]
80
+ return [(fields[i][0], fields[i + 1]) for i in range(0, len(fields) - 1, 2)]
81
+
82
+
83
+ def tree_dirs(repo: Path, rev: str, path: str) -> list[str]:
84
+ out = git(repo, "ls-tree", "-d", "--name-only", f"{rev}:{path}", check=False)
85
+ return [line for line in out.splitlines() if line]
86
+
87
+
88
+ def show(repo: Path, rev: str, path: str) -> str | None:
89
+ proc = subprocess.run(["git", "show", f"{rev}:{path}"], cwd=repo, capture_output=True, text=True, encoding="utf-8")
90
+ return proc.stdout if proc.returncode == 0 else None
91
+
92
+
93
+ # --------------------------------------------------------------------------- context
94
+
95
+
96
+ class Context:
97
+ def __init__(self, data: dict, state_dir: Path | None = None):
98
+ self.data = data
99
+ self.state_dir = state_dir
100
+ self.payloads: dict[str, dict[str, Path]] = {}
101
+ self._bundles: dict[str, Bundle] = {}
102
+
103
+ # ---- persistence
104
+ @staticmethod
105
+ def default_state_dir() -> Path:
106
+ base = os.environ.get("TXA_STATE_DIR") or (
107
+ str(Path(os.environ["RUNNER_TEMP"]) / "txa-ci") if os.environ.get("RUNNER_TEMP") else ".txa-ci"
108
+ )
109
+ return Path(base)
110
+
111
+ @classmethod
112
+ def load(cls, state_dir: Path) -> "Context":
113
+ path = state_dir / "state.json"
114
+ if not path.exists():
115
+ raise SystemExit(f"txaci: no pipeline state at {path} - run `txaci init` first")
116
+ return cls(json.loads(path.read_text("utf-8")), state_dir)
117
+
118
+ def save(self) -> None:
119
+ if self.state_dir is None:
120
+ return
121
+ self.state_dir.mkdir(parents=True, exist_ok=True)
122
+ (self.state_dir / "state.json").write_text(json.dumps(self.data, indent=2), "utf-8")
123
+
124
+ # ---- accessors
125
+ @property
126
+ def mode(self) -> str:
127
+ return self.data["mode"]
128
+
129
+ @property
130
+ def repo_root(self) -> Path:
131
+ return Path(self.data["repo_root"])
132
+
133
+ @property
134
+ def policy(self) -> dict:
135
+ return self.data["policy"]
136
+
137
+ @property
138
+ def offline(self) -> bool:
139
+ return bool(self.data.get("offline"))
140
+
141
+ @property
142
+ def targets(self) -> list[dict]:
143
+ return self.data["targets"]
144
+
145
+ @property
146
+ def outside(self) -> list[str]:
147
+ return self.data.get("outside", [])
148
+
149
+ def bundle(self, target: dict) -> Bundle:
150
+ key = target["path"]
151
+ if key not in self._bundles:
152
+ self._bundles[key] = Bundle.read(self.repo_root / key)
153
+ return self._bundles[key]
154
+
155
+ def artifact(self, target: dict, arch: str) -> dict:
156
+ return self.data.setdefault("artifacts", {}).setdefault(f"{target['path']}#{arch}", {})
157
+
158
+ def payload_dir(self, target: dict, arch: str) -> Path:
159
+ import hashlib
160
+
161
+ tag = hashlib.sha1(f"{target['path']}#{arch}".encode()).hexdigest()[:12]
162
+ root = self.state_dir or Path(".txa-ci")
163
+ return root / "payloads" / tag
164
+
165
+ def record(self, step: int, target: dict, status: str, messages: list[dict]) -> None:
166
+ results = [r for r in self.data.setdefault("results", []) if not (r["step"] == step and r["target"] == target["path"])]
167
+ results.append({"step": step, "target": target["path"], "status": status, "messages": messages})
168
+ self.data["results"] = sorted(results, key=lambda r: (r["target"], r["step"]))
169
+
170
+ def results(self) -> list[dict]:
171
+ return self.data.get("results", [])
172
+
173
+
174
+ def _base_state(mode: str, repo_root: Path, policy: dict) -> dict:
175
+ return {
176
+ "tool": f"txaci {__version__}",
177
+ "mode": mode,
178
+ "repo_root": str(repo_root.resolve()),
179
+ "policy": policy,
180
+ "created": datetime.now(timezone.utc).isoformat(timespec="seconds"),
181
+ "targets": [],
182
+ "outside": [],
183
+ "results": [],
184
+ "artifacts": {},
185
+ }
186
+
187
+
188
+ def init_ci(repo_root: Path, base: str, head: str, pr: int | None, state_dir: Path) -> Context:
189
+ """Build the change set of a pull request. The policy always comes from the base commit."""
190
+ data = _base_state("ci", repo_root, parse_policy(show(repo_root, base, POLICY_FILE)))
191
+ data.update({"base": base, "head": head, "pr": pr})
192
+ changes = changed_files(repo_root, base, head)
193
+ data["changes"] = [{"status": s, "path": p} for s, p in changes]
194
+ releases: dict[str, dict] = {}
195
+ for status, path in changes:
196
+ parts = PurePosixPath(path).parts
197
+ if not parts or parts[0] != "manifests":
198
+ data["outside"].append(path)
199
+ continue
200
+ release = "/".join(parts[:5]) if len(parts) >= 6 else str(PurePosixPath(path).parent)
201
+ entry = releases.setdefault(release, {"path": release, "changed": [], "deleted": []})
202
+ entry["changed"].append(path)
203
+ if status == "D":
204
+ entry["deleted"].append(path)
205
+ for release, entry in sorted(releases.items()):
206
+ package = str(PurePosixPath(release).parent)
207
+ version = PurePosixPath(release).name
208
+ base_versions = tree_dirs(repo_root, base, package)
209
+ existed = version in base_versions
210
+ still_there = (repo_root / release).is_dir() and any((repo_root / release).iterdir())
211
+ if not still_there:
212
+ mode = "removal"
213
+ elif existed:
214
+ mode = "update"
215
+ elif base_versions:
216
+ mode = "new-version"
217
+ else:
218
+ mode = "new-app"
219
+ data["targets"].append({
220
+ "path": release,
221
+ "mode": mode,
222
+ "existing_versions": [v for v in base_versions if v != version],
223
+ "changed": entry["changed"],
224
+ })
225
+ ctx = Context(data, state_dir)
226
+ ctx.save()
227
+ return ctx
228
+
229
+
230
+ def init_local(release_dirs: list[Path], offline: bool = False,
231
+ payloads: dict[str, dict[str, Path]] | None = None) -> Context:
232
+ """Validate release directories on disk (used by `txaci validate` and txacreate)."""
233
+ first = Path(release_dirs[0]).resolve()
234
+ repo_root = find_repo_root(first) or first.parent
235
+ policy_file = repo_root / POLICY_FILE
236
+ policy = parse_policy(policy_file.read_text("utf-8") if policy_file.exists() else None)
237
+ data = _base_state("local", repo_root, policy)
238
+ data["offline"] = offline
239
+ ctx = Context(data)
240
+ for directory in release_dirs:
241
+ directory = Path(directory).resolve()
242
+ try:
243
+ rel = directory.relative_to(repo_root).as_posix()
244
+ except ValueError:
245
+ rel = directory.as_posix()
246
+ siblings = [p.name for p in directory.parent.iterdir() if p.is_dir() and p.name != directory.name] \
247
+ if directory.parent.is_dir() else []
248
+ siblings = [s for s in siblings if semver.is_valid(s)]
249
+ data["targets"].append({
250
+ "path": rel,
251
+ "mode": "new-version" if siblings else "new-app",
252
+ "existing_versions": siblings,
253
+ "changed": [],
254
+ })
255
+ if payloads and rel in payloads:
256
+ ctx.payloads[rel] = payloads[rel]
257
+ elif payloads and str(directory) in payloads:
258
+ ctx.payloads[rel] = payloads[str(directory)]
259
+ return ctx