1 Commits
Author SHA1 Message Date
tmu f2d4168293 Merge branch 'feature/setup'
CI / build (push) Canceled after 0s
CI / maintain (push) Canceled after 0s
CI / publish (push) Canceled after 0s
2026-09-13 21:03:25 +00:00
14 changed files with 514 additions and 913 deletions

No files matched your search

+4 -65
View File
@@ -12,64 +12,17 @@ on:
workflow_dispatch: {} workflow_dispatch: {}
jobs: 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: build:
needs: release-gate
if: needs.release-gate.outputs.skip != 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
# `image` extends the runner's default job image (catthehacker/act) # Bind-mount the shared pages tree so the coverage step below can write
# with Node 26 pre-planted in the tool cache layout, so setup-node's # into it. The runner whitelists this path via `container.valid_volumes`
# version probe hits and never downloads (see docker/Dockerfile). The # (docker-space `setup/gitea.sh`); `image` is omitted on purpose so the
# tag MUST equal the exact version pinned in `.node-version`; the bump # runner keeps using its default job image.
# ritual is documented in CONTRIBUTING.md § CI runner image. The volume
# bind-mounts the shared pages tree so the
# coverage step below can write into it; the runner whitelists this
# path via `container.valid_volumes` (docker-space `setup/gitea.sh`).
container: container:
image: gitea.e1nsnull.de/tmu/act-ci:26.8.2
volumes: volumes:
- /data/gitea-pages:/data/gitea-pages - /data/gitea-pages:/data/gitea-pages
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# Fail fast when the job container is not the baked image: a stale
# tag on the runner (`forcePull=false` in its pull log) silently
# reintroduces the per-job download. Mirrors the publish job's
# NPM_TOKEN assert — cheap, and it names the invariant.
- name: Assert the baked tool cache is present
run: |
test -f "/opt/hostedtoolcache/node/$(tr -d '[:space:]' < .node-version)/x64.complete"
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version-file: .node-version node-version-file: .node-version
@@ -116,14 +69,8 @@ jobs:
# the Actions tab for visibility, but must never gate a merge — so # the Actions tab for visibility, but must never gate a merge — so
# continue-on-error and intentionally NOT in `publish`'s `needs`. # continue-on-error and intentionally NOT in `publish`'s `needs`.
maintain: maintain:
needs: release-gate
if: needs.release-gate.outputs.skip != 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
continue-on-error: true continue-on-error: true
# Same baked image as `build` — without it this job re-downloads Node
# per run (see docker/Dockerfile).
container:
image: gitea.e1nsnull.de/tmu/act-ci:26.8.2
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
@@ -137,10 +84,6 @@ jobs:
if: startsWith(gitea.ref, 'refs/tags/') if: startsWith(gitea.ref, 'refs/tags/')
needs: build needs: build
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Same baked image as `build` — setup-node still owns the registry-url
# `.npmrc` rewrite here; only the Node download is skipped.
container:
image: gitea.e1nsnull.de/tmu/act-ci:26.8.2
# The release page is created with the run's automatic Gitea token # The release page is created with the run's automatic Gitea token
# (`github.token`), not `NPM_TOKEN`, so it needs `contents: write`. # (`github.token`), not `NPM_TOKEN`, so it needs `contents: write`.
permissions: permissions:
@@ -159,10 +102,6 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version-file: .node-version node-version-file: .node-version
# Same lockfile/key as `build`, and tag runs can read caches
# saved on `main` — without this, every release pays a cold
# `npm ci` despite the warm shared npm cache.
cache: "npm"
registry-url: "https://registry.npmjs.org/" registry-url: "https://registry.npmjs.org/"
- run: npm ci - run: npm ci
# Consume the dist/ that `build` produced and gated, instead of # Consume the dist/ that `build` produced and gated, instead of
+1 -1
View File
@@ -1 +1 @@
26.8.2 26
+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> **Known problems:** <open issues, caveats, follow-ups>
``` ```
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). 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).
- **Leaf task** (no indented children): implement on the current branch and commit. - **Leaf task** (no indented children): implement on the current branch and commit.
+1 -27
View File
@@ -7,30 +7,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.1.4] - 2026-09-14 [Unreleased]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts
- fix CI to node from custom image
- upgrade dependencies
## [0.1.3] - 2026-09-14
- change to custom image for CI
## [0.1.2] - 2026-09-14
- upgrade dependencies
## [0.1.1] - 2026-09-14
- upgrade dependencies
## [0.1.0] - 2026-09-14
- basic setup
[Unreleased]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.1.4...main
[0.1.4]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.1.3...0.1.4
[0.1.3]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.1.2...0.1.3
[0.1.2]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.1.1...0.1.2
[0.1.1]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.1.0...0.1.1
[0.1.0]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/20308c5a6d8cccfb09b02ac2ebebd8055e91cd11...0.1.0
+6 -24
View File
@@ -11,7 +11,6 @@ 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) - **`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)) - **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)) - **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)) - **There is no local `npm run publish`, and `publish:publint` / `publish:attw` don't go in `check`.** (see [Publishing workflow](#publishing-workflow))
## Editor configuration ## Editor configuration
@@ -28,7 +27,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: 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: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. - `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.
- `check:*` — read-only verification; never modifies files. Aggregated by `npm run check`. - `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. - `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. - `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.
@@ -86,32 +85,15 @@ This is why every test in the suite pairs an `expectTypeOf(...)` with an `assert
- **Base branch:** `main` - **Base branch:** `main`
- **Branch naming:** `feature/<desc>` / `fix/<desc>` / `chore/<desc>` - **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. - **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:** `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. - **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. 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)). - CI runs `npm run check` + `npm run test:ci` on every push to `main` — this is the authoritative gate.
- **Releases are NOT triggered by pushes.** Only the maintainer triggers a release (see [Publishing workflow](#publishing-workflow)). - **Releases are NOT triggered by pushes.** Only the maintainer triggers a release (see [Publishing workflow](#publishing-workflow)).
## CI runner image
The `build` / `maintain` / `publish` jobs run in `gitea.e1nsnull.de/tmu/act-ci:<version>` ([docker/Dockerfile](./docker/Dockerfile)) — the runner's default act image with the Node distribution overlaid at the exact `/opt/hostedtoolcache` layout `actions/setup-node` probes before downloading, so no job pays the ~50 MB fetch. The image tag MUST equal the exact version pinned in `.node-version`; `release-gate` uses no Node and stays on the default image. The script is deliberately NOT an `npm run` script: building requires a docker daemon and registry credentials, so it belongs to no feedback tier — per [Script prefix convention](#script-prefix-convention), no existing prefix fits and that is the signal.
Bumping Node is one coordinated change, committed as a unit:
1. Edit `.node-version` to the new exact `x.y.z` — floats like `26` resolve to the latest patch at runtime and silently bust the baked entry; `scripts/runner-image.sh` refuses them.
2. `docker login gitea.e1nsnull.de` (user + package/access token), then `./scripts/runner-image.sh --push` — it reads the version from `.node-version` and builds/pushes `<IMAGE_REPO>:<version>`.
3. Repoint the three `container.image` tags in [.gitea/workflows/ci.yml](./.gitea/workflows/ci.yml) to the same version.
Skipping step 2 fails CI at image pull; skipping step 3 silently reverts to the per-job download.
Two invariants the image must satisfy for the probe to hit, both easy to break:
- **The `x64.complete` marker.** `actions/tool-cache` accepts a cached tool only when `<version>/<arch>.complete` exists next to the directory (`tc.find()` checks it); a plausible-looking `node/<version>/x64/` alone is ignored and the download happens anyway. See the comment in [docker/Dockerfile](./docker/Dockerfile).
- **Tag freshness.** The tag encodes only the Node version, so a Dockerfile change (like the marker above) produces _new content under an unchanged tag_. `act_runner` skips the pull when a tag of that name already exists locally (`forcePull=false` in the job log), so the runner must either force-pull (`force_pull` under `container:` in its `config.yaml`, if the installed version has it) or have the tag removed on the runner host (`docker rmi gitea.e1nsnull.de/tmu/act-ci:<version>`) after any image change. Symptom of getting this wrong: CI keeps running the previous image while the registry shows the new digest.
## Publishing workflow ## Publishing workflow
Publishing is CI-only by policy. Local `npm publish` is not supported. The maintainer triggers releases from `main`: 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. 1. All intended changes are merged to `main` and passing CI.
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. 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 (graduated changelog + package.json bump, amended into one commit), tags it, and pushes everything to Gitea. 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 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. 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.
+1 -10
View File
@@ -35,7 +35,7 @@ Maintenance:
✔ Add a minimal dir-listing webserver to the gitea docker setup (e.g. caddy `file_server browse` reusing the existing reverse proxy, or any single-binary static server, lipanski/docker-static-website) @done (9/13/2026, 9:02:37 PM) ✔ Add a minimal dir-listing webserver to the gitea docker setup (e.g. caddy `file_server browse` reusing the existing reverse proxy, or any single-binary static server, lipanski/docker-static-website) @done (9/13/2026, 9:02:37 PM)
✔ drop the `actions/upload-artifact` coverage step in favour of the shared-dir layout @done (9/13/2026, 10:37:22 PM) ✔ drop the `actions/upload-artifact` coverage step in favour of the shared-dir layout @done (9/13/2026, 10:37:22 PM)
☐ Explore serving coverage for non-tag pushes (e.g. `main/coverage`, PR previews) @low ☐ Explore serving coverage for non-tag pushes (e.g. `main/coverage`, PR previews) @low
✔ Manually verify the coverage was created on a real tag push (needs main) @low @done (9/14/2026, 1:55:03 PM) ☐ Manually verify the coverage was created on a real tag push (needs main) @low
→ design: no deploy step in CI; the webserver just exposes the shared directory (decided over Gitea Pages / Codecov — neither confirmed available/ wanted) → design: no deploy step in CI; the webserver just exposes the shared directory (decided over Gitea Pages / Codecov — neither confirmed available/ wanted)
☐ serve docs over self hosted server @low ☐ serve docs over self hosted server @low
☐ Add a minimal dir-listing webserver to the gitea docker setup for serving docs (reuse existing reverse proxy) ☐ Add a minimal dir-listing webserver to the gitea docker setup for serving docs (reuse existing reverse proxy)
@@ -45,12 +45,3 @@ Maintenance:
☐ Add a minimal dir-listing webserver to the gitea docker setup for serving landing page (reuse existing reverse proxy) ☐ Add a minimal dir-listing webserver to the gitea docker setup for serving landing page (reuse existing reverse proxy)
☐ CI writes landing page to a shared volume keyed by project + tag (e.g. `/landing/tiny-pattern-ts/<tag>/`) ☐ CI writes landing page to a shared volume keyed by project + tag (e.g. `/landing/tiny-pattern-ts/<tag>/`)
☐ Browse to `…/tiny-pattern-ts/index.html` in the browser ☐ Browse to `…/tiny-pattern-ts/index.html` in the browser
✔ Stop Gitea CI re-downloading Node on every job @done
✔ Share the warm npm cache with the publish job @done
✔ Bake Node into the CI job image (docker/Dockerfile, container.image in ci.yml) @done
✔ Build/push gitea.e1nsnull.de/tmu/act-ci:26.8.2 and confirm setup-node skips the download @done
✔ Write the `<version>/x64.complete` marker — actions/tool-cache ignores a bare directory, so the probe missed and the download continued @done
✔ Log tool-cache state from the job container to find it (temporary, removed once understood) @done
✔ Guard the invariant in CI (`Assert the baked tool cache is present`) @done
☐ Enable force-pull for the runner so a changed act-ci image is never missed @low
→ the tag encodes only the Node version, so a Dockerfile change yields new content under an unchanged tag; with `forcePull=false` the runner keeps the old image (see CONTRIBUTING § CI runner image)
-8
View File
@@ -27,14 +27,6 @@
"knope", "knope",
"runwisp", "runwisp",
"glab", "glab",
"hostedtoolcache",
"nodebase",
"frontends",
"catthehacker",
"nsnull",
"dedup",
"dedupe",
"repoint",
"postversion", "postversion",
"prebuild", "prebuild",
"Zilla", "Zilla",
-51
View File
@@ -1,51 +0,0 @@
# CI job image for the Gitea act_runner: the runner's default job image with
# Node pre-planted where actions/setup-node looks first.
#
# Why this layout: setup-node ignores `node` on PATH; its only fast path is a
# probe of /opt/hostedtoolcache/node/<version>/<arch>. Without an entry there
# it downloads the ~50 MB distribution on EVERY job (the runner's job
# containers are ephemeral, so its tool cache never survives a job). The
# official node images keep exactly the layout setup-node expects under
# /usr/local, so this layer is a pure file overlay — no scripts, no env.
#
# Why not a host bind of /opt/hostedtoolcache: binds never self-prune. Docker
# images are content-addressed: the base layers dedupe against the act image
# the host already has, and `docker image prune` / re-pulls are the cleanup
# story.
#
# NODE_VERSION must match `.node-version` exactly. setup-node resolves a float
# like `26` to the latest known patch at runtime, so a bump silently busts the
# baked entry; `.node-version` is pinned to x.y.z and scripts/runner-image.sh
# guards the coupling. Rebuild + repoint `container.image` in
# .gitea/workflows/ci.yml on every bump.
#
# The extra `nodebase` stage is load-bearing: `COPY --from=` resolves its value
# as a *stage name* at parse time, before build args exist, so
# `COPY --from=node:${NODE_VERSION}` collapses to the invalid `node:` on
# frontends that do not expand args there. ARGs declared before the first FROM
# *are* expanded in FROM, so routing through a named stage works everywhere.
# Global scope: only visible to FROM lines, but that is exactly where we need it.
ARG NODE_VERSION=26.8.2
FROM node:${NODE_VERSION} AS nodebase
FROM catthehacker/ubuntu:act-latest
# ARGs do not cross stage boundaries; redeclare (with the same default, so a
# bare `docker build -f docker/Dockerfile .` still works) for the paths below.
# Keep this default in sync with the global one above.
ARG NODE_VERSION=26.8.2
# node image: bin/ + lib/ under /usr/local → tool cache: bin/ + lib/ under <ver>/x64.
COPY --from=nodebase /usr/local /opt/hostedtoolcache/node/${NODE_VERSION}/x64
# actions/tool-cache only accepts a cached tool when the sibling marker file
# "<version>/<arch>.complete" exists — tc.find() checks it and falls back to
# downloading otherwise, however complete the directory is. The marker is what
# tc.cacheDir() writes after *it* installs a tool, so a pre-baked entry has to
# reproduce it explicitly.
RUN touch "/opt/hostedtoolcache/node/${NODE_VERSION}/x64.complete"
# Fail the build (not CI) if the overlay or the version arg were wrong.
# Shell form on purpose: exec form (`RUN [...]`) does not expand ARG values.
RUN "/opt/hostedtoolcache/node/${NODE_VERSION}/x64/bin/node" --version
+486 -486
View File
File diff suppressed because it is too large. Load diff
+4 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "tiny-pattern-ts", "name": "tiny-pattern-ts",
"version": "0.1.4", "version": "0.0.0",
"description": "Pattern matching for TypeScript/ESM environments (F#-style, not regex)", "description": "Pattern matching for TypeScript/ESM environments (F#-style, not regex)",
"keywords": [ "keywords": [
"adt", "adt",
@@ -49,7 +49,6 @@
"fix:oxfmt": "oxfmt ${LEFTHOOK_FILES:-.}", "fix:oxfmt": "oxfmt ${LEFTHOOK_FILES:-.}",
"fix:oxlint": "oxlint --fix src scripts", "fix:oxlint": "oxlint --fix src scripts",
"create:branch": "./scripts/branch.sh", "create:branch": "./scripts/branch.sh",
"create:finish": "./scripts/finish.sh",
"create:release": "./scripts/release.sh", "create:release": "./scripts/release.sh",
"maintain": "npm run maintain:knip; npm run maintain:outdated", "maintain": "npm run maintain:knip; npm run maintain:outdated",
"maintain:knip": "knip --include dependencies,exports,files", "maintain:knip": "knip --include dependencies,exports,files",
@@ -77,8 +76,8 @@
"expect-type": "1.4.0", "expect-type": "1.4.0",
"knip": "^6.34.0", "knip": "^6.34.0",
"lefthook": "^2.1.12", "lefthook": "^2.1.12",
"oxfmt": "^0.68.0", "oxfmt": "^0.66.0",
"oxlint": "^1.83.0", "oxlint": "^1.81.0",
"oxlint-tsgolint": "^7.0.2001", "oxlint-tsgolint": "^7.0.2001",
"publint": "^0.3.24", "publint": "^0.3.24",
"typescript": "^7.0.2" "typescript": "^7.0.2"
@@ -87,6 +86,6 @@
"node": ">=26" "node": ">=26"
}, },
"allowScripts": { "allowScripts": {
"lefthook@2.1.14": true "lefthook@2.1.12": true
} }
} }
+6 -10
View File
@@ -31,12 +31,11 @@ set -eu
# describes nothing anyone wants, and `publish:*` already sets the precedent for # describes nothing anyone wants, and `publish:*` already sets the precedent for
# a prefix without one. # a prefix without one.
# #
# Also rejected here: reusing `pubv`'s preflight (release-shaped, third-party, # Also rejected: a full git-flow CLI wrapping the merge too (merging ends in
# and it would make branch start pay a build + pack it has no use for). The # "review the diff yourself", which is judgment, and only the start half carries
# merge half was originally rejected too ("review the diff yourself" is # a verification burden); and reusing `pubv`'s preflight (release-shaped,
# judgment), but it now has its own front door — `create:finish` — which owns # third-party, and it would make branch start pay a build + pack it has no use
# the merge-side preconditions and the post-merge `verify`, so the start half # for).
# does not have to carry that burden.
# #
# Every refusal is non-mutating except the baseline test, which runs on `main` # 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 # after we switch there — so a red `main` restores the branch you started on
@@ -106,10 +105,7 @@ git show-ref --verify --quiet "refs/heads/${BASE}" || {
# Derive the remote rather than hardcoding it: this repo has `origin` (ssh) and # 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 # `origin_https`, and `main` tracks the latter — `git fetch origin main` would
# check currency against a ref that is never updated here. # check currency against a ref that is never updated here.
# `--quiet` echoes the unresolved `main@{upstream}` literal to stdout on UPSTREAM=$(git rev-parse --quiet --abbrev-ref --symbolic-full-name "${BASE}@{upstream}" 2>/dev/null || true)
# 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 if [ -n "${UPSTREAM}" ]; then
git fetch --quiet "${UPSTREAM%/*}" "${UPSTREAM#*/}" || { git fetch --quiet "${UPSTREAM%/*}" "${UPSTREAM#*/}" || {
echo "error: '${UPSTREAM}' check failed: could not reach '${UPSTREAM%/*}'." >&2 echo "error: '${UPSTREAM}' check failed: could not reach '${UPSTREAM%/*}'." >&2
-136
View File
@@ -1,136 +0,0 @@
#!/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)."
+4 -56
View File
@@ -13,12 +13,6 @@ set -eu
# package.json + the lockfile; `--amend` folds them into pubv's single commit; # package.json + the lockfile; `--amend` folds them into pubv's single commit;
# tag AFTER the amend (so the tag is never orphaned) and push. # 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 # Rejected: the conventional-commits family (our history is gitmoji, not
# Conventional; and we want hand-written notes); changesets/rtk (config + a # Conventional; and we want hand-written notes); changesets/rtk (config + a
# heavier version/publish flow that fights our CI-only publish); knope/kacl/ # heavier version/publish flow that fights our CI-only publish); knope/kacl/
@@ -29,52 +23,18 @@ set -eu
# exactly what this ~30-line version replaces. # exactly what this ~30-line version replaces.
CHANGELOG="CHANGELOG.md" CHANGELOG="CHANGELOG.md"
BASE="main"
if ! command -v code >/dev/null 2>&1; then 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 echo "Error: 'code' (VS Code CLI) not found; install it or remove the editor step." >&2
exit 1 exit 1
fi 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..." echo "Running pubv..."
pubv --no-tag --no-push --tag-prefix=none pubv --no-tag --no-push --tag-prefix=none
echo "Opening ${CHANGELOG} in VS Code..."
code --wait "${CHANGELOG}"
echo "Reading version from ${CHANGELOG}..." echo "Reading version from ${CHANGELOG}..."
VERSION=$( VERSION=$(
@@ -92,21 +52,9 @@ echo "Release version: ${VERSION}"
echo "Updating package.json and package-lock.json..." echo "Updating package.json and package-lock.json..."
npm version "${VERSION}" --no-git-tag-version 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..." echo "Amending release commit..."
git add package.json package-lock.json "${CHANGELOG}" git add package.json package-lock.json "${CHANGELOG}"
# The exact message format is load-bearing: the `release-gate` job in git commit --amend -m ":bookmark: Release ${VERSION}"
# .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}..." echo "Creating tag ${VERSION}..."
git tag "${VERSION}" git tag "${VERSION}"
-33
View File
@@ -1,33 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Build (and optionally push) the CI job image from docker/Dockerfile.
# Run wherever docker + registry credentials live (the runner host, or any
# machine that can reach the registry). The registry/repo below MUST match
# the `container.image` references in .gitea/workflows/ci.yml — the runner
# pulls the image by name.
#
# Usage: scripts/runner-image.sh [--push]
IMAGE_REPO="gitea.e1nsnull.de/tmu/act-ci"
NODE_VERSION="$(tr -d '[:space:]' < .node-version)"
if [[ ! "${NODE_VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "error: .node-version must be pinned to an exact x.y.z, got '${NODE_VERSION}'." >&2
echo " setup-node resolves floats like '26' to the latest patch at runtime," >&2
echo " which silently busts the tool-cache entry baked into the image." >&2
exit 1
fi
IMAGE="${IMAGE_REPO}:${NODE_VERSION}"
# --pull: refresh the act base layer so the derivative does not float on an
# aging default image forever (layer dedup keeps this cheap).
docker build --pull --build-arg "NODE_VERSION=${NODE_VERSION}" -t "${IMAGE}" -f docker/Dockerfile .
if [[ "${1:-}" == "--push" ]]; then
docker push "${IMAGE}"
fi
echo "built ${IMAGE}"
echo "reminder: bump container.image in .gitea/workflows/ci.yml to this tag"