From 13559a8e96e6a795d898679dd31aa3fe87aa08ef Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 20 Sep 2026 16:36:52 -0500 Subject: [PATCH 1/8] Docs(feat): Export the public LuaLS API why: The unified site needs a revision-bound LuaLS artifact without treating the unpublished MCP and workspace scaffolds as products. what: - Export verified public declarations, guides, and examples. - Add and execute the installed-package quickstart. - Validate local Markdown fragments and exporter drift. --- .gitignore | 3 +- examples/quickstart.lua | 41 ++++++ lua/libtmux/query.lua | 1 + lua/libtmux/runtime/luv.lua | 1 + lua/libtmux/runtime/nvim.lua | 1 + scripts/check.py | 31 ++++- scripts/export-docs | 222 ++++++++++++++++++++++++++++++ scripts/package.py | 9 +- tests/tooling/test_docs_export.py | 41 ++++++ 9 files changed, 345 insertions(+), 5 deletions(-) create mode 100644 examples/quickstart.lua create mode 100755 scripts/export-docs create mode 100644 tests/tooling/test_docs_export.py diff --git a/.gitignore b/.gitignore index f0887f5..212eac7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ /build/ /dist/ /coverage/ +/docs/_build/ __pycache__/ *.pyc @@ -14,7 +15,7 @@ __pycache__/ .vscode/ # Local environment and tool state. -/.cache/ +/.cache /.direnv/ /.envrc.local .env diff --git a/examples/quickstart.lua b/examples/quickstart.lua new file mode 100644 index 0000000..2b7e605 --- /dev/null +++ b/examples/quickstart.lua @@ -0,0 +1,41 @@ +local adapter = require("libtmux.runtime.luv") + +local function must(value, err) + if err ~= nil then + error(err, 0) + end + return value +end + +local binary = assert(os.getenv("TMUX_BIN"), "set TMUX_BIN to an absolute tmux executable") +local socket = assert(os.getenv("TMUX_SOCKET"), "set TMUX_SOCKET to an explicit socket") + +local result = must(adapter.run(function(runtime) + local server = must(runtime:connect({ binary = binary, socket_path = socket }):await()) + + -- docs:begin main + local created = must( + server + :new_session({ name = "quickstart", window_name = "main", argv = { "/bin/sh" } }) + :await() + ) + local logs = must(created.session:new_window({ name = "logs", argv = { "/bin/cat" } }):await()) + local split = + must(logs.pane:split({ direction = "right", percent = 40, argv = { "/bin/cat" } }):await()) + + local marker = "libtmux-lua-quickstart" + must(created.pane:send_text("printf 'libtmux ready\\n'; tmux wait-for -S " .. marker):await()) + must(created.pane:send_keys({ "Enter" }):await()) + must(server:command({ "wait-for", marker }):await()) + + local capture = must(created.pane:capture({ history_lines = 20 }):await()) + assert(must(capture:text()):find("libtmux ready", 1, true), "pane output was not captured") + print(created.session:reference().id, logs.window:reference().id, split.pane:reference().id) + + must(created.session:kill():await()) + -- docs:end main + must(server:close():await()) + return true +end)) + +assert(result) diff --git a/lua/libtmux/query.lua b/lua/libtmux/query.lua index d103ec5..3ffe882 100644 --- a/lua/libtmux/query.lua +++ b/lua/libtmux/query.lua @@ -1,5 +1,6 @@ local internal = require("libtmux._internal.query") local wire = require("libtmux._internal.query_wire") +---@class libtmux.query local M = {} local methods = {} local compiled_queries = setmetatable({}, { __mode = "k" }) diff --git a/lua/libtmux/runtime/luv.lua b/lua/libtmux/runtime/luv.lua index f7e177d..6027a48 100644 --- a/lua/libtmux/runtime/luv.lua +++ b/lua/libtmux/runtime/luv.lua @@ -1,5 +1,6 @@ local runtime = require("libtmux._internal.runtime") local errors = require("libtmux._internal.error") +---@class libtmux.runtime.luv local M = {} local running = false diff --git a/lua/libtmux/runtime/nvim.lua b/lua/libtmux/runtime/nvim.lua index ab72eeb..5f83175 100644 --- a/lua/libtmux/runtime/nvim.lua +++ b/lua/libtmux/runtime/nvim.lua @@ -1,6 +1,7 @@ local runtime = require("libtmux._internal.runtime") local luv = require("libtmux.runtime.luv") local errors = require("libtmux._internal.error") +---@class libtmux.runtime.nvim local M = {} -- Inline generics preserve the body callback's contextual type in LuaLS. diff --git a/scripts/check.py b/scripts/check.py index 28b5116..c790ce3 100644 --- a/scripts/check.py +++ b/scripts/check.py @@ -9,6 +9,7 @@ import subprocess import sys import time +from urllib.parse import unquote if __package__: from .runtime_config import clean_environment, executable_path, identify @@ -57,13 +58,36 @@ def lua_environment(lua): def docs(): files = [ROOT / "AGENTS.md", *ROOT.glob("README*.md"), *ROOT.glob(".github/*.md"), *ROOT.glob("docs/**/*.md")] + files = [path for path in files if "_build" not in path.parts] + anchors = {} + + def markdown_anchors(path): + if path not in anchors: + counts = {} + found = set(re.findall(r']+>|[`*_~]", "", heading).lower() + slug = re.sub(r"[^\w\- ]", "", plain) + slug = re.sub(r"\s+", "-", slug.strip()) + duplicate = counts.get(slug, 0) + counts[slug] = duplicate + 1 + found.add(f"{slug}-{duplicate}" if duplicate else slug) + anchors[path] = found + return anchors[path] + for path in files: for target in re.findall(r"\]\(([^)]+)\)", path.read_text()): if target.startswith(("https:", "http:", "#", "mailto:")): - continue - destination = target.split("#", 1)[0] - if destination and not (path.parent / destination).exists(): + destination = path if target.startswith("#") else None + else: + destination_name = target.split("#", 1)[0] + destination = path.parent / destination_name if destination_name else path + if destination and not destination.exists(): raise SystemExit(f"Broken link in {path.relative_to(ROOT)}: {target}") + if destination and destination.suffix == ".md" and "#" in target: + fragment = unquote(target.split("#", 1)[1]) + if fragment and fragment not in markdown_anchors(destination): + raise SystemExit(f"Broken anchor in {path.relative_to(ROOT)}: {target}") if not (ROOT / "CLAUDE.md").is_symlink() or os.readlink(ROOT / "CLAUDE.md") != "AGENTS.md": raise SystemExit("CLAUDE.md must remain a relative symlink to AGENTS.md") run(["git", "diff", "--check"], timeout=5) @@ -116,6 +140,7 @@ def execute(gate): if not executable.exists(): raise SystemExit("Missing pinned LuaLS 3.19.1; see CONTRIBUTING.md") run([str(executable), "--check=.", "--checklevel=Warning", "--logpath=.cache/luals"], env=env) + run([sys.executable, "scripts/export-docs", "--luals", str(executable), "--check"], env=env) run([sys.executable, "scripts/check_editor.py"], env=env, timeout=20) elif gate == "mid": run([sys.executable, "-m", "unittest", "discover", "-s", "tests/tooling", "-v"], timeout=5) diff --git a/scripts/export-docs b/scripts/export-docs new file mode 100755 index 0000000..c453223 --- /dev/null +++ b/scripts/export-docs @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +"""Export the public LuaLS model, source guides, and examples as deterministic JSON.""" + +import argparse +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import tempfile + + +ROOT = Path(__file__).resolve().parent.parent +SOURCE = ROOT +EXPECTED_MODULES = { + "libtmux.query": { + "NULL", "compile", "count", "decode_json", "encode_json", "exists", "filter", + "first", "iter", "one", "one_or_nil", "select", "to_table", "where", + }, + "libtmux.runtime.luv": {"run"}, + "libtmux.runtime.nvim": {"start"}, +} +EXPECTED_TYPES = { + "libtmux.Entity", "libtmux.Request", "libtmux.Runtime", "libtmux.Selection", + "libtmux.Server", "libtmux.Snapshot", +} + + +class ExportError(RuntimeError): + pass + + +def source_revision(): + result = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=SOURCE, check=True, capture_output=True, text=True, + ) + revision = result.stdout.strip() + if not re.fullmatch(r"[0-9a-f]{40}", revision): + raise ExportError("could not resolve source revision") + return revision + + +def tool_version(executable): + result = subprocess.run([str(executable), "--version"], check=True, capture_output=True, text=True) + match = re.search(r"\d+\.\d+\.\d+", result.stdout + result.stderr) + if not match: + raise ExportError("could not resolve LuaLS version") + return match.group(0) + + +def normalize(value, key=None): + if isinstance(value, dict): + result = {} + for child_key, child in value.items(): + if child_key == "DOC": + continue + result[child_key] = normalize(child, child_key) + if isinstance(result.get("file"), str): + result["file"] = "lua/" + result["file"].removeprefix("lua/") + return result + if isinstance(value, list): + if key in {"start", "finish"} and len(value) == 2 and all(isinstance(item, int) for item in value): + return [value[0] + 1, value[1]] + return [normalize(item) for item in value] + return value + + +def declaration_sort_key(item): + return ( + item.get("name", ""), item.get("type", ""), item.get("file", ""), + item.get("start", [0, 0])[0], + ) + + +def public_declarations(raw): + declarations = [] + for entry in raw: + name = entry.get("name", "") + if not name.startswith("libtmux."): + continue + item = normalize(entry) + item["fields"] = sorted( + (field for field in item.get("fields", []) if not field.get("name", "").startswith("_")), + key=declaration_sort_key, + ) + item["defines"] = sorted(item.get("defines", []), key=declaration_sort_key) + declarations.append(item) + return sorted(declarations, key=lambda item: item["name"]) + + +def rock_metadata(): + rockspecs = sorted((SOURCE / "rockspecs").glob("libtmux-*.rockspec")) + if not rockspecs: + raise ExportError("could not find libtmux rockspec") + release = next((path for path in rockspecs if "-scm-" not in path.name), rockspecs[-1]) + rockspec = release.read_text() + version = re.search(r'^version = "([^"]+)"', rockspec, re.MULTILINE) + tag = re.search(r'^\s+tag = "([^"]+)"', rockspec, re.MULTILINE) + if not version or not tag: + raise ExportError("could not read rock version and source tag") + return {"name": "libtmux", "version": version.group(1), "source_tag": tag.group(1)} + + +def content_files(patterns): + paths = sorted({path for pattern in patterns for path in SOURCE.glob(pattern) if path.is_file()}) + return [{"path": path.relative_to(SOURCE).as_posix(), "content": path.read_text()} for path in paths] + + +def validate(payload): + declarations = {entry["name"]: entry for entry in payload["declarations"]} + missing = EXPECTED_TYPES - declarations.keys() + if missing: + raise ExportError(f"missing public types: {', '.join(sorted(missing))}") + for module, expected in EXPECTED_MODULES.items(): + entry = declarations.get(module) + if not entry: + raise ExportError(f"missing public module: {module}") + actual = {field["name"] for field in entry.get("fields", [])} + if actual != expected: + raise ExportError( + f"public module coverage differs for {module}: " + f"missing={sorted(expected - actual)} extra={sorted(actual - expected)}" + ) + for entry in payload["declarations"]: + for node in [*entry.get("defines", []), *entry.get("fields", [])]: + path = node.get("file") + if path and (not path.startswith("lua/") or "/tests/" in path): + raise ExportError(f"invalid declaration source: {path}") + if node.get("name", "").startswith("_"): + raise ExportError(f"private helper exported: {entry['name']}.{node['name']}") + text = json.dumps(payload, sort_keys=True) + if re.search(r"/(?:home|Users)/", text): + raise ExportError("machine-specific path in export") + if "luals.config" in text or "package.loaded" in text: + raise ExportError("tool configuration leaked into export") + + +def build_payload(luals): + with tempfile.TemporaryDirectory(prefix="libtmux-lua-docs-") as temporary: + temporary = Path(temporary) + output = temporary / "output" + log = temporary / "log" + annotated = temporary / "source" + shutil.copytree(SOURCE / "lua", annotated / "lua") + for relative, module in { + "libtmux/query.lua": "libtmux.query", + "libtmux/runtime/luv.lua": "libtmux.runtime.luv", + "libtmux/runtime/nvim.lua": "libtmux.runtime.nvim", + }.items(): + path = annotated / "lua" / relative + text = path.read_text() + marker = f"---@class {module}" + if marker not in text: + text, count = re.subn(r"^(local M = \{\})$", f"{marker}\\n\\1", text, count=1, flags=re.MULTILINE) + if count != 1: + raise ExportError(f"could not annotate public module {module}") + path.write_text(text) + output.mkdir() + log.mkdir() + command = [ + str(luals), f"--doc={annotated / 'lua'}", f"--doc_out_path={output}", + f"--configpath={ROOT / '.luarc.json'}", f"--logpath={log}", + ] + result = subprocess.run(command, cwd=SOURCE, capture_output=True, text=True) + if result.returncode: + raise ExportError(f"LuaLS export failed: {result.stdout}{result.stderr}") + raw = json.loads((output / "doc.json").read_text()) + + payload = { + "schema": 1, + "port": "lua", + "source": {"repository": "libtmux/libtmux-lua", "revision": source_revision()}, + "exporter": {"name": "scripts/export-docs", "version": 1, "luals": tool_version(luals)}, + "package": rock_metadata(), + "declarations": public_declarations(raw), + "guides": content_files(["README.md", "docs/**/*.md"]), + "examples": content_files(["examples/**/*.lua"]), + } + validate(payload) + return payload + + +def write_bundle(output, name, files): + directory = output / name + shutil.rmtree(directory, ignore_errors=True) + for entry in files: + target = directory / entry["path"] + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(entry["content"]) + + +def main(): + global SOURCE + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", type=Path, default=ROOT) + parser.add_argument("--luals", type=Path, default=ROOT / ".cache/tools/luals-3.19.1/bin/lua-language-server") + parser.add_argument("--output", type=Path, default=ROOT / "docs/_build") + parser.add_argument("--check", action="store_true") + args = parser.parse_args() + SOURCE = args.source.resolve() + if not (SOURCE / "lua/libtmux/init.lua").is_file(): + raise SystemExit(f"Invalid source checkout: {SOURCE}") + if not args.luals.is_file(): + raise SystemExit("Missing pinned LuaLS 3.19.1; see CONTRIBUTING.md") + try: + payload = build_payload(args.luals) + if not args.check: + args.output.mkdir(parents=True, exist_ok=True) + (args.output / "api.json").write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n") + write_bundle(args.output, "guides", payload["guides"]) + write_bundle(args.output, "examples", payload["examples"]) + print( + f"Documentation export: {len(payload['declarations'])} declarations; " + f"{sum(len(item.get('fields', [])) for item in payload['declarations'])} fields" + ) + except (ExportError, OSError, subprocess.SubprocessError, json.JSONDecodeError) as error: + raise SystemExit(str(error)) from error + + +if __name__ == "__main__": + main() diff --git a/scripts/package.py b/scripts/package.py index d63dbae..66e44bc 100644 --- a/scripts/package.py +++ b/scripts/package.py @@ -7,6 +7,7 @@ import json import os from pathlib import Path +import re import shutil import subprocess import sys @@ -129,8 +130,13 @@ def live_example(prefix, version, *, lua, cwd, env): raise RuntimeError("installed snapshot example returned unexpected output") if list(fixture.path.glob("libtmux-lua-pin-*")): raise RuntimeError("installed snapshot example leaked its socket alias") + quickstart = run([lua, "quickstart.lua"], cwd=cwd, env=child_env) + if not re.fullmatch(r"\$\d+\t@\d+\t%\d+\n", quickstart): + raise RuntimeError(f"installed quickstart returned unexpected output: {quickstart!r}") + if fixture.run("has-session", "-t", "quickstart", check=False).returncode == 0: + raise RuntimeError("installed quickstart left its session running") fixture.run("has-session", "-t", "$0") - print("PASS installed public snapshot example and borrowed-server cleanup", flush=True) + print("PASS installed public snapshot/quickstart examples and borrowed-server cleanup", flush=True) def source_rock(spec, work, env, *, rocks, public_tag): @@ -285,6 +291,7 @@ def rocks(arguments, *, cwd, override_env=None): shutil.copyfile(ROOT / "tests/unit/imports.lua", work / "imports.lua") shutil.copyfile(ROOT / "examples/native_query.lua", work / "native_query.lua") shutil.copyfile(ROOT / "examples/snapshot.lua", work / "snapshot.lua") + shutil.copyfile(ROOT / "examples/quickstart.lua", work / "quickstart.lua") artifacts = work / "artifacts" artifacts.mkdir() dependency_rocks = {} diff --git a/tests/tooling/test_docs_export.py b/tests/tooling/test_docs_export.py new file mode 100644 index 0000000..d672823 --- /dev/null +++ b/tests/tooling/test_docs_export.py @@ -0,0 +1,41 @@ +import json +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[2] + + +class DocsExportTest(unittest.TestCase): + def test_public_modules_are_named_at_the_exported_table(self): + modules = { + "lua/libtmux/query.lua": "libtmux.query", + "lua/libtmux/runtime/luv.lua": "libtmux.runtime.luv", + "lua/libtmux/runtime/nvim.lua": "libtmux.runtime.nvim", + } + for relative, name in modules.items(): + lines = (ROOT / relative).read_text().splitlines() + table = lines.index("local M = {}") + self.assertEqual(f"---@class {name}", lines[table - 1], relative) + + def test_exporter_is_a_deterministic_json_entrypoint(self): + exporter = ROOT / "scripts/export-docs" + self.assertTrue(exporter.is_file()) + source = exporter.read_text() + self.assertIn("sort_keys=True", source) + self.assertIn('"schema": 1', source) + self.assertIn('parser.add_argument("--source"', source) + self.assertIn("shutil.copytree(SOURCE / \"lua\"", source) + self.assertIn("if marker not in text", source) + + def test_committed_product_scaffolds_are_not_export_inputs(self): + exporter = ROOT / "scripts/export-docs" + if not exporter.is_file(): + self.fail("scripts/export-docs is missing") + source = exporter.read_text() + self.assertNotIn("packages/mcp", source) + self.assertNotIn("packages/workspace", source) + + +if __name__ == "__main__": + unittest.main() From 0ede9329dbd295da754175e34c4092c75e07fd17 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 20 Sep 2026 16:38:13 -0500 Subject: [PATCH 2/8] Docs(fix): Isolate LuaLS export markers why: Export-only class markers changed the tagged core source and made the immutable release source rock differ from the tested checkout. what: - Inject public-module markers only into the exporter copy. - Assert runtime modules remain free of export-only annotations. --- lua/libtmux/query.lua | 1 - lua/libtmux/runtime/luv.lua | 1 - lua/libtmux/runtime/nvim.lua | 1 - tests/tooling/test_docs_export.py | 8 ++++---- 4 files changed, 4 insertions(+), 7 deletions(-) diff --git a/lua/libtmux/query.lua b/lua/libtmux/query.lua index 3ffe882..d103ec5 100644 --- a/lua/libtmux/query.lua +++ b/lua/libtmux/query.lua @@ -1,6 +1,5 @@ local internal = require("libtmux._internal.query") local wire = require("libtmux._internal.query_wire") ----@class libtmux.query local M = {} local methods = {} local compiled_queries = setmetatable({}, { __mode = "k" }) diff --git a/lua/libtmux/runtime/luv.lua b/lua/libtmux/runtime/luv.lua index 6027a48..f7e177d 100644 --- a/lua/libtmux/runtime/luv.lua +++ b/lua/libtmux/runtime/luv.lua @@ -1,6 +1,5 @@ local runtime = require("libtmux._internal.runtime") local errors = require("libtmux._internal.error") ----@class libtmux.runtime.luv local M = {} local running = false diff --git a/lua/libtmux/runtime/nvim.lua b/lua/libtmux/runtime/nvim.lua index 5f83175..ab72eeb 100644 --- a/lua/libtmux/runtime/nvim.lua +++ b/lua/libtmux/runtime/nvim.lua @@ -1,7 +1,6 @@ local runtime = require("libtmux._internal.runtime") local luv = require("libtmux.runtime.luv") local errors = require("libtmux._internal.error") ----@class libtmux.runtime.nvim local M = {} -- Inline generics preserve the body callback's contextual type in LuaLS. diff --git a/tests/tooling/test_docs_export.py b/tests/tooling/test_docs_export.py index d672823..bbb995c 100644 --- a/tests/tooling/test_docs_export.py +++ b/tests/tooling/test_docs_export.py @@ -7,16 +7,16 @@ class DocsExportTest(unittest.TestCase): - def test_public_modules_are_named_at_the_exported_table(self): + def test_export_annotations_are_isolated_from_runtime_modules(self): modules = { "lua/libtmux/query.lua": "libtmux.query", "lua/libtmux/runtime/luv.lua": "libtmux.runtime.luv", "lua/libtmux/runtime/nvim.lua": "libtmux.runtime.nvim", } + exporter = (ROOT / "scripts/export-docs").read_text() for relative, name in modules.items(): - lines = (ROOT / relative).read_text().splitlines() - table = lines.index("local M = {}") - self.assertEqual(f"---@class {name}", lines[table - 1], relative) + self.assertNotIn(f"---@class {name}", (ROOT / relative).read_text(), relative) + self.assertIn(f'"{relative.removeprefix("lua/")}": "{name}"', exporter) def test_exporter_is_a_deterministic_json_entrypoint(self): exporter = ROOT / "scripts/export-docs" From 5aaff1e0eb44648f9ae01ccc656d27067ec82bcb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 20 Sep 2026 16:48:50 -0500 Subject: [PATCH 3/8] Docs(ci): Publish revision-bound site trees why: Port documentation must build from the selected source revision and write only its owned libtmux.org prefix. what: - Build trunk, maintenance, tag, alias, and pull-request versions. - Pin the site checkout, deployment workflow, and third-party actions. - Isolate preview publication and cleanup from production versions. --- .github/workflows/docs-preview-cleanup.yml | 35 +++ .github/workflows/docs.yml | 276 +++++++++++++++++++++ 2 files changed, 311 insertions(+) create mode 100644 .github/workflows/docs-preview-cleanup.yml create mode 100644 .github/workflows/docs.yml diff --git a/.github/workflows/docs-preview-cleanup.yml b/.github/workflows/docs-preview-cleanup.yml new file mode 100644 index 0000000..e2f0829 --- /dev/null +++ b/.github/workflows/docs-preview-cleanup.yml @@ -0,0 +1,35 @@ +name: docs-preview-cleanup + +on: + pull_request_target: + types: [closed] + +permissions: + contents: read + id-token: write + +concurrency: + group: docs-preview-lua-pr-${{ github.event.pull_request.number }} + cancel-in-progress: false + +jobs: + cleanup: + runs-on: ubuntu-latest + environment: docs-preview-cleanup + steps: + - uses: aws-actions/configure-aws-credentials@4a1596c86fd706dc0521e32f0121ad1a6d1adb74 # v6.0.0 + with: + role-to-assume: ${{ secrets.LIBTMUX_DOCS_PREVIEW_CLEANUP_ROLE_ARN }} + aws-region: us-east-1 + + - name: Delete this Lua preview + env: + BUCKET: ${{ secrets.LIBTMUX_DOCS_BUCKET }} + PR: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + [[ "$PR" =~ ^[0-9]+$ ]] || { + echo 'pull request number must be numeric' >&2 + exit 1 + } + aws s3 rm "s3://$BUCKET/en/lua/pr-$PR/" --recursive diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..cf150a5 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,276 @@ +name: docs + +on: + pull_request: + push: + branches: + - master + - 'v*.x' + tags: + - 'v*' + workflow_dispatch: + inputs: + source-ref: + description: Exact source ref to build + required: true + default: master + version: + description: URL version slug + required: true + default: latest + version-kind: + description: Version policy + required: true + type: choice + options: [trunk, branch, tag, alias, pr] + default: trunk + is-default: + description: Make this version canonical + required: true + type: boolean + default: false + resolves-to: + description: Immutable target for an alias + required: false + default: '' + publish: + description: Publish after the exact build passes + required: true + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: docs-deploy-${{ github.repository }} + cancel-in-progress: false + +jobs: + identity: + runs-on: ubuntu-latest + outputs: + source-ref: ${{ steps.identity.outputs.source_ref }} + source-repository: ${{ steps.identity.outputs.source_repository }} + matrix: ${{ steps.identity.outputs.matrix }} + should-publish: ${{ steps.identity.outputs.should_publish }} + steps: + - id: identity + env: + EVENT: ${{ github.event_name }} + REPOSITORY: ${{ github.repository }} + SHA: ${{ github.sha }} + REF_NAME: ${{ github.ref_name }} + REF_TYPE: ${{ github.ref_type }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} + INPUT_SOURCE_REF: ${{ inputs.source-ref }} + INPUT_VERSION: ${{ inputs.version }} + INPUT_KIND: ${{ inputs.version-kind }} + INPUT_DEFAULT: ${{ inputs.is-default }} + INPUT_RESOLVES_TO: ${{ inputs.resolves-to }} + INPUT_PUBLISH: ${{ inputs.publish }} + run: | + set -euo pipefail + source_ref='' + source_repository="$REPOSITORY" + matrix='' + + case "$EVENT" in + pull_request) + source_ref="$PR_HEAD_SHA" + source_repository="$PR_HEAD_REPOSITORY" + publish=false + [[ "$source_repository" == "$REPOSITORY" ]] && publish=true + matrix=$(jq -cn \ + --arg version "pr-$PR_NUMBER" \ + --argjson publish "$publish" \ + '{include: [{version: $version, kind: "pr", isDefault: false, resolvesTo: "", environment: "docs-preview", publish: $publish}]}') + ;; + push) + source_ref="$SHA" + if [[ "$REF_TYPE" == tag ]]; then + [[ "$REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+((alpha|beta|rc)[0-9]+)?$ ]] || { + echo "unsupported Lua documentation tag: $REF_NAME" >&2 + exit 1 + } + alias=stable + alias_default=true + if [[ "$REF_NAME" =~ (alpha|beta|rc)[0-9]+$ ]]; then + alias=next + alias_default=false + fi + matrix=$(jq -cn \ + --arg tag "$REF_NAME" \ + --arg alias "$alias" \ + --argjson alias_default "$alias_default" \ + '{include: [ + {version: $tag, kind: "tag", isDefault: false, resolvesTo: "", environment: "docs", publish: true}, + {version: $alias, kind: "alias", isDefault: $alias_default, resolvesTo: $tag, environment: "docs", publish: true} + ]}') + elif [[ "$REF_NAME" == master ]]; then + matrix='{"include":[{"version":"latest","kind":"trunk","isDefault":true,"resolvesTo":"","environment":"docs","publish":true}]}' + elif [[ "$REF_NAME" =~ ^v[0-9]+\.x$ ]]; then + matrix=$(jq -cn --arg version "$REF_NAME" \ + '{include: [{version: $version, kind: "branch", isDefault: false, resolvesTo: "", environment: "docs", publish: true}]}') + else + echo "unsupported documentation branch: $REF_NAME" >&2 + exit 1 + fi + ;; + workflow_dispatch) + source_ref="$INPUT_SOURCE_REF" + [[ "$INPUT_VERSION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || { + echo "invalid version slug: $INPUT_VERSION" >&2 + exit 1 + } + case "$INPUT_KIND" in + trunk|branch|tag|alias|pr) ;; + *) echo "invalid version kind: $INPUT_KIND" >&2; exit 1 ;; + esac + if [[ "$INPUT_KIND" == alias && -z "$INPUT_RESOLVES_TO" ]]; then + echo 'an alias requires resolves-to' >&2 + exit 1 + fi + if [[ "$INPUT_KIND" != alias && -n "$INPUT_RESOLVES_TO" ]]; then + echo 'resolves-to applies only to aliases' >&2 + exit 1 + fi + if [[ "$INPUT_KIND" == pr ]]; then + [[ "$INPUT_VERSION" =~ ^pr-[0-9]+$ && "$INPUT_DEFAULT" == false ]] || { + echo 'PR previews require pr-N and cannot be default' >&2 + exit 1 + } + environment=docs-preview + else + environment=docs + fi + matrix=$(jq -cn \ + --arg version "$INPUT_VERSION" \ + --arg kind "$INPUT_KIND" \ + --arg resolves "$INPUT_RESOLVES_TO" \ + --arg environment "$environment" \ + --argjson is_default "$INPUT_DEFAULT" \ + --argjson publish "$INPUT_PUBLISH" \ + '{include: [{version: $version, kind: $kind, isDefault: $is_default, resolvesTo: $resolves, environment: $environment, publish: $publish}]}') + ;; + *) echo "unsupported event: $EVENT" >&2; exit 1 ;; + esac + + { + echo "source_ref=$source_ref" + echo "source_repository=$source_repository" + echo "matrix=$matrix" + echo "should_publish=$(jq -r 'any(.include[]; .publish)' <<<"$matrix")" + } >> "$GITHUB_OUTPUT" + + build: + needs: identity + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.identity.outputs.matrix) }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ needs.identity.outputs.source-repository }} + ref: ${{ needs.identity.outputs.source-ref }} + fetch-depth: 0 + persist-credentials: false + + - id: source + env: + SELECTED_REF: ${{ needs.identity.outputs.source-ref }} + run: | + set -euo pipefail + sha=$(git rev-parse HEAD) + selected=$(git rev-parse "$SELECTED_REF^{commit}") + [[ "$sha" == "$selected" ]] || { + echo "selected source resolved to $selected, checkout is $sha" >&2 + exit 1 + } + echo "sha=$sha" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + path: .docs-generator + persist-credentials: false + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: libtmux/docs + ref: d28aa869613f41506c2c8bc6feea789247436fc5 + path: .site + persist-credentials: false + + - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + with: + version: 2026.9.9 + + - name: Bootstrap the pinned LuaLS exporter + run: python .docs-generator/scripts/bootstrap_native.py luals + + - name: Export the selected Lua source + run: | + python .docs-generator/scripts/export-docs \ + --source "$GITHUB_WORKSPACE" \ + --luals "$GITHUB_WORKSPACE/.docs-generator/.cache/tools/luals-3.19.1/bin/lua-language-server" \ + --output "$GITHUB_WORKSPACE/docs/_build" + + - uses: pnpm/action-setup@f520eceda224fe1a4aed5a2a27a194379a409996 # v6.0.0 + with: + package_json_file: .site/package.json + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '26' + cache: pnpm + cache-dependency-path: .site/pnpm-lock.yaml + - run: pnpm install --frozen-lockfile + working-directory: .site + + - name: Build the selected Lua documentation tree + working-directory: .site + env: + LIBTMUX_DOCS_PORT: lua + LIBTMUX_DOCS_VERSION: ${{ matrix.version }} + LIBTMUX_DOCS_VERSION_KIND: ${{ matrix.kind }} + LIBTMUX_DOCS_IS_DEFAULT: ${{ matrix.isDefault }} + LIBTMUX_DOCS_RESOLVES_TO: ${{ matrix.resolvesTo }} + LIBTMUX_DOCS_SOURCE_REF: ${{ needs.identity.outputs.source-ref }} + LIBTMUX_DOCS_SOURCE_SHA: ${{ steps.source.outputs.sha }} + LIBTMUX_DOCS_CHECKOUT_LUA: ${{ github.workspace }} + run: ./scripts/build-site.sh --ports lua --versions "${{ matrix.version }}" --skip-refs --skip-pagefind + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: docs-lua-${{ matrix.version }} + path: .site/_site/en/lua/${{ matrix.version }} + if-no-files-found: error + retention-days: 1 + + publish: + needs: [identity, build] + if: ${{ !cancelled() && needs.build.result == 'success' && needs.identity.outputs.should-publish == 'true' }} + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.identity.outputs.matrix) }} + permissions: + contents: read + id-token: write + uses: libtmux/docs/.github/workflows/reusable-deploy.yml@d28aa869613f41506c2c8bc6feea789247436fc5 + with: + path-prefix: lua/${{ matrix.version }} + artifact: docs-lua-${{ matrix.version }} + version-kind: ${{ matrix.kind }} + port: lua + version: ${{ matrix.version }} + label: ${{ matrix.version }} + is-default: ${{ matrix.isDefault }} + resolves-to: ${{ matrix.resolvesTo }} + environment: ${{ matrix.environment }} + secrets: + role-arn: ${{ secrets.LIBTMUX_DOCS_ROLE_ARN }} + bucket: ${{ secrets.LIBTMUX_DOCS_BUCKET }} + distribution: ${{ secrets.LIBTMUX_DOCS_DISTRIBUTION }} From 230711dc718bafc107bab91a9a907490656f0450 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 20 Sep 2026 17:24:35 -0500 Subject: [PATCH 4/8] Docs(ci): Pin completed Ruby/Lua site and queue deploys --- .github/actionlint.yaml | 5 +++++ .github/workflows/docs.yml | 6 +++--- 2 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 .github/actionlint.yaml diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000..d5b9823 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,5 @@ +paths: + .github/workflows/docs.yml: + ignore: + # actionlint 1.7.12 predates GitHub Actions' queue: max support. + - 'unexpected key "queue" for "concurrency" section' diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index cf150a5..868095f 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -44,7 +44,7 @@ permissions: concurrency: group: docs-deploy-${{ github.repository }} - cancel-in-progress: false + queue: max jobs: identity: @@ -201,7 +201,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: libtmux/docs - ref: d28aa869613f41506c2c8bc6feea789247436fc5 + ref: 4a13b5714224125636b027d23991117c3c56a81f path: .site persist-credentials: false @@ -259,7 +259,7 @@ jobs: permissions: contents: read id-token: write - uses: libtmux/docs/.github/workflows/reusable-deploy.yml@d28aa869613f41506c2c8bc6feea789247436fc5 + uses: libtmux/docs/.github/workflows/reusable-deploy.yml@4a13b5714224125636b027d23991117c3c56a81f with: path-prefix: lua/${{ matrix.version }} artifact: docs-lua-${{ matrix.version }} From bd2274d5082982ec0f5a5d7f12fc23eb95262f28 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 20 Sep 2026 17:27:32 -0500 Subject: [PATCH 5/8] Docs(fix): Isolate preview publication roles --- .github/workflows/docs.yml | 2 +- tests/tooling/test_docs_workflow.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 tests/tooling/test_docs_workflow.py diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 868095f..47e3911 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -271,6 +271,6 @@ jobs: resolves-to: ${{ matrix.resolvesTo }} environment: ${{ matrix.environment }} secrets: - role-arn: ${{ secrets.LIBTMUX_DOCS_ROLE_ARN }} + role-arn: ${{ matrix.environment == 'docs-preview' && secrets.LIBTMUX_DOCS_PREVIEW_ROLE_ARN || secrets.LIBTMUX_DOCS_ROLE_ARN }} bucket: ${{ secrets.LIBTMUX_DOCS_BUCKET }} distribution: ${{ secrets.LIBTMUX_DOCS_DISTRIBUTION }} diff --git a/tests/tooling/test_docs_workflow.py b/tests/tooling/test_docs_workflow.py new file mode 100644 index 0000000..96b7a3d --- /dev/null +++ b/tests/tooling/test_docs_workflow.py @@ -0,0 +1,17 @@ +import pathlib +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] + + +class DocsWorkflowTest(unittest.TestCase): + def test_preview_publication_uses_the_preview_role(self): + workflow = (ROOT / ".github/workflows/docs.yml").read_text() + + self.assertIn( + "role-arn: ${{ matrix.environment == 'docs-preview' && " + "secrets.LIBTMUX_DOCS_PREVIEW_ROLE_ARN || " + "secrets.LIBTMUX_DOCS_ROLE_ARN }}", + workflow, + ) From 49d7bacd280d8f58ca3b1d28e63e092ecbdcfd01 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 25 Sep 2026 18:22:15 -0500 Subject: [PATCH 6/8] Docs(feat): Require every handle class in the export The exporter checked for Entity, Server and four other types. Session, Window, WindowLink, Pane, Client, Buffer, Configurable and Reference could vanish from the published reference without failing it. A tooling test now fails when entity.lua declares a class the exporter does not require. --- scripts/export-docs | 6 ++++-- tests/tooling/test_docs_export.py | 8 ++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/scripts/export-docs b/scripts/export-docs index c453223..8739b77 100755 --- a/scripts/export-docs +++ b/scripts/export-docs @@ -22,8 +22,10 @@ EXPECTED_MODULES = { "libtmux.runtime.nvim": {"start"}, } EXPECTED_TYPES = { - "libtmux.Entity", "libtmux.Request", "libtmux.Runtime", "libtmux.Selection", - "libtmux.Server", "libtmux.Snapshot", + "libtmux.Buffer", "libtmux.Client", "libtmux.Configurable", "libtmux.Entity", + "libtmux.Pane", "libtmux.Reference", "libtmux.Request", "libtmux.Runtime", "libtmux.Selection", + "libtmux.Server", "libtmux.Session", "libtmux.Snapshot", "libtmux.Window", + "libtmux.WindowLink", } diff --git a/tests/tooling/test_docs_export.py b/tests/tooling/test_docs_export.py index bbb995c..8b046bf 100644 --- a/tests/tooling/test_docs_export.py +++ b/tests/tooling/test_docs_export.py @@ -1,5 +1,6 @@ import json from pathlib import Path +import re import unittest @@ -28,6 +29,13 @@ def test_exporter_is_a_deterministic_json_entrypoint(self): self.assertIn("shutil.copytree(SOURCE / \"lua\"", source) self.assertIn("if marker not in text", source) + def test_every_handle_class_is_a_required_export(self): + entity = (ROOT / "lua/libtmux/_internal/entity.lua").read_text() + classes = re.findall(r"^---@class (libtmux\.\w+)", entity, re.MULTILINE) + self.assertIn("libtmux.Session", classes) + exporter = (ROOT / "scripts/export-docs").read_text() + self.assertEqual([name for name in classes if f'"{name}"' not in exporter], []) + def test_committed_product_scaffolds_are_not_export_inputs(self): exporter = ROOT / "scripts/export-docs" if not exporter.is_file(): From 9095f2fcbcf91e5b278f9cd189715e71d3557db8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 25 Sep 2026 19:33:00 -0500 Subject: [PATCH 7/8] Docs(ci): Pin the docs shell with per-kind reference domains The site checkout and reusable-deploy.yml pinned libtmux/docs 4a13b571, which predates the reference's domain buckets, member ranking and Lua signature fixes. Pin both to libtmux/docs bc24b1c (#19), so the tree this workflow publishes matches what the shell renders. --- .github/workflows/docs.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 47e3911..5d93b66 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -201,7 +201,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: libtmux/docs - ref: 4a13b5714224125636b027d23991117c3c56a81f + ref: bc24b1c570a15898b643c3650857fef61e769371 path: .site persist-credentials: false @@ -259,7 +259,7 @@ jobs: permissions: contents: read id-token: write - uses: libtmux/docs/.github/workflows/reusable-deploy.yml@4a13b5714224125636b027d23991117c3c56a81f + uses: libtmux/docs/.github/workflows/reusable-deploy.yml@bc24b1c570a15898b643c3650857fef61e769371 with: path-prefix: lua/${{ matrix.version }} artifact: docs-lua-${{ matrix.version }} From 1887c6207b160bf77b360f36938fa3445cd2b0b5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 25 Sep 2026 19:42:14 -0500 Subject: [PATCH 8/8] Examples(fix): Signal the quickstart through the pinned tmux The quickstart ran a bare `tmux wait-for -S` in its pane. CI's server is a pinned build at an absolute path, so the pane's `tmux` was another binary that never reached it, and `server:command({"wait-for", ...})` blocked until the package gate's 290-second timeout. The pane now runs the same binary, and the wait is bounded at ten seconds so a missed signal fails instead of hanging. --- examples/quickstart.lua | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/examples/quickstart.lua b/examples/quickstart.lua index 2b7e605..bf2efae 100644 --- a/examples/quickstart.lua +++ b/examples/quickstart.lua @@ -23,10 +23,13 @@ local result = must(adapter.run(function(runtime) local split = must(logs.pane:split({ direction = "right", percent = 40, argv = { "/bin/cat" } }):await()) + -- The pane signals through the same tmux binary; a bare `tmux` there may be + -- another build that cannot reach this server. local marker = "libtmux-lua-quickstart" - must(created.pane:send_text("printf 'libtmux ready\\n'; tmux wait-for -S " .. marker):await()) + local signal = ("printf 'libtmux ready\\n'; '%s' wait-for -S %s"):format(binary, marker) + must(created.pane:send_text(signal):await()) must(created.pane:send_keys({ "Enter" }):await()) - must(server:command({ "wait-for", marker }):await()) + must(server:command({ "wait-for", marker }, { timeout = 10000 }):await()) local capture = must(created.pane:capture({ history_lines = 20 }):await()) assert(must(capture:text()):find("libtmux ready", 1, true), "pane output was not captured")