name: CI on: push: branches: [main] # Releases are tag pushes (`scripts/release.sh` tags bare `x.y.z`). A # `branches` filter alone matches no tag ref, so without this both the # tag-gated `publish` job and the coverage publish step never fire. tags: ["*"] pull_request: branches: [main] 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 # `image` extends the runner's default job image (catthehacker/act) # with Node 26 pre-planted in the tool cache layout, so setup-node's # version probe hits and never downloads (see docker/Dockerfile). The # tag MUST equal the exact version pinned in `.node-version`; the bump # 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: image: gitea.e1nsnull.de/tmu/act-ci:26.8.2 volumes: - /data/gitea-pages:/data/gitea-pages steps: - 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. 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 with: node-version-file: .node-version cache: "npm" - run: npm ci - run: npm run build - run: npm run check # Compile the prose examples into gitignored tests. Kept as an # explicit step (not a `pretest:ci` hook) so it is visible in the # job log and `test:ci` stays a plain command. - run: npm run create:doc-tests # Fails the build below 100% coverage on `src/` (`c8 --all --100`); # the same run produces the report published below. See # development/ci.md § Coverage threshold. - run: npm run test:ci # Publish this tag's coverage to the self-hosted pages server, # served read-only at # https://pages.e1nsnull.de////coverage/. Wipe only # this tag's `coverage/`, so sibling docs/landing trees and older # tags survive; pruning stale tags is a manual chore. The # precompress pass emits `.br` / `.gz` / `.zst` sidecars next to # every text asset, so `static-web-server` can serve the precompressed # variant and keep the original as fallback. - name: Publish coverage to the pages server if: startsWith(gitea.ref, 'refs/tags/') env: REPO: ${{ github.repository }} REF: ${{ gitea.ref }} run: | TAG="${REF#refs/tags/}" DEST="/data/gitea-pages/${REPO}/${TAG}/coverage" rm -rf "${DEST}" mkdir -p "${DEST}" cp -R coverage/. "${DEST}/" node --strip-types scripts/precompress.ts "${DEST}" echo "Coverage: https://pages.e1nsnull.de/${REPO}/${TAG}/coverage/" # Fast, offline packaging gate. `attw` stays in `publish` (it needs # a pack + full resolution matrix); `publint` packs too but is cheap # enough to run on every push so a packaging break fails here, not # at release time. - run: npm run publish:publint # Persist the exact dist/ that `check`, `test:ci` and `publint` were # run against, so `publish` ships those bytes instead of rebuilding # (which could in principle differ and would pay the build twice). - uses: actions/upload-artifact@v4 with: name: dist path: dist/ # Consumer typecheck against the minimum supported TypeScript (README # § Requirements), run over `dist/`'s emitted declarations and the whole # suite. Deliberately a separate job, not a step in `build`: the compiler is # a different major picked by `npx`, and it must never enter # `devDependencies`, the local `check`/`verify` tiers, or the lockfile. See # development/ci.md § TypeScript compatibility. compat: needs: build runs-on: ubuntu-latest # 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: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version-file: .node-version cache: "npm" - run: npm ci # Reuse the exact `dist/` that `check`, `test:ci` and `publint` were # run against, so the compat gate judges the shipped artifact and # pays no rebuild. - uses: actions/download-artifact@v4 with: name: dist path: dist/ - run: npm run test:compat # Advisory scans (dead code, dependency freshness). Non-blocking: surfaced in # 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 # 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: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version-file: .node-version cache: "npm" - run: npm ci - run: npm run maintain publish: if: startsWith(gitea.ref, 'refs/tags/') # `compat` gates the release: an artifact that is not consumable at the # claimed TypeScript floor must never ship. needs: [build, compat] 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 # (`github.token`), so it needs `contents: write`. permissions: contents: write # The npm token is optional: `secrets` is not an allowed context in a # step `if` (see GitHub's context-availability table), so it is lifted # into job-level `env`, where an unset secret arrives as the empty # string and skips the publish rather than attempting an unauthenticated # one. Set NPM_TOKEN in the Gitea repo: Settings → Actions → Secrets. env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: 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/" - run: npm ci # Consume the dist/ that `build` produced and gated, instead of # rebuilding here — `publish` must ship the tested artifact. - uses: actions/download-artifact@v4 with: name: dist path: dist/ - run: npm run publish:publint - run: npm run publish:attw # The Gitea release page is created *before* `npm publish` on # purpose: a broken page then fails CI without burning an npm # version. The page is cheap to retry, a published version is not. # The body is the matching Keep-a-Changelog section; an unknown tag # makes the extractor exit non-zero, so the page can never go up # empty. - name: Extract release notes from CHANGELOG.md env: TAG_REF: ${{ gitea.ref }} run: ./scripts/release-notes.sh "${TAG_REF#refs/tags/}" > release-notes.md - name: Create the Gitea release id: gitea_release uses: https://gitea.com/actions/gitea-release-action@v1 with: body_path: release-notes.md - name: Publish to npm id: npm_publish if: env.NPM_TOKEN != '' run: npm publish --access public env: NODE_AUTH_TOKEN: ${{ env.NPM_TOKEN }} # All-or-nothing: the tag is only released once *both* the release # page and the npm package are up. A skipped npm publish (NPM_TOKEN # unset) has no `success` outcome, so `always()` reaches this check # even after a failure and turns the skipped half into an explicit # red job instead of a silently green one. - name: Require both releases if: always() run: | GITEA="${{ steps.gitea_release.outcome }}" NPM="${{ steps.npm_publish.outcome }}" if [ "${GITEA}" != success ] || [ "${NPM}" != success ]; then echo "::error::incomplete release — gitea=${GITEA:-skipped} npm=${NPM:-skipped}" exit 1 fi