diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 49e7668..5941245 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -74,12 +74,13 @@ jobs: git config --global --add safe.directory /src git -C /src log --oneline -1 - # The @semantic-release/git plugin pushes a `chore(release): x.y.z` - # commit back to main, which is a push to the branch this workflow - # runs on. Guarded here rather than by [skip ci], whose handling in - # Gitea is one more thing that would have to be verified — and this - # is a two-line test of a fact we control. - - name: Skip the changelog commit + # Nothing currently pushes a `chore(release):` commit — main is a + # protected branch, so .releaserc.yml carries no @semantic-release/git + # and the release page is the changelog. This guard is kept for the + # day someone adds that plugin back: without it the commit-back is a + # push to the branch this workflow runs on, and the loop is a release + # per release. Six lines against that is cheap. + - name: Skip a changelog commit, if one ever exists id: guard working-directory: /src run: | @@ -161,7 +162,6 @@ jobs: -p @semantic-release/commit-analyzer@13 \ -p @semantic-release/release-notes-generator@14 \ -p @semantic-release/changelog@7 \ - -p @semantic-release/git@11 \ -p @semantic-release/exec@7 \ -p conventional-changelog-conventionalcommits@9 \ semantic-release \ diff --git a/.gitignore b/.gitignore index f9b2c5e..3b3893d 100644 --- a/.gitignore +++ b/.gitignore @@ -84,3 +84,7 @@ build/android/build/ build/android/.gradle/ build/android/gen/ build/android/overlay.json + +# Written by @semantic-release/changelog purely to carry the release notes +# into scripts/gitea-release.sh; the release page is the changelog. +.release-notes.md diff --git a/.releaserc.yml b/.releaserc.yml index 6ba3449..1c375b2 100644 --- a/.releaserc.yml +++ b/.releaserc.yml @@ -77,32 +77,41 @@ plugins: section: Build hidden: true - # Write CHANGELOG.md. + # Render the notes to a file. # - # This plugin is load-bearing for more than the changelog: it is how the - # release notes reach the Gitea API *without being interpolated into a - # shell command*. Release notes are rendered commit messages — arbitrary + # **This plugin is here to carry the notes, not to maintain a document.** + # It is how they reach the Gitea API *without being interpolated into a + # shell command*: release notes are rendered commit messages — arbitrary # text carrying backticks, quotes and `$` — so templating # ${nextRelease.notes} into `publishCmd` would be a shell injection with # the commit log as its input. scripts/gitea-release.sh reads the top # section of this file instead, and the only thing interpolated below is # a semver string. + # + # The target is a gitignored build artifact rather than CHANGELOG.md, + # because nothing commits it back — see below. - - "@semantic-release/changelog" - - changelogFile: CHANGELOG.md - changelogTitle: "# Changelog" + - changelogFile: .release-notes.md + changelogTitle: "# Release notes" - # Create the Gitea release, whose body is that changelog section. + # Create the Gitea release, whose body is that section. # `publish` runs after `prepare`, so the tag already exists by here. - - "@semantic-release/exec" - publishCmd: "./scripts/gitea-release.sh ${nextRelease.version}" - # Commit the changelog back to main. + # **There is deliberately no @semantic-release/git here.** # - # This is the push that would otherwise re-enter release.yml; the - # workflow guards on this subject rather than trusting [skip ci], whose - # handling in Gitea is one more thing that would have to be verified. - # The subject must also satisfy scripts/commit-check.sh. - - - "@semantic-release/git" - - assets: - - CHANGELOG.md - message: "chore(release): ${nextRelease.version} [skip ci]" + # `main` is a protected branch with `enable_push: false` and an empty + # push whitelist, so a changelog commit-back would be rejected by the + # pre-receive hook — *after* the tag had already been pushed, leaving a + # tagged release the run then reported as failed. The alternative was to + # whitelist the CI user, which weakens a protection someone set on + # purpose and lets a bot push to main without passing the checks every + # human PR has to. + # + # So the release page is the changelog. Tags are not protected, so the + # tag push semantic-release does itself is unaffected. CHANGELOG.md in + # the repo is a signpost to the releases page and is not written by any + # of this; a file that claimed to be a changelog and silently stopped + # updating would be worse than no file at all. + diff --git a/CHANGELOG.md b/CHANGELOG.md index 825c32f..f58ba04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1 +1,21 @@ # Changelog + +The changelog is the releases page: + + + +Every release there is generated from the Conventional Commits it +contains, by `.gitea/workflows/release.yml` on merge to `main`. Each one +carries its notes as its body, grouped by change type, with a link to the +commit behind every line. + +**This file is not generated and is not a copy of that.** `main` is a +protected branch, so nothing pushes a changelog commit back to it — and a +file that claimed to be a changelog while silently never updating would +be worse than no file at all. `make release-dry` prints what the next +merge would release. + +History before `v0.0.1` is in `git log`. The versions before it were cut +by hand and are not on the releases page; the entries this file used to +hold were generated against a GitHub remote this project no longer has, +and every link in them was dead. diff --git a/CLAUDE.md b/CLAUDE.md index 9ab5adf..d5634d1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2060,7 +2060,13 @@ Tests use `database.NewTestDB(t)` for in-memory SQLite, built by the same ## Git Workflow -Feature branches and PRs are the norm, but direct pushes to `main` are allowed. Pre-commit runs vet, lint, codegen check, and frontend typecheck in parallel. Pre-push runs the full test suite. +Feature branches and PRs are the only way in: **`main` is a protected +branch** (`enable_push: false`, an empty push whitelist, and `CI / check*` ++ `CI / e2e*` as required status checks), so a direct push is rejected by +the pre-receive hook. This file said otherwise for a long time. Tags are +*not* protected, which is what lets `release.yml` push one. + +Pre-commit runs vet, lint, codegen check, and frontend typecheck in parallel. Pre-push runs the full test suite. ## CI @@ -2092,9 +2098,19 @@ Four things about it are load-bearing: triggers nothing. All four publishers additionally skip `v0.0.0` explicitly, cleanly rather than by failing, because a floor is not a shipment. -- **The changelog commit must not re-enter the workflow.** The `git` - plugin pushes `chore(release): x.y.z` back to `main`; the job guards on - that subject rather than trusting `[skip ci]`. +- **The release page is the changelog, and that follows from the branch + protection.** `@semantic-release/git` would push a `chore(release):` + commit back to `main`, which the pre-receive hook rejects — *after* the + tag had been pushed, leaving a tagged release the run then reports as + failed. Whitelisting the CI user was the alternative and was declined: + it weakens a protection someone set on purpose and lets a bot push to + `main` without the checks every human PR passes. So the plugin is + absent, `@semantic-release/changelog` writes to a gitignored + `.release-notes.md` purely to carry the notes into + `scripts/gitea-release.sh`, and `CHANGELOG.md` is a signpost to the + releases page rather than a file that would silently stop updating. + The workflow keeps its `chore(release):` guard anyway, for the day + someone adds the plugin back. - **An asset upload waits for the release to exist.** semantic-release pushes the tag in `prepare` and creates the release in `publish`, so the tag push that starts these workflows happens *before* there is a diff --git a/Makefile b/Makefile index 4db17a8..83354a0 100644 --- a/Makefile +++ b/Makefile @@ -206,7 +206,6 @@ release-dry: ## Print the version a merge to main would release -p @semantic-release/commit-analyzer@13 \ -p @semantic-release/release-notes-generator@14 \ -p @semantic-release/changelog@7 \ - -p @semantic-release/git@11 \ -p @semantic-release/exec@7 \ -p conventional-changelog-conventionalcommits@9 \ semantic-release --dry-run --no-ci diff --git a/scripts/gitea-release.sh b/scripts/gitea-release.sh index 63779ea..f78c31f 100755 --- a/scripts/gitea-release.sh +++ b/scripts/gitea-release.sh @@ -6,13 +6,17 @@ # Gitea's API is /api/v1 and @semantic-release/github speaks GitHub's. # That is the whole of the Gitea-shaped work: one POST. # -# **The notes come from CHANGELOG.md, not from an argument.** Release -# notes are rendered commit messages — arbitrary text carrying backticks, -# quotes and `$` — so interpolating ${nextRelease.notes} into a shell -# command would be an injection whose input is the commit log. The -# changelog plugin has already written them to the top of CHANGELOG.md by -# the time `publish` runs, so the only thing crossing the shell boundary -# here is a semver string, which is validated below anyway. +# **The notes come from a file, not from an argument.** Release notes are +# rendered commit messages — arbitrary text carrying backticks, quotes and +# `$` — so interpolating ${nextRelease.notes} into a shell command would +# be an injection whose input is the commit log. @semantic-release/changelog +# has already written them to .release-notes.md by the time `publish` runs, +# so the only thing crossing the shell boundary here is a semver string, +# which is validated below anyway. +# +# That file is a gitignored build artifact, not a document: `main` is a +# protected branch, so nothing commits a changelog back to it and the +# release page is the changelog. See .releaserc.yml. # # Usage: scripts/gitea-release.sh # e.g. 0.0.1 # @@ -48,10 +52,10 @@ tag="v${version}" notes=$(awk ' /^## / { seen++; if (seen > 1) exit } seen { print } -' CHANGELOG.md) +' .release-notes.md) if [ -z "$notes" ]; then - echo "gitea-release: found no release section at the top of CHANGELOG.md" >&2 + echo "gitea-release: found no release section at the top of .release-notes.md" >&2 echo ' the changelog plugin runs in prepare and this runs in publish, so' >&2 echo ' an empty section means the plugin order in .releaserc.yml moved.' >&2 exit 1