Skip to content
Open
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
# Changelog

## Unreleased

- `publish(signed, *, host, title, name=None, notebook=None, role="notebook", revises=None)` and
`publish_attestation(node, *, host, name)`: write a signed record, or a withdrawal or other
lifecycle attestation on one, to a GitHub Pages host made from the host template's publish mode,
as one commit through the Git Data API (typedstandards#141). `GitHubPagesHost(repository, *,
branch="main", token=None, ...)` names the repository; the token is `token=`, else
`TYPEDSTANDARDS_GITHUB_TOKEN`, a fine-grained token. The default name is the notebook's stem,
the record's date and the first eight hex of its `envelopeHash`. A listed hash is not written
again; a listed name with another record needs `revises=`. Refusals raise
`PublishRefusedError` before any write; an API error, or a second non-fast-forward, raises
`PublishError`. The receipt is `{name, commit, bundle_url, verify_url, registry_url, written,
run}`, with `run` `None`.
- README: publishing, the token, and the seed in a hosted notebook and in GitHub Codespaces.

## 0.1.1 — 2026-10-05

- Fixed: every mapping input to `sign`, `withdraw`, `attest` and `verify` reaches the CLI as a
Expand Down
163 changes: 160 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ The wrapper looks for Node in this order:
Only the calls that run the CLI need Node: `sign`, `withdraw`, `attest`, `view`, `verify`,
`cli_version()`, and `show` without a precomputed result. With no Node, or one older than 20.19.0,
each of those raises `typedstandards.NodeLocatorError`, whose message names the floor and
`TYPEDSTANDARDS_NODE`. `pin`, `badge_cell`, `comparison_cell`, `sidecar` and
`show(record, result)` run without Node.
`TYPEDSTANDARDS_NODE`. `pin`, `badge_cell`, `comparison_cell`, `sidecar`, `show(record, result)`,
`publish` and `publish_attestation` run without Node.

Linux and macOS are tested. Windows is untested.

Expand Down Expand Up @@ -190,10 +190,167 @@ ts.show(bundle) # in Jupyter; ts.show(bundle, marimo=True) in Marimo
string or a list of strings); `show` labels it as the signer's and checks nothing about it.

The notebook helpers edit the notebook as JSON, splicing the new cell into `cells` so every
other byte of the file stays as written. `httpx` is imported only inside `pin`, PyYAML only
other byte of the file stays as written. `httpx` is imported only inside `pin` and the publish
calls, PyYAML only
inside `sidecar`, and `marimo` only inside a Marimo call; `IPython`, `marimo` and `nbformat`
are not dependencies.

## Publishing to a GitHub Pages host

`publish` writes a signed record to a GitHub repository made from the
[host template](https://github.com/npstorey/typedstandards-host-template) in its publish mode,
whose workflow builds the site from the repository's `host.json` and deploys it to GitHub Pages.
Each call is one commit, made through GitHub's Git Data API; the host's workflow then builds and
deploys it. `publish` does not wait for the deploy. The template's README sets a host up for this:
[Publishing from a notebook](https://github.com/npstorey/typedstandards-host-template#publishing-from-a-notebook).

That setup names your key in `host-policy.json`'s `signer`, and `publish` refuses a record signed
by any other key. The CLI prints a `did:key` only in what it signs, so read yours from the first
record you sign, before its first publish:

```python
signed = ts.sign(record, output_file="dog-licensing.ipynb")
signed["package"]["signer"]["identifier"] # did:key:z6Mk…: the policy's signer
```

A copy starts with no records; its first publish adds the first.

```python
import typedstandards as ts

host = ts.GitHubPagesHost("owner/repo") # branch="main"; the token from TYPEDSTANDARDS_GITHUB_TOKEN
signed = ts.sign(record, output_file="dog-licensing.ipynb") # record: an envelope input, as under Use
receipt = ts.publish(signed, host=host, notebook="dog-licensing.ipynb", title="Dog licensing by district")
receipt["verify_url"] # the verifier's link for the served bundle

withdrawal = ts.withdraw(
{"targetNodeId": signed["envelopeHash"], "reason": "...", "signer": signed["package"]["signer"]}
)
ts.publish_attestation(withdrawal, host=host, name=receipt["name"])
```

- **`GitHubPagesHost(repository, *, branch="main", token=None, ...)`**: the repository as
`owner/name`. Its `repr` shows the repository and the branch only. The HTTP client a call builds
ignores proxy and certificate environment variables; pass `client=` (an `httpx.Client`) for
those.
- **`publish(signed, *, host, title, name=None, notebook=None, role="notebook", revises=None)`**
writes what `sign` printed (or its path) to `records/<name>.signed.json` and appends its entry to
`host.json`: `{name, signed, attestations: [], title, extensions: {role}}`. `host.json` is
rewritten with two-space indentation; no other field of it changes.
- **`publish_attestation(node, *, host, name)`** writes what `withdraw` or `attest` printed (or
its path) to `records/<name>.<kind>-<eight hex of its nodeId>.json` and adds that path to the
record's `attestations`, in one commit. A node already listed there is not written again.

### Names

The **default name**, with `notebook=`, is `<stem>/<date>-<eight hex>`: the notebook file's stem,
the date of the record's `createdAt` (UTC), and the first eight hex characters of its
`envelopeHash`, for example `dog-licensing/2026-10-04-ebb38315`. The signed document does not carry
the notebook's file name, so the stem comes from the argument. A rerun signs to another
`envelopeHash`, so it gets a new name, and publishing the same signed document again finds it
listed under its name and writes nothing (`written: False`).

With an explicit `name=`, a name the host already lists with the same `envelopeHash` writes
nothing, and a name listed with another record is refused, unless `revises=` is given. Then the
record is written under `<name>-<its first eight hex>`, and the `revises=` node goes on the listed
record's entry, in the same commit. Sign the node first:

```python
node = ts.attest(
{
"type": "attestation/revises/v1",
"targetNodeId": prior["envelopeHash"], # the listed record
"successorNodeId": signed["envelopeHash"], # this one
"signer": signed["package"]["signer"],
}
)
ts.publish(signed, host=host, name="dog-licensing", title="Dog licensing, rerun", revises=node)
```

`publish` compares the node's fields only: its type, its `successorNodeId` with this record's
`envelopeHash`, and its `targetNodeId` with the listed record's. Under the default name, a
`revises=` node goes on the entry of the listed record it targets. Whether a record is listed is
read by name: the same signed document under two explicit names becomes two entries.

A name is `/`-separated segments of letters, digits, `.`, `_` and `-`, with no `.` or `..`
segment (host-core's rule), and no `records` or `evidence` segment, which the verifier reads as a
record page's URL rather than a bundle's.

### Refusals

Each raises `typedstandards.PublishRefusedError` before any write request:

- a token that does not start `github_pat_`, or that holds whitespace, a quote or `op://` (the
message names where the token came from, never its value);
- a name that fails the rule above, or a bundle URL with a `records` or `evidence` segment;
- an empty title;
- a record whose `output` is a BlobRef (signed with `output_url=`): the host serves the signed
file only, so sign with `output_file=` alone;
- a record or node whose signer is not the `signer` in the host's `host-policy.json`, or whose
type the policy does not name;
- a role that no rule for `active` records in `host-policy.json` admits: such a record would fail
the host's build, and with it every later deploy;
- a listed name with another record and no `revises=`, or a `revises=` whose fields do not match;
- for `publish_attestation`, a claim-to-claim node (`corroborates`, `contradicts`), a name the
host does not list, or a node whose `targetNodeId` is not that record's `envelopeHash`.

A ref update GitHub does not accept re-reads the branch head first, since a write that errored may
have landed. If the commit did not land and the branch moved, `publish` plans again from the new
head once; a second failure raises `typedstandards.PublishError`, as does an API error, whose
message names the request and GitHub's own message.

### The receipt

| Key | Value |
|---|---|
| `name` | the name the record is listed under (the derived name for a revision) |
| `commit` | the new commit's id, or `None` when nothing was written |
| `bundle_url` | `<origin>/bundles/<name>.bundle.json`, from `host.json`'s `origin` |
| `verify_url` | the verifier's link for `bundle_url`, the same link `badge_cell` writes |
| `registry_url` | `<origin>/.well-known/typed-publisher.json`, or `None` for a host with no registry |
| `written` | `True` for a new commit, `False` when the host already listed it |
| `run` | `None`: `publish` does not wait for the deploy |

### The token

A fine-grained personal access token, for the one publishing repository, with **Contents: read
and write** and nothing else (enough for a public repository; a private one is unmeasured). Give
it an expiry. It comes from `token=`, else from `TYPEDSTANDARDS_GITHUB_TOKEN`, read when a publish
runs; it is sent only as the `Authorization` header of the client the call builds and closes, and
it is in no message, log record, receipt or file. Never write it as a literal in a cell. Locally,
set it with the seed, from the secret store that starts the kernel (`op run --env-file=… --
jupyter lab`); in a hosted notebook, from the hosting service's secret store, as for the seed
below.

Commits made through the API are unsigned, so a branch rule that requires signed commits or pull
requests refuses them. The template's README gives the ruleset for a publishing branch: no
deletion and no force push.

### The seed in a hosted notebook

A hosted kernel has no launcher to set the seed's variable, so the author's own code sets it, in
one cell that prints nothing:

```python
import os

os.environ["TYPEDSTANDARDS_SIGNING_SEED_B64"] = read_secret("TYPEDSTANDARDS_SIGNING_SEED_B64")
```

where `read_secret` stands for the hosting service's own call that reads a stored secret. The
wrapper still never reads the variable; the CLI does.

The seed must never be pasted into a cell, printed (by `print`, `%env`, or a cell whose last
expression is the value), or saved in the `.ipynb` in any other way: the notebook is the file that
is signed and published. In a hosted notebook, the hosting service's runtime holds the seed for as
long as the kernel runs. Make the seed once, outside any notebook (`openssl rand -base64 32`), and
keep it in a password manager as well as the service's secret store: a record signed by a key that
is lost can never be withdrawn or revised.

In GitHub Codespaces, a Codespaces secret named `TYPEDSTANDARDS_SIGNING_SEED_B64` arrives in the
codespace as an environment variable, so no line is needed; neither a repository Actions variable
nor an Actions secret is a place for the seed.

## Versions

Each wrapper release pins one CLI version exactly. A CLI upgrade reaches users as a wrapper release
Expand Down
192 changes: 192 additions & 0 deletions scripts/github_stub.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
"""An in-memory stand-in for the GitHub REST API calls ``typedstandards.publish`` makes, served
through ``httpx.MockTransport``. No network, no file, no digest.

It models one repository: a branch head, commits holding whole file maps, blobs, trees, the
contents API's raw read, and a fast-forward-only ``PATCH git/refs/heads/<branch>``. Every request
is recorded as ``(method, path)`` in ``requests``. A request whose ``Authorization`` is not
``Bearer <token>`` answers 401. Object ids are counters, not hashes.

Shared by ``tests/test_publish.py``, ``scripts/smoke_wheel.py`` and the test of
``scripts/smoke_publish.py``, after the stub of ``spike/test/stub-api.mjs`` in the measured
reference client.
"""

from __future__ import annotations

import base64
import copy
import json
from collections.abc import Callable
from typing import Any
from urllib.parse import unquote

import httpx

API = "https://api.github.com"

#: host-policy.json's `display` with `notebook` in the active rule, as P1 changes the template's.
TEMPLATE_DISPLAY = [
{
"$comment": "An active note is shown as current.",
"status": "active",
"extensions": {"role": ["note", "notebook"]},
"as": "current",
},
{"$comment": "A withdrawn record stays listed, marked withdrawn.", "status": "withdrawn", "as": "withdrawn"},
]


def manifest(origin: str = "https://publish.example.org", records: list[dict[str, Any]] | None = None) -> dict:
"""A host.json shaped like the template's, with its own origin and records."""
return {
"$comment": "The host manifest.",
"origin": origin,
"visibility": "public",
"registry": {"$comment": "This host's statement about its key."},
"index": {"$comment": "The records this host serves."},
"records": records if records is not None else [],
}


def policy(signer: str, display: list[dict[str, Any]] | None = None, **extra: Any) -> dict:
"""A host-policy.json shaped like the template's, naming ``signer``."""
return {
"$comment": "The display policy.",
"signer": signer,
"type": "content/analysis/v1",
"display": display if display is not None else copy.deepcopy(TEMPLATE_DISPLAY),
"unmatched": "refuse",
**extra,
}


def dumps(value: Any) -> bytes:
return (json.dumps(value, indent=2, ensure_ascii=False) + "\n").encode("utf-8")


class FakeGitHub:
"""One repository's API. ``files`` maps a path to its bytes on the branch's head commit."""

def __init__(
self,
files: dict[str, bytes],
*,
token: str,
repository: str = "example-owner/example-host",
branch: str = "main",
) -> None:
self.repository = repository
self.branch = branch
self.token = token
self.requests: list[tuple[str, str]] = []
self.bodies: list[Any] = []
self._count = 0
self.blobs: dict[str, bytes] = {}
self.trees: dict[str, dict[str, bytes]] = {}
self.commits: dict[str, dict[str, Any]] = {}
first = self._id("c")
self.trees[self._id("t")] = dict(files)
self.commits[first] = {"tree": list(self.trees)[-1], "parents": [], "message": "initial"}
self.head = first
#: Before each of the next N ref updates, another writer's commit lands on the branch.
self.concurrent_writes = 0
#: Statuses to answer the next ref updates with after applying them (a write that landed).
self.landed_but_failed: list[int] = []
#: Called with each request before it is answered; may return a response to send instead.
self.hook: Callable[[httpx.Request], httpx.Response | None] | None = None

# --- state -------------------------------------------------------------------------------

def _id(self, kind: str) -> str:
"""A fresh 40-hex object id (a counter; ``kind`` is for the reader)."""
self._count += 1
return f"{self._count:040x}"

def files_at(self, commit: str | None = None) -> dict[str, bytes]:
return self.trees[self.commits[commit or self.head]["tree"]]

def json_at(self, path: str, commit: str | None = None) -> Any:
return json.loads(self.files_at(commit)[path])

def writes(self) -> list[tuple[str, str]]:
return [r for r in self.requests if r[0] not in {"GET", "HEAD"}]

def other_writer_commits(self) -> None:
"""A concurrent writer's commit: an unrelated file added on the branch."""
files = dict(self.files_at())
files[f"other/{self._count}.txt"] = b"another writer\n"
tree = self._id("t")
self.trees[tree] = files
commit = self._id("c")
self.commits[commit] = {"tree": tree, "parents": [self.head], "message": "another writer"}
self.head = commit

# --- the API -----------------------------------------------------------------------------

def transport(self) -> httpx.MockTransport:
return httpx.MockTransport(self.handle)

def handle(self, request: httpx.Request) -> httpx.Response:
path = request.url.path
self.requests.append((request.method, path))
body = json.loads(request.content) if request.content else None
self.bodies.append(body)
if self.hook is not None:
answer = self.hook(request)
if answer is not None:
return answer
if request.headers.get("authorization") != f"Bearer {self.token}":
return httpx.Response(401, json={"message": "Bad credentials"})
repo = f"/repos/{self.repository}"
if not path.startswith(repo + "/"):
return httpx.Response(404, json={"message": "Not Found"})
rest = path[len(repo) :]
method = request.method

if method == "GET" and rest == f"/git/ref/heads/{self.branch}":
return httpx.Response(200, json={"ref": f"refs/heads/{self.branch}", "object": {"sha": self.head}})
if method == "GET" and rest.startswith("/git/commits/"):
commit = self.commits.get(rest.rsplit("/", 1)[1])
if commit is None:
return httpx.Response(404, json={"message": "Not Found"})
return httpx.Response(200, json={"tree": {"sha": commit["tree"]}})
if method == "GET" and rest.startswith("/contents/"):
ref = request.url.params.get("ref", self.head)
commit = self.commits.get(ref)
content = None if commit is None else self.trees[commit["tree"]].get(unquote(rest[len("/contents/") :]))
if content is None:
return httpx.Response(404, json={"message": "Not Found"})
if "raw" in request.headers.get("accept", ""):
return httpx.Response(200, content=content)
encoded = base64.b64encode(content).decode()
return httpx.Response(200, json={"encoding": "base64", "content": encoded})
if method == "POST" and rest == "/git/blobs":
sha = self._id("b")
self.blobs[sha] = base64.b64decode(body["content"])
return httpx.Response(201, json={"sha": sha})
if method == "POST" and rest == "/git/trees":
base = self.trees.get(body.get("base_tree"))
if base is None:
return httpx.Response(422, json={"message": "base_tree not found"})
files = dict(base)
for item in body["tree"]:
files[item["path"]] = self.blobs[item["sha"]]
sha = self._id("t")
self.trees[sha] = files
return httpx.Response(201, json={"sha": sha})
if method == "POST" and rest == "/git/commits":
sha = self._id("c")
self.commits[sha] = {"tree": body["tree"], "parents": body["parents"], "message": body["message"]}
return httpx.Response(201, json={"sha": sha, "verification": {"verified": False, "reason": "unsigned"}})
if method == "PATCH" and rest == f"/git/refs/heads/{self.branch}":
if self.concurrent_writes > 0:
self.concurrent_writes -= 1
self.other_writer_commits()
commit = self.commits.get(body["sha"])
if commit is None or body.get("force") or commit["parents"] != [self.head]:
return httpx.Response(422, json={"message": "Update is not a fast forward"})
self.head = body["sha"]
if self.landed_but_failed:
return httpx.Response(self.landed_but_failed.pop(0), json={"message": "Server Error"})
return httpx.Response(200, json={"object": {"sha": self.head}})
return httpx.Response(404, json={"message": f"stub: no route for {method} {path}"})
Loading
Loading