Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
a519485
Refactor: Restructure the repository root
b-re-w Oct 3, 2026
c1254b1
Docs: Point the README quick start at the sample's new Python path
b-re-w Oct 3, 2026
718dc1f
Docs: Point the guide's getting-started at the sample's new Python path
b-re-w Oct 3, 2026
06a9c29
Fix: Keep tcl and toolchain-lite's pyproject.toml in the restructure
b-re-w Oct 3, 2026
4e74929
Merge pull request #63 from thisisthepy/feat/restructure-root
b-re-w Oct 3, 2026
7332b24
Chore: Relicense to Apache-2.0; no em-dashes; writing rules in AGENTS.md
b-re-w Oct 3, 2026
74581ee
CI: ios-e2e.yml -> test-ios.yml; drop main-source-guard and protect-m…
b-re-w Oct 3, 2026
fac4d5d
CI: test-ios.yml's path filter names its new file
b-re-w Oct 3, 2026
ea19338
Merge pull request #66 from thisisthepy/feat/apache-writing
b-re-w Oct 3, 2026
368df6e
Merge pull request #68 from thisisthepy/feat/ci-files
b-re-w Oct 3, 2026
6b09877
CI: Release toolchain-lite 0.1.0 on PyPI as native tcl wheels
b-re-w Oct 3, 2026
3a44a97
Merge pull request #70 from thisisthepy/feat/toolchain-lite-pypi
b-re-w Oct 3, 2026
c15f92c
CI: Tag toolchain-lite releases toolchain-lite-v<version>
b-re-w Oct 3, 2026
7a0658f
Docs: Absolute README links, status words, AGENTS layout table, relea…
b-re-w Oct 3, 2026
f4e34a5
Merge pull request #72 from thisisthepy/feat/tcl-tag
b-re-w Oct 3, 2026
a813d50
Merge pull request #75 from thisisthepy/feat/docs-cleanup
b-re-w Oct 3, 2026
3833cb3
Refactor: Restore toolchain/ and move tcl into it as cli source sets
b-re-w Oct 3, 2026
7a85593
Fix: Build tcl's native image as an executable, not a shared library
b-re-w Oct 3, 2026
57b287b
Merge pull request #79 from thisisthepy/feat/toolchain-module
b-re-w Oct 3, 2026
5b0db2a
Release: sync from 57b287b27ab24e7b463ae36726e4886b8a8c8072 57b287b
github-actions[bot] Oct 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 0 additions & 29 deletions .fleet/receipt.json

This file was deleted.

148 changes: 148 additions & 0 deletions .github/scripts/pypi/build_wheel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""Assemble the platform wheel that carries the native `tcl` binary (publish-pypi.yml).

python3 .github/scripts/pypi/build_wheel.py --binary toolchain/build/native/nativeCompile/tcl --out dist

Adapted from pypackpack's .github/scripts/pypi/build_wheel.py (pypackpack #53). The wheel holds:
toolchain_lite/__init__.py, __main__.py toolchain/src/cliMain/python/ (`python -m toolchain_lite`)
toolchain_lite-<v>.data/scripts/tcl the native binary (tcl.exe on Windows)
dist-info: METADATA from pyproject.toml's [project], LICENSE, WHEEL, RECORD

The platform tag is read from the binary, never assumed: the highest GLIBC_x.y symbol it needs on
Linux (manylinux_x_y_<arch>), its LC_BUILD_VERSION minos on macOS (macosx_<maj>_<min>_<arch>),
win_amd64 / win_arm64 on Windows. On Linux every NEEDED library must be in ALLOWED_LINUX_LIBS.
"""
from __future__ import annotations

import argparse
import base64
import hashlib
import platform
import re
import subprocess
import sys
import tomllib
import zipfile
from pathlib import Path

ROOT = Path(__file__).resolve().parents[3]
PACKAGE_SRC = ROOT / "toolchain/src/cliMain/python"
IMPORT_NAME = "toolchain_lite"
BINARY = "tcl"
# glibc's own libraries plus libz, which every glibc distribution ships and Native Image links.
ALLOWED_LINUX_LIBS = {
"libc.so.6", "libm.so.6", "libdl.so.2", "libpthread.so.0", "librt.so.1", "libz.so.1",
"ld-linux-x86-64.so.2", "ld-linux-aarch64.so.1",
}
FIXED_DATE = (1980, 1, 1, 0, 0, 0)


def run(*cmd: str) -> str:
return subprocess.run(cmd, check=True, capture_output=True, text=True).stdout


def linux_tag(binary: Path) -> str:
needed = set(re.findall(r"\(NEEDED\)\s+Shared library: \[([^\]]+)\]", run("readelf", "-d", str(binary))))
extra = needed - ALLOWED_LINUX_LIBS
if extra:
sys.exit(f"{binary} needs libraries a manylinux wheel may not assume: {sorted(extra)}")
versions = {tuple(map(int, v)) for v in re.findall(r"GLIBC_(\d+)\.(\d+)", run("objdump", "-T", str(binary)))}
major, minor = max(versions) if versions else (2, 17)
machine = platform.machine().lower()
arch = {"amd64": "x86_64", "arm64": "aarch64"}.get(machine, machine)
return f"manylinux_{major}_{minor}_{arch}"


def macos_tag(binary: Path) -> str:
match = re.search(r"cmd LC_BUILD_VERSION.*?minos (\d+)\.(\d+)", run("otool", "-l", str(binary)), re.S)
if not match:
sys.exit(f"{binary} has no LC_BUILD_VERSION; cannot tell its minimum macOS")
major, minor = int(match[1]), int(match[2])
if major >= 11:
minor = 0 # pip matches macosx_11+ tags on the major version only
arch = "arm64" if platform.machine() == "arm64" else "x86_64"
return f"macosx_{major}_{minor}_{arch}"


def windows_tag() -> str:
return "win_arm64" if platform.machine().lower() in ("arm64", "aarch64") else "win_amd64"


def platform_tag(binary: Path) -> str:
if sys.platform.startswith("linux"):
return linux_tag(binary)
if sys.platform == "darwin":
return macos_tag(binary)
if sys.platform == "win32":
return windows_tag()
sys.exit(f"no wheel platform tag for {sys.platform}")


def metadata(project: dict) -> str:
lines = [
"Metadata-Version: 2.4",
f"Name: {project['name']}",
f"Version: {project['version']}",
f"Summary: {project['description']}",
]
for author in project.get("authors", []):
lines.append(f"Author-email: {author['name']} <{author['email']}>")
lines.append(f"License-Expression: {project['license']}")
lines += [f"License-File: {f}" for f in project.get("license-files", [])]
lines += [f"Classifier: {c}" for c in project.get("classifiers", [])]
if project.get("keywords"):
lines.append(f"Keywords: {','.join(project['keywords'])}")
lines.append(f"Requires-Python: {project['requires-python']}")
lines += [f"Project-URL: {label}, {url}" for label, url in project.get("urls", {}).items()]
lines.append("Description-Content-Type: text/markdown")
readme = (ROOT / project["readme"]).read_text(encoding="utf-8")
return "\n".join(lines) + "\n\n" + readme


def record_line(arcname: str, data: bytes) -> str:
digest = base64.urlsafe_b64encode(hashlib.sha256(data).digest()).rstrip(b"=").decode()
return f"{arcname},sha256={digest},{len(data)}"


def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--binary", type=Path, required=True)
parser.add_argument("--out", type=Path, required=True)
args = parser.parse_args()

project = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"]
name, version = project["name"], project["version"]
dist = re.sub(r"[-_.]+", "_", name).lower() # wheel and dist-info names use the normalized form
tag = f"py3-none-{platform_tag(args.binary)}"
dist_info = f"{dist}-{version}.dist-info"
exe = f"{BINARY}.exe" if sys.platform == "win32" else BINARY

files: list[tuple[str, bytes, int]] = [] # (arcname, data, unix mode)
for source in sorted(PACKAGE_SRC.glob("*.py")):
files.append((f"{IMPORT_NAME}/{source.name}", source.read_bytes(), 0o644))
files.append((f"{dist}-{version}.data/scripts/{exe}", args.binary.read_bytes(), 0o755))
files.append((f"{dist_info}/METADATA", metadata(project).encode(), 0o644))
for license_file in project.get("license-files", []):
files.append((f"{dist_info}/licenses/{license_file}", (ROOT / license_file).read_bytes(), 0o644))
scripts = "".join(f"{k} = {v}\n" for k, v in project.get("scripts", {}).items())
if scripts:
files.append((f"{dist_info}/entry_points.txt", f"[console_scripts]\n{scripts}".encode(), 0o644))
wheel = f"Wheel-Version: 1.0\nGenerator: toolchain build_wheel.py\nRoot-Is-Purelib: false\nTag: {tag}\n"
files.append((f"{dist_info}/WHEEL", wheel.encode(), 0o644))
record = "\n".join(record_line(a, d) for a, d, _ in files) + f"\n{dist_info}/RECORD,,\n"
files.append((f"{dist_info}/RECORD", record.encode(), 0o644))

args.out.mkdir(parents=True, exist_ok=True)
path = args.out / f"{dist}-{version}-{tag}.whl"
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive:
for arcname, data, mode in files:
info = zipfile.ZipInfo(arcname, FIXED_DATE)
info.external_attr = (0o100000 | mode) << 16
info.compress_type = zipfile.ZIP_DEFLATED
archive.writestr(info, data)
print(path)
return 0


if __name__ == "__main__":
sys.exit(main())
62 changes: 62 additions & 0 deletions .github/scripts/pypi/smoke_wheel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""Install a built wheel into a fresh venv and check that what a PyPI user runs works.

python3 .github/scripts/pypi/smoke_wheel.py dist/toolchain_lite-<v>-<tag>.whl

Adapted from pypackpack's smoke_wheel.py (pypackpack #53). Fails (exit 1) unless, from that venv:
1. `tcl --help` exits 0 and prints the usage line;
2. `tcl --version` and `python -m toolchain_lite --version` print "tcl <pyproject.toml's version>";
3. `tcl install six` in an empty directory creates a pyproject.toml that depends on six (a real
install, which needs uv: tcl finds it on PATH or pypackpack downloads it into ~/.pypackpack/uv).
"""
from __future__ import annotations

import os
import subprocess
import sys
import tempfile
import tomllib
from pathlib import Path

ROOT = Path(__file__).resolve().parents[3]


def check(cmd: list[str], expect: str | None, cwd: Path | None = None) -> None:
result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
shown = " ".join(Path(c).name if i == 0 else c for i, c in enumerate(cmd))
if result.returncode != 0 or (expect and expect not in result.stdout):
print(f"FAIL {shown}: exit {result.returncode}\n{result.stdout}{result.stderr}")
sys.exit(1)
print(f"ok {shown}")


def main() -> int:
wheel = Path(sys.argv[1]).resolve()
version = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"]["version"]
with tempfile.TemporaryDirectory() as tmp:
venv = Path(tmp) / "venv"
subprocess.run([sys.executable, "-m", "venv", str(venv)], check=True)
bin_dir = venv / ("Scripts" if os.name == "nt" else "bin")
exe = ".exe" if os.name == "nt" else ""
python = bin_dir / f"python{exe}"
subprocess.run([str(python), "-m", "pip", "install", "--quiet", "--no-index", str(wheel)], check=True)

tcl = str(bin_dir / f"tcl{exe}")
check([tcl, "--help"], "Usage: tcl install <package>")
expected = f"tcl {version}"
check([tcl, "--version"], expected)
check([str(python), "-m", "toolchain_lite", "--version"], expected)

work = Path(tmp) / "work"
work.mkdir()
check([tcl, "install", "six"], None, cwd=work)
pyproject = work / "pyproject.toml"
if not pyproject.is_file() or "six" not in pyproject.read_text(encoding="utf-8"):
print("FAIL tcl install six: no pyproject.toml that depends on six")
return 1
print("ok tcl install six wrote pyproject.toml")
return 0


if __name__ == "__main__":
sys.exit(main())
28 changes: 11 additions & 17 deletions tools/release/README.md → .github/scripts/release/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,18 @@ between runs, and any existing `release` (e.g. a stale one from an older workflo
ignored and overwritten.

1. Every push to `develop` triggers `.github/workflows/release-sync.yml`, which runs
`tools/release/sync-release.sh --push`. It rebuilds `release` from scratch as exactly
`.github/scripts/release/sync-release.sh --push`. It rebuilds `release` from scratch as exactly
one commit (`Release: sync from develop <sha>`) whose parent is that develop commit and
whose tree is the develop tree minus the files below. This happens even when nothing
is dropped. It is a no-op only if `release` already has that tree and that parent.
The push uses `--force-with-lease` pinned to the remote sha the run observed.
2. The workflow opens (or updates) a pull request `release` -> `main` with the default
`GITHUB_TOKEN`. This needs the repository setting *Actions -> General -> Allow GitHub
Actions to create and approve pull requests*. A PR opened with `GITHUB_TOKEN`
triggers no workflows, so `main-source-guard` would never run on it; the workflow
therefore sets the `only-release-into-main` commit status on the release commit
itself, which satisfies the required check. No PAT is needed; an optional
Actions to create and approve pull requests*. No PAT is needed; an optional
`RELEASE_PR_TOKEN` secret is used for the PR step when present.
3. Merging that PR is the only way to change `main`. `main-source-guard.yml` fails
any PR into `main` whose head is not `release`.
3. Merging that PR is the only way to change `main`. The maintainer protects `main` in the
repository settings (locked) and merges the `release` PR after checking that its head is
`release` (maintainer decision 2026-10-03: settings only, no scripts or workflows for it).
4. On push to `main`, `pages.yml` deploys `docs/guide/` to GitHub Pages.

## What is dropped on release
Expand All @@ -33,19 +31,15 @@ Only Markdown is ever dropped.

## Local use

tools/release/sync-release.sh --dry-run # list paths that would be dropped
tools/release/sync-release.sh # regenerate local release branch
tools/release/sync-release.sh --push # also push to origin
tools/release/test-sync-release.sh # tests (throwaway repo in .tmp/)
.github/scripts/release/sync-release.sh --dry-run # list paths that would be dropped
.github/scripts/release/sync-release.sh # regenerate local release branch
.github/scripts/release/sync-release.sh --push # also push to origin
.github/scripts/release/test-sync-release.sh # tests (throwaway repo in .tmp/)

The script uses plumbing and a private index, so your working tree, index and HEAD are untouched.
It refuses to run while `release` is checked out.

## Protecting main

tools/release/protect-main.sh # print what would be applied
tools/release/protect-main.sh --apply # apply via gh api (needs admin)

This requires a pull request, blocks force-push and deletion. Classic protection cannot
restrict a PR's source branch, so add the check `only-release-into-main` as required
(the script does; the workflow must have run once first).
`main`'s protection is set by the maintainer in the repository settings (the branch is locked;
the maintainer merges the `release` pull request). Agents do not script or change it.
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# Tests for sync-release.sh. Builds a throwaway repo under <repo>/.tmp/.
set -u
HERE="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "$HERE/../.." && pwd)"
ROOT="$(cd "$HERE/../../.." && pwd)" # .github/scripts/release -> repository root
SYNC="$HERE/sync-release.sh"
SCRATCH="$ROOT/.tmp/release-sync-test.$$"
FAILS=0; PASSES=0
Expand Down
24 changes: 0 additions & 24 deletions .github/workflows/main-source-guard.yml

This file was deleted.

Loading