7 Commits
Author SHA1 Message Date
tmu 67e3e13b5e 🚀 Release 0.1.0
CI / release-gate (push) Successful in 2s
CI / build (push) Successful in 37s
CI / maintain (push) Failing after 30s
CI / publish (push) Failing after 4s
2026-09-14 11:48:55 +00:00
tmu 595759ca7a 🔀 Merge feature/setup into main 2026-09-14 11:47:58 +00:00
tmu 8495adc47b 👷 Retitle release commits to 🚀
The release commit message is a machine-read convention (release-gate
in ci.yml skips the redundant main CI run on it), so the emoji carries
weight: change the mint in scripts/release.sh, the recognized pattern,
and both docs in one atomic commit. No tags or release commits exist
yet, so nothing historical parses differently. 🚀 matches the
gitmoji semantic (deploy/publish) better than 🔖 here.
2026-09-14 11:47:00 +00:00
tmu 3df672d306 👷 Skip main CI run for release commits
A release pushes main and then a tag pointing at the same commit, so
the branch run re-verifies the identical SHA the tag run already
verifies (and publishes). Add a cheap release-gate job that recognizes
the '🔖 Release x.y.z' commit message on main and skips the
full build/maintain jobs; tag, PR, and ordinary main pushes are
unaffected, and the gate fails open (runs CI) if it errors.
2026-09-14 11:41:49 +00:00
tmu 42cbe2194e ♻️ Finalize changelog notes before pubv
pubv's bump heuristic reads [Unreleased], so the maintainer must write the
notes before pubv runs, not after. pubv refuses a dirty tree (its prompt
defaults to No), so the edit is committed as a staging commit and folded back
into pubv's single release commit.
2026-09-14 11:16:47 +00:00
tmu 9dd973a950 🐛 Refresh origin/HEAD before pubv preflight
pubv resolves the default branch from the local refs/remotes/origin/HEAD,
which git fetch never updates, so a clone or default-branch change left it
stale and pubv warned that main was not the default. Refresh it from the
remote before pubv runs, and assert releases are cut from main explicitly.
2026-09-14 11:08:59 +00:00
tmu 034296f011 ✨ Add create:finish merge front door
The merge half of the branching model was still prose, so it drifted per
session. create:finish mirrors create:branch: it asserts the merge-side
preconditions, fast-forwards a stale main (divergence is refused), merges
--no-ff, runs npm run verify, and deletes the branch only when green. The
push stays with create:release so the merge is reviewable first.
2026-09-14 11:08:59 +00:00
9 changed files with 255 additions and 21 deletions

No files matched your search

+37
View File
@@ -12,7 +12,42 @@ on:
workflow_dispatch: {}
jobs:
# Cheap gate that collapses the release double-run. `scripts/release.sh`
# pushes `main` and the tag seconds apart, and the tag points at exactly
# the HEAD commit that push delivers — so the branch run would verify the
# identical tree the tag run verifies anyway (plus `publish`). When a push
# to `main` is headed by a release commit (`:rocket: Release x.y.z`, the
# single commit release.sh creates), the full CI is skipped here and the
# tag run becomes the authoritative one for that SHA. All other pushes —
# PRs, tags, ordinary `main` merges — see `skip=false` and run as before.
#
# Coupling: the pattern below MUST stay in sync with the release commit
# message in `scripts/release.sh`. Failure mode if the tag push ever fails
# after `main` accepted the release commit: no CI fires; fix by re-running
# `git push --tags`.
release-gate:
runs-on: ubuntu-latest
outputs:
skip: ${{ steps.decide.outputs.skip }}
steps:
- uses: actions/checkout@v4
- id: decide
env:
REF: ${{ gitea.ref }}
run: |
# Keyed on the ref, not just the message: a tag run checks out
# the same release commit, and `publish` needs its `build`.
if [ "${REF}" = "refs/heads/main" ] &&
git log -1 --format=%s | grep -qE '^:rocket: Release [0-9]+\.[0-9]+\.[0-9]+$'; then
echo 'Release commit on main — the tag run covers this SHA; skipping full CI.'
echo 'skip=true' >>"${GITHUB_OUTPUT}"
else
echo 'skip=false' >>"${GITHUB_OUTPUT}"
fi
build:
needs: release-gate
if: needs.release-gate.outputs.skip != 'true'
runs-on: ubuntu-latest
# Bind-mount the shared pages tree so the coverage step below can write
# into it. The runner whitelists this path via `container.valid_volumes`
@@ -69,6 +104,8 @@ jobs:
# the Actions tab for visibility, but must never gate a merge — so
# continue-on-error and intentionally NOT in `publish`'s `needs`.
maintain:
needs: release-gate
if: needs.release-gate.outputs.skip != 'true'
runs-on: ubuntu-latest
continue-on-error: true
steps:
+1 -1
View File
@@ -51,7 +51,7 @@ Never start a long-lived / blocking process such as `npm run watch`. It runs unt
**Known problems:** <open issues, caveats, follow-ups>
```
Once the user has no further objections, merge back: `git checkout main && git merge --no-ff <branch>`. The branching model is documented in [CONTRIBUTING.md § Branching model](./CONTRIBUTING.md#branching-model).
Once the user has no further objections, merge back: `npm run create:finish` (on the branch — it merges `--no-ff`, runs `npm run verify`, and deletes the branch). The branching model is documented in [CONTRIBUTING.md § Branching model](./CONTRIBUTING.md#branching-model).
- **Leaf task** (no indented children): implement on the current branch and commit.
+4 -1
View File
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.1.0] - 2026-09-14
- basic setup
[Unreleased]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts
[Unreleased]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.1.0...main
[0.1.0]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/20308c5a6d8cccfb09b02ac2ebebd8055e91cd11...0.1.0
+7 -6
View File
@@ -11,6 +11,7 @@ CI and review will bounce these even though `npm run check` and the linters don'
- **`oxlint-disable` directives live in source, not `.oxlintrc.json`.** The trade-off must sit next to the code it silences. This is a _human_ last-resort convention; agents must not add these — see [AGENTS.md § Never do](./AGENTS.md#never-do). (rationale: README § Tooling decisions)
- **Don't put slow / network / whole-project scans in `check` or pre-commit.** Advisory scans are not correctness gates; they belong under `maintain:`. (see [Feedback tiers](#feedback-tiers) and [Script prefix convention](#script-prefix-convention))
- **New work starts with `npm run create:branch`, never a hand-written `git switch -c`/`git checkout -b`.** The command carries the branch precondition; branching around it skips the clean-tree, current-`main` and green-baseline checks, and the skip is invisible until a failure can no longer be attributed. (see [Branching model](#branching-model))
- **Work is merged back with `npm run create:finish`, never a hand-written `git merge`.** The command carries the merge-side preconditions (clean tree, current `main`, a `feature/`/`fix/`/`chore/` branch) and runs `npm run verify` after the merge, so a merge cannot land unverified. (see [Branching model](#branching-model))
- **There is no local `npm run publish`, and `publish:publint` / `publish:attw` don't go in `check`.** (see [Publishing workflow](#publishing-workflow))
## Editor configuration
@@ -27,7 +28,7 @@ Examples from history: `:sparkles: Add watch tier with watch:test child`, `:recy
Script names in `package.json` use a prefix that signals _when_ the script is intended to run. A `<prefix>:<name>` script is implicitly aggregated by a `<prefix>` script (if one exists) and run by the corresponding lefthook hook or CI step. Picking the right prefix documents the script's intended lifecycle:
- `create:*` — front doors of the repo's own workflow; these mutate git state rather than the source. `create:branch` opens a unit of work (asserts a clean tree, a current `main` and a green baseline before it branches), `create:release` closes one (maintainer-only). No bare `create` aggregator on purpose — see `publish:*` for the precedent.
- `create:*` — front doors of the repo's own workflow; these mutate git state rather than the source. `create:branch` opens a unit of work (asserts a clean tree, a current `main` and a green baseline before it branches), `create:finish` closes the branch half (merges the current unit of work into `main` and verifies the result), `create:release` closes the release half (maintainer-only). No bare `create` aggregator on purpose — see `publish:*` for the precedent.
- `check:*` — read-only verification; never modifies files. Aggregated by `npm run check`.
- `fix:*` — mutating counterpart of a `check:*` script. Aggregated by `npm run fix`; the diff is the review surface.
- `test:*` — test scripts. `test` is the canonical entry point (`check:tsc` + unit tests); `test:unit` skips the typecheck for fast local iteration; `test:ci` adds c8 coverage.
@@ -85,8 +86,8 @@ This is why every test in the suite pairs an `expectTypeOf(...)` with an `assert
- **Base branch:** `main`
- **Branch naming:** `feature/<desc>` / `fix/<desc>` / `chore/<desc>`
- **Starting work:** `npm run create:branch -- <prefix>/<desc>`. It refuses, without changing anything, unless the working tree is clean (untracked files included), no merge/rebase/cherry-pick is in progress, `main` matches its upstream, and `npm run test` is green on `main` — so a later failure is always attributable to your edits. The prefix is still _your_ call, inferred from the task; the script validates it rather than guessing it.
- **Merging:** `git checkout main && git merge --no-ff <branch>` (local PR — review the diff yourself before closing the branch).
- CI runs `npm run check` + `npm run test:ci` on every push to `main` — this is the authoritative gate.
- **Merging:** `npm run create:finish` (on the branch). It asserts the same clean-tree / no-operation / current-`main` preconditions, fast-forwards a stale `main` (a true divergence is refused), merges the branch `--no-ff`, runs `npm run verify`, and deletes the branch only after the merge is green. The push is deliberately left to `create:release`, so the merge stays local and reviewable — read the diff yourself before finishing.
- CI runs `npm run check` + `npm run test:ci` on every push to `main` — this is the authoritative gate. The one exception: a push headed by a release commit (`:rocket: Release x.y.z`) skips the full `build`/`maintain` jobs, because `create:release` pushes the tag for that exact commit right after and the tag run is the authoritative one (see `release-gate` in [.gitea/workflows/ci.yml](./.gitea/workflows/ci.yml)).
- **Releases are NOT triggered by pushes.** Only the maintainer triggers a release (see [Publishing workflow](#publishing-workflow)).
## Publishing workflow
@@ -94,6 +95,6 @@ This is why every test in the suite pairs an `expectTypeOf(...)` with an `assert
Publishing is CI-only by policy. Local `npm publish` is not supported. The maintainer triggers releases from `main`:
1. All intended changes are merged to `main` and passing CI.
2. The maintainer runs `npm run create:release` — an interactive prompt suggests a version (based on the latest CHANGELOG entry); the maintainer confirms or edits it.
3. `scripts/release.sh` creates a single release commit (changelog + package.json bump, amended into one commit), tags it, and pushes everything to Gitea.
4. CI runs on the push (the `build` and `maintain` jobs); the `publish` job then fires on the tag, consuming the `dist/` artifact the `build` job produced. The job graph lives in [.gitea/workflows/ci.yml](./.gitea/workflows/ci.yml) — keep that file, not this list, as the source of truth. The publish-tier checks must pass before the artifact is published. The `publish` job also creates the Gitea release page from the matching Keep-a-Changelog section (`scripts/release-notes.sh`); it runs _before_ `npm publish` so a broken page fails CI without consuming a version, and `npm publish` stays the last step.
2. The maintainer runs `npm run create:release`. VS Code opens `CHANGELOG.md` to finalize the `[Unreleased]` notes; because pubv refuses a dirty tree, any edit is committed first (then folded into the release commit), and pubv's interactive prompt suggests a version from those notes — the maintainer confirms or edits it.
3. `scripts/release.sh` creates a single release commit (graduated changelog + package.json bump, amended into one commit), tags it, and pushes everything to Gitea.
4. CI fires on both pushes: the `publish` job runs on the tag (`build` + publish-tier checks + release page + `npm publish`), while the branch run's `release-gate` job recognizes the release commit and skips `build`/`maintain` — the tag verifies the identical SHA, so no work is duplicated. The job graph lives in [.gitea/workflows/ci.yml](./.gitea/workflows/ci.yml) — keep that file, not this list, as the source of truth. The publish-tier checks must pass before the artifact is published. The `publish` job also creates the Gitea release page from the matching Keep-a-Changelog section (`scripts/release-notes.sh`); it runs _before_ `npm publish` so a broken page fails CI without consuming a version, and `npm publish` stays the last step.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "tiny-pattern-ts",
"version": "0.0.0",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tiny-pattern-ts",
"version": "0.0.0",
"version": "0.1.0",
"license": "MIT",
"devDependencies": {
"@arethetypeswrong/cli": "^0.18.5",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "tiny-pattern-ts",
"version": "0.0.0",
"version": "0.1.0",
"description": "Pattern matching for TypeScript/ESM environments (F#-style, not regex)",
"keywords": [
"adt",
@@ -49,6 +49,7 @@
"fix:oxfmt": "oxfmt ${LEFTHOOK_FILES:-.}",
"fix:oxlint": "oxlint --fix src scripts",
"create:branch": "./scripts/branch.sh",
"create:finish": "./scripts/finish.sh",
"create:release": "./scripts/release.sh",
"maintain": "npm run maintain:knip; npm run maintain:outdated",
"maintain:knip": "knip --include dependencies,exports,files",
+10 -6
View File
@@ -31,11 +31,12 @@ set -eu
# describes nothing anyone wants, and `publish:*` already sets the precedent for
# a prefix without one.
#
# Also rejected: a full git-flow CLI wrapping the merge too (merging ends in
# "review the diff yourself", which is judgment, and only the start half carries
# a verification burden); and reusing `pubv`'s preflight (release-shaped,
# third-party, and it would make branch start pay a build + pack it has no use
# for).
# Also rejected here: reusing `pubv`'s preflight (release-shaped, third-party,
# and it would make branch start pay a build + pack it has no use for). The
# merge half was originally rejected too ("review the diff yourself" is
# judgment), but it now has its own front door — `create:finish` — which owns
# the merge-side preconditions and the post-merge `verify`, so the start half
# does not have to carry that burden.
#
# Every refusal is non-mutating except the baseline test, which runs on `main`
# after we switch there — so a red `main` restores the branch you started on
@@ -105,7 +106,10 @@ git show-ref --verify --quiet "refs/heads/${BASE}" || {
# Derive the remote rather than hardcoding it: this repo has `origin` (ssh) and
# `origin_https`, and `main` tracks the latter — `git fetch origin main` would
# check currency against a ref that is never updated here.
UPSTREAM=$(git rev-parse --quiet --abbrev-ref --symbolic-full-name "${BASE}@{upstream}" 2>/dev/null || true)
# `--quiet` echoes the unresolved `main@{upstream}` literal to stdout on
# failure, so it cannot be paired with a `$(...) || fallback`; the non-quiet
# form prints nothing on failure and the fallback runs.
UPSTREAM=$(git rev-parse --abbrev-ref --symbolic-full-name "${BASE}@{upstream}" 2>/dev/null) || UPSTREAM=""
if [ -n "${UPSTREAM}" ]; then
git fetch --quiet "${UPSTREAM%/*}" "${UPSTREAM#*/}" || {
echo "error: '${UPSTREAM}' check failed: could not reach '${UPSTREAM%/*}'." >&2
+136
View File
@@ -0,0 +1,136 @@
#!/bin/sh
set -eu
# Feature-finish front door. Run as `npm run create:finish`.
#
# Why this exists: `create:branch` opens a unit of work, but the close half
# (`git checkout main && git merge --no-ff <branch>`) stayed prose in the
# branching model, so it drifted per contributor and per session. This is the
# mirror image of `create:branch`: it asserts the same preconditions (clean
# tree, no in-progress operation, `main` matching its upstream), merges the
# current `feature/`/`fix/`/`chore/` branch into `main`, proves the result with
# `npm run verify`, and only then deletes the branch.
#
# `create:branch`'s comment argued against wrapping the merge as "judgment —
# review the diff yourself". That judgment still lives here, just moved: the
# maintainer reviews the handover *before* invoking this, and the script only
# commits the merge, never the push. The push is owned by `create:release`, so
# the release commit and its tag leave together and a local merge stays
# reviewable (and can be reverted with `git revert -m 1`) until then. `--no-ff`
# keeps the unit of work visible in `git log`.
#
# Unlike `create:branch` a stale `main` is fast-forwarded instead of refused:
# the tree is clean (checked above) and `main` is not the checked-out branch
# yet, so there is no local state to lose. True divergence (local commits *and*
# upstream commits) is still refused — that needs a human.
#
# On a merge conflict we abort and return to the feature branch, so a failed
# finish never strands you on a half-merged `main`.
BASE="main"
PREFIXES="feature fix chore"
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || {
echo "error: not inside a git work tree." >&2
exit 1
}
STATE_ROOT=$(git rev-parse --absolute-git-dir)
for state in MERGE_HEAD rebase-merge rebase-apply CHERRY_PICK_HEAD BISECT_LOG; do
[ -e "${STATE_ROOT}/${state}" ] && {
echo "error: a '${state}' operation is in progress; finish or abort it first." >&2
exit 1
}
done
# `--porcelain` is deliberately stricter than `git diff --quiet`: it also
# reports untracked files, which would otherwise not be part of the merge and
# silently outlive the branch deletion.
DIRTY=$(git status --porcelain)
if [ -n "${DIRTY}" ]; then
echo "error: working tree is not clean:" >&2
echo "${DIRTY}" | sed 's/^/ /' >&2
exit 1
fi
START_REF=$(git symbolic-ref --quiet --short HEAD || true)
if [ -z "${START_REF}" ]; then
echo "error: detached HEAD; switch to the branch you want to finish." >&2
exit 1
fi
if [ "${START_REF}" = "${BASE}" ]; then
echo "error: already on '${BASE}'; switch to the branch to finish." >&2
exit 1
fi
MATCH=0
for p in ${PREFIXES}; do
case "${START_REF}" in
"${p}/"*) MATCH=1 ;;
esac
done
if [ "${MATCH}" -ne 1 ]; then
echo "error: '${START_REF}' must start with one of: ${PREFIXES}." >&2
echo " refusing to merge a branch that is not a unit of work." >&2
exit 1
fi
git show-ref --verify --quiet "refs/heads/${BASE}" || {
echo "error: no local '${BASE}' to merge into." >&2
exit 1
}
# Derive the remote rather than hardcoding it: this repo has `origin` (ssh) and
# `origin_https`, and `main` tracks the latter — `git fetch origin main` would
# check currency against a ref that is never updated here.
# `--quiet` echoes the unresolved `main@{upstream}` literal to stdout on
# failure, so it cannot be paired with a `$(...) || fallback`; the non-quiet
# form prints nothing on failure and the fallback runs.
UPSTREAM=$(git rev-parse --abbrev-ref --symbolic-full-name "${BASE}@{upstream}" 2>/dev/null) || UPSTREAM=""
BEHIND=0
if [ -n "${UPSTREAM}" ]; then
git fetch --quiet "${UPSTREAM%/*}" "${UPSTREAM#*/}" || {
echo "error: '${UPSTREAM}' check failed: could not reach '${UPSTREAM%/*}'." >&2
echo " refusing to merge onto a possibly stale '${BASE}'." >&2
exit 1
}
BEHIND=$(git rev-list --count "${BASE}..${UPSTREAM}")
AHEAD=$(git rev-list --count "${UPSTREAM}..${BASE}")
if [ "${AHEAD}" -ne 0 ] && [ "${BEHIND}" -ne 0 ]; then
echo "error: '${BASE}' has diverged from '${UPSTREAM}' (ahead ${AHEAD}, behind ${BEHIND})." >&2
echo " reconcile '${BASE}' with '${UPSTREAM}' before finishing." >&2
exit 1
fi
else
echo "warning: '${BASE}' has no upstream; freshness against the remote is unchecked." >&2
fi
echo "Finishing into ${BASE}:"
git --no-pager log --oneline --no-decorate "${BASE}..${START_REF}" | sed 's/^/ /'
git switch --quiet "${BASE}"
if [ "${BEHIND}" -ne 0 ]; then
echo "Fast-forwarding ${BASE} to ${UPSTREAM} (${BEHIND} commit(s))."
git merge --quiet --ff-only "${UPSTREAM}"
fi
if ! git merge --quiet --no-ff -m ":twisted_rightwards_arrows: Merge ${START_REF} into ${BASE}" "${START_REF}"; then
echo "error: merge of '${START_REF}' failed; aborting and returning to it." >&2
git merge --abort 2>/dev/null || true
git switch --quiet "${START_REF}"
exit 1
fi
echo "Verify: npm run verify"
if ! npm run --silent verify; then
echo "error: 'npm run verify' is red after the merge." >&2
echo " the merge is local and not yet pushed; fix it on '${BASE}' and commit," >&2
echo " then drop the now-merged branch with 'git branch -d ${START_REF}'." >&2
exit 1
fi
git branch --delete "${START_REF}" >/dev/null
echo "Merged ${START_REF} into ${BASE} and deleted the branch."
echo "Next: npm run create:release (or git push, for a merge with no release)."
+56 -4
View File
@@ -13,6 +13,12 @@ set -eu
# package.json + the lockfile; `--amend` folds them into pubv's single commit;
# tag AFTER the amend (so the tag is never orphaned) and push.
#
# The notes are finalized in VS Code *before* pubv: the [Unreleased] body is
# what pubv's bump heuristic reads, so editing afterwards would inform the
# changelog only, not the version choice. pubv refuses a dirty tree, so that
# edit is committed as a staging commit and folded back into the single release
# commit below.
#
# Rejected: the conventional-commits family (our history is gitmoji, not
# Conventional; and we want hand-written notes); changesets/rtk (config + a
# heavier version/publish flow that fights our CI-only publish); knope/kacl/
@@ -23,18 +29,52 @@ set -eu
# exactly what this ~30-line version replaces.
CHANGELOG="CHANGELOG.md"
BASE="main"
if ! command -v code >/dev/null 2>&1; then
echo "Error: 'code' (VS Code CLI) not found; install it or remove the editor step." >&2
exit 1
fi
# Releases are cut from `main` (see CONTRIBUTING § Publishing workflow). Make
# that explicit rather than relying on pubv's default-branch check, so the
# error names `main` even when the remote's default is configured differently.
CURRENT=$(git symbolic-ref --quiet --short HEAD || true)
if [ "${CURRENT}" != "${BASE}" ]; then
echo "Error: releases are cut from '${BASE}', but HEAD is '${CURRENT:-detached}'." >&2
exit 1
fi
# pubv decides the "default branch" by reading the *local*
# `refs/remotes/origin/HEAD`, not by asking the remote, and `git fetch` never
# updates that ref. After a default-branch change — or a clone from when the
# default was different — it goes stale and pubv warns/fails because the
# current branch (main) does not match it, even though main *is* the remote
# default. Refresh it from the remote first, so pubv's branch preflight
# compares against reality. (Without a network this fails, but so would the
# push pubv is about to do, so it is a real error rather than one to swallow.)
if ! git remote set-head origin --auto >/dev/null 2>&1; then
echo "Error: could not refresh origin/HEAD; check connectivity to origin." >&2
exit 1
fi
# The [Unreleased] body drives pubv's bump heuristic, so finalize it first.
echo "Opening ${CHANGELOG} in VS Code to finalize the release notes..."
code --wait "${CHANGELOG}"
# pubv refuses a dirty tree (its "continue with a dirty tree?" prompt defaults
# to No), so a changed changelog must be committed before it runs. That commit
# is staging only — the fold below rewrites it into the single release commit.
NOTES_MSG=":memo: Finalize release notes"
if [ -n "$(git status --porcelain -- "${CHANGELOG}")" ]; then
echo "Committing finalized release notes..."
git add "${CHANGELOG}"
git commit -m "${NOTES_MSG}"
fi
echo "Running pubv..."
pubv --no-tag --no-push --tag-prefix=none
echo "Opening ${CHANGELOG} in VS Code..."
code --wait "${CHANGELOG}"
echo "Reading version from ${CHANGELOG}..."
VERSION=$(
@@ -52,9 +92,21 @@ echo "Release version: ${VERSION}"
echo "Updating package.json and package-lock.json..."
npm version "${VERSION}" --no-git-tag-version
# If pubv's graduation commit sits on top of our staging notes commit, drop it
# back into the index so the amend below rewrites the notes commit into the one
# release commit. A message check, not a flag, so a re-run after pubv aborted
# still folds a notes commit left behind by the earlier attempt.
if [ "$(git log -1 --format=%s HEAD~1 2>/dev/null || true)" = "${NOTES_MSG}" ]; then
git reset --soft HEAD~1
fi
echo "Amending release commit..."
git add package.json package-lock.json "${CHANGELOG}"
git commit --amend -m ":bookmark: Release ${VERSION}"
# The exact message format is load-bearing: the `release-gate` job in
# .gitea/workflows/ci.yml recognizes `:rocket: Release x.y.z` on main and
# skips the full CI run, since the tag push immediately after verifies the
# identical SHA (and publishes). Keep the two in sync.
git commit --amend -m ":rocket: Release ${VERSION}"
echo "Creating tag ${VERSION}..."
git tag "${VERSION}"