Skip to content

feat: a reusable deploy workflow the six packages call - #6

Merged
toastygm merged 1 commit into
mainfrom
feat/5_reusable-deploy-workflow
Aug 29, 2026
Merged

feat: a reusable deploy workflow the six packages call#6
toastygm merged 1 commit into
mainfrom
feat/5_reusable-deploy-workflow

Conversation

@toastygm

Copy link
Copy Markdown
Contributor

Six packages must publish https://www.heroiclands.org/<package>/. Two did, each
with its own ~100-line workflow; four had none. This adds
.github/workflows/deploy-package-site.yml — the first reusable workflow here —
so a package publishes with 13 lines instead of a copy.

The split: the workflow owns the runner, the completeness guard, the hosting
project, the custom domain and the upload
— identical for every package, and
exactly the parts that must not drift. The package owns its build.

The three decisions

1. The build contract is a named npm script, not steps this workflow knows.
It runs npm run build:site and takes whatever tree that leaves. thalorna's is
build:site-content && hugo && build:site-root; sohl's is
docs:prepare && docs:html && build:kb && site:assemble; the seventh package
will differ again. Teaching this workflow any of those shapes means editing this
repository every time a package changes how it builds — the coupling the issue
exists to remove. What is required is only the output: the site under
build/site/<package>/ (Hugo publishDir), with build/site uploaded whole,
so the deployed tree carries its own prefix physically and the router passes the
path through unchanged.

One generalisation makes that contract sufficient rather than nearly so: the
checkout is fetch-depth: 0 (so tags come with it) and GH_TOKEN is in the
build step's environment. That is what lets a package's own build resolve and
check out another ref of its own repository — which is the one thing sohl's
current workflow does that a naive "run one script" contract could not express.
Nothing here knows that any package does it.

2. Hugo is pinned here, shared, overridable. hugo-version defaults to
0.163.3 — the version both live workflows already pin, so adopting changes
nothing — and a caller can override it to trial a build ahead of the others, or
set "" to skip installing Hugo at all. One bump for six packages beats six
bumps that drift; the override is what stops a shared pin becoming a shared
blocker.

3. Secrets are named, never secrets: inherit. Inheriting hands a workflow
in another repository every secret the caller holds — npm publish tokens,
Foundry credentials, release tokens — for a job that needs two. Naming them
documents the requirement at the call site and fails when one is missing rather
than three steps later.

Both are declared optional so a repository still being set up can call this for
the build-and-guard half — but it must say so with allow-unpublished: true.
Otherwise missing credentials fail the run. thalorna's workflow skips the
upload with a notice; that was right during a migration and is wrong now, since
it is a deploy workflow reporting success without deploying.

The guard, per mode

> 1000 could not survive as written, and "no check" was not an option. So it is
derived from publish.site, read from package-build.config.yaml — never passed
in, so the guard cannot be told one thing while the build does another.

site: content site: homepage
<pkg>/index.html non-empty yes yes
<pkg>/404.html non-empty yes yes
build/site/_headers non-empty yes yes
page count (index.html files) min-pages ≤ n ≤ max-pages exactly 1
caller may set the bounds yes — and min-pages is required no

Homepage-only is the stricter check, not the absent one. It is two-sided: a
build that emitted nothing fails, and a build that emitted more than the
homepage fails. kethira and harnadventures publish one page because
publishing their content would breach the fan-content licences they ship under,
so passing min-pages or max-pages in homepage mode fails the run — a
boundary a caller can widen is not a boundary. That also gives
HeroicLands/package-build#55's "publishes exactly one page, asserted rather than
left to configuration drift" an assertion on the publishing side.

For content packages min-pages is required: a content package with no floor has
no guard, and a guard that cannot fail is the failure mode being removed. The two
fixed checks are not extras — 404.html is what stops Pages answering an
unmatched path with a 200 carrying the home page, and _headers (checked at the
deployment root, the only place Pages reads it) is what keeps the project's
other hostnames out of the index.

Project and custom domain

Both created if missing, idempotently, before the upload. The project check is a
lookup then a create, so a bad token or revoked scope still fails rather than
hiding inside a tolerated error. The domain — <package>.pkg.heroiclands.org,
the hostname heroiclands-site's router derives and does not store — is added by
inspection (list, add if absent) and verified rather than assumed if the create
loses a race. Doing it here is what makes a package self-publishing: the router
needs no edit to gain a package, but only if the hostname exists.

What was verified, and what was not

I cannot run a GitHub Actions workflow locally, so:

Verified by execution.

  • actionlint 1.7.12 with shellcheck 0.11.0: clean, on the workflow and on
    all six caller workflows. A negative control (renaming min-pages to
    min-pagez) produces property "min-pagez" is not defined in object type {...}, which proves actionlint really is resolving the hyphenated expressions
    against the declared workflow_call interface rather than passing them over.
  • The config step, run against all six real package-build.config.yaml files.
    It correctly reads kethira/hm3/harnensemble as homepage mode, and
    correctly refuses sohl, thalorna and harnadventures, which still carry the
    pre-5.0 boolean. Nine validation cases: missing config, missing
    contentPackage, unknown mode, absent publish: block, min-pages on a
    homepage package, max-pages on a homepage package, and a content package with
    no floor — each refused with the intended message.
  • The guard, against 11 synthetic trees: homepage with 0 / 1 / 2 pages,
    missing 404.html, missing _headers, an empty index.html, a missing package
    directory (the "published public/ instead" mistake), content at 1715 / 1000 /
    2 pages against a 1000 floor (2 being the shape of the actual incident), and
    1500 against a 1200 ceiling. All 11 behaved as intended.
  • The credentials gate, all four combinations.

That work found one real bug before it shipped: yq -r '.publish.site // "homepage"' — the obvious spelling, and the one I wrote first — silently maps
site: false to homepage, because // is an alternative operator that fires
on false as well as on absent. Three of the six repositories carry site: false
or did, so the boolean refusal would never have fired for exactly the packages
that needed it. Fixed, and commented at the site.

Not verified. No run has happened: the Cloudflare calls (project lookup and
create, domain list and add), peaceiris/actions-hugo, cloudflare/wrangler-action,
the workflow_call handshake itself, and the presence of yq on the runner (it
ships with the GitHub-hosted ubuntu images; the step checks for it and fails with
that message rather than assuming). The first adoption is where those are proven,
and it should be a package that is not live.

sohl and thalorna can adopt without changing what they publish

Not done here — no package repository is touched by this PR.

  • thalorna adopts as-is. Its build:site already produces
    build/site/thalorna/ with a 404.html and build/site/_headers, which is
    precisely the contract; every check in the guard is one its own workflow already
    ran, plus 404.html (which its workflow also checks) and the ceiling (which it
    can omit). Its caller is project: sohl-thalorna, min-pages: 1200.
  • sohl adopts by moving two steps it already runs — resolve the newest release
    tag, build the API documentation from it — out of its workflow and into its own
    build:site, which is why the checkout is unshallow with tags and why GH_TOKEN
    is in the build environment. That changes where the steps live, not what is
    published: same tag resolved, same docs, same site:assemble --api. It is also
    the correct home for them under decision 1 — the release-tag rule is sohl's
    build, not every package's.

Both must additionally migrate publish.site from the pre-5.0 boolean, which they
owe the toolchain regardless.

Contradicting the issue

The issue reads as though publish.site modes are available now. They are half
landed: package-build#51 and #55 are closed (the mode exists, homepage is the
default), but #52–#54 — requiring the homepage note, rejecting address fields on
it, link-checking it — are open, and sohl, thalorna and harnadventures still
carry the boolean. So no homepage-only package can build its one page yet.
This workflow is ready ahead of them and refuses the boolean rather than guessing,
which is the sequencing #52 asks for. The four new packages adopt once the note
type is required; the two live ones can adopt as soon as they migrate the flag.

Closes #5

Six packages publish a subtree of one site from six repositories. Two did
it with a hand-written ~100-line workflow each; the four with none were
about to get four more copies, and duplicated build logic is what produced
the incidents this closes over.

The workflow owns the runner, the completeness guard, the hosting project,
the custom domain and the upload — the parts that are identical everywhere
and must not drift. The package owns its build: one named npm script whose
steps this workflow never learns.

The completeness guard survives and becomes two-sided. A content package
must state a floor; a homepage-only package is held to exactly one page,
and the caller cannot widen that bound, because for kethira and
harnadventures it is a fan-content licensing boundary rather than a
preference.

Closes #5
@toastygm
toastygm merged commit da799bd into main Aug 29, 2026
1 check passed
@toastygm
toastygm deleted the feat/5_reusable-deploy-workflow 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.

A reusable deploy workflow, so a package publishes /<package>/ without copying 100 lines

1 participant