From 7c1d8f0fe0b63ad09a646d510ecd2e9d8cf5ff18 Mon Sep 17 00:00:00 2001 From: Nathan Storey Date: Sun, 4 Oct 2026 20:13:59 -0400 Subject: [PATCH 1/4] tests: hold that no mapping input reaches the CLI through a pipe Red at this commit, against 7513583's package code: a served bundle over 2 MiB fails verify, and an input mapping over 2 MiB fails sign, with UsageError (--input - cannot be read: EAGAIN); every mapping input reaches the CLI as --input - on a pipe (npstorey/typedstandards-python#6, npstorey/typedstandards#138). The spawned fixture now records each input file's bytes as the child starts, so the D9 tests read the document the CLI received from its --input file, and the run-time guard checks those files for the seed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012qX8dRbkzJr3B24DbHS3wM Signed-off-by: Nathan Storey --- tests/conftest.py | 26 +++++- tests/test_commands.py | 9 +- tests/test_d9.py | 14 ++- tests/test_guards.py | 2 + tests/test_large_inputs.py | 171 +++++++++++++++++++++++++++++++++++++ 5 files changed, 215 insertions(+), 7 deletions(-) create mode 100644 tests/test_large_inputs.py diff --git a/tests/conftest.py b/tests/conftest.py index 3ec26af..1131288 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -47,17 +47,39 @@ def seed(monkeypatch: pytest.MonkeyPatch) -> str: return value +#: The CLI flags whose value is an input document's path (or ``-`` for standard input). +INPUT_FLAGS = frozenset({"--input", "--signed", "--attestation"}) + + +def _input_files(args: list[str]) -> dict[str, bytes]: + """The bytes of every input file the argv names, read as the child starts: the wrapper + removes its temporary files before it returns.""" + files = {} + for flag, value in zip(args, args[1:], strict=False): + if flag in INPUT_FLAGS and value != "-" and os.path.isfile(value): + with open(value, "rb") as handle: + files[value] = handle.read() + return files + + @pytest.fixture def spawned(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]: """Record every child process the wrapper starts: its argv, its keyword arguments, the - stdin bytes it was given, and os.environ at that moment. The call still runs.""" + stdin bytes it was given, the bytes of each input file its argv names (``files``, read at + spawn), and os.environ at that moment. The call still runs.""" calls: list[dict[str, Any]] = [] real_popen = subprocess.Popen real_communicate = subprocess.Popen.communicate class RecordingPopen(real_popen): # type: ignore[misc, valid-type] def __init__(self, args: Any, *rest: Any, **kwargs: Any) -> None: - self._record = {"args": list(args), "kwargs": dict(kwargs), "environ": dict(os.environ), "stdin": None} + self._record = { + "args": list(args), + "kwargs": dict(kwargs), + "environ": dict(os.environ), + "stdin": None, + "files": _input_files([str(a) for a in args]), + } calls.append(self._record) super().__init__(args, *rest, **kwargs) diff --git a/tests/test_commands.py b/tests/test_commands.py index 4ce7b34..5c0d6d6 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -121,12 +121,15 @@ def test_sign_an_output_file_inline(seed: str, tmp_path: Path) -> None: assert signed["package"]["contentHash"]["sha256"] == hashlib.sha256(output.read_bytes()).hexdigest() -def test_mapping_input_goes_on_stdin(seed: str, spawned: list[dict[str, Any]]) -> None: +def test_mapping_input_goes_in_a_temporary_file(seed: str, spawned: list[dict[str, Any]]) -> None: value = self_certifying_input() typedstandards.sign(value) (call,) = cli_calls(spawned, "sign") - assert call["args"][3:] == ["--input", "-"] - assert json.loads(call["stdin"].decode("utf-8")) == value + (path,) = flag_values(call["args"], "--input") + assert call["args"][3:] == ["--input", path] + assert call["stdin"] is None + assert json.loads(call["files"][path].decode("utf-8")) == value + assert not Path(path).exists() def test_success_diagnostics_are_logged(seed: str, caplog: pytest.LogCaptureFixture) -> None: diff --git a/tests/test_d9.py b/tests/test_d9.py index 0561276..980fec1 100644 --- a/tests/test_d9.py +++ b/tests/test_d9.py @@ -33,6 +33,16 @@ def cli_calls(spawned: list[dict[str, Any]], command: str) -> list[dict[str, Any return [c for c in spawned if len(c["args"]) > 2 and c["args"][1] == entry and c["args"][2] == command] +def received_input(call: dict[str, Any]) -> bytes: + """The document the CLI read: the file its ``--input`` named, as it was when the child started + (the wrapper sends a mapping as a temporary file, never on a pipe; typedstandards-python#6).""" + (path,) = [call["args"][i + 1] for i, a in enumerate(call["args"]) if a == "--input"] + assert path != "-", "the document reached the CLI on standard input" + assert call["stdin"] is None + assert not Path(path).exists(), "the temporary file was left behind" + return call["files"][path] + + def test_fixture_is_the_pinned_copy() -> None: assert hashlib.sha256(BUNDLE_PATH.read_bytes()).hexdigest() == BUNDLE_SHA256 assert "trustRegistry" in bundle() @@ -54,7 +64,7 @@ def test_verify_drops_only_trust_registry(spawned: list[dict[str, Any]]) -> None assert result["nodeId"] == original["packageHash"] assert given == original, "the caller's bundle was changed" (call,) = cli_calls(spawned, "verify") - received = json.loads(call["stdin"].decode("utf-8")) + received = json.loads(received_input(call)) expected = {k: v for k, v in original.items() if k != "trustRegistry"} assert received == expected assert list(received) == list(expected), "key order changed" @@ -64,7 +74,7 @@ def test_verify_drops_it_from_a_path_too(spawned: list[dict[str, Any]]) -> None: result = typedstandards.verify(BUNDLE_PATH) assert result["ok"] is True (call,) = cli_calls(spawned, "verify") - received = json.loads(call["stdin"].decode("utf-8")) + received = json.loads(received_input(call)) assert received == {k: v for k, v in bundle().items() if k != "trustRegistry"} diff --git a/tests/test_guards.py b/tests/test_guards.py index 4afa27c..31ced91 100644 --- a/tests/test_guards.py +++ b/tests/test_guards.py @@ -210,6 +210,8 @@ def test_every_child_inherits_the_environment(seed: str, spawned: list[dict[str, pytest.fail("the seed reached an argument") if call["stdin"] is not None and seed.encode() in call["stdin"]: pytest.fail("the seed reached stdin") + if any(seed.encode() in content for content in call["files"].values()): + pytest.fail(f"the seed reached an input file of {call['args'][2:3]}") if dict(os.environ) != before: pytest.fail(f"the environment changed: {_changed_keys(before, dict(os.environ))}") diff --git a/tests/test_large_inputs.py b/tests/test_large_inputs.py new file mode 100644 index 0000000..fdab52e --- /dev/null +++ b/tests/test_large_inputs.py @@ -0,0 +1,171 @@ +"""No mapping input reaches the CLI through a pipe (npstorey/typedstandards-python#6). + +CLI 0.2.0 reads ``--input -`` with a synchronous ``readFileSync(0)``, which fails with EAGAIN on a +piped document larger than a pipe buffer holds (npstorey/typedstandards#138): from Python on macOS +a 65,536-byte input failed, and on Linux a 692,380-byte one. So the wrapper writes every mapping +input to a temporary file, which it removes before it returns, and the child's standard input is +the null device. The documents here are over 2 MiB, above both measured thresholds. +""" + +from __future__ import annotations + +import copy +import json +import subprocess +from pathlib import Path +from typing import Any + +import pytest +from support import analysis_input, self_certifying_input + +import typedstandards + +#: Above both thresholds measured in typedstandards#138. +LARGE = 2 * 1024 * 1024 + + +def cli_calls(spawned: list[dict[str, Any]], command: str) -> list[dict[str, Any]]: + entry = str(typedstandards.cli_entry()) + return [c for c in spawned if len(c["args"]) > 2 and c["args"][1] == entry and c["args"][2] == command] + + +def flag_values(args: list[str], flag: str) -> list[str]: + return [args[i + 1] for i, a in enumerate(args) if a == flag] + + +def large_text() -> str: + """Over 2 MiB of UTF-8 text with multi-byte characters.""" + lines = [] + size = 0 + number = 0 + while size <= LARGE: + line = f"Résumé of row {number:07d}: café 実験 — 1.50\n" + lines.append(line) + size += len(line.encode("utf-8")) + number += 1 + return "".join(lines) + + +def served_bundle(signed: dict[str, Any]) -> dict[str, Any]: + """``view``'s output with a top-level trustRegistry inlined before ``package``, as host-core + serves a bundle under a registry (the shape of fixtures/first-note.bundle.json). The key's + fields are copied from what the CLI returned; nothing is computed here.""" + bundle = typedstandards.view( + signed, + visibility="public", + title="A large test record", + trust_registry_url="https://example.org/.well-known/typed-publisher.json", + ) + key = signed["signature"] + registry = { + "$comment": "A test registry for one throwaway key.", + "keys": [ + { + "kid": key["kid"], + "publicKey": key["publicKey"], + "status": "active", + "activatedAt": signed["package"]["metadata"]["createdAt"], + "deprecatedAt": None, + "revokedAt": None, + "signerIdentity": signed["package"]["signer"], + } + ], + } + served: dict[str, Any] = {} + for name, value in bundle.items(): + if name == "package": + served["trustRegistry"] = registry + served[name] = value + return served + + +@pytest.fixture +def large_bundle(seed: str, tmp_path: Path) -> dict[str, Any]: + """A served bundle over 2 MiB: a UTF-8 file over 2 MiB signed inline under a fresh seed.""" + output = tmp_path / "large.txt" + output.write_text(large_text(), encoding="utf-8") + assert output.stat().st_size > LARGE + bundle = served_bundle(typedstandards.sign(analysis_input(), output_file=output)) + assert len(json.dumps(bundle, ensure_ascii=False).encode("utf-8")) > LARGE + return bundle + + +def test_verify_a_large_served_bundle_as_a_mapping(large_bundle: dict[str, Any]) -> None: + result = typedstandards.verify(large_bundle) + assert result["ok"] is True + assert result["nodeId"] == large_bundle["packageHash"] + + +def test_verify_a_large_served_bundle_as_a_path(large_bundle: dict[str, Any], tmp_path: Path) -> None: + path = tmp_path / "large.bundle.json" + path.write_text(json.dumps(large_bundle, ensure_ascii=False), encoding="utf-8") + result = typedstandards.verify(path) + assert result["ok"] is True + assert result["nodeId"] == large_bundle["packageHash"] + assert path.exists() + + +def test_sign_from_a_large_input_mapping(seed: str) -> None: + text = large_text() + value = analysis_input(output=text) + assert len(json.dumps(value, ensure_ascii=False).encode("utf-8")) > LARGE + signed = typedstandards.sign(value) + assert signed["package"]["output"] == text + assert typedstandards.verify(signed)["ok"] is True + + +def assert_inputs_were_files(calls: list[dict[str, Any]]) -> None: + """Each call's input reached the child as a file that existed when it started, the child's + standard input was not a pipe, and every such file and its directory are gone now.""" + assert calls + for call in calls: + inputs = flag_values(call["args"], "--input") + assert len(inputs) == 1, call["args"] + (path,) = inputs + assert path != "-", f"{call['args'][2]} got its input on standard input" + assert call["kwargs"].get("stdin") is subprocess.DEVNULL, f"{call['args'][2]}'s stdin was not the null device" + assert call["stdin"] is None + assert path in call["files"], f"{path} was not a file when {call['args'][2]} started" + assert not Path(path).exists(), f"{path} was left behind" + assert not Path(path).parent.exists(), f"{Path(path).parent} was left behind" + + +def test_no_mapping_input_reaches_the_cli_on_a_pipe(seed: str, spawned: list[dict[str, Any]]) -> None: + signed = typedstandards.sign(self_certifying_input()) + signer = signed["package"]["signer"] + typedstandards.withdraw({"targetNodeId": signed["envelopeHash"], "reason": "r", "signer": signer}) + typedstandards.attest( + { + "type": "attestation/corroborates/v1", + "targetNodeId": signed["envelopeHash"], + "scope": "the whole record", + "signer": {"bindingTier": "pseudonymous", "displayName": "Example corroborator"}, + } + ) + assert typedstandards.verify(signed)["ok"] is True + assert typedstandards.verify(served_bundle(signed))["ok"] is True + for command, count in (("sign", 1), ("withdraw", 1), ("attest", 1), ("verify", 2)): + calls = cli_calls(spawned, command) + assert len(calls) == count, command + assert_inputs_were_files(calls) + # The signed document reached the CLI as given. + first = cli_calls(spawned, "verify")[0] + assert json.loads(first["files"][flag_values(first["args"], "--input")[0]]) == signed + + +def test_temporary_files_are_removed_when_the_cli_fails(seed: str, spawned: list[dict[str, Any]]) -> None: + signed = typedstandards.sign(self_certifying_input()) + spawned.clear() + with pytest.raises(typedstandards.UsageError): # exit 2 + typedstandards.sign({"type": "content/analysis/v1"}) + with pytest.raises(typedstandards.UsageError): + typedstandards.withdraw({"targetNodeId": signed["envelopeHash"]}) + with pytest.raises(typedstandards.UsageError): + typedstandards.attest({"type": "attestation/corroborates/v1"}) + tampered = copy.deepcopy(signed) + tampered["package"]["output"] = {"tampered": True} + with pytest.raises(typedstandards.VerificationError) as caught: # exit 1 + typedstandards.verify(tampered) + assert caught.value.document["ok"] is False + for command in ("sign", "withdraw", "attest", "verify"): + assert_inputs_were_files(cli_calls(spawned, command)) From b956475389c733e1f18048b2f767882c19acad66 Mon Sep 17 00:00:00 2001 From: Nathan Storey Date: Sun, 4 Oct 2026 20:13:59 -0400 Subject: [PATCH 2/4] Pass every mapping input to the CLI as a temporary file, never on a pipe sign, withdraw, attest and verify wrote a mapping input to the CLI's standard input (--input -). CLI 0.2.0 reads it with readFileSync(0), which fails with EAGAIN once the document is larger than a pipe buffer holds, so verify failed on every large served bundle (the D9 workaround turns a bundle path into a mapping). Each mapping is now written to a temporary file, as view already did, removed when the call returns or raises; the child's standard input is the null device. verify still drops only a bundle's top-level trustRegistry. Fixes npstorey/typedstandards-python#6; the CLI side is npstorey/typedstandards#138. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012qX8dRbkzJr3B24DbHS3wM Signed-off-by: Nathan Storey --- CHANGELOG.md | 8 ++++ README.md | 8 ++-- src/typedstandards/_cli.py | 14 ++++--- src/typedstandards/_commands.py | 67 +++++++++++++++++++-------------- 4 files changed, 59 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e08d1f6..15b7b93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Unreleased + +- Fixed: every mapping input to `sign`, `withdraw`, `attest` and `verify` reaches the CLI as a + temporary file, removed before the call returns, and no longer on standard input. A document + larger than a pipe buffer holds, such as a served bundle that signs a notebook inline, failed + with `UsageError` (`--input - cannot be read: EAGAIN`) (npstorey/typedstandards-python#6, + npstorey/typedstandards#138). `verify` still drops only a bundle's top-level `trustRegistry`. + ## 0.1.0 — 2026-10-04 - `sign`, `withdraw`, `attest`, `view` and `verify`: pass-throughs to `@typedstandards/cli` 0.2.0, diff --git a/README.md b/README.md index ce2ef65..53a6b95 100644 --- a/README.md +++ b/README.md @@ -76,8 +76,8 @@ bundle = ts.view(signed, visibility="public", title="Example analysis") result = ts.verify(bundle) # {ok, nodeId, failures, checks, lifecycle} ``` -Each function returns the CLI's stdout parsed as JSON. An input may be a mapping (sent to the CLI -as JSON on standard input) or the path of a JSON file. +Each function returns the CLI's stdout parsed as JSON. An input may be a mapping (written as JSON +to a temporary file the CLI reads) or the path of a JSON file. | Function | CLI command | Returns | |---|---|---| @@ -87,7 +87,9 @@ as JSON on standard input) or the path of a JSON file. | `view(signed, *, visibility, attestations=(), trust_registry_url=None, package_url=None, title=None)` | `view` | the commitment view, package inline | | `verify(input, *, blobs=(), full=True)` | `verify` (`--json` when `full`) | `{ok, nodeId, failures, checks, lifecycle}` | -`view` writes the mappings it is given to temporary files, removed before it returns. The CLI's +The temporary files are removed before the function returns, whether the CLI succeeded or not. No +input reaches the CLI through a pipe, where CLI 0.2.0 fails on a document larger than a pipe buffer +holds. The CLI's [README](https://github.com/npstorey/typedstandards/tree/main/packages/cli#readme) describes each command's inputs. What the CLI prints on stderr when it succeeds (attention readings, such as an offline `registry_unavailable`) is logged at INFO on the `typedstandards` logger. diff --git a/src/typedstandards/_cli.py b/src/typedstandards/_cli.py index d85fb6c..a89d822 100644 --- a/src/typedstandards/_cli.py +++ b/src/typedstandards/_cli.py @@ -43,18 +43,20 @@ def _parse(stdout: bytes) -> Any: return json.loads(stdout.decode("utf-8")) -def run(command: str, args: Sequence[str] = (), *, stdin: bytes | None = None) -> Any: +def run(command: str, args: Sequence[str] = ()) -> Any: """Run one CLI command and return its stdout parsed as JSON. The child inherits this process's environment: no ``env`` is passed, and nothing here reads - or sets a variable for it. What the CLI writes on stderr when it succeeds (attention - readings, such as an offline ``registry_unavailable``) is logged at INFO on the - ``typedstandards`` logger. + or sets a variable for it. Its standard input is the null device: every input reaches the CLI + as a file it names, never through a pipe (typedstandards#138). What the CLI writes on stderr + when it succeeds (attention readings, such as an offline ``registry_unavailable``) is logged at + INFO on the ``typedstandards`` logger. """ node = locate_node() entry = cli_entry() - feed: dict[str, Any] = {"input": stdin} if stdin is not None else {"stdin": subprocess.DEVNULL} - proc = subprocess.run([node, str(entry), command, *args], capture_output=True, check=False, **feed) + proc = subprocess.run( + [node, str(entry), command, *args], capture_output=True, check=False, stdin=subprocess.DEVNULL + ) stderr = proc.stderr.decode("utf-8", errors="replace") code = proc.returncode if code == 0: diff --git a/src/typedstandards/_commands.py b/src/typedstandards/_commands.py index 2fd56e9..5779b69 100644 --- a/src/typedstandards/_commands.py +++ b/src/typedstandards/_commands.py @@ -1,9 +1,11 @@ """The five pass-through commands: ``sign``, ``withdraw``, ``attest``, ``view``, ``verify``. Each takes the CLI's inputs as Python values and returns the CLI's stdout parsed as JSON. -An input given as a mapping is sent as JSON on standard input (``--input -``); a ``str`` or -``os.PathLike`` is a path the CLI reads. The wrapper computes nothing the format defines: the -CLI builds, signs, hashes and verifies. +An input given as a mapping is written as JSON to a temporary file, whose path the CLI reads and +which is removed before the call returns; a ``str`` or ``os.PathLike`` is a path the CLI reads. +No input reaches the CLI through a pipe: CLI 0.2.0 reads ``--input -`` with a synchronous read +that fails with EAGAIN on a document larger than a pipe buffer holds (typedstandards#138). The +wrapper computes nothing the format defines: the CLI builds, signs, hashes and verifies. """ from __future__ import annotations @@ -11,7 +13,8 @@ import json import os import tempfile -from collections.abc import Iterable, Mapping +from collections.abc import Iterable, Iterator, Mapping +from contextlib import contextmanager from pathlib import Path from typing import Any @@ -25,12 +28,17 @@ def _json_bytes(value: Mapping[str, Any]) -> bytes: return json.dumps(value, ensure_ascii=False, allow_nan=False).encode("utf-8") -def _input_args(value: Input, flag: str) -> tuple[list[str], bytes | None]: +@contextmanager +def _input_args(value: Input, flag: str) -> Iterator[list[str]]: + """``[flag, path]`` for one input. A mapping is written to a temporary file, removed when the + block exits, whether the CLI succeeded or not.""" if isinstance(value, Mapping): - return [flag, "-"], _json_bytes(value) - if isinstance(value, (str, os.PathLike)): - return [flag, os.fspath(value)], None - raise TypeError(f"{flag} takes a mapping (sent as JSON on stdin) or a path, not {type(value).__name__}") + with tempfile.TemporaryDirectory(prefix="typedstandards-input-") as directory: + yield [flag, _as_file(value, directory, "input.json", flag)] + elif isinstance(value, (str, os.PathLike)): + yield [flag, os.fspath(value)] + else: + raise TypeError(f"{flag} takes a mapping (written to a temporary file) or a path, not {type(value).__name__}") def _as_file(value: Input, directory: str, name: str, what: str) -> str: @@ -57,27 +65,27 @@ def sign( ``{package, envelopeHash, signature}``. The CLI reads the signing seed from its own environment, which it inherits from this process. """ - args, stdin = _input_args(input, "--input") - if output_file is not None: - args += ["--output-file", os.fspath(output_file)] - if output_url is not None: - args += ["--output-url", output_url] - if content_type is not None: - args += ["--content-type", content_type] - return run("sign", args, stdin=stdin) + with _input_args(input, "--input") as args: + if output_file is not None: + args += ["--output-file", os.fspath(output_file)] + if output_url is not None: + args += ["--output-url", output_url] + if content_type is not None: + args += ["--content-type", content_type] + return run("sign", args) def withdraw(input: Input) -> dict[str, Any]: """``typedstandards withdraw``: sign an ``attestation/withdraws/v1``. Returns ``{node, nodeId, signature}``.""" - args, stdin = _input_args(input, "--input") - return run("withdraw", args, stdin=stdin) + with _input_args(input, "--input") as args: + return run("withdraw", args) def attest(input: Input) -> dict[str, Any]: """``typedstandards attest``: sign a ``supersedes``, ``revises``, ``corroborates`` or ``contradicts`` attestation. Returns ``{node, nodeId, signature}``.""" - args, stdin = _input_args(input, "--input") - return run("attest", args, stdin=stdin) + with _input_args(input, "--input") as args: + return run("attest", args) def view( @@ -93,7 +101,7 @@ def view( ``signed`` is what :func:`sign` returned (or its path); each of ``attestations`` is what :func:`withdraw` or :func:`attest` returned (or its path). Mappings are written to temporary - files, removed before this returns, since only one input can be standard input. + files, removed before this returns. """ if isinstance(attestations, (Mapping, str, os.PathLike)): raise TypeError("attestations takes a list of attestations, not one") @@ -114,7 +122,8 @@ def _without_trust_registry(value: Input) -> Input: """G0 D9 = A, typedstandards#136: CLI 0.2.0's verify exits 2 on a bundle's top-level ``trustRegistry``, which host-core inlines in every bundle it serves under a registry. Drop that one key from a bundle (a document with ``packageHash``) and change nothing else; any other - document, and a bundle file without the key, reach the CLI as given. + document, and a bundle file without the key, reach the CLI as given. A bundle that loses the + key reaches the CLI as a temporary file, like any mapping, also when it was given as a path. Remove this workaround when the wrapper pins a CLI whose verify accepts the key. """ @@ -148,9 +157,9 @@ def verify( """ if isinstance(blobs, (str, os.PathLike)): raise TypeError("blobs takes a list of paths, not one") - args, stdin = _input_args(_without_trust_registry(input), "--input") - for blob in blobs: - args += ["--blob", os.fspath(blob)] - if full: - args.append("--json") - return run("verify", args, stdin=stdin) + with _input_args(_without_trust_registry(input), "--input") as args: + for blob in blobs: + args += ["--blob", os.fspath(blob)] + if full: + args.append("--json") + return run("verify", args) From cb7ca9a9698a980f76868fbdaea8113def1134d2 Mon Sep 17 00:00:00 2001 From: Nathan Storey Date: Sun, 4 Oct 2026 20:33:20 -0400 Subject: [PATCH 3/4] CLAUDE.md: name publish.sh's modes, who pushes the rollback tags, and worktree pushes Three corrections the last orchestrator proposed in its closeout (typedstandards#135, G0 D17 = A): - publishing names scripts/publish.sh and its dry-run, live and read-back modes; - the orchestrator pushes the rollback tags, or hands them to the owner in the merge script; - a branch that adds or changes .gitleaks.toml is pushed from its own worktree, because gitleaks reads that file from the directory it runs in. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012qX8dRbkzJr3B24DbHS3wM Signed-off-by: Nathan Storey --- CLAUDE.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ed9f935..ca15a2c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,8 +75,8 @@ gate on their own. ## Rollback tags Bracket every phase merge: `rollback/pre-produce-py-p` at the pre-merge anchor and -`rollback/produce-py-p-merged` at the merge commit. The orchestrator pushes them, not -implementation sessions. +`rollback/produce-py-p-merged` at the merge commit. The orchestrator pushes them, or hands them +to the owner as part of the merge script; implementation sessions never do. ## Push guard @@ -85,6 +85,9 @@ commit, so a fix on top does not clear an earlier commit: the flagged bytes must pushed history. Pushes go to the owner as one command, after `gitleaks git --log-opts="main..HEAD"` over the outgoing range is clean. Never bypass the guard and never tune its patterns on your own initiative. `.gitleaks.toml` allows only Ed25519 `did:key` identifiers, which are public keys. +gitleaks reads `.gitleaks.toml` from the directory it runs in, so a branch that adds or changes +that file is pushed from its own worktree (`git -C .worktrees/ push …`) until `main` carries +the change. ## Phrasing, commits, merges, releases @@ -94,7 +97,12 @@ initiative. `.gitleaks.toml` allows only Ed25519 `did:key` identifiers, which ar Commits are signed (SSH). - Work lands by PR to `main` as merge commits; never push to `main`. Merging is the orchestrator's call on evidence in a gated sprint, the owner's otherwise. -- Publishing to PyPI is the owner's act, from a tested script with a `DRY_RUN` mode. +- Publishing to PyPI is the owner's act, through `scripts/publish.sh` from a clean checkout of `main` + at the release commit (its header has the details). `DRY_RUN=1` builds the sdist and the wheel, + checks them, smoke-tests the wheel in a fresh environment, prints each file's SHA-256 and uploads + nothing. The live run, under `op run` with the PyPI token's secret reference, uploads exactly + the files the dry run built and reads the release back from PyPI for about five minutes. + `READ_BACK=1` runs only the read-back, for a run that stopped after uploading. - A CLI upgrade reaches users as a wrapper release that moves the pin: `package.json`, `package-lock.json` (`npm install --package-lock-only --ignore-scripts`) and `CLI_VERSION` together; `tests/test_version.py` fails on any one left behind. From 19eb4544de67ad3dda92def86fb153018118bfc4 Mon Sep 17 00:00:00 2001 From: Nathan Storey Date: Sun, 4 Oct 2026 20:38:04 -0400 Subject: [PATCH 4/4] Release 0.1.1 The version moves to 0.1.1 and the Unreleased entry is dated 2026-10-05, the publish day the owner named (typedstandards#135, G0 D14 = A). uv.lock is unchanged: the package's version is dynamic and its editable entry carries none. The CLI pin stays 0.2.0. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012qX8dRbkzJr3B24DbHS3wM Signed-off-by: Nathan Storey --- CHANGELOG.md | 2 +- src/typedstandards/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15b7b93..f494fdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 0.1.1 — 2026-10-05 - Fixed: every mapping input to `sign`, `withdraw`, `attest` and `verify` reaches the CLI as a temporary file, removed before the call returns, and no longer on standard input. A document diff --git a/src/typedstandards/__init__.py b/src/typedstandards/__init__.py index 6668625..26760da 100644 --- a/src/typedstandards/__init__.py +++ b/src/typedstandards/__init__.py @@ -26,7 +26,7 @@ ) from .pin import Pinned, pin -__version__ = "0.1.0" +__version__ = "0.1.1" #: The version of @typedstandards/cli this release vendors (package.json pins it exactly). CLI_VERSION = "0.2.0"