Shared GitHub configuration for the HeroicLands organisation: Actions every repository uses, and community health files GitHub falls back to when a repository has none of its own.
Nothing here is published to npm. The build toolchain,
package-build, delivers a
single command line and is scoped by what it reads: the content tree, lang/,
styles/, src/, the assets and the manifest. Repository governance is none of
those, which is what this repository is for.
githooks/ is the canonical set copied unchanged into each repository’s
.githooks/. Both hooks run with POSIX sh, Git and standard POSIX utilities;
they need no Node, npm or Python installation. The message hook needs an
available UTF-8 locale to recognize Unicode decoration while preserving prose.
It uses the inherited UTF-8 locale or selects an installed UTF-8 locale within
its own process, including when the caller uses LC_ALL=C. If none is available,
it asks for one to be installed or enabled and refuses the commit.
pre-commitrefuses commits onmainandmaster. Feature branches and detached HEAD are allowed.git config hooks.protectedBranch falseorgit config hooks.allowCommitOnMain truedisables this guard for a clone.commit-msgapplies the bounded assistant-credit grammar documented underactions/no-attributionbelow. Ordinary prose and human coauthor identities pass. Git comment lines and everything after its verbose-diff scissors line are ignored.git config hooks.noAttribution falsedisables this local guard.
Install or refresh the tracked copy from the canonical repository:
canonical=$(mktemp -d)
git clone --depth 1 https://github.com/HeroicLands/.github.git "$canonical"
mkdir -p .githooks
cp "$canonical"/githooks/* .githooks/
chmod +x .githooks/commit-msg .githooks/pre-commit
rm -rf "$canonical"
git config core.hooksPath .githooksCommit .githooks/ with its executable bits. When refreshing, remove any extra
files the drift check reports. Run git config core.hooksPath .githooks from the
repository’s native setup step so each new clone installs the hooks. A repository
that uses npm can include that command in its prepare script; a repository
without npm can put it in its setup shell script. Local configuration applies
to this clone. git commit --no-verify bypasses both local guards; server and
pull-request checks still enforce their rules.
Run the drift action after checking out the consuming repository, on either forge:
name: Git hooks
on:
pull_request:
types: [opened, reopened, synchronize]
permissions:
contents: read
jobs:
githooks:
name: Canonical git hooks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: HeroicLands/.github/actions/githooks@mainThe action compares the checkout’s .githooks/ with the githooks/ tracked in
its own action repository, resolved through github.action_path. It reports
every missing, different or extra hook by path and fails without editing files.
No token or toolchain installation is needed. Updating means copying the
canonical files again; local exceptions belong in Git configuration.
Run the shell integration tests and the shared grammar tests with:
npm ci --prefix ci
sh ci/githooks.test.sh
node --test ci/githooks.test.mjsIts job runs only on Gitea, and only for a repository whose variable
HL_DEPLOY_ENABLED is true.
Builds a package's website and publishes it at
https://www.heroiclands.org/<package>/. The first reusable workflow here, so
it sets the convention: reusable workflows live in .gitea/workflows/ and
are called with uses:; composite actions live in actions/ and are used
as a step.
# .gitea/workflows/deploy-site.yml — in the package repository
name: Deploy the site
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
jobs:
deploy:
uses: HeroicLands/.github/.gitea/workflows/deploy-package-site.yml@main
with:
project: sohl-thalorna
min-pages: 1200
secrets:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}| Input | Default | |
|---|---|---|
project |
— | the Cloudflare Pages project hosting this package |
min-pages |
0 |
fewest pages a complete build emits; required in content mode, rejected in homepage mode |
max-pages |
0 |
most it emits, 0 for no ceiling; rejected in homepage mode |
build-script |
build:site |
the npm script that builds the site |
site-dir |
build/site |
the directory uploaded — the deployment root |
config |
package-build.config.yaml |
read for contentPackage |
node-version |
24 |
|
domain-suffix |
pkg.heroiclands.org |
the namespace the router derives an origin in |
allow-unpublished |
false |
build and verify without credentials instead of failing |
Secrets are named, never secrets: inherit — see Secrets
below.
The workflow runs npm run build:site and takes whatever tree it leaves
behind. It does not know the steps, and this is the load-bearing decision:
a content package's script is package-build deps fetch && package-build site build, sohl's is
docs:prepare && docs:html && build:kb && site:assemble, and a package added a
year from now will differ again. Teaching this workflow any of those shapes
would mean editing this repository every time a package changed how it
builds — the coupling the shared workflow exists to remove.
What the workflow requires of the script is only its output: the site under
<site-dir>/<package>/, which is where package-build site build writes
build/site/<package>, with <site-dir> uploaded whole. The deployed tree
then carries its own prefix physically, so the router proxies /<package>/…
straight through without rewriting the path and the deployment behaves
identically at the project's own address
(heroiclands-site#25).
A build that publishes anywhere else 404s at every address this deploy serves —
and only once the route is live.
The checkout is unshallow (fetch-depth: 0, so tags come with it). That is what lets a package's
build resolve and check out another ref of its own repository — sohl
documents its newest release tag, not main — with nothing here knowing that
any package does so.
A Cloudflare Pages deploy replaces the whole tree, so a build that silently emits nothing takes the live site down and reports success. Nothing is uploaded until the tree has been proven. Every run checks the same files; the bounds a caller states only change the page count.
caller states min-pages |
caller states neither bound | |
|---|---|---|
<pkg>/index.html |
non-empty, <main> not empty |
non-empty, <main> not empty |
<pkg>/404.html |
non-empty | non-empty |
<site-dir>/_headers |
non-empty | non-empty |
pages (index.html count) |
min-pages ≤ n ≤ max-pages |
exactly 1 |
| bounds settable by the caller | yes, and min-pages is required |
no |
The landing is <pkg>/index.html. A build that emitted no homepage can still
leave an index.html of chrome around an empty <main>, which is non-empty and
tallies as a page. The guard refuses an empty <main> rather than replacing the
live site with a blank page
(#21).
Stating no bound is the stricter check, not the absent one. It is two-sided: a
build that emitted nothing fails, and so does a build that emitted more than the
homepage. kethira and harnadventures publish one page because publishing
their content would breach the fan-content licences they ship under (Keléstia's
Fan Material Guidelines; Lythia's terms), so "exactly one page" is a licensing
boundary. The only input that leaves it is stating a bound, which a package
publishing its homepage alone has no reason to do.
min-pages is required once a caller states any bound. A content package that states no
floor has no guard at all, and a guard that cannot fail is the failure mode this
workflow exists to prevent. Set it below the real figure with room for growth —
it catches a collapsed build, it is not an assertion about today's page count.
The two fixed checks are not optional extras. 404.html is what makes Pages
answer an unmatched path with a real 404 instead of a 200 carrying the home
page, which reads as success to every link checker there is. _headers marks
every address the deployment answers on that is not the canonical one noindex,
and Pages reads it only at the deployment root — which is why it is checked
there rather than in the package subtree.
The workflow writes _headers for you if the build does not. It is
identical for every package — :project, :version and :package are
Cloudflare's own placeholders, so not even the package name appears in it — and
the reason it exists is generic too, so each package copying the same rules only
spread the payload and lost the reasoning with it. The default is written after
the build and before the guard; a package that emits its own keeps it byte for
byte, and an empty _headers is still the package's file and still fails the
guard.
There are three rules, one per family of address a deployment answers on
besides its canonical path: <project>.pages.dev, one
<deployment>.<project>.pages.dev per deployment, and the
<package>.<domain-suffix> custom domain this workflow gives the project. The
third was missing until #9 — every live package answered 200
with no X-Robots-Tag at its pkg.heroiclands.org address, which is the newest
of the three and the only one a reader is plausibly handed.
The rules are deliberately scoped to those hostnames rather than applied
globally: under its own domain a copy of the repository stays indexable, and
only the host-assigned addresses do not. A placeholder matches one label —
inside a host the delimiter is the dot — so :package.pkg.heroiclands.org needs
four labels and a literal pkg, and www.heroiclands.org cannot match it. That
holds only while domain-suffix names a dedicated namespace and not the domain
the canonical site itself is served from.
heroiclands-site's router strips X-Robots-Tag when it proxies, because the
hosting cannot tell its request from a reader's, so the header reaches the
canonical address and is removed there. The third rule is the one that depends
on that strip, since the custom domain is the origin the router fetches; the
first two never reach it. The strip is asserted in the router's own suite, which
gates every pull request there (heroiclands-site#30), and it was live end to end
until heroiclands-site#26 moved the origin off *.pages.dev.
The bounds are the mode. package-build decides what a package publishes
from its authored content tree, so the configuration states no mode. A caller
that states min-pages publishes content pages; one that states neither
publishes its homepage alone.
Both are created if missing, idempotently, before the upload — checked first, so a genuine error (a bad token, a revoked scope) still fails the run rather than hiding inside a tolerated create.
A Pages project is account state, not repository state, so without creating
it a clone of a package repository plus a scoped token still could not publish.
The custom domain is <package>.<domain-suffix>, e.g.
kethira.pkg.heroiclands.org: the router derives a package's origin from that
prefix alone and holds no list of packages, so adding a package is no edit to
heroiclands-site — but only if the hostname exists.
Registering the domain does not create its DNS record.
POST …/pages/projects/{project}/domains attaches a hostname and answers
success: true for the half it did; the dashboard hides the difference, because
Activate domain does both. A separate step therefore creates
CNAME <package>.pkg → <project>.pages.dev, proxied, since the router
fetches these hostnames as origins.
Two things about that step are load-bearing and should survive any tidying:
- It stays separate from the domain step. That step returns early when the hostname is already registered — exactly the state this bug leaves behind — so folding the record creation into it would skip every package that already has the problem.
- A record that already exists pointing elsewhere is a warning, not an overwrite. The step creates what is missing; it does not replace deliberate state.
The zone is resolved by name from domain-suffix rather than hardcoded, walking
the suffix a label at a time (pkg.heroiclands.org → zone heroiclands.org),
so a consumer publishing under another suffix still works.
The API token needs two permissions, both of them:
| Permission | What it is for |
|---|---|
| Account → Cloudflare Pages → Edit | the hosting project, the custom domain, the upload |
Zone → DNS → Edit (on the zone behind domain-suffix) |
the CNAME that makes the domain resolve |
The second is not optional and not only for a new package. A token carrying Pages access alone runs every step green, registers the hostname, and publishes an address that does not resolve — which is precisely what happened four times on 2026-08-29. A DNS call that fails, including on a permissions error, now fails the run and names the missing permission.
Passed by name, 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 of them. Naming them also
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. It has to say so with allow-unpublished: true;
otherwise missing credentials fail the run. Skipping the upload quietly would
make this a deploy workflow that reports success without deploying, which is the
same failure the completeness guard exists to catch.
One shape, twice parameterised. sohl and thalorna publish content and so
state a floor; the other four publish a homepage and cannot.
# sohl-thalorna/.gitea/workflows/deploy-site.yml — what all six look like
name: Deploy the site
on:
push: { branches: [main] }
workflow_dispatch:
permissions: { contents: read }
jobs:
deploy:
uses: HeroicLands/.github/.gitea/workflows/deploy-package-site.yml@main
with: { project: sohl-thalorna, min-pages: 1200 }
secrets:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}| repository | project |
min-pages |
|---|---|---|
Song-of-Heroic-Lands-FoundryVTT |
sohl-site |
1000 |
sohl-thalorna |
sohl-thalorna |
1200 |
HarnMaster-3-FoundryVTT |
hm3-site |
— |
sohl-kethira-basic |
sohl-kethira-basic |
— |
harn-ensemble |
harn-ensemble |
— |
harn-adventures |
harn-adventures |
— |
sohl is the one exception: its workflow is deploy-sohl.yml and it also runs
on a new release, because half of what it publishes — the API documentation —
tracks the newest release tag rather than main.
The floors are each package's own, taken from a real build with room to spare:
sohl's knowledgebase compiles ~1,500 pages and thalorna's content tree
~2,500. Raise a floor when a package grows; never raise one to make a red run
green.
Everything else — the package name, the publishing mode, the origin hostname,
the guard, the runner — is derived or shared. Adding the
seventh package is a project: name and, if it publishes content, a floor.
- Set
contentPackageinpackage-build.config.yaml. - Give the repository a
build:sitescript that writesbuild/site/<package>/(package-build site build) with a404.htmlin it.build/site/_headersis written for you unless the build produces one. - Add the four secrets-and-
projectlines above, with aCLOUDFLARE_API_TOKENcarrying both permissions in the table above. - Push. The hosting project, the custom domain and its DNS record are created on the first run; the router already knows how to reach them.
A composite action is a step; a reusable workflow is a whole job — a runner, a
checkout, a toolchain, a concurrency group, an environment. An action can
declare none of those, so when the thing being copied is a job, uses: at
the job level is what moves it; when it is a step, it is an action.
When it is neither — CI code that runs on a push, talks to the GitHub API and
wants nothing but a token — it is still an action rather than an npm package.
Minting a package to hold a few hundred lines of fetch calls would add a
release pipeline to maintain, and neither build toolchain is the right home for
repository governance.
@main is deliberate throughout: these are org-internal and every caller wants
the current one. A caller that needs to pin can reference a SHA.
Its jobs run only on Gitea, and only for a repository whose variable
HL_RELEASE_ENABLED is true.
Versions a Foundry package with changesets and cuts the GitHub Release that Foundry installs from.
# .gitea/workflows/release.yml — in the package repository
name: Version and Release
on:
push:
branches: [main]
# Manual recovery: re-runs the decision for a version that is merged and
# versioned but never got its release. An existing tag is skipped rather
# than duplicated, so running this is safe.
workflow_dispatch:
permissions:
contents: write # push the version branch, create the tag and Release
pull-requests: write # open and update the Version Packages pull request
jobs:
release:
uses: HeroicLands/.github/.gitea/workflows/release-foundry-package.yml@main
with:
package-kind: module| Input | Default | |
|---|---|---|
package-kind |
— | system or module; selects build/dist/<kind>.zip and .json |
build-script |
build:noci |
the npm script that builds and verifies the tree |
post-release-script |
"" |
an npm script run after the Release, same job, built tree intact |
node-version |
24 |
permissions are declared by the caller, not here. A reusable workflow
cannot grant itself more than the caller holds, and one caller — sohl —
needs two more (id-token: write, actions: write) for what its
post-release-script does. Declaring a fixed block here would have capped
every caller at the narrowest set.
Gitea starts no workflow for an event the job token causes. The Version Packages
pull request is opened and regenerated with RELEASE_BOT_TOKEN, a real user's
token, so each push starts the repository's checks like any other pull request,
and the review check in actions/changesets blocks the
merge until the release note has been read on its final commit.
The workflow runs npm run <build-script> and npm run build:pack-release,
and knows nothing about what either does — the same contract
deploy-package-site.yml makes with build:site. What it requires is only the
output: build/dist/<package-kind>.zip and build/dist/<package-kind>.json,
both non-empty.
build:pack-release is fixed rather than an input because all six callers
spell it the same way. An input for an axis nothing varies on is not
flexibility; it is a knob that invites divergence, which is the disease being
treated.
build-script must not begin with its own npm ci — dependencies are
already installed by the time it runs. That is why the default is build:noci
and not build: in all six repositories build is literally
npm ci && npm run build:noci, so passing it installs twice.
Every caller takes the default, so nothing varies on this axis either. It
stays an input where build:pack-release did not, because it is the same
named-script seam deploy-package-site.yml makes with build:site — the
contract is "one npm script, contents unknown here", and a package whose script
is named differently should be able to call this without editing it. An input
that every caller leaves alone is a seam; the one to avoid is an input that
invites callers to differ where they currently agree.
Every steps.<id>.outputs.* reference must match the action.yml of the
action it reads. This is the defect the whole
workflow exists to end, and it is worth stating as a rule because nothing
catches it:
An output name that does not exist resolves to the empty string. No error,
no warning, and actionlint is clean — a workflow cannot know an action's
output names without resolving the action. So
if: steps.changesets.outputs.hasChangesets == 'false' becomes '' == 'false',
always false; the gated step is skipped forever and the job reports green
while releasing nothing.
actions/version-pr declares has-changesets and pr-number;
actions/release-tag declares version, tag, commit and pushed.
Kebab-case names need bracket notation — outputs['has-changesets'] — because
a hyphen inside a property name parses as subtraction. ci/workflows.test.mjs
checks every output a workflow reads against the action's action.yml.
Release happens only when both hold: no changesets remain (a bump just landed) and this version is not already tagged (an ordinary push, a re-run or a hand-cut tag must not re-release).
The second half branches on git ls-remote's exit code, not on truthiness
(#13):
| exit | meaning | what happens |
|---|---|---|
| 0 | the tag exists | skip — nothing to release |
| 2 | no matching ref | release |
| 128 | the remote could not be reached | fail the job |
The obvious if git ls-remote …; then skip; else release; fi conflates 2 and
128, so a transient network or auth failure reads as this version is untagged
and the workflow proceeds to release a version that may already have one. Every
other guard in the file fails closed; that one failed open, and did the
irreversible thing on failure. stderr is deliberately not redirected — on 128 it
carries the only explanation.
A second guard covers the other silent direction: the two assets are checked non-empty before the Release is created. A Release is published the instant it exists and is what every installed copy updates from, so a pack script that failed quietly would otherwise produce a tag, a Release, and nothing to install.
A version X.Y.Z with Z non-zero publishes as a GitHub prerelease; X.Y.0
publishes as a normal release and takes "Latest". No exceptions, no
per-package opt-in — it is org-wide, and it applies to future releases only.
Published releases are history and are never relabelled.
The digits carry a release channel here, not a severity:
| bump | channel |
|---|---|
major / minor |
goes to users — a real release |
patch |
beta only, never meant to be consumed |
0.9.1, 0.9.2, … accumulate toward the next user release, which one minor
then cuts as 0.10.0.
The channel lives in the digits rather than in a semver prerelease component
because Foundry has to sort these. Foundry's own versions are <major>.<build>
and its isNewerVersion is a naive dot-split with a string fallback — it has no
notion of -beta.0, so 0.9.0-beta.0 would sort wrongly against 0.9.0. A
numeric-only scheme sorts correctly; what it cannot do on its own is say which
channel a version is on. The GitHub label is what says that to a human.
The decision is keyed on the version the decide step already reads from
package.json, not on the tag string — the tag is that same value with a v
glued on, and re-parsing it would make the rule depend on the tag spelling.
prerelease and make_latest are set together and always explicitly, both
taken from the actions/github-release inputs. Both defaults are wrong for this family: the action
defaults prerelease to false and GitHub defaults make_latest to true, so an
unlabelled patch would be published as the current release and would take the
Latest badge off the last real one. Labelling the prerelease while leaving
Latest to the default fixes only half of it — a prerelease that still claims
Latest defeats the point.
| version | prerelease |
make_latest |
published as |
|---|---|---|---|
0.9.0 |
false |
true |
release, Latest |
0.9.1 |
true |
false |
prerelease |
0.10.0 |
false |
true |
release, Latest |
1.0.0 |
false |
true |
release, Latest |
0.0.1 |
true |
false |
prerelease |
0.5.3 |
true |
false |
prerelease |
1.2.3.4, 1.0.0-beta.1, 0.9, 01.2.3, v0.9.1, … |
— | — | the run fails |
The parse is strict — exactly three dot-separated numeric components, no leading
zeros — and it fails closed, like every other guard in the file. A version
this rule cannot classify stops the run rather than falling through to
"release", because "release" is the outcome with consequences: it is published
the instant it exists, it takes the Latest badge, and every installed copy
updates from it. An unclassifiable shape must not be resolved by guessing the
irreversible answer. It also catches the specific mistake this scheme invites —
a 1.0.0-beta.1 written by someone reaching for changesets' prerelease mode,
which is exactly the spelling Foundry mis-sorts.
Blast radius. Packages that already shipped patch releases as ordinary
releases — sohl-kethira-basic (v0.5.1, v0.5.2, v0.5.3) and harn-adventures
(v0.0.1) — will publish their next patch release as a prerelease. That is
the intended consequence of an org-wide rule, not a regression, and nothing
already published changes.
Five callers are one line. Only sohl is not, and the difference is what it
does after the release, not how it builds.
| repository | package-kind |
also passes |
|---|---|---|
harn-adventures |
module |
— |
harn-ensemble |
module |
— |
sohl-thalorna |
module |
— |
sohl-kethira-basic |
module |
— |
HarnMaster-3-FoundryVTT |
system |
— |
Song-of-Heroic-Lands-FoundryVTT |
system |
post-release-script: release:post |
# the five: permissions, then one line
permissions:
contents: write
pull-requests: write
jobs:
release:
uses: HeroicLands/.github/.gitea/workflows/release-foundry-package.yml@main
with: { package-kind: module }sohl grants two permissions more — id-token: write for npm Trusted
Publishing and actions: write to dispatch its site deploy — and its
release:post script does the two things no other package does: republish
/sohl/ from the new tag, and publish @heroiclands/sohl-types. Nothing here
knows what that script contains, which is the point; it runs after the
Release, so a package's own follow-on work can never be why the Release was not
cut.
package-build and content-language-server publish to npm, not to a GitHub
Release that Foundry installs from, so they are not callers of this workflow.
They version and tag on Gitea through
release-npm-package.yml, and publish
from their own .github/workflows/release.yml, the only workflow GitHub runs,
when the push mirror delivers the tag.
Parse it; don't read it. The check that matters is that the job delegates and carries no steps of its own — a half-migrated file looks right at a glance.
git show origin/main:.gitea/workflows/release.yml | yq '.jobs[] | has("steps")' # must be falseTwo runs prove a release path, not one, because "green while doing nothing" is the shape being guarded against: the release cuts a tag and a Release with both assets, and the next ordinary push runs green cutting nothing.
A gate that only runs at release time is not a gate. Migrating the six found
that three of them had a red build:noci no CI job executed — the release
workflow was the only thing that ran it, so a release was the first place anyone
would have found out, and two of the six had never cut one. Before pointing new
automation at a repository's build, run it.
Gitea reads .gitea/workflows/, where every reusable workflow lives. Every
check runs on Gitea; the one workflow GitHub runs is a package's own
release.yml, which publishes a tag to npm.
A caller reaches the Gitea copies as
HeroicLands/.github/.gitea/workflows/<file>@main. Gitea resolves that path
against its own HeroicLands/.github, which lists HeroicLands under Settings →
Actions → Collaborative Owners; without that entry no repository can use its
workflows or actions. The shared actions are referenced in the bare form,
HeroicLands/.github/actions/<name>@main, which Gitea fetches from github.com.
| Workflow | Job | Gate | Secrets |
|---|---|---|---|
release-foundry-package.yml |
release |
github.server_url != 'https://github.com' && vars.HL_RELEASE_ENABLED == 'true' |
RELEASE_BOT_TOKEN, GH_RELEASE_TOKEN |
deploy-package-site.yml |
deploy |
github.server_url != 'https://github.com' && vars.HL_DEPLOY_ENABLED == 'true' |
CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID |
release-npm-package.yml |
release |
github.server_url != 'https://github.com' && vars.HL_RELEASE_ENABLED == 'true' |
RELEASE_BOT_TOKEN |
A called workflow reads the caller's repository variables, so a repository
turns releasing on by setting its own HL_RELEASE_ENABLED to true and
deploying on by setting HL_DEPLOY_ENABLED to true. A repository with neither
variable runs neither job. The secrets are organisation-level on Gitea and are
passed by name; secrets: inherit is never used.
Every job runs on ubuntu-latest, a host-mode runner that already carries Node,
yq, jq and curl, apart from the Foundry release's book job, which
runs on the runner its caller names. Nothing is installed into a system path.
The Foundry release's gate sits on its decide job; book and release
depend on it. The job token never starts other workflows, so every push and
pull request that has to
run checks is made with RELEASE_BOT_TOKEN.
# .gitea/workflows/release.yml — in the package repository
name: Version and Release
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
jobs:
release:
uses: HeroicLands/.github/.gitea/workflows/release-foundry-package.yml@main
with:
package-kind: module
secrets:
RELEASE_BOT_TOKEN: ${{ secrets.RELEASE_BOT_TOKEN }}
GH_RELEASE_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }}The inputs, outputs, build contract, channel rule and asset guard are those of the GitHub copy above. The release sequence:
- Version Packages. With changesets pending,
actions/version-prruns the repository'schangeset:versionscript onchangeset-release/mainand force-pushes the result as one commit withRELEASE_BOT_TOKEN. It opens the pull request asWIP:with no scheduled merge, or updates the one already open. The owner reads the release and removes the prefix; once a pull request is ready, a later run schedules its squash merge for when the checks pass. Because the bot is a user of the forge, its push starts the repository's checks. - Decide. With none pending, the job reads the version from
package.jsonand computesv<version>and its channel exactly as the GitHub copy does. A tag that exists elsewhere means nothing to release. A tag that already names the checked-out commit resumes the release, so a re-run after a failed upload finishes it. - Books. When the release is real, a book runner is named and the package declares books
(
package-build pdf --list-assetslists at least one), abookjob builds them on the runner named by thebook-runnerinput and hands the PDFs to the release job as therelease-booksartifact. The input is empty by default, which leaves the books to the package'sbuild:pack-releasein the release job; a caller opts in withbook-runner: book, the runner whose image carries Typst. The job runspackage-build pdf --out build/dist --book-version <version>afternpm run build:deps;book-scriptnames an npm script to run in its place when the books need more first. It is called with the same--outand--book-version, so it should end inpackage-build pdf. A package with no books, or a caller that names no runner, passes through the job without running a step. The GitHub copy accepts both inputs and ignores them. - Build and package. The same two npm scripts and the same asset guard.
When the books were built by the
bookjob, packaging runs with--no-pdfand the PDFs are downloaded intobuild/distbefore the guard. - Tag. The job pushes
v<version>to the forge with the bot's token. A resumed run skips this step. - GitHub Release.
actions/github-releasewaits for the push mirror to deliver the tag to GitHub at the same commit, creates the release as a draft, uploads and checks every asset, and publishes it withprereleaseandmake-latestset explicitly.GH_RELEASE_TOKENis a GitHub token with Contents read and write; the job writes no git ref on GitHub. - Post-release. The optional
post-release-scriptruns withGITEA_TOKEN(the job token) andRELEASE_TAGin its environment.
| Secret | |
|---|---|
RELEASE_BOT_TOKEN |
the release bot's personal access token on Gitea, with write access to the repository |
GH_RELEASE_TOKEN |
a GitHub fine-grained personal access token with Contents read and write on the releasing repositories |
# .gitea/workflows/release.yml — in the package repository
name: Version and Tag
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
jobs:
release:
uses: HeroicLands/.github/.gitea/workflows/release-npm-package.yml@main
secrets:
RELEASE_BOT_TOKEN: ${{ secrets.RELEASE_BOT_TOKEN }}This workflow versions and tags. It never publishes: npm trusted publishing
needs an OIDC identity that Gitea cannot mint. Publishing happens on GitHub, in
the package's own .github/workflows/release.yml, the file npm's trusted
publisher is bound to.
- Version Packages. With changesets pending,
actions/version-propens or updates the pull request, exactly as for a Foundry package. - Tag. With none pending,
actions/release-tagpushesv<version>at the commit that set the version, usingRELEASE_BOT_TOKEN. A tag that already exists is left where it is, so an ordinary push tomaindoes nothing. - Mirror. The push mirror carries the tag to GitHub within seconds.
- Publish, on GitHub. The package's
release.ymlruns on that tag push. It refuses a tag whose commit is not onmainor whose name is notv<version>of the taggedpackage.json, skips the publish when npm already has the version, and otherwise runs the tests andnpm publish --provenancethrough trusted publishing. It then creates the GitHub Release from the version's CHANGELOG section, unless one exists.
| Input | Default | |
|---|---|---|
node-version |
24 |
the Node major the versioning runs under |
| Output | |
|---|---|
tagged |
true when this run pushed the tag, false otherwise |
version |
the version in package.json, set when no changesets were pending |
tag |
v<version>, set when no changesets were pending |
# .gitea/workflows/deploy-site.yml — in the package repository
name: Deploy site
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
jobs:
deploy:
uses: HeroicLands/.github/.gitea/workflows/deploy-package-site.yml@main
with:
project: <pages-project>
min-pages: 40
max-pages: 150
secrets:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}The inputs, the build contract, the _headers default, the completeness guard
and the hosting steps are those of the GitHub copy above. The Gitea copy adds a
step that fails with the list of missing tools when yq, jq or curl is
absent from the runner, and gives the build step no forge token.
changesets.yml and no-attribution.yml run the local action against the pull
request.
ci/workflows.test.mjs parses each reusable workflow and fails when a
safety-relevant element changes: the run: body of the build and pack steps,
the completeness guard and its page bounds, the _headers default, the
already-released guard and the version and tag computation, the release asset
list, and the declared secrets. It also asserts that every job carries its
HL_*_ENABLED gate or depends on the job that does, that the workflows hold no
GitHub-only release machinery, and that no expression builds a link from
github.server_url, which on Gitea is the runner's internal address.
Every step output a workflow reads from a shared action is checked against that
action's action.yml, and a kebab-case name must use bracket notation.
release-npm-package.yml is held to its gate, to the two shared actions in
order, to RELEASE_BOT_TOKEN as its only secret, and to never publishing: no
npm publish, deploy:npm, changeset publish, id-token, npm token or
registry-url.
npm ci --prefix ci
node --test actions/*/*.test.mjs ci/*.test.mjsA pull request that touches .gitea/workflows/, ci/ or actions/ runs the
tests and every action's tests through .gitea/workflows/workflow-parity.yml.
Dependency updates come from Renovate on Gitea. Dependabot's branches live on the GitHub mirror, which deletes every branch Gitea lacks, so only Renovate's Gitea pull requests survive. GitHub's Dependabot security alerts stay on; they write nothing.
.gitea/workflows/renovate.yml runs on the lint runner every Sunday at 02:00
Los Angeles time. Gitea evaluates cron in UTC, so the workflow fires at 09:00
and 10:00 UTC and its first step keeps whichever is 02:00 locally. It runs a
pinned Renovate release as the Gitea user renovate (organisation secret
RENOVATE_TOKEN), autodiscovering HeroicLands/*. Archived repositories and
forks are skipped. RENOVATE_GITHUB_COM_TOKEN, a read-only github.com token,
supplies release notes when it exists and is not needed otherwise. No
repository needs a Renovate file: renovate/config.cjs is the global
configuration and merges the shared preset default.json. A repository that
wants its own rules may add a renovate.json extending
local>HeroicLands/.github.
| Pull request | Branch | Merged |
|---|---|---|
| All patch updates | renovate/patch |
Automatically, once checks pass |
| All minor updates, pins and digests | renovate/minor |
By the owner |
| Lockfile maintenance | renovate/lock-file-maintenance |
Automatically, once checks pass |
| Each major update | renovate/<package>-<major>.x |
By the owner |
Commit subjects read chore(deps): update patch dependencies. Declared ranges
are kept: a version inside the range changes the lockfile only, and
peerDependencies widen. Pull requests carry no labels, and at most four are
open per repository.
Renovate asks Gitea to squash-merge the patch and lockfile pull requests when their checks
succeed. main still needs one approving review and Gitea cannot exempt a
user, so the last step of the run approves them as heroiclands-bot
(RELEASE_BOT_TOKEN) with renovate/approve-dependency-prs.mjs. That script lists
the open pull requests of every organisation repository and approves only an
open, non-draft pull request from renovate on the branch renovate/patch or
renovate/lock-file-maintenance,
unless the bot's approval already stands on its current head commit. A new push
dismisses the approval, and the next run approves again. Minor and major pull
requests are never approved by it. Both kinds it approves move dependencies only
within their declared ranges, and CI gates the merge.
Open Actions, choose Renovate and Run workflow. The inputs:
dry-run:full(the default) logs what Renovate would do and changes nothing,lookupandextractstop earlier,noneopens real pull requests and approves the patch ones.repositories: an autodiscover filter such asHeroicLands/package-build; empty means every repository.ignore-schedule: on by default, so a manual run is not held to the Sunday window.
Try a new configuration with dry-run: full and one repository, read the log,
then run with none against that repository before the Sunday run takes over.
npx renovate-config-validator --strict default.json renovate/config.cjs
checks both files.
The job runs only while the repository variable HL_RENOVATE_ENABLED is
true on HeroicLands/.github. Set it to anything else, or disable the
workflow, and nothing runs. Pull requests already open stay open; close them or
pause the schedule with the variable before changing the preset.
RENOVATE_VERSION in the workflow is an exact version. Raise it in a pull
request here and validate both files with the new version first.
The organisation's default security policy, inherited by every repository that
publishes none of its own. Four repositories had none at all —
harn-adventures, heroiclands-site, package-build and this one
(#14) — and package-build
is the one that mattered most: it executes during every consuming package's
build and is published to npm, which makes it the organisation's highest-value
supply-chain target and it had no policy, no chooser entry and no private
reporting.
A repository publishing its own keeps it whole. GitHub does not merge the two — the local file wins outright — so a repository with genuinely different scope publishes one, and a repository with nothing to add publishes nothing. Six do today; the four above inherit this.
A default file is rendered for many repositories, so it can name none of
them. This one says "this repository's Security tab" rather than linking an
address, and it carries no relative links — a relative link in a default file
resolves against this repository, not the one displaying it, so
.github/ISSUE_REPORTING.md#7 would land here and 404 for every inheritor.
The same limit is why .github/ISSUE_TEMPLATE/config.yml is deliberately not
defaulted, even though GitHub supports it as one. Its private-reporting entry
is a contact_link holding a literal URL:
- name: Report a security vulnerability (private)
url: https://github.com/HeroicLands/<repo>/security/advisories/newThere is no templating, so a single default would send every repository's
reporter to whichever repository was hardcoded. That is the exact bug that
opened #14 — harn-ensemble's chooser pointed at sohl-kethira-basic — so
defaulting the chooser would institutionalise it rather than fix it. Six
repositories carry a correct self-referencing entry; the three without one want
their own, not a shared wrong one.
Private vulnerability reporting is a repository setting, so no pull request
can turn it on and this file does not make the advisory form appear. Nine of the
ten eligible repositories have it off while six advertise it, which means a
reporter who follows the instruction finds no form and is left with giving up or
opening a public issue — the outcome the policy exists to prevent, since every
repository sets blank_issues_enabled: false and the chooser is the only entry
point.
It can be enabled from the API, one call per repository:
for r in sohl-thalorna sohl-kethira-basic \
harn-adventures harn-ensemble HarnMaster-3-FoundryVTT \
heroiclands-site package-build .github; do
gh api --method PUT "repos/HeroicLands/$r/private-vulnerability-reporting"
doneSong-of-Heroic-Lands-FoundryVTT already has it on. Verify with
gh api repos/HeroicLands/<repo>/private-vulnerability-reporting --jq .enabled,
which must report true for all ten before #14 is done.
Checks the HeroicLands organisation's issue labels against labels.yml at the
root of this repository, or syncs them to it. Organisation labels exist on Gitea
only; the action refuses to run on GitHub.
Every repository uses the same organisation labels and declares none of its own.
labels.yml is the one registry: the name, colour, description and exclusivity
of each label, in the groups type/, resolution/, status/, flag/, area/
and ci/. A pull request that changes it runs check; main runs sync.
# .gitea/workflows/labels.yml
name: Labels
on:
pull_request:
paths: ["labels.yml"]
push:
branches: [main]
paths: ["labels.yml"]
jobs:
labels:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./actions/labels
with:
# A pull request reports what a sync would change and writes nothing;
# main applies it. The consequence is reviewed before it happens.
mode: ${{ github.event_name == 'push' && 'sync' || 'check' }}
org: HeroicLands
token: ${{ secrets.LABELS_TOKEN }}| Input | Default | |
|---|---|---|
mode |
check |
check validates and reports; sync applies |
registry |
labels.yml |
path to the registry |
org |
— | the organisation whose labels are compared; without it, check validates the file alone |
token |
— | a token that can write the organisation's labels; without one, check validates the file alone |
A registry entry is name, color, an optional description of at most 255
characters, and an optional exclusive: true, which makes the scope before the
/ (type/, resolution/) take one label at a time.
The registry is a closed set. A label the file declares is created or
corrected; a label the organisation has and the file does not is deleted,
along with its presence on every issue carrying it. That is the point of a
registry rather than a starting point — labels otherwise accumulate from
templates, integrations and typos until the set means nothing — and it is why
check runs on the pull request that changes the file, so the deletion is
reviewed before it is applied.
Findings are reported as file:line:column: severity: message, the form every
C-family compiler and ESLint emit, so an error matcher resolves them without
being taught the layout.
Gitea addresses a label by its numeric id, read when the labels are listed, and
renames it with name. Colours are compared without a leading #. The lists
are read to the end whatever the page size, which Gitea caps at 50.
After a sync, the action reads the labels back and fails, naming each
difference, when any label still differs from the registry. Gitea answers a
delete with 204 even when it removed nothing, so the read-back is what proves
a sync took effect.
Run the action's regression tests with node --test actions/labels/*.test.mjs;
they run an organisation label store that behaves as Gitea does.
Fails when a committed comment carries a TODO/FIXME marker.
It reads only the checked-out files, so it runs the same on GitHub and Gitea.
# as a step in an existing workflow — it needs a checkout and nothing else
- uses: actions/checkout@v4
- uses: HeroicLands/.github/actions/todos@main| Input | Default | |
|---|---|---|
paths |
src |
files and directories to scan, comma- or newline-separated |
extensions |
.ts,.tsx,.js,.jsx,.mjs,.cjs |
which files to pick out of a scanned directory |
markers |
TODO,FIXME |
the forbidden words, matched case-sensitively |
A directory is walked recursively, skipping node_modules; a path naming a
file is scanned as named, whatever its extension. A path that does not exist
fails, and so does a run that selected no files at all — a check that examined
nothing is not a passing check.
Why a marker is forbidden at all. It is a note to nobody. It duplicates the issue that should carry the work and drifts out of sync with it, and in a published doc comment it leaks onto the API site as documentation prose. File or find an issue, record the code-site context there, and leave the code clean.
What it will not flag. String and template-literal contents are blanked
before matching, and only the comment portion of a line is examined — so
const label = "TODO" is not a finding. One useful consequence: a markdown
code span is a backtick string to the blanker, so a doc comment can state the
rule (the `TODO` marker) without tripping it.
Findings are file:line:column: severity: message, and the column is the
marker's own rather than the start of the line, so an editor opens on the word.
The rule is universal, and only the file selection ever differed between repositories — which is why those are inputs and nothing else is. Like the label registry, this is repository hygiene rather than a build: it runs in CI, it needs no build, and it wants nothing but a checkout. It takes no dependency either, not even to format a finding: it is four fields joined by colons, and acquiring a build toolchain to write one line would be the wrong trade.
Fails a pull request whose title, body, or commit messages credit an AI
assistant. The commit list is read from
GITHUB_API_URL and followed to its last page whatever page size the forge
applies (Gitea caps it at 50).
# .gitea/workflows/no-attribution.yml
name: No Attribution
on:
pull_request:
types: [opened, edited, reopened, synchronize]
permissions:
contents: read
pull-requests: read
jobs:
no-attribution:
name: No AI attribution
runs-on: ubuntu-latest
steps:
# Needs no checkout: the subjects are the pull request, not the tree.
- uses: HeroicLands/.github/actions/no-attribution@main
with:
token: ${{ github.token }}| Input | Default | |
|---|---|---|
token |
— | needs contents: read and pull-requests: read |
The check rejects complete Co-Authored-By: trailers naming Codex, ChatGPT,
OpenAI (including OpenAI Codex and OpenAI ChatGPT), Claude, Claude Code,
Anthropic, GitHub Copilot, Gemini or Cursor.
Generic AI, AI assistant and an AI assistant credits also fail. Names are
matched as complete identities, so human coauthors such as Claudette Smith and
Jean-Claude Martin pass. The assistant mailboxes codex@openai.com,
chatgpt@openai.com, noreply@openai.com, claude@anthropic.com and
noreply@anthropic.com identify a credit even with a generic display name;
an ordinary employee address at a provider domain does not.
Standalone signatures beginning with Generated by, Generated with,
Assisted by or Written by followed by one of those identities fail.
Standalone AI-assisted and AI-generated also fail, including forms followed
by by or with and a supported identity. Matching ignores case and allows
terminal punctuation, indentation, bullets, emoji, Markdown emphasis and links.
The whole clause must match: explanatory prose such as “The check rejects Generated with Claude Code signatures” passes. This is a bounded credit grammar, not a detector for arbitrary natural-language attribution. It checks PR titles, bodies and all paginated commit messages. Repository files, issues, comments and Git author/committer metadata are outside its scope. An API read failure fails the check rather than accepting unread commit messages.
Findings are address:line:column: severity: message. The address identifies the
subject — pull/123/body or commit/<sha> — and positions refer to the original
text, including any Markdown decoration preceding the credit.
Nothing is edited. Correct the PR title or body directly. For an offending pushed commit, consult the maintainer about correcting its message without rewriting shared history.
The canonical githooks/commit-msg applies the same grammar before a commit
exists. Its regression tests derive the identities, phrases, mailboxes and
fixture matrix from this action. Repositories copy the canonical hooks and
check them with actions/githooks.
Run the action's regression tests with:
node --test actions/no-attribution/no-attribution.test.mjsThe tests exercise the action entry point with event payloads and mocked commit API responses served in each forge's paging style, including a pull request whose commits span more than one page. CI runs them for PR changes, including body edits.
Two checks on the changesets a pull request carries, under one check name so a repository can make it required once.
# .gitea/workflows/changesets.yml
name: Changesets
on:
pull_request:
types: [opened, reopened, synchronize]
pull_request_review:
types: [submitted, dismissed]
permissions:
contents: read
pull-requests: read
jobs:
changesets:
name: Changeset review
runs-on: ubuntu-latest
steps:
# Needs no checkout: the subjects are the pull request's files and reviews.
- uses: HeroicLands/.github/actions/changesets@main
with:
token: ${{ github.token }}| Input | Default | |
|---|---|---|
token |
— | needs contents: read and pull-requests: read |
release-branch |
changeset-release/main |
the Version Packages pull request's head branch |
A changeset is a Markdown file directly under .changeset/ other than its
README. Two or more added by one pull request fail, one finding per file. The
case this catches is stacking: when one pull request merges into another's
branch, each brings its own changeset, and the release reaches its readers as
fragments of one change. Fold them into a single entry written for the person
who meets the change. Editing an existing changeset is not an addition.
The Version Packages pull request concatenates every changeset since the last
release, and nobody has read the result as a whole until it opens. The bot also
rebuilds its branch from scratch on every merge to main, discarding any edit
made to it. So the check passes on the release pull request only when an
approving review names its current head commit: the release note was read
after its last regeneration, not before.
The review is a read of the generated CHANGELOG section as one release note:
merge bullets that describe one change from several pull requests, drop what a
later pull request in the same release superseded, and regroup under the labels
the release needs. Commit any edit to the release branch, then approve. An
approval of an earlier commit is reported as stale, and each reviewer's latest
decisive review counts, as the forge counts it. GitHub reports a change request
as CHANGES_REQUESTED and a dismissal as the state DISMISSED; Gitea reports
REQUEST_CHANGES and keeps a dismissed review's original state with
dismissed: true. Both are read, so a dismissed approval never counts and a
change request withdraws an earlier approval on either forge.
The bot opens and updates the release pull request with the workflow token, which starts no workflow, so a regenerated head has no result for this check until someone reviews it. As a required check, that holds the merge until the review exists.
Findings are address: severity: message: the changeset's path for the first
check, pull/<n>/reviews for the second. An API read failure fails the check.
Nothing is edited.
The pull request's files and reviews are read from GITHUB_API_URL and followed
to the last page, so a pull request with more files than one page holds is
checked whole on Gitea as on GitHub.
Run the action's regression tests with:
node --test actions/changesets/changesets.test.mjsOpens or updates the Version Packages pull request on the forge the job runs
on. A new pull request opens as WIP: with no scheduled merge; the owner reads
the release and removes the prefix, and a later run schedules the squash merge
of the ready pull request for when its checks succeed. It speaks the Gitea API
at the runner's GITHUB_API_URL.
# a step in the release job, after a checkout with history and `npm ci`
- name: Open or update the Version Packages pull request
id: version
uses: HeroicLands/.github/actions/version-pr@main
with:
token: ${{ secrets.RELEASE_BOT_TOKEN }}| Input | Default | |
|---|---|---|
token |
— | the bot's personal access token; the push and every API call use it |
version-script |
changeset:version |
the npm script that versions the packages and refreshes the lockfile |
branch |
changeset-release/main |
the release branch the pull request comes from |
base |
main |
the branch it merges into |
title |
chore(release): version packages |
the pull request's title |
commit-message |
chore(release): version packages |
the release branch's one commit message |
author-name |
the token's login | the commit's author and committer |
author-email |
the token account's email |
| Output | |
|---|---|
has-changesets |
true when changesets were pending and the pull request is open, false when none were |
pr-number |
the pull request's number, set with has-changesets: true |
has-changesets is the literal false when nothing is pending, never empty, so
a release step gated on steps.version.outputs['has-changesets'] == 'false'
runs exactly when a version bump has just merged.
What a run does.
- Pending? A changeset is a Markdown file directly under
.changeset/other than its README. With none, the action reportsfalse, makes no API call and pushes nothing. - Version. It resets the release branch to the checked-out commit and runs
npm run <version-script>, so the script gets a shell and a&&chain belongs inpackage.json. The script runs with the bot's identity in the environment, sochangeset versionwith"commit": truecan commit. - One commit. Whatever the script committed and whatever it left in the
tree become a single commit on the checked-out commit, with the bot as
author and committer. The repository's own git hooks run on it. Pending
changesets that change nothing fail the run: a
private: truepackage versions nothing unless.changeset/config.jsonsetsprivatePackages.version. - Push. The commit is force-pushed to the release branch with the bot's
token, whatever token checked the tree out: every persisted
extraheaderand credential helper is set aside for that one push. A push made with the job token starts no workflow, and the bot's push does, so the pull request runs its checks on every regeneration. The release branch must not be protected. - Pull request. The open pull request from the release branch into the
base is found by reading every page of open pull requests, 50 to a page,
and is updated in place; with none, one is opened with the title
WIP: chore(release): version packages. A pull request from a fork's branch of the same name is not it. The body isMerging this pull request releases v<version>.followed by that version's CHANGELOG section, taken by the same rule the release step uses. - Merge. A pull request whose title starts with
WIP:or[WIP], which includes every one this run just opened, is held: no merge is scheduled, and the run ends. For a ready pull request the squash merge is scheduled with{"Do": "squash", "merge_when_checks_succeed": true}. Gitea answers 201 when it schedules and 405 when a merge is already scheduled; both are success. The pull request then merges by itself once branch protection's approval and required checks both hold.
A re-run keeps the reviewed branch. When the remote release branch already starts with this exact version commit (same parent, same tree), nothing is pushed, so an approval stands and a release-note edit committed on top of it is kept; the body is then read from the branch's head. Any new commit on the base regenerates the branch from scratch, which discards those edits and, under Gitea's "dismiss stale approvals", the approval with them. The release note is read on the final commit.
Findings are address: severity: message. An API refusal names the endpoint,
the method, the status and the forge's answer, as in
repos/HeroicLands/x/pulls: error: POST answered 403: …; a failing git
command or version script names what ran and its exit status. The token is
never printed.
Tags the commit that set package.json's version with v<version> and pushes
that one tag to the checkout's remote, with the checkout's credentials.
# a step in the release job, after a checkout with the bot's token and
# `fetch-depth: 0`
- name: Tag the version commit
id: tag
if: steps.changesets.outputs['has-changesets'] == 'false'
uses: HeroicLands/.github/actions/release-tag@main| Output | |
|---|---|
version |
the version package.json declares |
tag |
v<version> |
commit |
the commit that set the version, which the tag names |
pushed |
true when this run created and pushed the tag, false when it already existed |
What a run does.
- Nothing pending. A pending changeset fails the run: a tree that has not been versioned is never tagged.
- The version. It must be
X.Y.Zwith no leading zeros and an optional prerelease suffix. Build metadata is refused. - The version commit. The newest commit on the first-parent history of
HEADthat touchespackage.jsonand whose parent declared another version: the squash-merged Version Packages pull request. A later commit that leaves the version alone does not move it. A shallow checkout cannot answer this and fails, namingfetch-depth: 0. - The remote.
git ls-remote --exit-codedecides. An existing tag is left where it is; one that names another commit is reported as a warning. A remote that cannot be read fails the run instead of reading as "untagged". - Push. The tag is pushed straight from the commit,
git push origin <commit>:refs/tags/v<version>, and nothing else is written.
Publishes the GitHub Release for a tag that another forge owns and the push mirror delivers. The job never writes a git ref on GitHub; the mirror is the only writer of GitHub's refs.
# a step in the release job, after the tag is pushed to the forge and the
# assets are built
- name: Publish the GitHub Release
uses: HeroicLands/.github/actions/github-release@main
with:
token: ${{ secrets.GH_RELEASE_TOKEN }}
tag: ${{ steps.decide.outputs.tag }}
name: Release ${{ steps.decide.outputs.tag }}
body-path: build/release-notes.md
prerelease: ${{ steps.decide.outputs.prerelease }}
make-latest: ${{ steps.decide.outputs.make_latest }}
files: |
build/dist/module.zip
build/dist/module.json
build/dist/*.jsonl
build/dist/*.pdf| Input | Default | |
|---|---|---|
token |
— | a GitHub token with Contents read and write on the release repository |
tag |
— | the tag to release; it must exist in the checkout |
name |
the tag | the release title |
body-path |
none | the release notes; at most 125,000 characters |
prerelease |
— | the literal true or false |
make-latest |
— | the literal true or false; true with prerelease: true is refused |
files |
— | newline-separated globs; a glob matching nothing is not an error, no file at all is |
wait-seconds |
900 |
how long to wait for the mirror to deliver the tag |
poll-seconds |
15 |
the pause between checks for the tag, and for Latest |
api-url |
https://api.github.com |
|
uploads-url |
https://uploads.github.com |
|
server-url |
https://github.com |
the host serving releases/latest/download |
| Output | |
|---|---|
release-url |
the release's page |
release-id |
the release's numeric id |
Which repository. The release lands in the repository
package.json#repository.url names, which must be
[git+]https://github.com/<owner>/<repo>[.git]. A Foundry manifest's
manifest, download and flags.metadataUrl derive from that field, so a
release made anywhere else leaves every installed copy pointing at nothing.
Before any request. The inputs are checked; every glob is expanded, and an
empty file, two files sharing a name, or a name GitHub rewrites on upload fails
the run. Every Foundry manifest among the files (a JSON file with manifest or
download) must carry the addresses a release serves:
| Field | Must be |
|---|---|
manifest |
https://github.com/<owner>/<repo>/releases/latest/download/<the manifest's own file name> |
download |
https://github.com/<owner>/<repo>/releases/download/<tag>/<a file of this release> |
flags.metadataUrl, when present |
the same shape as download |
version, when present |
the tag without its leading v |
Then, in order.
- The tag on GitHub, at the right commit. The commit the tag names in the
checkout is the expected one. The action polls
GET /repos/<owner>/<repo>/git/ref/tags/<tag>, following an annotated tag to its commit. A 404 keeps waiting; a commit other than the expected one fails the run before anything is written, because a release is never attached to a tag the mirror delivered differently. Afterwait-secondsit fails, saying the push mirror has not delivered the tag to GitHub and to re-run once it has. - An existing release. A published release for the tag whose assets match the files by name, size and digest, and whose prerelease flag matches, is a success with nothing written. One that differs fails the run: a published release is never edited here. A draft for the tag, found by reading every page of releases, is reused and its fields brought up to date.
- Draft. Otherwise a draft is created with the tag, name, body and
prerelease flag, and no
target_commitish, so GitHub never creates the tag itself. - Upload. Every asset already on the draft is deleted, then each file is
uploaded with its size and a content type by extension (
.zipapplication/zip,.jsonapplication/json,.pdfapplication/pdf, anything elseapplication/octet-stream). - Check. The draft's assets are read back: each file present, uploaded, of its size and digest, and nothing else. A mismatch fails the run and the draft stays unpublished.
- Publish. One update sets
draft: false,prerelease, andmake_latestas the string"true"or"false". A draft is invisible toreleases/latest, so no installed copy fetches a release whose assets are still uploading. - Prove Latest. With
make-latest: true,GET /releases/latestmust name the tag, andhttps://github.com/<owner>/<repo>/releases/latest/download/<file>must redirect to this tag's copy of every file and resolve to its size, requested without credentials as Foundry requests it. A release that is public but not served there fails the run, since the manifest address still serves the previous version. Withmake-latest: false,releases/latestmust not name the tag.
A re-run after any failure resumes: a draft is reused, a published and complete release passes straight to the Latest proof.
The token. A fine-grained personal access token whose resource owner is
the organisation, limited to the releasing repositories, with Contents: read
and write and nothing else. It is passed by name, as GH_RELEASE_TOKEN; the
API calls carry it, and the download check sends no credentials.
Findings are address: severity: message: the API path for a refusal or a
mismatch, the file for a local problem, the input's name for an input.
Run both actions' regression tests with:
node --test actions/version-pr/version-pr.test.mjs actions/github-release/github-release.test.mjsThe Dockerfiles for the Gitea Actions runners' job images. See
runners/README.md.