name: Release # The sixth workflow, and the one that decides whether the other three # run at all. On every push to main it reads the Conventional Commits # since the last tag, and if any of them is releasable it writes the # changelog, pushes the tag, and creates the Gitea release whose body is # that changelog section. The publishing workflows are keyed on `v*`, so # the tag push is what starts them. # # **Why the tag is pushed with PACKAGE_TOKEN and not the Actions token.** # Gitea, like GitHub, does not start a workflow from a ref pushed by a # workflow's own token (go-gitea#33123). The token is what decides this, # not the workflow — so semantic-release is handed a repositoryUrl # carrying a *user* PAT, and the resulting push is attributed to a person # and triggers the `v*` workflows normally. # # That limitation is used deliberately in the bootstrap step below, where # a tag that must *not* trigger anything is pushed with the Actions token # instead. on: push: branches: [main] workflow_dispatch: # Cutting a tag is not a thing to cancel halfway: a superseded run must # finish, not be killed between `git push --tags` and the release POST. concurrency: group: release-main cancel-in-progress: false jobs: release: runs-on: ubuntu-latest container: image: ubuntu:24.04 env: SERVER_URL: ${{ github.server_url }} OWNER: ${{ github.repository_owner }} REPO: ${{ github.repository }} PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }} DEBIAN_FRONTEND: noninteractive steps: - name: System packages run: | set -eu apt-get update -qq apt-get install -y -qq --no-install-recommends ca-certificates curl git jq - name: Node toolchain run: | set -eu curl -fsSL https://deb.nodesource.com/setup_22.x | bash - apt-get install -y -qq --no-install-recommends nodejs node --version # By hand rather than actions/checkout, like the other five: that is # a JS action and needs node inside the container before any step has # installed it. The full history is required — semantic-release # reads tags and walks commits, and a shallow clone silently makes # every release look like the first one. - name: Clone repo at this commit run: | set -eu git clone --quiet \ "https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git" /src # -B main rather than --detach, which the other five workflows # use: semantic-release resolves the release branch and then # pushes a commit and a tag to it, and a detached HEAD is a # worse starting point for both than a local branch named after # the one being released. Pinned to this commit, not to # whatever main points at by the time the container started. git -C /src checkout --quiet -B main "${{ github.sha }}" git config --global --add safe.directory /src git -C /src log --oneline -1 # 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: | set -eu subject=$(git log -1 --format='%s') case "$subject" in "chore(release):"*) echo "this is the release commit itself; nothing to do" echo "skip=true" >> "$GITHUB_OUTPUT" ;; *) echo "skip=false" >> "$GITHUB_OUTPUT" ;; esac # semantic-release calls the first release of a repo with no tags # 1.0.0, and offers no option to say otherwise. A floor tag is the # only way to start at 0.0.1, so this creates one — once, ever. # # **It is pushed with the Actions token on purpose.** v0.0.0 is a # floor, not a shipment: pushing it with a user PAT would start the # Arch, Homebrew and Android workflows for a version that does not # exist. The very limitation the header describes is what makes # this inert. - name: Seed the version floor if: steps.guard.outputs.skip == 'false' working-directory: /src env: ACTIONS_TOKEN: ${{ secrets.GITEA_TOKEN }} run: | set -eu git fetch --quiet --tags origin if [ -n "$(git tag --list 'v[0-9]*')" ]; then echo "floor already set; newest tag is $(git describe --tags --abbrev=0 --match 'v[0-9]*')" exit 0 fi # Prefer the Actions token because a ref it pushes starts no # workflow, which is the whole point for a tag that is a floor # rather than a shipment. Falling back to the PAT is safe # rather than merely convenient: all four publishing workflows # skip v0.0.0 explicitly, so the worst case is four jobs that # start and immediately say there is nothing to build. token="${ACTIONS_TOKEN:-$PACKAGE_TOKEN}" [ -n "$ACTIONS_TOKEN" ] || echo "note: GITEA_TOKEN is unset; using the PAT" # **On the parent, not on HEAD.** The floor marks what has # already been released, so tagging the commit being pushed # leaves nothing between the floor and HEAD — semantic-release # then correctly reports there is nothing to release, which is # exactly what the first run of this workflow did. HEAD^ is the # first parent, so on the merge commit this fires for it is main # as it was before the merge, and everything the merge brought # in is releasable. floor=$(git rev-parse "${{ github.sha }}^" 2>/dev/null || true) if [ -z "$floor" ]; then echo "HEAD has no parent, so no commit can precede the floor" >&2 exit 1 fi echo "no v* tag exists — seeding v0.0.0 so the first release is 0.0.1" git tag v0.0.0 "$floor" git push --quiet \ "https://x-access-token:${token}@${SERVER_URL#https://}/${REPO}.git" \ refs/tags/v0.0.0 echo "seeded v0.0.0 at $floor (parent of ${{ github.sha }})" # Pinned rather than installed into the repo: this is a Go project # and a package.json at its root invites the npm plugin and every # tool that looks for one. conventional-changelog-conventionalcommits # is in the list because both the analyzer and the notes generator # name that preset and neither depends on it. # # **That preset is held at 9 and the reason is worth keeping.** At # 10 it is silently incompatible with the writer that # release-notes-generator@14 pulls in (^8): every release note comes # out as a bare `## 0.0.1 (date)` heading with **no sections and no # commits under it**, and nothing errors. The version would have # been right, the tag would have been right, every job would have # been green, and the release body would have been empty. Check the # notes, not the exit code, before moving any of these. - name: Run semantic-release if: steps.guard.outputs.skip == 'false' working-directory: /src run: | set -eu git config user.name "yellowjacket-ci" git config user.email "yj@yellowjacket.app" npx --yes \ -p semantic-release@25 \ -p @semantic-release/commit-analyzer@13 \ -p @semantic-release/release-notes-generator@14 \ -p @semantic-release/changelog@7 \ -p @semantic-release/exec@7 \ -p conventional-changelog-conventionalcommits@9 \ semantic-release \ --repository-url "https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git"