fix: refuse a build that emitted no landing - #23
Merged
Conversation
Since package-build 15 (HeroicLands/package-build#182) a package's landing is published at `/<package>/homepage-<shortcode>/` and Hugo generates `/<package>/index.html` separately, so losing the landing no longer empties the package root: a homepage-mode build that emitted none tallies one page, passes every check, and replaces the live site with chrome around an empty `<main>`. Require the addressed landing — of the callers whose toolchain publishes one. The two shapes are identical on disk, so decide from the installed `@heroiclands/package-build`, read after `npm ci`: from 15 on a missing landing is a collapsed build and is refused, below 15 the package root is the landing and is counted as before. An unreadable version is refused as well, since the question only arises once the landing is already missing. The root check stays the separate assertion it was — the deployment root exists and is non-empty — and the tally is untouched, so the homepage licensing bound still refuses a genuine second page. Refs #21
This was referenced Sep 4, 2026
toastygm
added a commit
that referenced
this pull request
Sep 4, 2026
Since package-build 15 ([package-build#182](HeroicLands/package-build#182)) a package's landing is published at `/<package>/homepage-<shortcode>/`, and `/<package>/` — the canonical address, the one every link points at and the one the site router proxies — is Hugo's site root: chrome around an empty `<main>`. What connects them is a `_redirects` entry the package authors, specified in `MIGRATING.md` §3 of that release. **Nothing checked for it.** The guard requires `_headers` at the deployment root and never `_redirects`, so a build that emits a perfectly good landing and no redirect passes every check in this file, deploys, and serves a blank page at the package's canonical URL. `sohl-kethira-basic` shipped exactly that; it was noticed by eye and fixed by hand ([sohl-kethira-basic#83](HeroicLands/sohl-kethira-basic#83)), not caught by anything here. Same visitor-facing outcome as #21, down a different path: #21 was a build that produced no landing, this is a build that produced one nothing routes to. **Require it.** **Keyed on the landing**, which by that point in the step *is* the new-shape signal. A post-#182 tree carrying no landing has already been refused by #23, so an empty `landing` at this line is a pre-#182 caller — and `harn-adventures`, `harn-ensemble` and `HarnMaster-3-FoundryVTT` serve their landing at `/<package>/` itself and correctly author no `_redirects`. So no second version read is needed: the one #23 added already stands between the two shapes. **Both path forms.** Cloudflare Pages matches a rule against the raw `url.pathname`, before any trailing-slash handling, so `/<package>` and `/<package>/` are distinct keys and a rule for only one of them leaves the other on the blank root. Each is required separately, with its own message. **The destination is derived, never assumed.** It comes from the landing that was actually found, so a package whose homepage note carries a different shortcode is measured against its own address. The **first** matching rule is the one read, as Pages reads it, so a wrong rule cannot be rescued by a correct one placed after it. **The status column is not read.** Every value a package would plausibly write there arrives at the landing — 301, 302, or a 200 rewrite — and what this guard exists to catch is a rule that is *absent*, not one that is misnumbered. **A guard, not a fallback**, unlike `_headers` above. The destination is the landing's own address, which is package-specific, so there is no generic file this workflow could write instead. ## Verification The guard step's `run:` body and the default-`_headers` step's were extracted verbatim with `yq` and driven against the real built trees of all four callers — `kethira` at `sohl-kethira-basic#84`, and `harnadventures`, `hm3` and `harnensemble` as their repositories build today — each with the `@heroiclands/package-build` its checkout actually installs (15.0.0 / 9.0.0 / 9.0.0 / 10.0.0). The three pre-#182 trees emit no `_headers`, so the workflow's own default step was run against them first, as it is in the job. | tree | installed | case | expected | result | | --- | --- | --- | --- | --- | | `kethira` | 15.0.0 | as built, real `_redirects` | pass | pass | | `kethira` | 15.0.0 | `_redirects` deleted | **reject** | rejected — missing or empty | | `kethira` | 15.0.0 | `_redirects` empty | **reject** | rejected — missing or empty | | `kethira` | 15.0.0 | only the `/kethira/` rule | **reject** | rejected — no rule for `/kethira` | | `kethira` | 15.0.0 | only the `/kethira` rule | **reject** | rejected — no rule for `/kethira/` | | `kethira` | 15.0.0 | both rules commented out | **reject** | rejected — no rule for `/kethira/` | | `kethira` | 15.0.0 | destination is not the landing | **reject** | rejected — names both addresses | | `kethira` | 15.0.0 | a wrong rule placed before the right one | **reject** | rejected — first match wins | | `kethira` | 15.0.0 | trailing `#` comment on a rule | pass | pass | | `kethira` | 15.0.0 | tab-separated columns | pass | pass | | `kethira` | 15.0.0 | destination without trailing slash | pass | pass | | `kethira` | 15.0.0 | `302` instead of `301` | pass | pass | | `kethira` | 15.0.0 | landing deleted (#21 regression) | **reject** | rejected — emitted no landing | | `kethira` | 15.0.0 | a genuine second content page | **reject** | rejected — licensing bound, `2 page(s)` | | `harnadventures` | 9.0.0 | as built, no `_redirects` | pass | pass — `1 page(s)` | | `hm3` | 9.0.0 | as built, no `_redirects` | pass | pass — `1 page(s)` | | `harnensemble` | 10.0.0 | as built, no `_redirects` | pass | pass — `1 page(s)` | | each of the three | 9/9/10 | root `index.html` removed | **reject** | rejected by the root check | `shellcheck -s bash` is clean on the extracted body, `actionlint` is clean on the file, and it parses as YAML. ## What this does not do It does not check the pinned `Cache-Control` that `MIGRATING.md` §3 asks for alongside the redirect. That is a caching decision, not a blank page, and it is the package's `_headers` to write. `#21` is untouched and stays open: its remaining criteria are that every caller reaches package-build >= 15 and that #20's transitional branch then goes, which is three content migrations in three other repositories. Closes #24 Co-authored-by: Tom Rodriguez <tmrodrig@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Since package-build 15 (package-build#182) a package's landing is published at
/<package>/homepage-<shortcode>/, and Hugo separately generates/<package>/index.html— chrome around an empty<main>, authored by nothing. The guard's root check was written when those were one file, so losing the landing used to mean losing that file. It no longer does: a homepage-mode build that emitted no landing at all leaves oneindex.htmlat the package root, tallies one page, passes every check, and replaces the live site with a blank page.Require the addressed landing. The difficulty is that a pre-#182 tree and a collapsed post-#182 tree are identical on disk — one
index.htmlat the package root and nothing else — so the tree cannot say which it is.The signal is the installed
@heroiclands/package-build.npm cihas already run by the time the guard executes, sonode_modules/@heroiclands/package-build/package.jsonstates the version that actually built this tree. From 15 on, no landing is a collapsed build and is refused; below 15 the package root is the landing and the tree is counted exactly as before.Not the declared range. A range is a claim about what may be resolved, and permission is not resolution:
>=9.0.0permits 15 while the lockfile pins 9, so reading that range as new-shape would refuse a legitimate old-shape build — the one outcome this must not produce. It would also put semver-range comparison (9 || 15,*, a dist-tag) into shell to answer a question the resolved version answers with a string compare. Two other signals were considered and rejected: the_redirectsentry that sends/<package>/to the landing is authored by the package, so its absence proves nothing; and testing<main>for emptiness in<dir>/index.htmlwould couple the guard to the Hugo theme's markup, which is precisely the coupling this workflow exists to avoid.An unreadable version is refused, like every other guard in this file. It is only consulted once the landing is already missing, so the tree is under suspicion by then; "which shape is this?" going unanswered is not a reason to deploy it.
The landing is matched with
-srather than-f, so an empty landing is a collapsed landing rather than a discountable one.What is not touched. The root check stays the separate assertion it is — the deployment root exists and is non-empty. The tally is unchanged, so the homepage licensing bound still refuses a genuine second page in both shapes.
Verification
The guard step's
run:body was extracted verbatim withyqand driven against real trees, each built from a pristinegit archive origin/mainextraction plusnpm ci && npm run build:site. The three pre-#182 trees emit no_headers, so the workflow's own default-_headersstep was extracted and run against them first, as it is in the job.kethira, as built2 index.html file(s); 1 page(s)kethira, landing deleted, Hugo's root keptharnadventures, as built1 page(s), landing is the package rootharnensemble, as built1 page(s), landing is the package roothm3, as built1 page(s), landing is the package rootharnadventures, rootindex.htmlremovedkethira+ a genuine second content page2 page(s), licensing boundkethira, no landing, nonode_moduleskethira, landing present but emptyshellcheck -s bashis clean on the extracted body,actionlintis clean on the file, and it parses as YAML.What this does not do
#21's first two acceptance criteria are not met and the issue stays open for them:
harn-adventures(9.0.0),harn-ensemble(10.0.0) andHarnMaster-3-FoundryVTT(9.0.0) are still on pre-#182 majors, and #20's transitional branch is still here because they need it. Bumping those three crosses five or six majors each and is three content migrations, not a version bump — and the hole would stay open forsohl-kethira-basic, the only caller on 15, for as long as they took. This closes it now for any caller on the new shape while leaving the old shape working; the branch and the version read both go when the last caller reaches 15. Reasoning recorded on the issue.Refs #21