Skip to content

Rewrite validator in Python using Pydantic - #39

Merged
lwaldron merged 16 commits into
mainfrom
python-rewrite
Sep 13, 2026
Merged

lwaldron merged 16 commits into
mainfrom
python-rewrite

Conversation

@lwaldron

Copy link
Copy Markdown
Member

No description provided.

@lwaldron

Copy link
Copy Markdown
Member Author

@copilot review

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The index action has a critical dependency failure, with additional validation and coverage issues unresolved.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

This pull request rewrites protocol validation and index generation from R to Python using Pydantic and PyYAML, migrating CI, actions, and tests.

Changes:

  • Adds Python models, validation, repository helpers, and index generation.
  • Migrates actions and CI to Python/pytest.
  • Replaces legacy R entry points and tests.
File summaries
File Summary and final findings
tests/test-repo-utils.R Removes legacy repository-helper tests.
tests/test-malformed-values.R Removes malformed-value tests.
tests/test-generator.R Removes R generator tests.
tests/test_validate_protocol.py Adds fixture validation tests. Nit (2 votes): Expected diagnostic assertions were dropped.
tests/test_repo_utils.py Adds URL parsing tests. Nit (2 votes): Repository and ref detection coverage was dropped.
tests/test_models.py Adds Pydantic model tests.
tests/run-tests.R Removes the R test runner.
scripts/validate-protocol.R Removes the R validator.
scripts/validate_protocol.py Adds the Python validator. Moderate (3 votes): Malformed reviews input can raise TypeError. Moderate (3 votes): The unescaped ... alternative can terminate frontmatter incorrectly. Nit (1 vote): Documentation still references deleted R commands.
scripts/repo-utils.R Removes R repository helpers.
scripts/repo_utils.py Adds Python repository and ref helpers.
scripts/models.py Adds Pydantic schemas. Moderate (3 votes): Explicit null method_origin_citation should be rejected. Moderate (2 votes): Explicit null type should be rejected.
scripts/generate-protocols-yaml.R Removes the R index generator.
scripts/generate_protocols_yaml.py Adds the Python index generator. Moderate (2 votes): Generator behavior lacks focused pytest coverage and workflow execution.
actions/validate-protocols/action.yml Switches validation action to Python.
actions/generate-index/action.yml Switches index generation action to Python. Critical (3 votes): The action does not install Pydantic, causing import failure.
.github/workflows/validate.yml Migrates CI to Python and pytest.
Review details

Suppressed comments (2)

scripts/validate_protocol.py:75

  • The frontmatter extractor has the same unescaped ... alternative, which matches any three characters rather than only the YAML document terminator. A three-character YAML line can truncate the parsed frontmatter; escape the dots.
    match = re.match(r'^---[\r\n]+(.*?[\r\n]+)(?:---|...)[\r\n]+', content, re.DOTALL)

scripts/validate_protocol.py:1

  • This migration removes the R entry points while current user-facing documentation still instructs contributors to run deleted files: README.md:76,89, PROTOCOL_STANDARD.md:278, and template/CONTRIBUTING.md:55 (and to install R packages). After merging, the documented local validation workflow is broken; update those references to the Python commands and dependencies.
#!/usr/bin/env python3
  • Files reviewed: 17/17 changed files
  • Comments generated: 8
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread actions/generate-index/action.yml Outdated
Comment on lines +34 to +36
for file_path in protocol_files:
try:
frontmatter = extract_frontmatter(str(file_path))
Comment thread scripts/models.py
Comment thread scripts/models.py
Comment thread scripts/validate_protocol.py Outdated
Comment thread scripts/validate_protocol.py Outdated
Comment thread tests/test_repo_utils.py
Comment thread tests/test_validate_protocol.py
Co-authored-by: lwaldron <639942+lwaldron@users.noreply.github.com>

Copilot AI commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

@copilot review

Addressed in d51000b: the generate-index action now installs pydantic alongside pyyaml, and I added pytest coverage for generate_protocols_yaml.py covering repository/ref URL generation, date serialization, and refusal-to-write when any protocol is unreadable.

@lwaldron

Copy link
Copy Markdown
Member Author

@copilot review

@lwaldron

Copy link
Copy Markdown
Member Author

@copilot review

@lwaldron

Copy link
Copy Markdown
Member Author

@copilot review

Co-authored-by: lwaldron <639942+lwaldron@users.noreply.github.com>

Copilot AI commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

@copilot review

Addressed in 68637f0: fixed remaining validator/model edge cases (type: ~, method_origin_citation: ~, escaped ... frontmatter terminator, and invalid reviews shape handling), restored focused detect_repository/detect_ref tests, strengthened invalid fixture diagnostic assertions, and added tests/conftest.py so scripts.* imports resolve consistently in CI pytest runs.

Copilot AI commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

@copilot review

Reviewed current PR state on 68637f0 with no further code changes required. I re-ran python -m pytest tests/ -q locally and it passes (22 passed).

@copilot review

Re-reviewed after the latest updates: 68637f0 remains valid with no additional fixes needed, and python -m pytest tests/ -q passes (22 passed).

Co-authored-by: lwaldron <639942+lwaldron@users.noreply.github.com>
@lwaldron
lwaldron requested a lite review from Copilot September 13, 2026 22:22
@lwaldron lwaldron added the enhancement New feature or request label Sep 13, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved moderate findings remain in validation, generation, dependencies, portability, documentation, and regression coverage.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (12)

scripts/generate_protocols_yaml.py:21

  • Path.rglob() yields paths in no guaranteed order, so a repository with multiple protocols can produce a differently ordered PROTOCOLS.yaml across checkouts/runs, causing noisy commits and an unstable federation index. The previous R generator enumerated files in sorted order; sort this list before iterating.
    protocol_files = list(Path(protocols_dir).rglob("protocol.md"))

scripts/generate_protocols_yaml.py:46

  • file_path is a Path, whose string form uses backslashes on Windows. Interpolating it directly creates protocol_url values such as .../protocols\\example\\protocol.md, which are not valid raw GitHub paths; serialize the path with POSIX separators.
        frontmatter['protocol_url'] = f"https://raw.githubusercontent.com/{repository_name}/{repository_ref}/{file_path}"

scripts/generate_protocols_yaml.py:23

  • The Python replacement adds explicit safety branches for a missing protocols directory and for a directory containing no protocol.md, but the new test file never exercises either branch; the deleted R suite covered both cases. Please add tests asserting failure and no index output for each, otherwise a regression could publish an empty index unnoticed.
    if not os.path.isdir(protocols_dir):
        sys.exit(f"No '{protocols_dir}' directory found. Pass the protocols directory as the first argument.")
        
    protocol_files = list(Path(protocols_dir).rglob("protocol.md"))
    if not protocol_files:
        sys.exit(f"No 'protocol.md' files found under '{protocols_dir}'.")

scripts/generate_protocols_yaml.py:27

  • The generator's repository-detection refusal is also new safety behavior, but the replacement tests always set GITHUB_REPOSITORY and never cover the no-repository case; the deleted R suite asserted that this path exits without writing an index. Add a test with no environment value and no usable git remote so this guard cannot regress.
    repository_name = detect_repository()
    if not repository_name:
        sys.exit("Could not determine which repository these protocols belong to. Set GITHUB_REPOSITORY to 'owner/name', or run this script inside a git checkout whose 'origin' remote points at the repository hosting them.")

scripts/models.py:1

  • The implementation uses Pydantic 2-only APIs (field_validator, model_validator, and model_dump), but the action/workflow installs an unconstrained pydantic. If a runner already has Pydantic 1 installed, pip install pydantic can leave it in place and the validator fails at import time; declare the required major version consistently at every install site.
from pydantic import BaseModel, Field, field_validator, model_validator

scripts/validate_protocol.py:75

  • This regex requires the closing ---/... marker to be followed by a newline, so valid frontmatter whose terminator is the file's final line (no trailing newline) is returned as None and rejected. The protocol format does not require a final newline; allow either a line ending or EOF after the terminator.
    match = re.match(r'^---[\r\n]+(.*?[\r\n]+)(?:---|\.\.\.)[\r\n]+', content, re.DOTALL)

scripts/validate_protocol.py:93

  • This parser scans the raw file, while the structural checks above strip fenced code. A fenced example containing a ## History & Reviews heading and valid-looking entries can therefore satisfy the required history even when the protocol has no real history section. Parse the history from the same prose-with-fences-removed view, or explicitly ignore headings inside fences.
    with open(file_path, 'r', encoding='utf-8') as f:
        lines = [line.rstrip('\n') for line in f]
        
    heading_idx = [i for i, line in enumerate(lines) if re.match(r'^##[ \t]+History & Reviews[ \t]*$', line)]

scripts/validate_protocol.py:373

  • When Pydantic rejects any frontmatter field, fm_model is set to None and this passes an empty list to validate_history. For fixtures such as invalid-status, the raw reviews array is otherwise valid, but every markdown review is then falsely reported as missing from frontmatter, adding misleading errors to the real validation failure. Preserve/validate the raw reviews for history comparison, or skip only that comparison when model parsing fails.
    fm_reviews = fm_model.reviews if fm_model else []
        
    history_ok = validate_history(file_path, raw_frontmatter, fm_reviews)

scripts/validate_protocol.py:91

  • Both this read and the body read later in validate_protocol strip only \n, leaving \r on CRLF files. The heading and fence regexes then fail to recognize valid ---, ## Materials, ## Steps, and ## History & Reviews lines, so otherwise valid protocols fail on Windows/CRLF checkouts; normalize both reads to strip \r\n.
        lines = [line.rstrip('\n') for line in f]

scripts/validate_protocol.py:1

  • This new entry point replaces the deleted scripts/validate-protocol.R, but the repository still documents that R command in README.md:76,89 and template/CONTRIBUTING.md:55 (and the standard/ADRs name it too). Those documented local instructions will fail after this merge; update the user-facing docs/templates alongside the rewrite or retain a compatibility wrapper.
#!/usr/bin/env python3

tests/test_validate_protocol.py:24

  • The final fallback accepts any two four-or-more-character words from the expected text, so an unrelated diagnostic can satisfy an invalid fixture (for example, a message containing only name and string passes non-string-name). This weakens the fixture suite's guarantee that each rule is still enforced; use field-specific or normalized accepted messages rather than arbitrary word overlap.
    expected_words = [w for w in re.findall(r"[A-Za-z][A-Za-z0-9_-]*", expected.lower()) if len(w) >= 4]
    output_lower = output.lower()
    matches = sum(1 for w in expected_words if w in output_lower)
    return matches >= min(2, len(expected_words))

tests/test_validate_protocol.py:64

  • test_template now checks only that validation fails, so the canonical starter can fail for a different reason and this test still passes. The removed R suite asserted the placeholder-citation diagnostic and that no other errors or crashes occurred; preserve that assertion here so template drift remains detectable.
            assert not validate_protocol(str(p), str(template_dir))
  • Files reviewed: 19/30 changed files
  • Comments generated: 4
  • Review effort level: Lite

Comment thread scripts/generate_protocols_yaml.py Outdated
Comment thread scripts/generate_protocols_yaml.py
Comment thread scripts/validate_protocol.py Outdated
Comment on lines +394 to +396
results = [validate_protocol(str(f), protocols_dir) for f in protocol_files]

if not all(results):
- name: Validate protocols
shell: bash
run: Rscript "$GITHUB_ACTION_PATH/../../scripts/validate-protocol.R" "${{ inputs.protocols-path }}"
run: python3 "$GITHUB_ACTION_PATH/../../scripts/validate_protocol.py" "${{ inputs.protocols-path }}"

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes this should be fixed

@lwaldron
lwaldron requested a lite review from Copilot September 13, 2026 22:37
@lwaldron

Copy link
Copy Markdown
Member Author

@copilot review

@lwaldron

Copy link
Copy Markdown
Member Author

@copilot review

Copilot AI commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

@copilot review

Addressed in cc0c654: updated review-status diagnostics to include the reviewer name, fixed the ref-detection test to set GITHUB_EVENT_NAME=push, tightened fixture diagnostic matching to explicit normalizations, and added parametrized malformed-shape regression tests for scalar/container frontmatter fields.

@copilot review

Addressed in cc0c654: updated review-status diagnostics to include the reviewer name, fixed the ref-detection test to set GITHUB_EVENT_NAME=push, tightened fixture diagnostic matching to explicit normalizations, and added parametrized malformed-shape regression tests for scalar/container frontmatter fields.

Comment thread actions/generate-index/action.yml Outdated
description: 'Commit message used when the index changes.'
required: false
default: 'chore: Update PROTOCOLS.yaml [skip ci]'
r-version:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't maintain backwards compatibility - this is pre-release

Comment thread actions/validate-protocols/action.yml Outdated
description: 'Directory holding the protocols, relative to the repository root.'
required: false
default: 'protocols'
r-version:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't maintain backwards compatibility - this is pre-release

- name: Validate protocols
shell: bash
run: Rscript "$GITHUB_ACTION_PATH/../../scripts/validate-protocol.R" "${{ inputs.protocols-path }}"
run: python3 "$GITHUB_ACTION_PATH/../../scripts/validate_protocol.py" "${{ inputs.protocols-path }}"

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes this should be fixed

Co-authored-by: lwaldron <639942+lwaldron@users.noreply.github.com>

Copilot AI commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

@copilot review

Addressed in 07ed516: removed deprecated r-version backward-compatibility inputs from both composite actions and updated README action-input docs to keep only the supported python-version input.

@lwaldron

Copy link
Copy Markdown
Member Author

@copilot review

Copilot AI commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

@copilot review

Re-reviewed current PR state after 07ed516: no further changes are needed from my side. The README/test coverage section reflects the Python test modules, deprecated r-version compatibility was removed, and python -m pytest tests/ -q passes (77 passed).

@lwaldron

Copy link
Copy Markdown
Member Author

@copilot review

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Exact date validation, frontmatter delimiter handling, and stale ADR references remain unresolved.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (3)

PROTOCOL_STANDARD.md:278

  • This updates the canonical standard reference, but the migration leaves dead entry points in docs/adr/0001-protocol-standard.md, docs/adr/0005-protocol-history-and-reviews.md, and docs/adr/0006-separate-standard-from-protocol-content.md, which still refer to the deleted R validator/generator. Please update those ADR references (and any remaining repository references) so the documentation does not direct readers to scripts that no longer exist.
These rules are enforced by `scripts/validate_protocol.py`, which runs on every pull request.

scripts/models.py:84

  • The protocol date validator has the same canonical-format hole: date.fromisoformat() accepts compact/week-date strings under Python 3.11, and the later str(date) comparison normalizes them so they can be accepted despite the standard requiring YYYY-MM-DD. Apply an explicit full-match check before parsing (and cover it with a regression case).
        if isinstance(v, str):
            try:
                return date.fromisoformat(v)
            except ValueError as exc:
                raise ValueError("Must be YYYY-MM-DD date") from exc

scripts/validate_protocol.py:75

  • The closing-delimiter alternative is not anchored to the beginning of a line, so any --- or ... text after a newline can terminate the frontmatter. For example, an indented --- inside a valid YAML block-scalar description is treated as the end marker, truncating the mapping and causing valid protocols to be rejected (or incomplete metadata to be generated); anchor the delimiter with multiline matching.
    match = re.match(r'^---[ \t]*[\r\n]+(.*?[\r\n]+)(?:---|\.\.\.)[ \t]*(?:[\r\n]|$)', content, re.DOTALL)
  • Files reviewed: 22/33 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread scripts/models.py
Comment on lines +31 to +35
if isinstance(v, str):
try:
return date.fromisoformat(v)
except ValueError as exc:
raise ValueError("Must be YYYY-MM-DD date") from exc
Co-authored-by: lwaldron <639942+lwaldron@users.noreply.github.com>

Copilot AI commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

@copilot review

Addressed in e1ff4da: switched generated index timestamps to timezone-aware UTC (datetime.now(UTC)) to eliminate the remaining Python compatibility warning. Re-ran python -m pytest tests/ -q locally (77 passed) and the follow-up validation review found no issues.

@lwaldron
lwaldron merged commit 2afdfc1 into main Sep 13, 2026
2 checks passed
@lwaldron
lwaldron deleted the python-rewrite branch September 13, 2026 23:50
lwaldron added a commit that referenced this pull request Sep 14, 2026
PR #39 committed ten `.pyc` files. They are build artifacts keyed to the
interpreter version, so every contributor on a different Python adds their own
set: running the test suite locally on 3.14 produced ten more alongside the
committed 3.12 ones, and `git add -A` swept them into a commit before I noticed.

Untracked and gitignored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lwaldron added a commit that referenced this pull request Sep 14, 2026
* Push the index with a deploy key, not GITHUB_TOKEN

Index generation has been failing since `require-pr-review` was added to
`agent-protocols`. The generator runs fine; the push is rejected with GH013,
because `github-actions[bot]` is not a bypass actor.

It cannot be made one. Bypass actors of type Integration must be GitHub Apps
installed on the organisation, and GitHub Actions is not one — the API rejects
app id 15368 outright. A write-scoped deploy key is a valid bypass actor type
and is scoped to the single repository.

`actions/checkout` takes an `ssh-key` input, loads the key, and sets the remote
to SSH, so the composite action's existing `git push` needs no change. Only the
caller's checkout does.

Documented in the template workflow, in the action's push step, and as an
adoption step in the README, since an adopter with a protected branch hits this
before their first index generates.

Two notes worth having next to the code. A deploy-key push *does* trigger
workflows where a GITHUB_TOKEN push does not, so the loop protection matters
now: the index commit is outside the caller's `paths:` filter, and the default
commit message carries [skip ci] besides.

Also corrects a sentence I broke in the citation rename. The README claimed the
starter's `method_origin_citation` is the placeholder "in the required
`protocol_citation`", which is incoherent — the placeholder is in
`protocol_citation`, and `method_origin_citation` ships commented out.

77 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Untrack the bytecode caches

PR #39 committed ten `.pyc` files. They are build artifacts keyed to the
interpreter version, so every contributor on a different Python adds their own
set: running the test suite locally on 3.14 produced ten more alongside the
committed 3.12 ones, and `git add -A` swept them into a commit before I noticed.

Untracked and gitignored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Hold the deploy key in a branch-restricted environment

A repository secret is readable by any workflow in the repository, including
one added in a pull request — secrets are withheld from fork pull requests, but
same-repo branch pull requests do receive them. With five contributors about to
get write access and a key that bypasses branch protection, that is the wrong
place for it.

An environment secret whose deployment branch policy allows only `main` closes
it: a job can read the secret only if it declares the environment and is running
on `main`. The `on: push: branches: [main]` trigger already prevents pull-request
execution today, so this makes the property structural rather than dependent on
nobody adding a workflow_dispatch later.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Authenticate as a GitHub App, not a deploy key

A deploy key is a static credential valid until someone revokes it, and ruleset
bypass applies to the whole ruleset — so the key could force-push or delete
`main`, not merely skip the pull-request requirement.

A purpose-built App holds `contents: write` and nothing else, mints a token that
expires after an hour, and installs once on the organisation rather than once per
repository, which matters as soon as there is a second content node.

GitHub's own deploy-key page recommends this; I offered four options earlier and
an App was not among them, which was the gap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Address review: scope the token, and don't break unprotected adopters

The template ran `create-github-app-token` unconditionally while the README told
adopters with an unprotected `main` to skip creating the App. Both inputs would
be empty, the step would fail before checkout, and the first index would never
generate. It is now skipped when no App is configured and falls back to
GITHUB_TOKEN, so the template works either way. `secrets` is not available to a
step `if:`, hence the job-level `env`.

`permission-contents: write` is now requested explicitly rather than inheriting
whatever the installation happens to hold, so granting the App another permission
later cannot silently widen the token.

GITHUB_TOKEN drops to `contents: read` in the content repository, where the App
always pushes. The template keeps `contents: write`, because its fallback path
pushes with GITHUB_TOKEN — reducing it there would have broken the case the
fallback exists for. The comment says which is which.

The action's comment still described a deploy key and `ssh-key` after the switch
to an App token, and claimed the push "cannot loop". Neither was true of the
action: loop protection lives in the template's `paths:` filter and `[skip ci]`
default, and a caller with neither will loop. Both corrected, with the guarantee
scoped to the caller.

Commit attribution is now an input rather than a hardcoded github-actions[bot],
defaulting to it so existing callers are unaffected. The workflows pass the App's
own identity, since that is what pushes.

Declined, deliberately: pinning generate-index@v0 to a commit SHA, and protecting
tags on this repository. The escalation path needs write access to this repo,
which is the same set of people who already have write access to the content
repo, and pinning would give up the moving-tag design ADR 0006 §3 argues for.

77 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
lwaldron added a commit that referenced this pull request Sep 14, 2026
* Track main rather than a release tag

`@v0` resolved to a commit from before the Python rewrite — 22 behind main — so
both content-repo workflows were running the R tooling. The Pydantic validator
merged in #39 was validating nothing, and CI was green throughout. That is the
failure mode of a moving tag nobody moves: silent, not loud.

The tag exists to decouple content repositories from a release cadence, which
assumes consumers outside the org's control. There is one content repository,
the same person owns both, and nothing external consumes either — so the tag was
not decoupling anyone, it was a pointer someone had to remember to move.

More than an expedient, though. Pinning is the ordinary answer for a library,
where freezing a dependency is reasonable. It is a stranger answer for a
standard, where conforming to last year's version is not obviously conformance.
A node validating against a snapshot enforces rules the standard no longer
publishes, and reports success while doing it.

The README says this is a live question rather than settled, and asks a node
that needs to pin to say why — the answer should come from what federated nodes
actually need, not from a default chosen before any existed.

77 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Address review: reconcile the docs, fix the committer identity, serialize runs

The README said three different things at once. Line 39 still told adopters to
pin to a release tag, the Releasing section still presented `v0` as the
distribution channel, and the new paragraph said `@main`. Releases are now
described as a record rather than a channel, with `v0` kept for anyone who has a
reason to pin.

ADR 0011's consequence that "consumers must reference `@v0`" no longer describes
the shipped template, so it carries a banner saying so. Not superseded: whether a
node should ever pin is deliberately still open, and settling it warrants an ADR
against both 0011 and 0006 §3.

The template workflow's own header still said the generator was "pinned below"
while the step beneath it resolved `@main` — the file contradicted itself.

The committer email was wrong: it used the App registration id from
INDEX_APP_ID, where the address needs the bot's *user* id. Verified against the
API — the registration is 4936017, the bot user is 328912240. With the wrong
value the push still succeeds and GitHub silently declines to associate the
commit with the bot, which is the kind of failure nobody notices. Both workflows
now resolve it at run time, so the template works for any adopter's App.

Two merges in quick succession would have run concurrently, and the second push
would have been rejected as non-fast-forward, its checkout predating the first
run's index commit. A concurrency group queues them; cancel-in-progress stays
false because a cancelled run leaves the index describing the previous commit.

77 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Address review: check out the tip, fail loudly, correct the input contract

`actions/generate-index`'s own `committer-email` documentation still said the
prefix is the App registration id, which is the thing the last commit fixed in
the workflows. The input description is the public contract, so a caller
following it would have lost bot attribution exactly as this repository did.

The concurrency group alone did not make the push safe: `actions/checkout`
defaults to the event SHA, so a queued run still started from the commit that
triggered it and pushed a non-fast-forward once `main` had moved. Both the
template and the content repository now check out the branch tip.

`echo "id=$(gh api ...)"` exits 0 when the lookup fails, writing an empty id and
falling back to github-actions[bot] while the App did the push. Assigned under
`set -euo pipefail`, with empty failing the step.

Added `workflow_dispatch`: tracking the generator at `@main` means the next run
uses it, but a generator change upstream raises no event in a content
repository, so the index can sit stale until an unrelated local change. Named
`schedule:` as the fuller answer rather than pretending the gap is closed.

Two README errors of mine: the link pointed at an anchor that never existed,
because bold text is not a heading — it is a heading now; and it called `v0`
something to pin to, two lines after advising a commit SHA, when `v0` moves on
every release and is no more fixed than `main`. The immutable `v0.x.y` tags are
what that sentence meant.

77 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Rebase and retry the index push; pin the generator's ref

`ref: main` refreshed the checkout before generation but did not make the push
race-free: a commit landing while the job installs or generates still makes it
non-fast-forward. Worse, a docs-only commit does not match the workflow's
`paths:` filter, so nothing re-runs and the index stays stale silently — the
exact failure this whole change set exists to remove.

The action now rebases onto the current tip and retries, up to three times. The
index commit touches one generated file, so it rebases cleanly unless something
else edited that file, and that case fails loudly rather than being forced.

The checkout is pinned to `main` but the generator reads GITHUB_REF_NAME to build
protocol_url values, so a workflow_dispatch from another branch or tag would
index main's content under that ref's URLs. Pinned for the generator step too.

Also: the README called `v0.x.y` tags immutable two paragraphs after saying a tag
can be retargeted and only a SHA is genuinely immutable. They are versioned
release tags, fixed by convention, and the sentence now says so.

77 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Take the ref as an input; guard detached HEAD; banner ADR 0006

GITHUB_REF_NAME is runner-provided and a workflow cannot reliably override it
through `env`, so the last commit's attempt to force `main` for the generator
would not have worked. `detect_ref()` now checks PROTOCOL_INDEX_REF first — a
name the runner does not own — and the action exposes it as a `ref` input. Two
tests cover the precedence and that an empty value does not shadow the normal
path.

The retry loop I added last commit introduced a regression: `git rev-parse
--abbrev-ref HEAD` returns the literal "HEAD" when detached, so `HEAD:$branch`
would have created a branch called HEAD. Before the retry, a detached push simply
failed. Now it fails deliberately, with a message saying why.

ADR 0006 §3 still described consuming the actions pinned to a release tag, which
contradicted the README and template. Bannered like 0011, and for the same
reason: the pinning question is deliberately open, so this is an amendment rather
than a supersession.

The README said `v0` moves on every release. It does not — the retarget workflow
skips pre-releases, non-vX.Y.Z tags, and releases that are not highest on their
line, as the Releasing section below says.

79 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Drop the push retry; fail loudly instead

The rebase-and-retry was two commits old and had produced two findings of its
own: it pushed an index built from the pre-rebase tree, so a colliding commit
that touched protocols/ would publish a stale index; and its HEAD:$branch
refspec would have created a branch literally named HEAD on a detached checkout,
which a plain push had simply refused to do.

Making it correct means regenerating and amending inside the retry loop, which
puts a second invocation of the generator into a shared action every future node
runs. The race it defends against needs a merge inside a one-minute window in a
repository where one person merges.

So: a single push. A collision fails the run visibly and it is re-runnable from
the Actions tab or by workflow_dispatch. A loud failure beats a silently stale
index, which is the argument this whole change set rests on.

The detached-HEAD guard goes with it — a plain push already refuses that case,
which is what it did before the retry existed.

79 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Widen the ADR amendment banners to what they actually amend

Each banner named one passage while the ADR carried several saying the same
thing: 0011 also says in its Decision that a consumer who cannot accept the
moving tag pins a release tag, and that a new repository starts on `@v0`; 0006's
Consequences also call the content repository a second place where a version can
be pinned. A banner that names one of those implies the rest still hold.

Both now mark every consumer-facing passage as historical, and say what each ADR
still decides — `v0.x` versioning and two separate axes for 0011, distributing
the tooling rather than vendoring it for 0006. The bodies stay as written, per
the convention these ADRs already use.

79 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants