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.
- txa-1.0.0/PKG-INFO +34 -0
- txa-1.0.0/README.md +15 -0
- txa-1.0.0/pyproject.toml +43 -0
- txa-1.0.0/setup.cfg +4 -0
- txa-1.0.0/src/txa/__init__.py +3 -0
- txa-1.0.0/src/txa/ci/__init__.py +1 -0
- txa-1.0.0/src/txa/ci/api.py +78 -0
- txa-1.0.0/src/txa/ci/cli.py +190 -0
- txa-1.0.0/src/txa/ci/context.py +259 -0
- txa-1.0.0/src/txa/ci/gate.py +235 -0
- txa-1.0.0/src/txa/ci/report.py +115 -0
- txa-1.0.0/src/txa/ci/steps.py +765 -0
- txa-1.0.0/src/txa/constants.py +41 -0
- txa-1.0.0/src/txa/core/__init__.py +1 -0
- txa-1.0.0/src/txa/core/config.py +71 -0
- txa-1.0.0/src/txa/core/console.py +42 -0
- txa-1.0.0/src/txa/core/credentials.py +104 -0
- txa-1.0.0/src/txa/core/engines.py +242 -0
- txa-1.0.0/src/txa/core/github.py +110 -0
- txa-1.0.0/src/txa/core/manifest.py +264 -0
- txa-1.0.0/src/txa/core/net.py +340 -0
- txa-1.0.0/src/txa/core/pe.py +215 -0
- txa-1.0.0/src/txa/core/semver.py +40 -0
- txa-1.0.0/src/txa/core/signing.py +121 -0
- txa-1.0.0/src/txa/core/winos.py +89 -0
- txa-1.0.0/src/txa/create/__init__.py +1 -0
- txa-1.0.0/src/txa/create/cli.py +450 -0
- txa-1.0.0/src/txa/create/draft.py +131 -0
- txa-1.0.0/src/txa/create/submit.py +90 -0
- txa-1.0.0/src/txa/pm/__init__.py +1 -0
- txa-1.0.0/src/txa/pm/cli.py +297 -0
- txa-1.0.0/src/txa/pm/installer.py +174 -0
- txa-1.0.0/src/txa/pm/source.py +125 -0
- txa-1.0.0/src/txa/pm/uninstall.py +91 -0
- txa-1.0.0/src/txa/schemas/about.schema.json +33 -0
- txa-1.0.0/src/txa/schemas/build.schema.json +77 -0
- txa-1.0.0/src/txa/schemas/core.schema.json +50 -0
- txa-1.0.0/src/txa.egg-info/PKG-INFO +34 -0
- txa-1.0.0/src/txa.egg-info/SOURCES.txt +44 -0
- txa-1.0.0/src/txa.egg-info/dependency_links.txt +1 -0
- txa-1.0.0/src/txa.egg-info/entry_points.txt +4 -0
- txa-1.0.0/src/txa.egg-info/requires.txt +10 -0
- txa-1.0.0/src/txa.egg-info/top_level.txt +1 -0
- txa-1.0.0/tests/test_core.py +139 -0
- txa-1.0.0/tests/test_gate.py +144 -0
- 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
|
txa-1.0.0/pyproject.toml
ADDED
|
@@ -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 @@
|
|
|
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
|