Skip to content

Add the watcher and the stamper - #14

Merged
Maximilian-Nesslauer merged 9 commits into
mainfrom
watcher
Aug 11, 2026
Merged

Add the watcher and the stamper#14
Maximilian-Nesslauer merged 9 commits into
mainfrom
watcher

Conversation

@averageksp

Copy link
Copy Markdown
Member

Closes #4, closes #13.

Two things, because the loop has nothing to call without the derivation: tools/stamp_release.py is #13, and everything else is #4.

What is here

File What it is
tools/stamp_release.py Authored document plus release archive in, release file out. No token, no network.
tools/hosts.py GitHub and SpaceDock behind one interface, GitHub polled conditionally against a stored ETag.
tools/watch.py One tick: scan, stamp, commit, append a mirror that appeared later, keep one issue per listing current, sweep the authored repository.
tools/verify_examples.py Re-derives the design repository's hand-stamped examples/ from their release hosts and diffs.
.github/workflows/watcher.yml Every ten minutes, as the App, off the hour boundary.
.github/workflows/checks.yml The unit tests on every pull request, the examples check on its own weekly clock because it talks to real hosts.

Each property from #4 has a place in the code rather than a comment claiming it:

  • state is the repository: stamped_versions() reads what is stamped off disk, and there is no queue anywhere
  • idempotent: the scan rule is set membership, so a re-run stamps nothing twice
  • stamped exactly once: check_for_a_swap() never overwrites, and reports the version with both hashes
  • one issue per listing: the body is rewritten every tick and a comment is only added when the failure itself changed, so a host that stays down is one issue and no notifications
  • the ETag store is derived cache, written outside the repository, so losing it costs one expensive tick
  • download.mirrors is append only, and only after the other host's archive downloads byte identical
  • release files and nothing else: every write goes through one function that refuses a path outside releases/

What I checked

verify_examples.py re-derives all eight hand-stamped examples from their live release hosts and they come out identical, byte for byte, through the host adapters. With --check-mirrors it also re-downloads every SpaceDock mirror and confirms the bytes match, so the mirror machinery is verified against the same claim the examples README makes.

On top of that, 54 offline tests, and sandbox ticks against the real hosts:

  • 21 StarMap releases and the AutoRemoveFinishedBurns history stamped from GitHub
  • the next tick costs one conditional request and no rate limit at all
  • stripping a mirror out of a stamped file and ticking again appends it back, on a tick where the authority answered 304
  • a tick against content-index as it stands today is a clean no-op: no listings yet, no open pull requests

One piece of #4 I left out on purpose

The open month re-resolution from RFC 0033. It cannot be done from the repository alone: a stamped file with no game_max looks exactly the same whether it was stamped open because the month was not over or the author never set one. Resolving it in the second case would apply an authored change to old stamps, which is the amendment path and not the watcher's. It needs either a marker in the release file or the derived cache, and that felt like your call rather than mine. Everything else about month bounds is in, including stamping an unfinished month as open.

Things that want a decision

StarMap cannot have its history stamped. Releases 0.4.0 to 0.4.5 each ship two archives, StarMapLauncher-x.y.z.zip and StarMapStandalone-x.y.z.zip, 0.0.1 has no asset at all, and the rc tag does not parse. The watcher reports all of that instead of guessing, because picking the wrong asset stamps a hash clients then verify against the wrong file. The way out is either a lookback window on that listing or an asset selector in [releases], which is an RFC 0031 question. 0.4.6 has exactly one archive and stamps fine.

The ETag invariant is "stamped or reported", not "stamped". If a release that can never be stamped, like that rc tag, kept the ETag suppressed, the listing would cost a full uncached release list on every tick forever. A payload that changes changes the ETag, so a renamed tag is still picked up on the next tick.

Swap detection is on the asset size and URL. A re-uploaded archive that happens to keep its byte count is not detectable without re-downloading every stamped archive on every tick, which is the whole tick's budget for an answer download.sha256 already gives the client. Stated in the code rather than pretended away.

The sweep depends on a contract that does not exist yet. It needs KSAModding/content-index#4's validation workflow to accept a dispatch carrying the pull request number and to report under a check named validate. Both are passed in watcher.yml so they can be changed without touching Python, and a dispatch the workflow refuses is logged once instead of failing the tick.

Two smaller calls I made: a listing marked delisted in index-status.toml is skipped, since stamping releases for something that is out of the snapshot would be the watcher arguing with a steward. And the install object is only stamped when it has something to say, which is why the StarMap example reproduces exactly as it stands, with no object at all. The moment that listing authors [install] target = "standalone", new stamps carry it. If you read RFC 0035 as wanting the object on a loader either way, that is a one line change.

@Maximilian-Nesslauer Maximilian-Nesslauer left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks, this looks like good work.

I am requesting changes for five things, each with an inline comment:

  1. The write guard in watch.py is lexical and the listing id is never validated, so a crafted id defeats the "release files and nothing else" property.
  2. A failed push and a dry run both persist the host ETags, so the next tick sees a 304 and the missed releases are never re-stamped.
  3. Two exception paths escape the HostError/StampError split: a malformed authored TOML aborts the whole tick, and an HTTPError from an asset download or a pagination request bypasses the reporting entirely.
  4. The stamper does not yet enforce the release-time rules of RFC 0035: install.path containment, target on a mod, target required on a loader [install], and [provides].launch existing in the archive.
  5. urljoin lets a SpaceDock-supplied value leave spacedock.info, so a stamped download.url or changelog can name any host.

I would also like to shorten most of the comments and simplify them closer to ASD-STE100 Simplified Technical English. I think that some of the (specifically Opus 5 like) output can be a bit hard to understand.

FYI i did use the help of LLMs to help review this PR myself since it is 3000+ LOC, but read through every line myself, changed stuff where i did not agree, discussed etc.

On the decisions you asked for

StarMap cannot have its history stamped. Releases 0.4.0 to 0.4.5 each ship two archives, StarMapLauncher-x.y.z.zip and StarMapStandalone-x.y.z.zip, 0.0.1 has no asset at all, and the rc tag does not parse. [...] The way out is either a lookback window on that listing or an asset selector in [releases], which is an RFC 0031 question.

Narrower than that, and I no longer think it needs an RFC.

The ambiguity is only 0.4.0 to 0.4.5. Everything from 0.0.2 to 0.3.6 stamps as it stands, because _asset filters to .zip first and that already resolves the .exe plus .zip pairs those releases ship to exactly one archive. 0.0.1 and rc are correctly rejected.

Taking the standalone for the remaining six does not work either, and I checked the archives rather than the names: 0.4.6 ships StarMap.exe plus StarMap.dll, while the 0.4.5 standalone ships StarMap.Loader.dll and no executable at all, and the launcher ships StarMap.Launcher.dll next to that loader. 0.4.6 is the two-process design collapsing into one binary, not the standalone continued, so "prefer standalone" would stamp archives that are not the thing the listing describes.

[provides].launch from point 4 settles it with no new field: the StarMap listing will say launch = "StarMap.exe", and an archive without it is the release-time rejection RFC 0035 already defines. Worth checking whether that also rejects 0.3.x before it is switched on.

So leave those six unstamped. If the history is ever wanted, the release pull request path (#6) is where a human stamps it deliberately, which the watcher could not do well anyway: stamping freezes the authored facts, and today's authored file is all a backfill has, so a backfilled 0.1.0 would claim game_min = 2026.8.3.5117.

The ETag invariant is "stamped or reported", not "stamped". If a release that can never be stamped, like that rc tag, kept the ETag suppressed, the listing would cost a full uncached release list on every tick forever.

Agreed, that is the right reading, and the reasoning in the one_listing comment is worth keeping exactly as written.

Swap detection is on the asset size and URL. A re-uploaded archive that happens to keep its byte count is not detectable without re-downloading every stamped archive on every tick, which is the whole tick's budget for an answer download.sha256 already gives the client.

Agreed as a trade-off. One addition: SpaceDockHost never sets size, so on a SpaceDock authority the check degrades to the URL alone and a re-upload is never detected. Worth a sentence in the check_for_a_swap docstring.

The sweep depends on a contract that does not exist yet. It needs KSAModding/content-index#4's validation workflow to accept a dispatch carrying the pull request number and to report under a check named validate.

Fine to depend on it, content-index#3 and #4 will implement both. One consequence to handle first, in the inline comment on _dispatch: until that lands every dispatch is refused, and a refusal currently retires that head commit permanently, so the first sweep would park every open pull request.

Two smaller calls I made: a listing marked delisted in index-status.toml is skipped [...] And the install object is only stamped when it has something to say.

Both right. The delisting skip is the correct reading of whose voice the index status is, and the conditional install object is what makes the StarMap example reproduce byte for byte under RFC 0035's per-type defaults.

One piece of #4 I left out on purpose. The open month re-resolution from RFC 0033. It cannot be done from the repository alone [...] It needs either a marker in the release file or the derived cache, and that felt like your call rather than mine.

This is the one I disagree with: I think the repository is enough, see the inline comment on resolve_bound. If you would rather keep it out of this PR that is fine, but then the docstring has to stop promising it and I will file the follow-up.

The rest are inline too, marked non-blocking, one per site so each can be answered and resolved on its own.

Comment thread tools/watch.py Outdated
cannot: release files and nothing else.
"""
path = Path(path)
if self.releases_root not in path.parents:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Blocking. The containment check is lexical.

Path.parents never normalizes, so for a listing id like ../x the path releases/../x/1.0.0.json still carries releases in its parents and the guard passes, while the file lands outside releases/.
The id comes straight from the authored TOML with no validation, and this job is the one actor with a branch-protection bypass on main, so this guard is the enforcement the PR body claims it is.

Two changes close it:

  • Resolve before comparing: path.resolve().is_relative_to(self.releases_root.resolve()).
  • Validate the listing id against the RFC 0031 id rules before using it as a path segment, and report a listing whose id fails them instead of processing it. That also covers folder() and stamped_versions(), which build paths from the same id.

Comment thread .github/workflows/watcher.yml Outdated
exit 1

- name: Save the derived cache
if: always()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Blocking. if: always() persists the ETags of a tick whose push failed.

The commits die with the runner, the ETag survives, the next tick gets a 304 and logs "unchanged since the last tick", and the missed releases are not re-stamped until the host's payload happens to change.
That contradicts the push step's own "the next tick rescans and re-stamps" message.

A dry run has the same shape: the Push step is skipped, but the Python side already stored the ETags and the mirror and swap timestamps, and this step uploads them, so a dry run suppresses the next real tick's scan.

Fix: gate this step on success() and on the dry run being off, and ideally watch.py should also skip storing host ETags when --dry-run is set, so the invariant "the ETag stands for stamped or reported" survives local runs too.

Related, one step up: the Push step's explicit dry-run condition drops the implicit success(), so it runs even after a failed tick.

Comment thread tools/watch.py Outdated
def tick(self):
for path in self.listings():
with path.open("rb") as handle:
authored = tomllib.load(handle)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Blocking. tomllib.load sits outside the per-listing try, so one malformed authored document aborts the whole tick with a traceback: no other listing is scanned, no issue is opened, and cache.save() is skipped.

That is exactly the property the # noqa comment two lines further down promises ("one listing never fails the tick").
Move the parse inside the guarded block and report a parse failure through issues.report like any other listing error.

Three things to watch while moving it:

  • delisted() has the same unguarded parse on index-status.toml, and failing open there would stamp releases a steward delisted.
  • A try/finally around the listing loop that always calls cache.save() would keep the ETags a partial tick already learned.
  • Once report is called from inside an except clause, it must not raise: an exception raised in one except clause is not routed to the sibling except Exception of the same try, so it would leave the loop anyway. Wrapping the API-shaped failures inside Issues.report covers it.

Comment thread tools/hosts.py Outdated
"should verify against"
)
raise StampError("the release carries no archive to download")
answer = http.get(release.url, api=release.url.startswith(GITHUB_API))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Blocking. Http.get re-raises the raw HTTPError for codes below 500, and only the first page request in releases() is wrapped (line 233).

A 404 on a deleted asset, or on a Link next page, therefore unwinds past stamp_pass and one_listing, which catch only HostError and StampError, into tick()'s broad catch.
The result is a log line unexpected: HTTPError(...) with no URL and no version, no author-facing issue, and no stored ETag, so a permanently deleted asset costs a full uncached scan plus a useless log line every tick, forever.

Apply the same translation the first page already has, at both sites: 404, 410 and 451 become StampError naming the URL, everything else becomes HostError.
The mirror path through mirrors_for shares _download, so fixing it there covers both.

Two neighbours in the same class, worth doing in one pass:

  • _read raises HostError for an oversized response, but oversize is permanent, so the watcher downloads and discards 512 MiB every tick and no issue ever opens. GitHubHost already has the asset size from the release list, so the GitHub case can be rejected before any bytes move.
  • GitHubHost.releases parses its payloads with a bare json.loads while SpaceDockHost.releases wraps its parse. A 200 carrying proxy HTML is neither HostError nor StampError and lands in the broad catch.

Comment thread tools/stamp_release.py Outdated
)


def install_object(handle, listing_id, content_type, authored_install):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Blocking. Four release-time rules of RFC 0035 are not enforced here, and since this module is the one place a release file is derived, the release pull request checks will inherit whatever it accepts:

  • install.path is copied through unvalidated. Rule 1 makes an absolute path, a ~ path, or a path whose normalized form escapes its anchor invalid, and the stamped file carries the resolved path to every client.
  • target is accepted on type = "mod". RFC 0035 states a mod's install location is not the author's to choose; a stamped target = "game-root" on a mod would direct every client to write into the game folder. Reject it, or if you read the RFC differently, let us settle that reading in the design repo first.
  • A mod-loader with an [install] section but no target is accepted. Per RFC 0035, target is required when the section is present and the type has no default.
  • [provides].launch is never checked against the archive. RFC 0035's errors table makes "launch naming a file absent from the release" a release-time rejection reported to the author, which is exactly this module's job, not the authored repo's schema check. Rule 4 also makes target = "standalone" without a launch invalid.

The first and last are the important ones; the middle two could also live in content-index#2's schema, but until that exists the stamper is the only gate.

Separately, while you are in stamp(): the authored spec_version is never read, so a document claiming a future format is silently stamped as 1, and there is no check that game_min_revision <= game_max_revision, so an authored game_max month below the min stamps an empty compatibility range.

Comment thread tools/stamp_release.py
return stamped


def merge_dependencies(derived, authored):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Two authored entries naming the same id both survive the merge, because by_id is built from the derived list only.

Whichever the resolver reads last wins, and the release file carries a contradiction no client can resolve.
Rejecting a duplicate id here makes it the author's error at publish time, which is where every other malformed-authored-document error already lands.

Comment thread tools/stamp_release.py Outdated
parser.add_argument("--out", type=Path, help="write here instead of to stdout")
arguments = parser.parse_args(argv)

with arguments.listing.open("rb") as handle:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The three input reads sit outside the except StampError, so a malformed release JSON, an unreadable listing, or a missing archive gives a traceback instead of the cannot stamp ... message this function promises.

That matters beyond the CLI: content-index-releases#6 has the release checks re-deriving with this module, so the same traceback would be what a contributor sees in a failed check.

Comment thread tools/hosts.py
payloads = [json.loads(first.body)]
following = LINK_NEXT.search(first.headers.get("Link", "") or "")
pages = 1
while following and pages < self.max_pages:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Two limits of the scan that are fine as limits but not fine silent.

max_pages=5 caps a listing at 500 releases with no signal crossing the boundary, so the tick settles and stores the ETag as if it had seen everything.
And the stored ETag covers page 1 only, so a change confined to a later page never invalidates it, which quietly weakens the "a tag that reappeared with different bytes is reported" property for a listing that large.

Reporting the truncation as a listing error is the useful form: refusing to settle would only force a full refetch each tick without ever reaching the tail, since GitHub lists newest first.

Comment thread tools/verify_examples.py

document = stamp(
authored, facts, archive, game_versions,
mirrors=mirrors, now=datetime.now(timezone.utc),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

_restamp re-derives with wall-clock now, while stamp() documents that the release checks pass the submission's own time so a re-derivation reaches the same answer as the stamp it is checking.

I think wall clock is actually right here, but for a reason the file does not state: the original stamp time is unrecorded, so neither wall clock nor release_date is always correct for a month game_max, and once the month pass exists the wall-clock answer is the corrected state an example should hold.
Latent either way, since no current example uses a month bound. Worth a comment rather than a change.

Comment thread .github/workflows/checks.yml Outdated
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
# examples/ in the design repository was stamped by hand from the same

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Three lines of prose for one step, and the last clause ("the one check of this tooling that nobody here wrote") is about how the check came to be rather than what it does.

Our convention is that comments describe the code as it is now, not its development history, so this is the shape I would trim throughout the PR rather than only here. Two other instances: class Sweep's docstring cites "the 2026-08-06 incident", and watch.py opens with a 32-line property table that the code below it already states.

Nothing here is wrong, and the reasoning is genuinely good. It just belongs in the pull request body and the RFC, which already carry it, rather than in the file where it has to be maintained alongside the code.

averageksp and others added 8 commits August 11, 2026 18:00
Authored document plus release archive in, release file out, per RFC 0031.
The one place a release file is derived: the watcher stamps with it, and the
release pull request checks re-derive with it and compare against what was
submitted, so the two paths cannot disagree.

It needs no token and touches no network, so it is testable offline against
examples/ in the design repository, where every value was produced by this
procedure by hand.
A scheduled tick that asks every listing's authority host for its releases and
stamps every release with no file under releases/<id>/ yet, commits it, appends
a mirror that only appeared later, keeps one error issue per listing current on
the authored repository, and sweeps that repository's open pull requests.

The tick holds no state. What is stamped in this repository is the state, so a
run GitHub delays, drops or cancels costs latency and not data, and a re-run
stamps nothing twice. The per-listing ETag store is derived cache written
outside the repository, and an ETag is only kept once every release behind it is
stamped or reported, so a stale cache can never hide a release.
CLAUDE.md rules them out and tools/update_game_versions.py already goes without them. Prose only, no behavior change.
The stamper is the one place a release file is derived, and the release
pull request checks re-derive with it, so what it accepts is what the
index accepts.

- paths in [install] and [provides] must be relative and stay inside
  their anchor, and the stamped value is the normalized form
- a mod cannot author target or path: the folder name is the identity
  the game sees, so the location is a default, not a field
- a mod-loader [install] needs a target, and standalone needs a launch
- [provides].launch has to exist in this release's archive
- an authored spec_version other than 1 is refused rather than stamped
- game_max below game_min is an empty range and is refused
- a duplicate authored dependency id is refused
- main() reads all its inputs inside the guard, so a missing archive
  reports instead of raising
An exception that is neither lands in the tick's broad catch, which logs
"unexpected", opens no issue for the author and stores no ETag, so a
permanent failure costs a full uncached scan every tick forever.

- an asset download translates 404, 410 and 451 to StampError and the
  rest to HostError, the way the first release-list page already did
- a failure on a later page is a HostError instead of a raw HTTPError
- a release list that is not JSON is a HostError, as SpaceDock's already
  was, so a 200 carrying proxy HTML does not escape
- an oversized archive is a StampError, and known-oversized from the
  release list is rejected before any bytes move
- a non-numeric SpaceDock id is a StampError, not a ValueError
- an unparseable timestamp yields None rather than being stamped raw
- a scan that hits max_pages reports the truncation instead of settling
  silently on a partial list

A SpaceDock-supplied path can also leave spacedock.info: urljoin returns
an absolute or protocol-relative value unchanged, so download.url,
download.mirrors and changelog could name any host. Resolved values are
now rejected unless the scheme and host still match the base.
Each of these is a property the tick already claims and did not keep
once something went wrong.

Release files and nothing else: the guard compared with Path.parents,
which never normalizes, so a listing id carrying ".." kept "releases" in
the parents while the file landed outside it. Both sides are resolved
now, and an id that breaks the RFC 0031 rules, or a file name that does
not match the id it declares, is reported instead of becoming a path.

One listing never fails the tick: the authored TOML was parsed outside
the per-listing guard, so one broken document aborted the whole tick.
Reporting can no longer raise either, which matters because it is called
from inside an except clause, where a raise leaves the loop. An
unreadable index-status.toml now fails closed, and the cache is saved in
a finally.

The ETag stands for stamped or reported: it was keyed per host, so two
listings on one repository blinded each other, and a lookback tick stored
it as if the skipped releases were done. A dry run stored it too.

One open issue per listing: a degraded issue list no longer creates a
duplicate, the label fallback is narrowed to the 422 it was written for,
and the issue reads are paginated.

A host that stays unreachable is now reported on every tick past the
threshold rather than exactly once, and a recovery closes only the issue
it opened.

Also: the same bytes at a new URL become a mirror rather than a log line,
since download.url is immutable; a refused sweep dispatch is retried on a
clock instead of retiring the commit; mirrors_for shares the mirror
budget; Api.send retries a 5xx and a secondary rate limit; a corrupt
stamped file is reported; and the open game_max month is resolved once
the month completes, which the stamper's docstring already promised.
The cache step ran on always(), so a tick whose push failed kept its
ETags while its commits died with the runner: the next tick took the 304
path over releases it never stamped, which is the opposite of what the
push step's own message promises. A dry run stored them too.

Both steps now require success() and a real run, and watch.py skips the
save entirely under --dry-run.

The README gains the month re-resolution, and verify_examples says why it
re-derives with the wall clock.
A delisted listing, a deleted document, and a listing whose [releases] section
is gone never reach the code that closes an issue, so the issue outlived the
listing it was about.

The releases-section case closes where the tick notices it. The other two are
swept at the end of a tick, guarded three ways because closing is the
destructive direction: a narrow dispatch knows nothing about the listings it
skipped, an empty listing set is how an unreadable index-status.toml looks, and
a failed issue read makes every id look orphaned.
The comment on the examples job, which the review pointed at, was still three
lines of development history. It now says what the step does.

The same pass over the longest blocks left: the workflow's schedule, cache and
argument comments, and the docstring of close_orphans. The ETag comment in
one_listing stays as it is, by request.
@averageksp

Copy link
Copy Markdown
Member Author

@Maximilian-Nesslauer
One more pass: the comment on the examples job in checks.yml was still the three lines you pointed at, so the trim commit missed the file the note was on. Fixed, along with the longest blocks left in watcher.yml, the same clause in the README table, and my own close_orphans docstring.

I left three blocks alone on purpose: the ETag comment in one_listing, which you asked to keep as written, and the dispatch-refusal and Api.send blocks, which are your own words from your own pass.

@Maximilian-Nesslauer Maximilian-Nesslauer left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM

@Maximilian-Nesslauer
Maximilian-Nesslauer merged commit df52e82 into main Aug 11, 2026
2 checks passed
@Maximilian-Nesslauer
Maximilian-Nesslauer deleted the watcher branch August 11, 2026 16:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:infra CI, workflows, automation area:publishing listing and release flows

Projects

Status: Inbox

Development

Successfully merging this pull request may close these issues.

The stamper, shared by the watcher and the release checks The watcher

2 participants