Skip to content

feat: mark patch releases as prereleases, org-wide - #27

Merged
toastygm merged 1 commit into
mainfrom
feat/26_patch-releases-are-prereleases
Sep 4, 2026
Merged

feat: mark patch releases as prereleases, org-wide#27
toastygm merged 1 commit into
mainfrom
feat/26_patch-releases-are-prereleases

Conversation

@toastygm

@toastygm toastygm commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

The release workflow invoked softprops/action-gh-release@v3 with tag_name,
name, body_path and files and no prerelease: input, so every release
every Foundry package cut published as an ordinary release and GitHub promoted
it to "Latest".

The rule

Patch releases are all marked prerelease, period. No exceptions. Only major
and minor releases are marked release.
X.Y.Z with Z non-zero publishes as
a GitHub prerelease; X.Y.0 publishes as a normal release.

Org-wide, not a per-package opt-in, and it applies to future releases only
nothing already published is relabelled.

Why

The semver digits carry a release channel, not a severity: minor/major
goes to users, patch is beta and is 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: its versions are <major>.<build> and
isNewerVersion is a naive dot-split with a string fallback, so 0.9.0-beta.0
would sort wrongly against 0.9.0. Numeric-only 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 — and today it says the opposite.

What the decision is keyed on

steps.decide.outputs.version — the value that step already reads from
package.json — not the tag string. The tag is that same value with a v glued
on; re-parsing it would mean stripping a prefix the step added itself, and would
make the rule depend on the tag spelling rather than on the version.

Inputs, verified

Both names checked against softprops/action-gh-release's own action.yml at
the v3 tag (5113cdc), per the standing rule this workflow already carries:

  • prerelease — "Identify the release as a prerelease. Defaults to false".
    Parsed as INPUT_PRERELEASE == 'true'; anything else is false.
  • make_latest — "Can be true, false, or legacy… Drafts and prereleases
    cannot be set as latest. Uses GitHub api default if not provided." Parsed by a
    literal match on those three strings and ignored otherwise.

Both are set together and always explicitly, because both defaults are wrong
here: 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. The decide step emits the
literals rather than a ${{ … && … || … }} ternary over prerelease.

The decision table

Driven over the logic extracted verbatim from the committed workflow:

version exit prerelease make_latest published as
0.9.0 0 false true release, Latest
0.9.1 0 true false prerelease
0.10.0 0 false true release, Latest
1.0.0 0 false true release, Latest
0.0.1 0 true false prerelease
0.5.3 0 true false prerelease
0.9.10 0 true false prerelease
10.0.0 0 false true release, Latest
1.2.3.4 1 run fails
1.0.0-beta.1 1 run fails
0.9 1 run fails
01.2.3 / 1.02.0 1 run fails
v0.9.1 1 run fails
0.9.0+build.5 1 run fails
1.2.x, empty 1 run fails

A malformed version fails the run

The parse is strict — exactly three dot-separated numeric components, no leading
zeros — and it fails closed, like every other guard in this file. A version
it 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 — stated plainly

Other packages have already shipped patch releases as ordinary releases:

  • sohl-kethira-basicv0.5.1, v0.5.2, v0.5.3
  • harn-adventuresv0.0.1

After this change, their next patch release publishes as a prerelease. That
is the intended consequence of an org-wide rule, not a regression. No existing
release is relabelled
— published releases are history.

Verification

  • Decision logic extracted verbatim from the committed workflow and driven over
    the table above.
  • prerelease and make_latest confirmed from the action's own action.yml at
    the v3 tag, not from memory.
  • actionlint (1.7.12, with shellcheck integration) clean on this file and on
    every workflow in the repository.
  • The file parses as YAML and the two new inputs resolve on the release step.
  • No consumer repository touched.

Closes #26

The release workflow invoked `softprops/action-gh-release@v3` with no
`prerelease:` input, so every release every Foundry package cut published as
an ordinary release and was promoted to "Latest".

The semver digits in this family carry a release CHANNEL, not a severity:
`minor`/`major` goes to users, `patch` is beta and is 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 because Foundry
has to sort these and `isNewerVersion` is a naive dot-split with a string
fallback, with no notion of `-beta.0`. Numeric-only sorts correctly; the
GitHub label is what tells a human which channel a version is on.

`X.Y.Z` with `Z` non-zero now publishes as a prerelease and does not take
"Latest"; `X.Y.0` publishes as a release and does. `prerelease` and
`make_latest` are set together and always explicitly — both defaults are
wrong here, and labelling the prerelease while leaving Latest to the default
would fix only half of it.

Keyed on the `version` the decide step already reads from package.json, not
on the tag string. The parse is strict (three numeric components, no leading
zeros) and fails closed like every other guard in the file: an
unclassifiable version stops the run rather than falling through to
"release", which is the outcome with consequences.
@toastygm
toastygm merged commit 32f2242 into main Sep 4, 2026
1 check passed
@toastygm
toastygm deleted the feat/26_patch-releases-are-prereleases branch September 4, 2026 19:18
toastygm added a commit to HeroicLands/Song-of-Heroic-Lands-FoundryVTT that referenced this pull request Sep 4, 2026
…patches (#1823)

Retypes **61** pending changesets from `minor` to `patch`. No prose is
touched; only the
bump line.

**The bump type is now the release channel, not the severity**

- `minor` / `major` — **ships to users.** A real release, marked as a
release on GitHub.
- `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`.

Those 61 were written under the older reading, where a breaking change
on 0.x was a
`minor`. **None has reached a user** — they describe work sitting
unreleased on `main`.
Under the meaning above they are patches, so this corrects them rather
than downgrading
them. Severity still reads in each entry's own `**Bold label**`, which
is where this
repository has always stated it.

**What it produces**

The next release computes as **`0.8.3`** (verified locally with
`changeset version`),
a beta carrying everything currently on `main`. `package.json` is
untouched here — the
version only moves when a release actually runs.

**Why it matters now.** Two satellites are blocked on content that
exists on `main` and
in no released version:

- **`sohl-thalorna` cannot build its Foundry packs at all.** Its beings
cite eight combat
techniques — `bflkbite`, `bflkgrab`, `bflkheadbutt`, `bflkkick`,
`bflkpunch`,
`limbblock`, `press`, `trip` — present here under
`assets/content/Skills/Combat_Techniques/`
and absent from `v0.8.2`. The actors pass reports 620 errors and stops:
_"refusing to
  compile packs from incomplete output."_
- **`sohl-kethira-basic` ships a field Foundry discards.** All 28
affiliation notes author
  `subType`; `v0.8.2` does not define `affiliation.subType`
  (HeroicLands/sohl-kethira-basic#30).

A satellite resolves those items against the **released** pack, so
neither can fix it
locally. `0.8.3` unblocks both without declaring a user release.

**Why the channel is in the digits and not a `-beta.0` tag**

Foundry's `isNewerVersion` is a dot-split with a string fallback and no
notion of a
prerelease component: `"0-beta" > "0"` makes it read `0.9.0-beta.0` as
**newer** than
`0.9.0`, so a final release would never register as an upgrade over its
own beta. A
numeric-only scheme always sorts correctly.

**Merge order.** `HeroicLands/.github#27` must land first — it marks
patch releases as
GitHub prereleases and keeps the Latest badge on the last real release.
Without it,
`0.8.3` publishes as an ordinary release, takes Latest, and every
installed copy is
offered it as an update, since `releases/latest/download/system.json` is
what the
manifest points at.

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.

Every release publishes as "Latest", so a beta patch release is presented to users as the real one

1 participant