diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 4ff3a99..0f3f2d1 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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 (`:bookmark: 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 '^:bookmark: 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: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 29d52aa..2cb58bd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -87,7 +87,7 @@ This is why every test in the suite pairs an `expectTypeOf(...)` with an `assert - **Branch naming:** `feature/` / `fix/` / `chore/` - **Starting work:** `npm run create:branch -- /`. 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. -- CI runs `npm run check` + `npm run test:ci` on every push to `main` — this is the authoritative gate. +- 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 (`:bookmark: 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 @@ -97,4 +97,4 @@ Publishing is CI-only by policy. Local `npm publish` is not supported. The maint 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. 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 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. +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. diff --git a/scripts/release.sh b/scripts/release.sh index 0649ec0..952489d 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -102,6 +102,10 @@ fi echo "Amending release commit..." git add package.json package-lock.json "${CHANGELOG}" +# The exact message format is load-bearing: the `release-gate` job in +# .gitea/workflows/ci.yml recognizes `:bookmark: 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 ":bookmark: Release ${VERSION}" echo "Creating tag ${VERSION}..."