Repository navigation
publish, publish_attestation and GitHubPagesHost: one Git Data API commit to a publish-mode host (typedstandards#141 P2) - #8
Merged
Conversation
…ped stub The tests for typedstandards#141 P2's acceptance 1 to 5, against a stub module whose names raise NotImplementedError, so the suite collects and each new test fails at its call: - tests/test_publish.py: a new record, a listed hash, a listed name with and without revises=, the name rules, a BlobRef output, the policy's signer, role and type, an empty title, the non-fast-forward retry, publish_attestation, and the Git Data API as the only writer; - tests/test_publish_token.py: the token's source and shape, a scanner of every captured output driven over each publish path and over offenders, no file opened, and the host's repr; - tests/test_readme.py: the README's publishing section; - scripts/github_stub.py: an in-memory GitHub API for httpx.MockTransport; - tests/fixtures/template-host*.json: the host template's host.json and host-policy.json at 70bfd18, verbatim and pinned by SHA-256. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LJBCtCpvKX2vdg53xqw8ji Signed-off-by: Nathan Storey <npstorey@users.noreply.github.com>
ts.publish(signed, host=, title=, name= or notebook=, role="notebook", revises=None) reads
host.json and host-policy.json at the branch head and writes the signed file and the edited
host.json in one commit: a blob each, a tree on the head's tree, a commit whose parent is the head,
and a fast-forward of the branch. A ref update that does not answer 200 re-reads the head first;
if the commit did not land and the branch moved, the call plans again from the new head once.
ts.publish_attestation(node, host=, name=) adds what withdraw or attest printed to a listed
record's entry the same way.
Refused before any write: a token that is not a fine-grained one or holds whitespace, a quote or
op://, a name failing host-core's rule or with a records or evidence segment, an empty title, a
BlobRef output, a signer or type the policy does not name, a role no active rule admits
(typedstandards#141 G0-3), and a listed name with another record unless revises= matches by type,
successorNodeId and targetNodeId (G0-4). A listed hash is a no-op. The receipt is
{name, commit, bundle_url, verify_url, registry_url, written, run: None}.
The module computes no hash (blob ids come back from the API), runs no CLI and reads no seed. The
token comes from token=, else TYPEDSTANDARDS_GITHUB_TOKEN when a publish runs, and is held only in
the client's Authorization header. scripts/smoke_publish.py is the live check for gate G3;
scripts/smoke_wheel.py now also publishes over MockTransport.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LJBCtCpvKX2vdg53xqw8ji
Signed-off-by: Nathan Storey <npstorey@users.noreply.github.com>
httpx's default trust_env=True iterates os.environ for proxy variables when a client is built, and the environment of a signing notebook holds the seed. The client publish builds now passes trust_env=False, so it reads no variable but the token's (and httpx's own SSLKEYLOGFILE) and no .netrc. A caller who needs a proxy or a certificate bundle passes client=. The new test drives the live-client path under the guard tests' RecordingEnviron; with trust_env=True it fails on read_all. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LJBCtCpvKX2vdg53xqw8ji Signed-off-by: Nathan Storey <npstorey@users.noreply.github.com>
The README's new section documents GitHubPagesHost, publish and publish_attestation, the default name first and then the derived name a listed name takes with revises=, the refusals, the receipt's keys (run is None: publish does not wait for the deploy), the token (fine-grained, one repository, Contents read and write, never a literal in a cell), and the seed in a hosted notebook: the one line that sets it from the hosting service's secret store, the unsafe forms, the custody sentence, and GitHub Codespaces. publish and publish_attestation join the calls that run without Node; a test pins it. CHANGELOG: under Unreleased; the version stays 0.1.1. The README test's ordering assertion compares the default name with the derived-name rule rather than with the first "revises=", which the call's signature line carries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LJBCtCpvKX2vdg53xqw8ji Signed-off-by: Nathan Storey <npstorey@users.noreply.github.com>
A notebook's verbose traceback (IPython's %xmode Verbose) prints the locals of every frame it shows. The scanner now also renders each exception with capture_locals=True, and an offender that raises from a frame holding the header proves it fails there. Against the current module, every token-shape refusal fails: the value is a local of the frame that raises. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LJBCtCpvKX2vdg53xqw8ji Signed-off-by: Nathan Storey <npstorey@users.noreply.github.com>
The token's shape is checked by a helper that returns the refusal's words, and the value is deleted before the refusal is raised. The request headers are built inside the calls that use them, so neither the API's constructor nor its request method holds them as a local. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LJBCtCpvKX2vdg53xqw8ji Signed-off-by: Nathan Storey <npstorey@users.noreply.github.com>
…did:key read A publish-mode copy starts with "records": [] and no records/ directory (the template's setup, step 5). The new test publishes into that state: the first entry is appended to the empty list and the tree on base_tree creates records/<name>.signed.json. It passes as written: publish already handles both. The README test fails: the README does not yet show the author how to read their did:key, which the template's setup puts in host-policy.json, before the first publish. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LJBCtCpvKX2vdg53xqw8ji Signed-off-by: Nathan Storey <npstorey@users.noreply.github.com>
…s setup The template's setup puts the author's did:key in host-policy.json's signer, and publish refuses any other signer. The README shows reading it from the first signed record (signed["package"]["signer"]["identifier"]), since the CLI prints a did:key only in what it signs, and links the template README's "Publishing from a notebook" section rather than restating it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LJBCtCpvKX2vdg53xqw8ji Signed-off-by: Nathan Storey <npstorey@users.noreply.github.com>
npstorey
marked this pull request as ready for review
October 8, 2026 12:26
…eaches host-core 0.1.1's build refuses one envelopeHash listed twice (build.ts:65-66 at typedstandards 116882a), so publish must not write a record any listed entry already carries, under any name. Three tests: a hash listed as "a" published as "b"; a hash listed under one default name published with notebook= of another stem; a revision whose hash is already listed. Each expects no write and a receipt naming the listed entry; at 576c637 each writes a commit. The read of every listed record's signed file adds a GET per entry to the expected request sequences, and a test pins one read per file per call. From a read of every HostError the build reaches, the throws publish did not yet refuse, a test each: a signer, display name or key unlike the listed records' (build.ts:93-94); a signature kid other than the signer (96-97); a signature host-core's checkSignature refuses (records.ts:44-48); an empty createdAt (build.ts:61-62); with registry null, a signer that is not self-certifying (147-148, produce-core's view); a signed or node path another entry already names (65-66, 70-71); a host.json parseManifest refuses (manifest.ts); a listed record whose file cannot be read (build.ts:49-50); a file host-core's strict UTF-8 JSON.parse refuses (json.ts:31-41); and, for the workflow's display step, a withdrawal or supersession no policy rule displays. The test host's first-note file is now a record signed by the test key, as a copy's would be. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LJBCtCpvKX2vdg53xqw8ji Signed-off-by: Nathan Storey <npstorey@users.noreply.github.com>
…build would host-core 0.1.1's build refuses one envelopeHash listed twice (build.ts:65-66), so a record published under a second name, or under the default name of another stem, fast-forwarded main to a manifest every later build refuses. publish now reads every listed record's signed file, once per call, and a record whose hash any entry carries is not written: written is False and the receipt names that entry, with its bundle_url and verify_url. The revises= target is found in the same reads. From a read of every HostError the build reaches at typedstandards 116882a, publish also refuses before any write: a file host-core's strict UTF-8 JSON.parse refuses; a signature checkSignature refuses or a kid other than the signer; an empty createdAt or signer.identifier; under a registry, a signer, display name or key unlike the first record's; with registry null, a signer that is not a pseudonymous did:key; a path another entry names; a host.json parseManifest refuses (ported); and a host whose build already fails on a listed record. publish_attestation refuses a withdrawal or supersession that would leave its record in a status no policy rule displays, which the template's display step would refuse. Two earlier tests published one document under two names and expected a second write, the defect itself; they now publish distinct records. One revises= case lists a third record by the test key instead of one signed by another key, which the scan now refuses first. README.md and CHANGELOG.md state the behaviour and its cost: one read per listed record. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LJBCtCpvKX2vdg53xqw8ji Signed-off-by: Nathan Storey <npstorey@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
P2 of the notebook-publish sprint (npstorey/typedstandards#141; G0 record: its comment 6027279902).
A notebook author publishes a signed record to a GitHub Pages host made from the host template's publish mode, with one call.
ts.GitHubPagesHost("owner/repo"),ts.publish(signed, host=…, title=…, notebook=… or name=…, role=…, revises=…)andts.publish_attestation(node, host=…, name=…)each write one Git Data API commit: blobs, a tree onbase_tree, a commit, and a fast-forward of the branch. Every refusal happens before any write. The token reaches no output. The README documents all of it, including the seed's custody in a hosted notebook. There is no release in this PR: the version stays 0.1.1, and the changes are under## Unreleased.Live smoke done (G3, 2026-10-08 at
576c637; again 2026-10-09 at9e24876): see "Live smoke" and "Fix after the seat's review" below.Branch and blast zone
ts141/p2-publish, head9e2487694d2c775cac363ebc4fd583b102fa2363, on6c0c20c. There are ten commits, each SSH-signed with a sign-off. The last two,3e10022(red) and9e24876(fix), answer the seat's review of576c637: see "Fix after the seat's review" below. The numstat that follows is at576c637.These are unchanged, as
git diff origin/main...HEADover them is empty:tests/guards.py,tests/test_guards.py,.gitleaks.toml,package.json,package-lock.json,pyproject.toml,.github/, the CLI pin, the Node floor and the version. Nothing undersrc/importshashlib, names the seed variable, passesenv=or changesos.environ.Acceptance (#141 P2, with G0-3 and G0-4)
Red at
130585a, over a typed stub:89 failed, 249 passed. Each new test fails at its call (NotImplementedError) or at the missing README section. Green at head:346 passed.httpx.MockTransportpaths (tests/test_publish.py,tests/test_publish_token.py):written: False.revises=;<name>-<first 8 hex of its envelopeHash>, with the revises node on the target's entry, in one commit.recordsorevidencesegment is refused (4 cases); a name failing host-core's rule is refused (10 cases).github_pat_, or holds whitespace, quotes orop://, is refused, and the message does not contain the value (10 cases)."records": []and norecords/directory (the template's setup, step 5); the first entry is appended.repr, exceptionstrorrepr, or traceback, including a traceback with frame locals.test_the_scanner_fails_on_an_offenderdrives it over a path that leaks: red at130585a, green with 6 cases./contents/, and one commit per ref update._Api.writerefuses anything but POST or PATCH under/git/./contents/lines insrc/are GETs.token=, elseTYPEDSTANDARDS_GITHUB_TOKEN, read when the call runs.repr(GitHubPagesHost(...))shows the repository and branch only, and the host neither pickles nor exposesvars().tests/test_readme.pychecks each of these:TYPEDSTANDARDS_SIGNING_SEED_B64from a hosted platform's secret store;<stem>/<date>-<8 hex>, plan §7), which comes before the derived-name rule, per the seat's note on G0-4;did:keyfrom the first signed record, before the first publish.The Codespaces line rests on GitHub's documentation, read 2026-10-06 (the account-specific Codespaces secrets page; the Actions secrets and variables concept pages). That a Jupyter kernel started in a codespace inherits the variable was not measured.
576c637:346 passedon Python 3.11, 3.12 and 3.14 with Node v24.21.0;ruff check:All checks passed!;ruff format --check:46 files already formatted.346 passed), and the wheel job from a fresh clone: sdist then wheel, installed into a fresh environment,scripts/smoke_wheel.pyexit 0. Its new line reads:publish: … in one commit, then a no-op, then a withdrawal, over MockTransport.scripts/smoke_publish.py(G3). It signs one record with the installed wheel and publishes it to the scratch copy. It prints only the receipt's fields. It exits 2 on a refusal, 3 on an API error, 4 on a CLI error and 5 on an origin mismatch. Three tests run it over the stub API. It has not been run live.gitleaks over
origin/main..HEAD: no leaks found. An offline emulation of the push guard's keyword scan (digests masked,pushguard.allowapplied) gave 0 hits over the range.Fixture provenance
tests/fixtures/template-host.jsonhost.json70bfd188a80c9ea344196ac2b27b3a91eb3439f9217e72c5491b18540d253925b28a6fetest_template_fixture_is_the_verbatim_copy)tests/fixtures/template-host-policy.jsonhost-policy.json70bfd189150db40fee1f2e17bf09d128c080f0bee9a3ac0c8bda9dbd0467e11a144c0f8Signed documents and nodes are made in each test session by the vendored CLI 0.2.0, under fresh seeds, and are not committed.
Deviations and premises that did not hold as written
notebook=. The signed document carries no file name. Only the stem is used, and the file is not read. The date iscreatedAt's, in UTC.trust_env=Falseonpublish's own client. httpx's default reads the whole environment, which holds the seed, when a client is built (measured). A caller who needs a proxy or a certificate bundle passesclient=.pinstill uses the default; that is out of scope here.%xmode Verbose) prints frame locals. The refusal path no longer holds the token as a local; tested red, then green.9e24876; see "Fix after the seat's review".typethe policy's top-leveltypedoes not name is refused, for the same reason as the role check: it would fail every later build.Flags, not fixed
SSLKEYLOGFILEeven withtrust_env=False.pinhas the same exposure.Live smoke (G3), 2026-10-08
The wheel was built at this PR's head (
576c637) from a clean detached worktree, and installed into a fresh Python 3.12 environment. The owner ran the smoke from that environment, against the scratch copynpstorey/typedstandards-publish-check. The token and the copy's kept seed reached the process only throughop run.Read back by ORCH:
GET commits/b9c0c2b: one commit, parentf166c95(the copy's previous head, so a fast-forward), messagePublish publish-check/2026-10-08-c18084e6. Files:host.jsonmodified,records/publish-check/2026-10-08-c18084e6.signed.jsonadded.verification: unsigned, as #139 F7 found for API commits. Committed at 12:24:45Z. The rules onmainaredeletionandnon_fast_forward, and they let the commit through.publishrun (37776666814) went from 12:24:48Z to 12:25:15Z:success.checkwasskipped.records.json:{"name":"publish-check/2026-10-08-c18084e6","status":"active","extensions":{"role":"notebook"}}, its only record.<stem>/<date>-<8 hex>: the stem fromnotebook=, the date fromcreatedAt(UTC), and the first eight hex of theenvelopeHash.Fix after the seat's review (
9e24876)The defect at
576c637. "The same signed document under two explicit names becomes two entries" is refused by host-core 0.1.1, the version the template pins. At typedstandards116882a,packages/host-core/src/build.ts:65-66throws… is the record <other> already serves (envelopeHash …). So a publish under another name fast-forwardedmainto a manifest that every later build refuses. That broke D8 A and the acceptance line "a listed hash: no write, written: False".The fix. Each call reads every listed record's signed file, once (one GET each). A record whose
envelopeHashany entry carries is not written:written: False, and the receipt names that entry. This holds undername=, undernotebook=, and for therevises=derived name. README:273 is corrected, and the README states the per-call cost.Red at
3e10022, green at9e24876. ORCH reproduced the three reds in a detached worktree:At
9e24876, ORCH ran368 passedon Python 3.11, 3.12 and 3.14 with Node 24. ruff is clean, gitleaks finds nothing inorigin/main..HEAD, and the guard files are unchanged. The implementing session also ran Python 3.12 with Node 22 (368 passed), and the wheel job from a fresh clone (smoke exit 0).Earlier tests changed in the fix commit.
docs.firstagain under a new name and expected a write, which is the defect. They now publish another record:test_every_write_is_a_git_data_call(docs.third);test_the_token_comes_from_the_argument_else_the_environment(docs.secondanddocs.thirdfor "b" and "c");test_revises_whose_fields_do_not_match_is_refused[another target]listed a record by another key, which the new scan refuses first. It now lists a same-signer record.paths()now includes the scan's reads.Every host-core refusal a publish could trigger (at
116882a): eachHostErrorthat build reaches was mapped to a refusal before any write, with its test, or argued unreachable. Beyond the duplicate hash, the fix also refuses:kiddisagrees with the records already listed (build.ts:94,:97);parseManifestwould refuse (the manifest is checked as it would be written);display.ts:142);The full table is in the implementing session's report, held by ORCH.
Flags, not fixed.
supersededrule, sopublish_attestationof a supersedes node is refused on a stock copy. P3 publishes none. Whether the template should carry that rule is a later ruling.verify.publishdoes not run the CLI (plan §9), so only a hand-edited document reaches that.G3 again, at
9e24876(2026-10-09). The wheel was rebuilt at this head into a fresh environment, and the owner ran the same command:Read back by ORCH:
75183d7, one commit, parentb9c0c2b(a fast-forward), unsigned. Files:host.jsonmodified,records/publish-check/2026-10-09-ef245b02.signed.jsonadded.publishran 01:03:35Z to 01:04:00Z:success.application/json; charset=utf-8withacao=*at 01:04:16Z.records.jsonlists both records,active, rolenotebook.A correction to the record. The copy had two failed
publishruns, not one:827b628(the deliberate failing record) andf166c95(the re-key, an empty manifest refused at build). Both are expected and documented.Model
Implemented by IMPL P2 on Opus 5.5 at high effort, as the impl agent file pins. Re-verified by ORCH NOTEBOOK-PUBLISH on Opus 5.5.
🤖 Generated with Claude Code