Skip to content

fix(site): write supporter register to staging branch - #319

Merged
tnunamak merged 8 commits into
mainfrom
fix/register-bot-branch
Sep 3, 2026
Merged

fix(site): write supporter register to staging branch#319
tnunamak merged 8 commits into
mainfrom
fix/register-bot-branch

Conversation

@tnunamak

@tnunamak tnunamak commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

A supporter who opens the website’s emailed confirmation link, the link that finalizes a pending supporter signature by creating its private record, could receive a failure if the private GitHub repository that stores supporter records uses its default Git branch. The daily GitHub Actions job, GitHub’s hosted workflow runner for repository automation, that copies safe fields into the public supporter list could also be blocked if it wrote directly to the protected default branch, main.

The website now sends every private repository file mutation through GitHub’s Contents API, the endpoint used here to create or delete files, to PDPP_PRIVATE_REPO_BRANCH, the deployment environment variable that names the target private-repository branch; it defaults to signatures. A failed private write becomes SigningUnavailableError, the website error type that returns a safe unavailable-service response instead of exposing the GitHub failure. The public publisher, the GitHub Actions job that copies only country, principlesVersion, publicName, signedOn, and type, checks out the private signatures branch and opens a pull request that updates only the generated public supporter register rather than writing main.

Round 2

The workflow resets its generated-output branch from the selected base branch, creates a signed bot commit, and force-pushes only that branch. Provider tests, the tests for the website module that writes supporter records to GitHub, mock GitHub API responses. They prove that the Contents API PUT request carries the configured branch and Developer Certificate of Origin (DCO) sign-off trailer, that an unset branch chooses signatures, and that withdrawal, the operation that removes a previously confirmed supporter record, does not search possible record-file paths after the configured branch lookup fails. The public-register test fixture, a sample private supporter record, includes an IP address and nested private data but asserts that neither reaches the five named public fields.

Round 3

The site verification command, pnpm --filter pdpp-site verify, runs type checking, all site tests, source-style checks, generated-artifact validation, and the production build. It previously failed because the new provider tests violated five source-style rules: the repository’s automated checks for import order, destructuring, top-level regular expressions, formatter output, and related formatting constraints. Those tests retain the same branch-failure scenarios and now satisfy those rules; the full command passes.

publish/supporters, the fixed branch that holds generated public-register changes, is no longer a manual-dispatch input. Before checkout, the workflow compares that publisher branch, publish/supporters, with the repository default branch, the branch GitHub treats as its main merge target, and fails with a clear error if they are equal; publication therefore cannot be directed to main or another arbitrary branch. The publisher only opens or updates its fixed-title pull request, the single reusable PR used for generated public-register updates. After the repository’s required pull-request checks pass, a maintainer uses GitHub’s Squash and merge control to merge that pull request as one commit. The workflow makes no auto-merge request because the repository disables auto-merge.

The credential the workflow uses to read the private supporter repository is PDPP_PRIVATE_REPO_TOKEN: a fine-grained personal access token, a GitHub token restricted to selected repository permissions, owned by maintainer account tnunamak. It is scoped to PDP-Connect/supporters-private with Contents read and write and is stored as the pdpp repository Actions secret. The token itself is neither branch-limited nor read-only; the workflow limits its own checkout to signatures, does not persist its credential, and has no private-repository write step.

actionlint, the GitHub Actions workflow validator, accepted the workflow. The branch guard accepted publish/supporters and rejected main. PR #319's checks are green.

Assisted-by: AI

@vercel

vercel Bot commented Sep 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
pdpp Ready Ready Preview Sep 3, 2026 11:00pm UTC

Request Review

Keep bot writes off the protected default branch by targeting the configured register branch, and include the bot DCO trailer required by repository policy.

Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
Assisted-by: AI
Run the allowlist-only register publisher from this repository so the private token is used only to read the supporters branch and public publication uses GITHUB_TOKEN.

Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
Assisted-by: AI
Retain the local handoff report in the worktree without shipping it in the pull request.

Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
Assisted-by: AI
Collect consented public entries in one pass so the register publisher satisfies the React Doctor performance check without changing its output.

Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
Assisted-by: AI
The publisher now resets its branch from the chosen base, opens or updates a fixed-title PR, and requests squash auto-merge instead of pushing to the protected default branch. Runtime provider tests exercise the GitHub branch paths and docs name the publisher's private read scope.

Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
State that github-actions[bot] reads only the private signatures checkout through the publication token, so the register access model matches the workflow and access table.

Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
Keep generated publication on a fixed branch, require manual squash merge, document the private token authority, and make the provider regression tests satisfy the site verification gate.

Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
@tnunamak
tnunamak merged commit 52219e7 into main Sep 3, 2026
5 checks passed
@tnunamak
tnunamak deleted the fix/register-bot-branch branch September 3, 2026 23:34
tnunamak added a commit that referenced this pull request Sep 4, 2026
Six corrections from an independent review of the oracle added in the previous
commit. Two of them are the difference between an assertion that proves
something and one that does not.

The withdrawal step could pass on a commit from an earlier run. `waitForCommit`
matched on a commit subject with no recency filter at all. Two of the three
subjects it waits for embed a per-run uuid and so were run-unique by accident of
how the app names them, but the third, "Record a withdrawal", is a constant the
app writes for every withdrawal by anybody. Any earlier withdrawal sitting in
recent history satisfied it on the first poll, so step 6 could report a pass
having proven nothing about this run -- which is precisely the vacuous pass the
step exists to rule out. Every wait now takes the `since` timestamp already
computed for the mailbox filter, applied both as a `since` parameter to the API
and as a per-candidate committer-date check, so a stale entry cannot slip
through even if the parameter were ignored. A commit with no committer date is
rejected rather than assumed recent.

The Signed-off-by assertion was documented backwards. The header said the
trailer came from the private repo's "require sign-off on web-based commits"
setting and that asserting it here was the only check on that policy. It is
added by the site, in `botCommitMessage`, and every write goes through it --
which landed on main in #319, after this branch was cut. The assertion still
catches the trailer going missing, but it checks app code this repo owns, not an
out-of-band setting, and a maintainer told otherwise would look in the wrong
place. The comment now says what it checks, and says plainly that nothing here
checks the repo policy. This commit is rebased onto main so the claim matches the
tree it is made against.

Four smaller corrections. Exit code 2 was undocumented and was the one path that
wrote no receipt, so a job keying off the receipt could not tell a missing
environment variable from a crash; that check moved inside the run and now
records a named `setup` failure and exits 1 like everything else. The stored
address was compared against the raw SIGNING_TEST_EMAIL, but the schema
lowercases it, so any operator whose address has a capital in it saw a correct
write reported as a broken one -- the oracle now normalises the same way the
schema does. Step 4 built the signatory file path from the wall clock while the
app files it under the year of the confirmation, which disagree for one second a
year across a UTC new year; the path is now read out of the commit that wrote it,
which also makes the step prove the commit added a file for this signatory rather
than assuming it. And a mailbox failure wrote `execFile`'s error message into the
receipt, which contains the entire command line: the reader is operator-supplied
and documented as swappable, so a replacement invoked with a credential in argv
would put it on disk. Only the program name and the exit status now cross into
the receipt.

The receipt also records the confirm and withdraw links, redacted to origin, path
and an eight-character token fingerprint. Under `--keep` the entry is deliberately
left live and withdrawable only from the email, so the receipt is the natural
second copy of that link; the tokens themselves stay off disk because they are
live single-use credentials.

Two documentation gaps the review found are closed in the header: the site allows
five submissions per IP per hour, which matters for a script whose purpose is
repeated runs from one address, and the 429 message now says how long to wait;
and `sanitizeUrl` is noted as not origin-safe on its own, since what rejects a
lookalike host is `extractLinks` searching for the origin as a literal prefix.

Sixteen tests added, 28 passing. Each was written failing first: the recency
filter is tested against a commit that predates the run, the path derivation
against a record confirmed in one year and read in the next, and the redaction
helpers against a reader invoked with a password in its arguments.

Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
tnunamak added a commit that referenced this pull request Sep 4, 2026
)

* test: walk the Supporter signing journey against a real deployment

The Supporter signing flow (the form on /principles that records someone as
having signed the PDPP Principles) crosses four systems that no unit test can
join up: a Next.js route handler, a key-value store holding the unconfirmed
submission, a transactional mail provider, and a write to a private GitHub
repository that holds the signatory records. Each seam is mocked in isolation,
and mocks encode the same assumptions as the code that calls them. Until now
nothing exercised the seams between them, which the signing module itself says
out loud: "Nothing in this file is exercised by a test or a live run yet."

The failures that class of gap hides are the ones that matter here. A
confirmation email whose links point at a different deployment still looks
correct. A token that verifies but whose pending record was never stored still
looks correct. A signatory file that lands on a branch nobody publishes from
still looks correct. Every one passes a green unit suite and breaks the real
journey for a real person trying to sign.

So this adds scripts/signing-journey-oracle.mjs, which walks the journey the way
a supporter does, against a deployment you name, and asserts the observable
outcome of each step: submit and expect a 303 redirect carrying signed=pending;
poll a mailbox for the confirmation; follow the confirm link and expect
signed=confirmed; check that GitHub received an "Add signatory <id>" commit whose
stored record matches what the form sent; follow the confirm link a second time
and expect signed=invalid, proving the link is single use; then withdraw, and
require the commits that delete the record and log the withdrawal. It writes a
JSON receipt naming every step's status and exits non-zero on any failure, so a
failed run says how far the journey got rather than only that it broke.

Two details are worth knowing because they are not visible in the diff. The
mailbox is read through a swappable command template rather than a Gmail library,
because the default reader is one maintainer's authenticated `gog` CLI and anyone
else has a different one. And the default reader is `gog gmail messages search`
rather than `gog gmail search`: the latter returns thread identifiers, Gmail
threads every confirmation together under one subject, and fetching a thread
yields its oldest message — so a thread-level reader hands the oracle a stale
email from an earlier run every time. That was observed during development, not
theorised.

Every extracted link is filtered on an origin equal to the deployment under test.
Preview deployments share one mailbox, so without that filter this could pass
while testing a build nobody asked for.

The accompanying test covers the link sanitiser and the receipt shape — the two
pieces that can be checked without a live deployment. The sanitiser is worth
isolating because all its failure modes are silent: a token corrupted by a mail
transport that hard-wraps a long line still looks like a link, and fails later as
an "invalid" outcome that would be misreported as a broken signing flow rather
than a broken parse.

Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>

* test: bound the signing oracle's commit assertions to the run under test

Six corrections from an independent review of the oracle added in the previous
commit. Two of them are the difference between an assertion that proves
something and one that does not.

The withdrawal step could pass on a commit from an earlier run. `waitForCommit`
matched on a commit subject with no recency filter at all. Two of the three
subjects it waits for embed a per-run uuid and so were run-unique by accident of
how the app names them, but the third, "Record a withdrawal", is a constant the
app writes for every withdrawal by anybody. Any earlier withdrawal sitting in
recent history satisfied it on the first poll, so step 6 could report a pass
having proven nothing about this run -- which is precisely the vacuous pass the
step exists to rule out. Every wait now takes the `since` timestamp already
computed for the mailbox filter, applied both as a `since` parameter to the API
and as a per-candidate committer-date check, so a stale entry cannot slip
through even if the parameter were ignored. A commit with no committer date is
rejected rather than assumed recent.

The Signed-off-by assertion was documented backwards. The header said the
trailer came from the private repo's "require sign-off on web-based commits"
setting and that asserting it here was the only check on that policy. It is
added by the site, in `botCommitMessage`, and every write goes through it --
which landed on main in #319, after this branch was cut. The assertion still
catches the trailer going missing, but it checks app code this repo owns, not an
out-of-band setting, and a maintainer told otherwise would look in the wrong
place. The comment now says what it checks, and says plainly that nothing here
checks the repo policy. This commit is rebased onto main so the claim matches the
tree it is made against.

Four smaller corrections. Exit code 2 was undocumented and was the one path that
wrote no receipt, so a job keying off the receipt could not tell a missing
environment variable from a crash; that check moved inside the run and now
records a named `setup` failure and exits 1 like everything else. The stored
address was compared against the raw SIGNING_TEST_EMAIL, but the schema
lowercases it, so any operator whose address has a capital in it saw a correct
write reported as a broken one -- the oracle now normalises the same way the
schema does. Step 4 built the signatory file path from the wall clock while the
app files it under the year of the confirmation, which disagree for one second a
year across a UTC new year; the path is now read out of the commit that wrote it,
which also makes the step prove the commit added a file for this signatory rather
than assuming it. And a mailbox failure wrote `execFile`'s error message into the
receipt, which contains the entire command line: the reader is operator-supplied
and documented as swappable, so a replacement invoked with a credential in argv
would put it on disk. Only the program name and the exit status now cross into
the receipt.

The receipt also records the confirm and withdraw links, redacted to origin, path
and an eight-character token fingerprint. Under `--keep` the entry is deliberately
left live and withdrawable only from the email, so the receipt is the natural
second copy of that link; the tokens themselves stay off disk because they are
live single-use credentials.

Two documentation gaps the review found are closed in the header: the site allows
five submissions per IP per hour, which matters for a script whose purpose is
repeated runs from one address, and the 429 message now says how long to wait;
and `sanitizeUrl` is noted as not origin-safe on its own, since what rejects a
lookalike host is `extractLinks` searching for the origin as a literal prefix.

Sixteen tests added, 28 passing. Each was written failing first: the recency
filter is tested against a commit that predates the run, the path derivation
against a record confirmed in one year and read in the next, and the redaction
helpers against a reader invoked with a password in its arguments.

Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>

---------

Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
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