Skip to content

feat: write a default _headers so no package has to copy it - #8

Merged
toastygm merged 1 commit into
mainfrom
feat/7_default-headers
Aug 29, 2026
Merged

feat: write a default _headers so no package has to copy it#8
toastygm merged 1 commit into
mainfrom
feat/7_default-headers

Conversation

@toastygm

Copy link
Copy Markdown
Contributor

deploy-package-site.yml requires _headers at the deployment root but does not
write it, so every package produces its own. The file is identical for every
package
:project and :version are Cloudflare's own placeholders, so not
even the package name appears in it. Four more packages are about to adopt the
workflow; without a default, each copies the same six lines and the reason they
exist gets copied or lost with them.

The change

One step, after the build and before the guard, writing the default when the
build produced no _headers:

https://:project.pages.dev/*
  X-Robots-Tag: noindex

https://:version.:project.pages.dev/*
  X-Robots-Tag: noindex

That position is the whole of it: the build is what may have produced a
_headers, and the guard is what insists on one. Earlier, and a build that
clears its output directory clobbers it; later, and it is after the check it
exists to satisfy.

A package that emits its own keeps it, byte for byte. The step tests -e,
not -s, so it does not touch an existing file at all — including an empty
one, which is still the package's own output and still fails the guard.
Treating empty as absent would replace a broken build's output with a passing
default and hide it. The guard is unchanged; its _headers error message
gains one sentence saying what a failure now means (the build wrote an empty
file, since writing none gets the default).

It also declines to create <site-dir> when the build emitted no tree at all —
the guard reports the missing tree, which is the more useful error.

The reasoning, recorded at the step

This is the half of the issue that is not code. The reasoning currently lives in
one consumer's build script (sohl-thalorna, utils/build-site-root.mjs) —
which this default makes removable — so it is written down where the default now
is:

  • Why the rules exist. A Pages project answers at host-assigned addresses —
    <project>.pages.dev, and one <deployment>.<project>.pages.dev per
    deployment — as well as at its path on www.heroiclands.org. Unadvertised, but
    they serve the same pages, and left alone they are indexed and compete with the
    canonical URL.
  • Why they are scoped to those hostnames rather than applying noindex
    globally. :project and :version are named single-segment wildcards, so the
    two rules match *.pages.dev and nothing else. That is what keeps a repository
    correct if it is taken elsewhere: under its own domain the site stays
    indexable, and only the host-assigned addresses do not. A page that must be
    noindex at every address says so in the document (<meta name="robots">),
    which is body content and passes through untouched.
  • What becomes of the header at the canonical address.
    heroiclands-site's Worker strips X-Robots-Tag when it proxies
    (worker/src/router.js, canonicalHeaders), because the hosting cannot tell
    the router's request from a reader's — same URL, same address. That is
    deliberate and tested there, and unchanged by this PR. The default's job is to
    emit exactly the header the router already knows to strip, which this payload
    is.

The README's guard section and "Adding a package" checklist are updated to match:
a package no longer has to write _headers.

What was verified, and what was not

No GitHub Actions workflow can be run locally, so — as with #6 — this is
honest about the line.

Verified by execution.

  • actionlint 1.7.12 with shellcheck 0.11.0: clean on the whole workflow.
    A negative control confirms shellcheck really is reaching the new step's
    script: unquoting ${SITE}/_headers in the new if produces
    SC2086:info:7:9: Double quote to prevent globbing and word splitting. Both
    extracted scripts also pass shellcheck -s bash standalone.

  • The two scripts were extracted from the YAML with yq and run against
    synthetic trees
    , so what was exercised is the workflow's own text, dedented
    by the same block-scalar rules the runner applies. 18 assertions, all passing:

    Case Result
    (a) no _headers → default written; byte-identical to sohl-thalorna's HEADERS constant (cmp, sha256 89ef3218…, 116 bytes); guard passes pass
    (b) package-supplied _headers (deliberately different content) → step exits without writing; content byte-identical, mtime unchanged after a 1s gap, so it is not even rewritten; guard passes pass
    (c) empty _headers present → left at 0 bytes; guard fails, naming _headers pass
    (d) no <site-dir> at all → nothing conjured; guard fails on the missing tree, not on _headers pass
    (e) default step run twice → second run leaves the file identical pass
    (f) homepage mode with the default written → exactly-one-page guard passes pass

    Case (a)'s byte-comparison is the acceptance criterion "sohl-thalorna could
    delete its local copy and change nothing", checked against that repository's
    actual exported constant rather than against a transcription of it.

Not verified. No run has happened. Unproven here: the workflow_call
handshake, Cloudflare's interpretation of the payload at a live edge (the
scoping claim is from the _headers placeholder semantics and from the file
sohl-thalorna already deploys, not from an observed response header), the
router's strip in production, and the block-scalar dedent as the runner
performs it — reproduced faithfully by yq, but not by GitHub. The first
adoption is where those are proven.

Contradicting the issue and the existing file

One thing worth a decision, found while writing the comment and not changed
here
: the guard's comment and the README both claimed _headers marks the
project's *.pages.dev and *.pkg.heroiclands.org addresses noindex. The
payload does not — both rules are *.pages.dev patterns, so
<package>.pkg.heroiclands.org, the custom domain this workflow adds and the
router proxies, carries no X-Robots-Tag and is indexable. I corrected the prose
in both places rather than leave two adjacent comments disagreeing, and left the
payload exactly as the issue specifies, since a third rule would break the
byte-for-byte criterion. Whether that hostname should be covered is a separate
call: an argument exists either way, since it is also the origin the router
fetches from. Happy to file it.

Follow-up, not done here

No package repository is touched by this PR. sohl-thalorna can now delete the
_headers writing from utils/build-site-root.mjs (its HEADERS constant and
the writeFileSync), which is its own change in its own repository — and worth
doing deliberately, since that file is where this reasoning has lived.

Closes #7

The deploy workflow requires `_headers` at the deployment root but does not
write it, so every package produces its own — and the file is identical for
all of them. `:project` and `:version` are Cloudflare's own placeholders, so
not even the package name appears in it. Four more packages are about to
adopt the workflow, and without a default each copies the same six lines and
the reason they exist gets copied or lost with them.

The default is written after the build and before the guard: the build is
what may have produced one, and the guard is what insists on one. A package
that emits its own keeps it byte for byte — the step only writes when there
is no file at all, so an empty `_headers` is still the package's file and
still fails the guard, which is unchanged.

The reasoning is recorded at the step, because it currently lives only in
`sohl-thalorna`'s `utils/build-site-root.mjs`, which this makes removable:
why a Pages project's host-assigned addresses need `noindex`, why the rules
are scoped to those hostnames rather than applied globally, and why
`heroiclands-site`'s router strips `X-Robots-Tag` when it proxies.

Closes #7
@toastygm
toastygm merged commit 28047ed into main Aug 29, 2026
1 check passed
@toastygm
toastygm deleted the feat/7_default-headers branch August 30, 2026 00:26
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.

The deploy workflow checks for _headers but does not write it, so every package copies the same six lines

1 participant