Skip to content

Publish the ACS site and schemas from this repository - #45

Merged
rocklambros merged 34 commits into
mainfrom
feat/github-pages-site
Sep 5, 2026
Merged

rocklambros merged 34 commits into
mainfrom
feat/github-pages-site

Conversation

@rocklambros

Copy link
Copy Markdown
Contributor

Enables GitHub Pages and publishes three things on every merge to main: a landing page matching the agentcontrolstandard.org design, the existing MkDocs specification site, and all 44 JSON schemas at the URIs their $id values declare.

Those $id URIs have always returned 404 because Pages was never enabled. Closing that is the point of this change.

What ships

Path
/ Landing page, self-hosted fonts, no third-party requests
/docs/ The specification site, themed to match
/schema/v0.1.0/ 44 schemas, each served at the URI its $id declares

Schema publish paths derive from each schema's own $id, validated and contained, and the build fails if any $id or $ref does not resolve, fragment included.

Review history

The plan went through a six-perspective adversarial premortem, per-task reviews with fix loops, a whole-branch review, and a re-review. Findings closed include:

  • $id was an unvalidated filesystem write primitive. A tail of ../index.html overwrote build output and an absolute tail escaped the output tree entirely.
  • GOVERNANCE.md could inject HTML into the published page through an unescaped quote in an href attribute.
  • The test suite never ran in CI, so every guard expired the moment a developer stopped running it by hand.
  • Every documentation page declared a canonical URL missing the /docs/ segment, and all 36 sitemap entries pointed at addresses that 404.
  • The documentation fetched release counts from api.github.com on every page load, sending each reader's IP to a third party.
  • The no-third-party guard read only HTML, so a url() in the stylesheet every page loads would have shipped with the suite green. Demonstrated, then fixed, then demonstrated failing.
  • Five inherited colour tokens failed measured WCAG contrast, the focus ring at 1.48:1 against a 3:1 requirement.

83 tests, all passing.

Ordering note

CONTRIBUTING.md records that Pages must be enabled before this merges. Until it is, the deploy fails at Configure Pages and the scheduled monitor fails on its cadence.

Design: design/2026-09-05-github-pages-landing.md
Plan: design/plans/2026-09-05-github-pages-site.md

Records the design for publishing three artifacts from this repository
to GitHub Pages on every merge to main: a landing page matching the
visual design of agentcontrolstandard.org, the existing MkDocs
specification site, and the JSON schemas.

The schema piece closes a known gap. All 44 schemas declare an $id under
the project-controlled GitHub Pages base, but Pages has never been
enabled, so every one of those URIs returns 404.

Publish paths derive from each schema's own $id rather than from
hardcoded directory names, and the build asserts that every $id and
$ref in the package resolves to a published file. A future spec version
directory publishes with no workflow edit.

Drops the Google Analytics block from the site config. Verified against
the real config that Material emits a googletagmanager script tag with
an empty ID when no key is set, including when the variable is unset
entirely, which leaks referrer and client IP for no benefit.
Six tasks, each ending in an independently testable deliverable:
the schema publisher, the landing page, build-time content injection,
the analytics removal, the deploy workflow, and Pages enablement.

Both pieces of custom logic in the plan were run against real
repository data before the plan was written down. The publisher places
all 44 schemas at their declared paths, including relocating
acs_schema.json out of ACS/ into v0.1.0/, and passes ref closure. The
governance parser reads five workstreams and skips the project lead
table above it.
Six independent reviewers attacked the plan and returned 36 findings at
Plausible or above, four of them Critical. Most were reproduced by
execution rather than argued, and the fixes below were verified the same
way against the real 44-schema tree.

The four Critical findings:

$id was an unvalidated filesystem write primitive. A tail of
../index.html overwrote the rendered landing page inside the shipping
artifact, and an absolute tail escaped the output tree entirely, because
pathlib discards the left operand when the right is absolute. It is now
pattern-validated, percent-decoded, and containment-checked.

GOVERNANCE.md could inject HTML into the published page. Escaping with
quote=False left a double quote intact while interpolating the captured
URL into an href attribute. Now escaped with quote=True behind a scheme
allowlist, asserted by parsing the output rather than grepping it.

The test suite never ran in CI. The deploy installs with --no-dev, which
excludes the group pytest lives in, so every guard expired the moment a
developer stopped running it by hand. A test job now gates the build.

The enablement runbook could not pass its own second step, because
configure-pages fails before Pages exists. It is skipped on pull
requests, and enablement moves ahead of the first deploy.

Also closed: fragment refs were verified to the file and never the
pointer, so renaming a $defs target shipped a broken package green; the
hero diagram was a hand-applied paste invisible to the placeholder
check, which shipped a blank hero with all tests passing; the smoke test
read a status code with the body discarded and its retry flags were
inert against a 404; a second spec version failed the deploy the design
advertised it would survive; duplicate $id collapsed two schemas
silently; five inherited colour tokens failed measured WCAG contrast,
the focus ring at 1.48:1 against a 3:1 requirement; and the page loaded
fonts from the same vendor whose analytics tag was removed for leaking
referrer and client IP.

Policy catches up with hosting: SECURITY.md put the site out of scope
and named the wrong domain, LICENSING.md covered none of the new
directories, and CODEOWNERS gated the workflow while leaving the code it
runs under the widest rule in the file.
The aggregate of 63 was right while both components were wrong, so the
per-task numbers were recomputed from the plan text rather than trusted.
Task 1 defines 29 tests once the parametrized cases are counted, and
Task 3 defines 30 across its two files.
Derives each destination from the schema's own $id rather than from
hardcoded directory names, then asserts that every $ref inside the
namespace resolves, fragment included.

Treats $id as untrusted input. It is a pull-request-writable string used
to build a filesystem path, and a fork's copy reaches this code on the
runner before review. An $id tail of ../index.html overwrote the
rendered landing page in the artifact, and an absolute tail wrote
outside the output tree entirely, because pathlib discards the left
operand when the right is absolute. Both are now rejected by pattern and
by a resolved-path containment check.

Also rejects a duplicate $id, which previously collapsed two schemas
into one published file with no error, and a draft under proposals/
claiming the normative namespace. Verifying the fragment as well as the
file catches a renamed $defs target, which the package depends on for
its signature definition.
Tokens mirror agentcontrolstandard.org, except five that failed a
measured contrast check and are annotated with their computed ratios.
The focus ring was a translucent overlay compositing to 1.48:1 against
the page where WCAG SC 1.4.11 requires 3:1, and the hexagon stroke at
the centre of the diagram measured 1.47:1 against its own fill.

The hero is a machine-filled placeholder rather than a hand-applied
paste, so the renderer's placeholder check covers it. Inter ships with
the site so the page contacts no third party, which is the same
reasoning that removed analytics.

The contact section separates general questions from vulnerability
reporting and Code of Conduct reports, so publishing an address does not
route those away from the channels that handle them independently.
tests/test_landing_page.py appeared in both Task 2's and Task 3's file
lists. It belongs to Task 3: the guards assert properties of the
rendered page, which does not exist until the renderer does.
The shipped comments carried three semicolons in prose and three
British spellings, both of which the project's writing constraints
forbid. The wording came from the plan, so the plan is corrected in
the same change.
The landing page review found three prose semicolons and three British
spellings in comments the plan supplied verbatim. Sweeping the whole
document for that class caught six more instances of licence in Task 6,
including a step heading.

The constraint applies to the shipped artifact, so the plan is corrected
to match what Task 2 now ships rather than the reverse.
Reads the version from the schema $id namespace and the roster from
GOVERNANCE.md, so neither can drift. The hero diagram is injected the
same way, because a hand-applied paste was invisible to the placeholder
check and shipped a blank hero with a green suite.

Escapes GOVERNANCE.md content with quote=True and rebuilds links only
from an allowlisted scheme. The previous quote=False left a double quote
intact while interpolating the URL into an href attribute, so a roster
row could add an event handler or a javascript: target to the published
page.

The parser now tolerates heading case and spacing, keeps escaped pipes,
stops at any heading level, and fails loudly on a wrong-width row rather
than dropping a workstream in silence.

Page guards now run against render() output. Running them against the
template left the injected sections, the only part no human reviews,
outside every check.
Three defects survived the earlier sweep because they were joined to
other tokens: hex_colour inside an identifier, licence inside a test
name, and actually inside a docstring. The word-boundary pattern used
for the sweep could not see any of them.

The implementer corrected all three while transcribing and reported
doing so. The plan now matches what ships.
Material emits a googletagmanager script tag whenever the analytics
block exists, including when GOOGLE_ANALYTICS_KEY is unset entirely, and
separately fetches Roboto from fonts.googleapis.com on every page.
Publishing as configured would have sent two third-party requests
carrying the referrer and client IP of every documentation reader, one
of them for no analytics data at all.

No environment value suppresses either, so the analytics block is gone
and theme fonts are off. The guard now asserts that no page fetches an
unexpected origin, rather than grepping for one vendor string.
The previous pattern matched every href, so ordinary prose links in
the documentation counted as asset fetches and had to be allowlisted
one by one. That widened the allowlist to include Google-hosted
domains, which would have let a real third-party script through the
guard that exists to stop one.

Matching only loading constructs makes the correct result an empty
set, so the guard needs no maintenance as the prose adds citations.
The test matched every href, so prose citations in the documentation
counted as asset fetches and had to be allowlisted individually. That
widened the list to include Google-hosted domains, which would have let
a real third-party script pass the guard that exists to stop one.

Matching only loading constructs makes an empty set the correct result,
so the guard needs no maintenance as the prose adds citations.
Task 4 supplies the guard. Task 5's test job, which build depends on,
is what runs it on every pull request. The header claimed enforcement
Task 4 has no means to provide.

pyproject.toml was also listed as a Task 4 modification, though Task 1
had already added the dev dependency group.
Adds a test job so the guards run on every pull request. Without it the
suite only ran when a developer remembered to, and the deploy job
installs with --no-dev, which excludes the group pytest lives in.

The deploy job now also checks the ref. workflow_dispatch can target any
branch, and gating on event name alone let a feature branch publish to
the production site. Concurrency groups every deploy together so they
serialise on the one Pages site, while pull request builds group per ref
and cancel stale runs.

Verification fetches and parses each document and asserts it serves its
own $id. The previous check read a status code with the body discarded,
so a file overwritten with different content still passed, and its
retry flags were inert: curl does not retry a 404 without --fail, which
is exactly how CDN propagation lag presents.

configure-pages is skipped on pull requests. It fails when Pages is not
enabled and a fork's token cannot read the Pages API. Artifact retention
goes to 30 days so re-running the last good deploy stays available as a
rollback.
The deploy job runs actions/checkout so the verify step can reach
tools/verify_published.py, but its permissions block listed only pages
and id-token. A job that declares its own block gets none for every
scope it omits, so the checkout had no read grant.

The other three jobs already restated contents: read for the same
reason. Only deploy was missing it.
The job runs actions/checkout so the verify step can reach
tools/verify_published.py, but its permissions block listed only
pages and id-token. A job that declares its own block gets none for
every scope it omits, so the checkout had no read grant and the
deploy would have failed before publishing anything.

The workflow keeps permissions denied by default at the top level,
so each job restates what it needs.
SECURITY.md put the documentation site out of scope and named the wrong
domain. Once this workflow ships the project operates a site, so a
researcher reading the policy would have been told not to report a
finding against it.

The contact-channels paragraph is replaced whole. Replacing only its
first sentence would have left a later sentence forbidding the exception
the first half grants, and dropping the paragraph would have deleted the
RFC 2606 rule that licenses eleven example addresses in docs/.

LICENSING.md covered none of landing/, tools/, tests/, or design/, so
the footer named licenses for files the scope map did not reach. It also
now records where the reused design tokens and diagram came from.

CODEOWNERS gated the workflow file and left the code that workflow runs
under the widest rule in the file. tools/, landing/, and GOVERNANCE.md
now carry the same admin review as the CI surface, because all three
decide what the public front door says.
CLAUDE.md records that the schema base 404s because Pages is not
enabled. That is accurate until Pages is turned on and false the moment
after, so Task 7 now replaces it once the URIs are confirmed resolving
rather than before.
The three rows the plan added carried two cells against a three-column
table, so they lacked the machine-readable identifier the table exists
to provide.

The hosting paragraph also described the site as already published,
which contradicted the schema namespace section a few paragraphs later
saying the base is not served because Pages is not enabled. The
namespace sentence is the accurate one today, so the hosting text now
waits, and Task 7 Step 10 updates both together once the URIs resolve.
The three rows added for landing, tools, tests, design, and the
vendored font carried two cells against a three-column table, so
they lacked the machine-readable identifier the table exists to
provide.

The hosting section also described the site as already published.
Pages is not enabled yet, which the schema namespace section states
correctly a few paragraphs later, so the two contradicted each other.
The built-with section listed seven names in a sentence. Each now
carries what ACS does with it, which says more than the logo wall the
source site used and avoids vendoring third-party marks.

The theme toggle rendered as a raw browser button beside pill-shaped
calls to action. Body text is also capped at a readable measure, which
was why the middle sections looked thin on a wide screen.

The page's origin guard matched every href, so the citations this adds
would have failed it. Scoping it to resource loads matches the sibling
guard in test_site_config.py, which had the same defect fixed earlier.
The two surfaces read as different websites: different typeface,
different palette, different mark. That was fine when the specification
link pointed at a separate property. Serving both from one origin makes
the break look like a mistake.

Material's own variables carry the landing page's tokens, so the docs
inherit the type, color, and shape without giving up Material's
navigation and search. The vendored Inter is reused rather than
refetched, so nothing loads from a third party.

Fixes a bug found while investigating. MkDocs built into _site/docs/
while site_url named the site root, so all 37 pages declared a canonical
URL missing the /docs/ segment and the sitemap listed 36 URLs that all
returned 404.
Ships the SIL Open Font License text and the Inter copyright notice
with the font the landing page vendors, which OFL-1.1 requires of
anyone redistributing it. NOTICE and the licensing scope map name it.

Points the README at the site this repository publishes. It still sent
readers to the .ai domain that SECURITY.md dropped in the same branch,
so the new front door had no inbound link from the repository.

Extends the restricted owner list to the build inputs that carry the
same publish privilege as the tools themselves. mkdocs.yml accepts a
hooks key that executes Python inside the build job.

Records in CONTRIBUTING.md that Pages must be enabled before these
workflows land, because the deploy and the monitor both fail until it
is, and nothing in the repository said so.

Also stops the sidebar clipping its own theme toggle on a short
viewport, drops a class the stylesheet never defined, stops publishing a
second copy of the inlined diagram, and makes the recorded font checksum
load-bearing rather than decorative.
… load

Material's bundled JavaScript fetches release and star counts from
api.github.com whenever the source partial carries
data-md-component="source". That sends every reader's IP address and
referrer to a third party on every page load, the same property this
branch already closed twice, once for Google Analytics and once for
the Roboto font fetch.

The existing guards scan src, href, and @import in markup, so a
runtime fetch was invisible to them. Overriding the partial under
overrides/, outside docs_dir so the override itself is never published,
keeps the repository link and drops the attribute that starts the
fetch. The new tests assert the trigger is absent from the built pages
and that the link survives the override.
overrides/partials/source.html renders into every documentation page,
so a change there reaches the published site as directly as a change
to the tools that build it. Adding the directory closed the gap that
adding the directory opened.

Also drops two em dashes from CONTRIBUTING.md, which the repository's
writing rules exclude.
The no-third-party guards read only HTML, so a url() in the stylesheet
every documentation page loads would have shipped with the suite green.
Demonstrated before fixing. Both guards now read stylesheets too and
share one definition, because two copies of the same regex is how one of
them stops matching without anyone noticing.

The directories holding that stylesheet and the theme assets now carry
the same restricted ownership as the theme templates, for the same
reason: all three render into every page, and a stylesheet reaches a
third party with no script at all.

Records that the source partial derives from Material for MkDocs under
the MIT License and notes that the mark exists at two paths under one
license. The overrides scope row for LICENSING.md is held pending a
separate licensing question and is not part of this commit.

Also fixes the theme toggle needing two clicks when the system
preference is dark, guards the duplicated mark and the diagram exclusion
against drift, and stops CLAUDE.md telling a future session the schema
namespace cannot be resolved.
The tree exists only to render the documentation site, so it belongs
with the documentation in the license scope map rather than with the
code.

The source partial is now written for this project rather than copied
from the theme. It keeps the class names the theme's stylesheet
supplies and drops the icon block, so the header shows the repository
name and links to it without the component hook that fetched release
counts from a third party on every page load.

The staleness test compared the file against the theme's own partial,
which only made sense while it was a copy. It now asserts the theme
still defines the classes the override depends on, so an upstream
rename fails the suite instead of silently unstyling the header.
Uses the GitHub mark from the Simple Icons set the theme bundles,
which is CC0 and so adds no attribution obligation to the pages that
inline it. The theme's default mark comes from a set with heavier
terms, and the icon is redistributed in every published page.

The component hook that fetched release counts from a third party
stays absent, which is the reason this override exists.
The icon disappeared once when the partial was rewritten, and a
person had to notice. Asserting the mark is inlined makes that a
failing build rather than something to spot.
The landing page had a pill reading "Switch theme" and the
documentation had a switch glyph cycling three states. Neither used
the marks readers already know, and the two behaved differently on
one site.

Both are now two-state controls showing the theme a click would give
rather than the one already showing. The documentation drops its
named Material palettes at the same time, because the ACS tokens in
the docs stylesheet already supersede them.

The landing page's marks are plain geometry written for this page,
so the control adds no third-party license.
The theme sets --md-typeset-a-color from its primary colour, and the
built pages carry a primary attribute whether or not the palette
names one. That tied with the scheme rules at equal specificity, so
documentation links rendered the theme's indigo in dark mode instead
of the project's blue.

Matching on both attributes settles it without touching the other
tokens.
The landing sidebar drew the project mark as styled text while the
documentation header drew the same file as an image. Both now show
the image.

The specification moved out of the external resources block. It is
served from this site, so it sits with the sections and leaves that
heading to GitHub and Slack, which are genuinely elsewhere.

Records that OWASP holds the rights to the reused design, which the
licensing notes had carried as an open question.
@rocklambros
rocklambros merged commit 337bd62 into main Sep 5, 2026
3 checks passed
@rocklambros
rocklambros deleted the feat/github-pages-site branch September 5, 2026 23:17
rocklambros added a commit that referenced this pull request Sep 5, 2026
Follow-up to #45, deliberately deferred until the URIs were confirmed
resolving.

The hosting notes in `CLAUDE.md` were written conditionally while Pages
was disabled, so they described what would happen rather than what does.
Writing them earlier would have traded one false statement for another.

Also records the rollback procedure the design carried as an open item,
and the live status: first deploy succeeded, all 44 schema URIs
confirmed serving their own `$id`, environment restricted to protected
branches.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant