Skip to content

fix: refuse a build that emitted no landing - #23

Merged
toastygm merged 1 commit into
mainfrom
bug/21_refuse-a-landingless-build
Sep 4, 2026
Merged

fix: refuse a build that emitted no landing#23
toastygm merged 1 commit into
mainfrom
bug/21_refuse-a-landingless-build

Conversation

@toastygm

@toastygm toastygm commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

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 one index.html at 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.html at the package root and nothing else — so the tree cannot say which it is.

The signal is the installed @heroiclands/package-build. npm ci has already run by the time the guard executes, so node_modules/@heroiclands/package-build/package.json states 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.0 permits 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 _redirects entry that sends /<package>/ to the landing is authored by the package, so its absence proves nothing; and testing <main> for emptiness in <dir>/index.html would 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 -s rather 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 with yq and driven against real trees, each built from a pristine git archive origin/main extraction plus npm ci && npm run build:site. The three pre-#182 trees emit no _headers, so the workflow's own default-_headers step was extracted and run against them first, as it is in the job.

tree installed package-build expected result
kethira, as built 15.0.0 pass pass — 2 index.html file(s); 1 page(s)
kethira, landing deleted, Hugo's root kept 15.0.0 reject rejected — "the build emitted no landing"
harnadventures, as built 9.0.0 pass pass — 1 page(s), landing is the package root
harnensemble, as built 10.0.0 pass pass — 1 page(s), landing is the package root
hm3, as built 9.0.0 pass pass — 1 page(s), landing is the package root
harnadventures, root index.html removed 9.0.0 reject rejected by the root check
kethira + a genuine second content page 15.0.0 reject rejected — 2 page(s), licensing bound
kethira, no landing, no node_modules reject rejected — version unreadable
kethira, landing present but empty 15.0.0 reject rejected

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

#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) and HarnMaster-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 for sohl-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

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
@toastygm
toastygm merged commit f804033 into main Sep 4, 2026
1 check passed
@toastygm
toastygm deleted the bug/21_refuse-a-landingless-build branch September 4, 2026 16:40
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>
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