From 2cded51b644782750d00661bb59999279948a90d Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 27 Oct 2026 22:08:27 -0400 Subject: [PATCH 1/8] Isolate terminal recording environments by default --- great_docs/_term_player/steps.py | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/great_docs/_term_player/steps.py b/great_docs/_term_player/steps.py index 672d9d5b..e5101edc 100644 --- a/great_docs/_term_player/steps.py +++ b/great_docs/_term_player/steps.py @@ -14,6 +14,7 @@ import re import select import shlex +import shutil import sys import time from collections.abc import Callable @@ -61,6 +62,9 @@ "HISTFILE": "", "BASH_SILENCE_DEPRECATION_WARNING": "1", } +# Kept from the current environment unless `record.inherit_env` is set, so a recording doesn't +# depend on what your own shell, editor, or CI service has set. `LC_*` variables are kept too. +KEPT_ENV = ("PATH", "HOME", "USER", "LOGNAME", "LANG", "TMPDIR", "TZ", "VIRTUAL_ENV") # Removed from the environment, since they'd override the terminal size _SIZE_VARS = ("COLUMNS", "LINES") @@ -81,6 +85,22 @@ def key_sequence(name: str) -> str | None: return None +def shell_env(settings: RecordSettings, environ: dict[str, str] | None = None) -> dict[str, str]: + """The environment the shell starts with. + + Only `KEPT_ENV` and `LC_*` are taken from `environ` (the current environment), unless + `settings.inherit_env` is set. `DEFAULT_ENV` and the script's `record.env` go on top. + """ + environ = dict(os.environ if environ is None else environ) + if settings.inherit_env: + env = {k: v for k, v in environ.items() if k not in _SIZE_VARS} + else: + env = {k: v for k, v in environ.items() if k in KEPT_ENV or k.startswith("LC_")} + env.update(DEFAULT_ENV) + env.update(settings.env) + return env + + def screen_text(emulator: TerminalEmulator) -> str: """The screen's text, with trailing spaces removed from each row and blank rows at the end left out.""" @@ -108,6 +128,7 @@ class RecordSettings: rows: int = 24 cwd: Path | None = None env: dict[str, str] = field(default_factory=dict) + inherit_env: bool = False typing_delay: float = 0.05 typing_jitter: float = 0.3 timeout: float = 10.0 @@ -324,9 +345,10 @@ def start(self) -> None: argv = shlex.split(self.settings.shell) except ValueError as e: raise StepsError(f"can't read record.shell: {e}") from None - env = {k: v for k, v in os.environ.items() if k not in _SIZE_VARS} - env.update(DEFAULT_ENV) - env.update(self.settings.env) + env = shell_env(self.settings) + if not self.settings.inherit_env: + # Programs that start a subshell use the one being recorded + env.setdefault("SHELL", shutil.which(argv[0]) or argv[0]) winsize = struct.pack("HHHH", self.settings.rows, self.settings.cols, 0, 0) pid, fd = pty.fork() From d240a6a6e57d79f2563fa04b4b041152dcda3d39 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 27 Oct 2026 22:08:38 -0400 Subject: [PATCH 2/8] Add JSON output to termshow check --- great_docs/cli.py | 108 +++++++++++++++++++++++++++++++--------------- 1 file changed, 74 insertions(+), 34 deletions(-) diff --git a/great_docs/cli.py b/great_docs/cli.py index d31c0d8f..dc0097e7 100644 --- a/great_docs/cli.py +++ b/great_docs/cli.py @@ -4614,7 +4614,16 @@ def _has_steps(script: str) -> bool: ) @click.option("--update", is_flag=True, help="Re-record the recordings that have drifted") @click.option("--shell", type=str, default=None, help="Run the steps in this shell instead") -def term_check(scripts: tuple[str, ...], ignore: tuple[str, ...], update: bool, shell: str | None): +@click.option( + "--json", "as_json", is_flag=True, help="Print the results as JSON, for CI and other tools" +) +def term_check( + scripts: tuple[str, ...], + ignore: tuple[str, ...], + update: bool, + shell: str | None, + as_json: bool, +): """Check that recordings made from steps still match what the steps produce. Each script's steps are run again, as `record --steps` would, and the @@ -4629,10 +4638,15 @@ def term_check(scripts: tuple[str, ...], ignore: tuple[str, ...], update: bool, Exits with status 1 if any recording has drifted or a step failed. With --update, recordings that have drifted are recorded again instead. + With --json, the results are printed as one JSON object instead, with + the same exit status. Progress still goes to stderr. + Examples: great-docs termshow check docs/assets/*.termshow.yml great-docs termshow check docs/assets/install.termshow --ignore 'v\\d+\\.\\d+' + great-docs termshow check docs/assets/*.termshow.yml --json > drift.json """ + import json import re from ._term_player.check import CheckError, check_script @@ -4647,54 +4661,80 @@ def term_check(scripts: tuple[str, ...], ignore: tuple[str, ...], update: bool, f"{pattern!r} isn't a valid regular expression ({e})", param_hint="--ignore" ) from None - drifted = failed = 0 + results = [] for script in scripts: script_path = Path(script) if script_path.name.endswith(".termshow"): script_path = script_path.with_name(script_path.name + ".yml") click.echo(f"Checking {script_path}", err=True) + entry = {"script": script_path.as_posix(), "recording": None} try: result = check_script(script_path, ignore=ignore, shell=shell) - except CheckError as e: - click.echo(f"✗ {script_path}: {e}") - failed += 1 - continue - except StepsError as e: - click.echo(f"✗ {script_path}: {e}") - if e.screen: - click.echo("\nThe screen at the time:") - for line in e.screen.splitlines(): - click.echo(f" │ {line}") - failed += 1 + except (CheckError, StepsError) as e: + entry |= {"status": "failed", "error": str(e)} + if isinstance(e, StepsError) and e.screen: + entry["screen"] = e.screen + results.append(entry) + if not as_json: + _echo_check_failure(entry) continue - screens = f"{result.snapshots} screen{'' if result.snapshots == 1 else 's'}" - if not result.drifted: - click.echo(f"✓ {result.recording_path} is up to date ({screens} compared)") - continue - drifted += 1 - click.echo(f"✗ {result.recording_path} has drifted:\n") - for difference in result.differences: - for line in difference.lines: - color = {"+": "green", "-": "red", "@": "cyan"}.get(line[:1]) - if line.startswith(("+++", "---")): - color = None - click.secho(f" {line}", fg=color) - click.echo() - if update: + entry |= { + "recording": result.recording_path.as_posix(), + "status": "drifted" if result.drifted else "up_to_date", + "compared": result.snapshots, + "differences": [{"at": d.where, "diff": d.lines} for d in result.differences], + } + if result.drifted and update: _write_termshow(result.recording, result.recording_path) - click.echo(f"↻ Recorded {result.recording_path} again") - - if len(scripts) > 1: + entry["status"] = "updated" + results.append(entry) + if not as_json: + _echo_check_result(entry) + + counts = { + status: sum(r["status"] == status for r in results) + for status in ("up_to_date", "drifted", "updated", "failed") + } + if as_json: + click.echo(json.dumps({"version": 1, "summary": counts, "results": results}, indent=2)) + elif len(scripts) > 1: + changed = counts["drifted"] + counts["updated"] click.echo( - f"\n{len(scripts) - drifted - failed} of {len(scripts)} up to date" - + (f", {drifted} {'updated' if update else 'drifted'}" if drifted else "") - + (f", {failed} couldn't be checked" if failed else "") + f"\n{counts['up_to_date']} of {len(scripts)} up to date" + + (f", {changed} {'updated' if update else 'drifted'}" if changed else "") + + (f", {counts['failed']} couldn't be checked" if counts["failed"] else "") ) - if failed or (drifted and not update): + if counts["failed"] or counts["drifted"]: raise SystemExit(1) +def _echo_check_failure(entry: dict) -> None: + click.echo(f"✗ {entry['script']}: {entry['error']}") + if entry.get("screen"): + click.echo("\nThe screen at the time:") + for line in entry["screen"].splitlines(): + click.echo(f" │ {line}") + + +def _echo_check_result(entry: dict) -> None: + recording = entry["recording"] + if entry["status"] == "up_to_date": + screens = f"{entry['compared']} screen{'' if entry['compared'] == 1 else 's'}" + click.echo(f"✓ {recording} is up to date ({screens} compared)") + return + click.echo(f"✗ {recording} has drifted:\n") + for difference in entry["differences"]: + for line in difference["diff"]: + color = {"+": "green", "-": "red", "@": "cyan"}.get(line[:1]) + if line.startswith(("+++", "---")): + color = None + click.secho(f" {line}", fg=color) + click.echo() + if entry["status"] == "updated": + click.echo(f"↻ Recorded {recording} again") + + @click.command("assets") @click.argument("dest", type=click.Path(file_okay=False)) def term_assets(dest: str) -> None: From 1190646b66cd857dba6b475d5500c2b3194cd035 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 27 Oct 2026 22:08:54 -0400 Subject: [PATCH 3/8] Document JSON output for termshow check --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2b58ae6b..3da6d4d7 100644 --- a/README.md +++ b/README.md @@ -411,7 +411,7 @@ Beyond `init`, `build`, and `preview`, Great Docs includes a full suite of quali | `great-docs termshow render` | Render SVG frames without a full site build | | `great-docs termshow export` | Export a recording as one animated SVG for READMEs | | `great-docs termshow validate` | Check a recording and its script for mistakes | -| `great-docs termshow check` | Re-run scripted steps and report recordings whose output has changed | +| `great-docs termshow check` | Re-run scripted steps and report recordings whose output has changed (`--json` for CI) | | `great-docs termshow assets` | Copy the web player for pages built without Quarto | | `great-docs termshow import-cast` | Import an asciinema `.cast` file | | `great-docs termshow import-tape` | Convert a VHS `.tape` file to a script with `steps:` | From 3e00d64328be42e1558e5d3b5992995c7243b8d2 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 27 Oct 2026 22:09:05 -0400 Subject: [PATCH 4/8] Add inherit_env option to termshow schema --- .../_term_player/schemas/termshow-script.schema.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/great_docs/_term_player/schemas/termshow-script.schema.json b/great_docs/_term_player/schemas/termshow-script.schema.json index bc8dede0..fe57ab7e 100644 --- a/great_docs/_term_player/schemas/termshow-script.schema.json +++ b/great_docs/_term_player/schemas/termshow-script.schema.json @@ -408,10 +408,15 @@ "type": "string" }, "env": { - "description": "Environment variables to set on top of the current environment.", + "description": "Environment variables to set for the shell.", "type": "object", "additionalProperties": { "type": ["string", "number", "boolean"] } }, + "inherit_env": { + "description": "Start the shell with the whole current environment. By default it only gets PATH, HOME, USER, LOGNAME, LANG, LC_*, TMPDIR, TZ, and VIRTUAL_ENV, so the recording doesn't depend on what your shell, editor, or CI service has set.", + "type": "boolean", + "default": false + }, "typing_delay": { "description": "Seconds between typed characters.", "type": "number", From 34afa314f9aa39d2665b7a83eb75cc5d33960c18 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 27 Oct 2026 22:09:16 -0400 Subject: [PATCH 5/8] Test termshow JSON output and guide examples --- tests/test_term_check.py | 169 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) diff --git a/tests/test_term_check.py b/tests/test_term_check.py index 75cf5d3c..70e15e94 100644 --- a/tests/test_term_check.py +++ b/tests/test_term_check.py @@ -2,7 +2,11 @@ from __future__ import annotations +import json import re +import shlex +import shutil +import subprocess import sys from pathlib import Path @@ -311,3 +315,168 @@ def test_bad_ignore_option(self, tmp_path: Path): result = CliRunner().invoke(cli, ["termshow", "check", str(script), "--ignore", "("]) assert result.exit_code == 2 assert "isn't a valid regular expression" in result.output + + +def _check_json(*args: str) -> tuple[int, dict]: + result = CliRunner().invoke(cli, ["termshow", "check", "--json", *args]) + # Progress goes to stderr, so stdout is only the JSON + return result.exit_code, json.loads(result.stdout) + + +@posix_only +class TestCheckJson: + def test_up_to_date(self, tmp_path: Path): + script = _project(tmp_path) + _record(script) + code, data = _check_json(str(script)) + assert code == 0 + assert data == { + "version": 1, + "summary": {"up_to_date": 1, "drifted": 0, "updated": 0, "failed": 0}, + "results": [ + { + "script": script.as_posix(), + "recording": (tmp_path / "demo.termshow").as_posix(), + "status": "up_to_date", + "compared": 3, + "differences": [], + } + ], + } + + def test_drifted(self, tmp_path: Path): + script = _project(tmp_path) + _record(script) + _project(tmp_path, name="there") + code, data = _check_json(str(script)) + assert code == 1 + assert data["summary"]["drifted"] == 1 + (result,) = data["results"] + assert result["status"] == "drifted" + assert [d["at"] for d in result["differences"]] == ["marker 'Version'", "the end"] + assert "+hello there" in result["differences"][0]["diff"] + + def test_updated(self, tmp_path: Path): + script = _project(tmp_path) + _record(script) + _project(tmp_path, name="there") + code, data = _check_json(str(script), "--update") + assert code == 0 + assert data["summary"] == {"up_to_date": 0, "drifted": 0, "updated": 1, "failed": 0} + assert data["results"][0]["differences"] + assert check_script(script).drifted is False + + def test_failed(self, tmp_path: Path): + missing = _project(tmp_path) + code, data = _check_json(str(missing)) + assert code == 1 + (result,) = data["results"] + assert result["status"] == "failed" + assert result["recording"] is None + assert "doesn't exist yet" in result["error"] + assert "screen" not in result + + def test_failed_step_has_the_screen(self, tmp_path: Path): + script = _project(tmp_path) + _record(script) + script.write_text(_SCRIPT + " - wait_for: never\n timeout: 0.3\n", encoding="utf-8") + code, data = _check_json(str(script)) + assert code == 1 + assert "'never' didn't appear" in data["results"][0]["error"] + assert 'echo "version $(cat version.txt)"' in data["results"][0]["screen"] + + def test_several_scripts(self, tmp_path: Path): + (tmp_path / "a").mkdir() + (tmp_path / "b").mkdir() + good, bad = _project(tmp_path / "a"), _project(tmp_path / "b") + _record(good) + _record(bad) + _project(tmp_path / "b", name="there") + code, data = _check_json(str(good), str(bad)) + assert code == 1 + assert data["summary"] == {"up_to_date": 1, "drifted": 1, "updated": 0, "failed": 0} + assert [r["status"] for r in data["results"]] == ["up_to_date", "drifted"] + + +# --------------------------------------------------------------------------- +# The CI recipe in the guide +# --------------------------------------------------------------------------- + +_GUIDE = Path(__file__).resolve().parents[1] / "docs/user_guide/41-terminal-recordings.qmd" + + +def _guide_block(opening: str) -> str: + text = _GUIDE.read_text(encoding="utf-8") + return re.search(re.escape(opening) + r"\n(.*?)^```", text, re.S | re.M)[1] + + +class TestGuideWorkflow: + def _workflow(self) -> dict: + from yaml12 import parse_yaml + + return parse_yaml(_guide_block('```{.yaml filename=".github/workflows/demos.yml"}')) + + def _commands(self) -> list[list[str]]: + commands = [] + for job in self._workflow()["jobs"].values(): + for step in job["steps"]: + for line in step.get("run", "").splitlines(): + words = shlex.split(line.strip()) + if words[:1] == ["great-docs"]: + commands.append(words[1:]) + return commands + + def test_jobs(self): + workflow = self._workflow() + assert set(workflow["on"]) == {"pull_request", "schedule", "workflow_dispatch"} + assert set(workflow["jobs"]) == {"check", "update"} + assert workflow["jobs"]["update"]["permissions"] == { + "contents": "write", + "pull-requests": "write", + } + + def test_commands_exist(self): + commands = self._commands() + assert [c[:2] for c in commands] == [ + ["termshow", "check"], + ["termshow", "check"], + ["termshow", "export"], + ] + assert "--update" in commands[1] + for words in commands: + group = cli.commands[words[0]] + command = group.commands[words[1]] + options = {o for p in command.params for o in getattr(p, "opts", [])} + for word in words[2:]: + if word.startswith("-"): + assert word in options, f"{' '.join(words)}: no option {word}" + + def test_actionlint(self, tmp_path: Path): + actionlint = shutil.which("actionlint") + if actionlint is None: + pytest.skip("actionlint isn't installed") + workflows = tmp_path / ".github" / "workflows" + workflows.mkdir(parents=True) + workflow = workflows / "demos.yml" + workflow.write_text( + _guide_block('```{.yaml filename=".github/workflows/demos.yml"}'), encoding="utf-8" + ) + result = subprocess.run( + [actionlint, str(workflow)], cwd=tmp_path, capture_output=True, text=True + ) + assert result.returncode == 0, result.stdout + result.stderr + + +@posix_only +def test_guide_json_example_matches(tmp_path: Path): + text = _GUIDE.read_text(encoding="utf-8") + blocks = re.findall(r"^```json\n(.*?)^```", text, re.S | re.M) + (example,) = [json.loads(b) for b in blocks if '"summary"' in b] + script = _project(tmp_path) + _record(script) + _project(tmp_path, name="there") + _, data = _check_json(str(script)) + assert set(example) == set(data) + assert set(example["summary"]) == set(data["summary"]) + assert set(example["results"][0]) == set(data["results"][0]) + assert set(example["results"][0]["differences"][0]) == set(data["results"][0]["differences"][0]) From 65abbad8e94121bc6656d9da2c55b83948d72fbd Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 27 Oct 2026 22:09:25 -0400 Subject: [PATCH 6/8] Test terminal recording environment isolation --- tests/test_term_steps.py | 65 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/tests/test_term_steps.py b/tests/test_term_steps.py index f06ab84c..34ee0639 100644 --- a/tests/test_term_steps.py +++ b/tests/test_term_steps.py @@ -5,6 +5,7 @@ import json import re import shlex +import shutil import sys import time from pathlib import Path @@ -15,6 +16,8 @@ from great_docs._term_player.parser import parse_termshow from great_docs._term_player.steps import ( ACTIONS, + DEFAULT_ENV, + KEPT_ENV, KEYS, WORDS, RecordSettings, @@ -23,6 +26,7 @@ key_sequence, load_steps, record_steps, + shell_env, ) from great_docs._term_player.validate import check_script_data, validate from great_docs.cli import cli @@ -167,6 +171,59 @@ def test_defaults(self): if "default" in prop: assert prop["default"] == getattr(defaults, name), name + def test_kept_env_is_documented(self): + description = _SCHEMA["$defs"]["record"]["properties"]["inherit_env"]["description"] + for name in (*KEPT_ENV, "LC_*"): + assert name in description + + +class TestShellEnv: + ENVIRON = { + "PATH": "/usr/bin", + "HOME": "/home/me", + "LANG": "en_US.UTF-8", + "LC_ALL": "en_US.UTF-8", + "PYTHONSTARTUP": "/editor/pythonrc.py", + "CI": "true", + "NO_COLOR": "1", + "COLUMNS": "200", + "PS1": "% ", + } + + def test_only_kept_variables(self): + env = shell_env(RecordSettings(), self.ENVIRON) + assert env == { + "PATH": "/usr/bin", + "HOME": "/home/me", + "LANG": "en_US.UTF-8", + "LC_ALL": "en_US.UTF-8", + **DEFAULT_ENV, + } + + def test_inherit_env(self): + env = shell_env(RecordSettings(inherit_env=True), self.ENVIRON) + assert env["PYTHONSTARTUP"] == "/editor/pythonrc.py" + assert env["CI"] == "true" + assert "COLUMNS" not in env + assert env["PS1"] == DEFAULT_ENV["PS1"] + + def test_record_env_goes_on_top(self): + settings = RecordSettings(env={"PS1": "> ", "CI": "1", "PATH": "/opt/bin"}) + env = shell_env(settings, self.ENVIRON) + assert (env["PS1"], env["CI"], env["PATH"]) == ("> ", "1", "/opt/bin") + + def test_current_environment(self, monkeypatch): + monkeypatch.setenv("TERMSHOW_TEST_LEAK", "1") + assert "TERMSHOW_TEST_LEAK" not in shell_env(RecordSettings()) + assert shell_env(RecordSettings(inherit_env=True))["TERMSHOW_TEST_LEAK"] == "1" + + def test_loaded_from_the_script(self, tmp_path: Path): + script = _write(tmp_path, "record:\n inherit_env: true\nsteps:\n - enter\n") + assert load_steps(script)[0].inherit_env is True + assert _errors({"record": {"inherit_env": "yes"}}) == [ + "record.inherit_env: must be true or false, got 'yes'" + ] + # --------------------------------------------------------------------------- # Validation @@ -257,6 +314,14 @@ def test_env_and_cwd(self, tmp_path: Path): assert "hi there" in _output(recording) assert str(tmp_path.resolve()) in _output(recording).replace("\r\n", "") + def test_clean_environment(self, monkeypatch): + monkeypatch.setenv("TERMSHOW_TEST_LEAK", "leaked") + step = Step("run", 'echo "[${TERMSHOW_TEST_LEAK:-clean}] [$SHELL]"') + output = _output(record_steps(_fast(), [step])) + assert f"[clean] [{shutil.which('sh')}]" in output + output = _output(record_steps(_fast(inherit_env=True), [step])) + assert "[leaked]" in output + def test_typing_takes_time(self): settings = RecordSettings(typing_delay=0.05, typing_jitter=0) recording = record_steps(settings, [Step("type", "abcdef")]) From f5e0264c8c2e71536f395f49a1c3bdc2e0f387bc Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 27 Oct 2026 22:09:37 -0400 Subject: [PATCH 7/8] Document terminal demo CI workflows --- docs/user_guide/41-terminal-recordings.qmd | 99 ++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/docs/user_guide/41-terminal-recordings.qmd b/docs/user_guide/41-terminal-recordings.qmd index 9e6cc09e..98d91818 100644 --- a/docs/user_guide/41-terminal-recordings.qmd +++ b/docs/user_guide/41-terminal-recordings.qmd @@ -1161,6 +1161,10 @@ The `record` section sets up the shell: - `cwd` is the directory to start in, relative to the script. - `env` sets environment variables. The shell starts with a plain `$ ` prompt and no history file, so nothing you record ends up in your own history. +- `inherit_env` (`false`) passes on your whole environment. By default the shell only gets a few + variables from it, such as `PATH`, `HOME`, and `LANG`, so the recording doesn't pick up + whatever your shell profile, editor, or CI service has set. That's what keeps a recording + made on your machine the same as one made in CI. List anything else the steps need in `env`. - `typing_delay` (0.05 seconds) and `typing_jitter` (0.3) set the typing speed and how much it varies. `delay` on a `type`, `run`, or `key` step overrides the speed for that step. - `timeout` (10 seconds) is how long to wait for a prompt or a pattern. `timeout` on a `run` or @@ -1245,6 +1249,101 @@ the next marker isn't. A `marker` between commands compares each command's outpu next one pushes it away, and a taller terminal (`record.rows`) helps with commands that print a lot. The check runs at the recording's size, whatever the script's `record` section says now. +### Keeping demos up to date in CI + +This GitHub Actions workflow does two jobs. On every pull request it checks the demos, and the +job fails if one has drifted, with the diff in its log. Once a week, or when you run it by hand, +it records the drifted demos again and opens a pull request with the new recordings, so a +release that changes your tool's output leads to a demo update you only have to review: + +```{.yaml filename=".github/workflows/demos.yml"} +name: Terminal demos + +on: + pull_request: + schedule: + - cron: "0 6 * * 1" + workflow_dispatch: + +jobs: + check: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install great-docs . + - run: great-docs termshow check docs/assets/*.termshow.yml + + update: + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install great-docs . + - run: great-docs termshow check docs/assets/*.termshow.yml --update + # For recordings that a README shows as an animated SVG + - run: | + for recording in docs/assets/*.termshow; do + great-docs termshow export "$recording" + done + - uses: peter-evans/create-pull-request@v7 + with: + branch: update-terminal-demos + commit-message: Record the terminal demos again + title: Update the terminal demos + body: "`great-docs termshow check --update` found demos whose output has changed." +``` + +`pip install great-docs .` installs your package along with Great Docs, so the steps run the +version of your tool in the pull request. The `update` job can only open a pull request if the +repository allows it: turn on **Allow GitHub Actions to create and approve pull requests** under +**Settings → Actions → General**. Both jobs fail if a step fails, such as a command that isn't +installed on the runner. + +A recording made on your Mac and checked on a Linux runner can differ in ways that have nothing +to do with your tool: `sh` is a different shell on each, and paths and some commands' output +differ too. Two things help. Set `record.shell` to `bash --norc` in each script, so the same +shell runs everywhere. And let the `update` job make the recordings, so the ones you commit come +from the same kind of machine that checks them. + +To use the results in another tool, or in a job summary, add `--json`. The output is one JSON +object, and the exit status is the same as without it: + +```bash +great-docs termshow check docs/assets/*.termshow.yml --json > drift.json +``` + +```json +{ + "version": 1, + "summary": {"up_to_date": 0, "drifted": 1, "updated": 0, "failed": 0}, + "results": [ + { + "script": "docs/assets/install-guide.termshow.yml", + "recording": "docs/assets/install-guide.termshow", + "status": "drifted", + "compared": 3, + "differences": [ + {"at": "marker 'Verify'", "diff": ["--- install-guide.termshow, at marker 'Verify'", "..."]} + ] + } + ] +} +``` + +`status` is `up_to_date`, `drifted`, `updated` (with `--update`), or `failed`. A failed result +has an `error` message instead of `differences`, and a `screen` with the screen's text when a +step timed out. + ## Importing Existing Recordings ### From asciinema From 2377891e563e749005a14aa3fb5282b41ce6d761 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Tue, 27 Oct 2026 22:09:46 -0400 Subject: [PATCH 8/8] Document terminal environment inheritance --- docs/user_guide/42-termshow-file-formats.qmd | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/user_guide/42-termshow-file-formats.qmd b/docs/user_guide/42-termshow-file-formats.qmd index fc885bf9..c13ef805 100644 --- a/docs/user_guide/42-termshow-file-formats.qmd +++ b/docs/user_guide/42-termshow-file-formats.qmd @@ -223,13 +223,17 @@ them as written when it saves. | `rows` | `24` | Terminal height | | `cwd` | the script's directory | Working directory, relative to the script's directory | | `env` | (none) | Environment variables to set. Values may be strings, numbers, or `true`/`false` | +| `inherit_env` | `false` | Start the shell with the whole current environment, not just the variables listed below | | `typing_delay` | `0.05` | Seconds between typed characters | | `typing_jitter` | `0.3` | How much each delay varies, as a fraction of it (0 to 1) | | `timeout` | `10` | Seconds to wait for the prompt or a `wait_for` pattern | | `prompt_pattern` | `\$ $` | Regular expression (Python syntax) that matches the shell's prompt | -The shell starts with `PS1="$ "`, `TERM=xterm-256color`, and no history file. `env` can change -any of these. `COLUMNS` and `LINES` are removed so the terminal size is `cols` by `rows`. +Only `PATH`, `HOME`, `USER`, `LOGNAME`, `LANG`, `LC_*`, `TMPDIR`, `TZ`, and `VIRTUAL_ENV` are +taken from the current environment, unless `inherit_env` is `true`. `SHELL` is set to the shell +being recorded. The shell then gets `PS1="$ "`, `TERM=xterm-256color`, and no history file, and +`env` can change any of these. With `inherit_env`, `COLUMNS` and `LINES` are still removed so +the terminal size is `cols` by `rows`, and `SHELL` is left as it is. Each step is one of these words or single-action mappings: