From 3b5c57e5025ce4e2476d488ae8f485db7b5d4e1c Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Mon, 5 Oct 2026 14:47:45 -0400 Subject: [PATCH] Add the tag-driven release workflow the docs describe docs/release.md said a pushed tag publishes the GitHub Release, but no workflow existed, so releases were made by hand and v0.2.1 never got one. The workflow checks the branch-alias line, refuses a tag while [Unreleased] has entries, and publishes the version's CHANGELOG section with a CycloneDX SBOM. It can be run by hand for an existing tag. docs/release.md now describes the release-branch process; it said to push to main and that CI runs on every push. --- .github/workflows/release.yml | 91 +++++++++++++++++++++++++++++++++++ CHANGELOG.md | 10 ++++ docs/release.md | 22 +++++++-- 3 files changed, 118 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6cf6f8b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,91 @@ +name: Release + +on: + push: + tags: + - 'v*.*.*' + workflow_dispatch: + inputs: + tag: + description: 'Existing tag to (re)publish, e.g. v0.2.1' + required: true + +concurrency: + group: ${{ github.workflow }}-${{ inputs.tag || github.ref }} + cancel-in-progress: false + +permissions: + contents: write + +jobs: + release: + name: Publish GitHub release + SBOM + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + TAG: ${{ inputs.tag || github.ref_name }} + + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + ref: refs/tags/${{ env.TAG }} + + # A branch-alias that lags the tag makes main advertise an old line, so Composer offers + # main to consumers pinned to that line. Failing here costs a re-tag. + - name: Assert branch-alias matches the tag + run: | + alias=$(jq -r '.extra["branch-alias"]["dev-main"] // empty' composer.json | sed 's/\.x-dev$//') + line=$(cut -d. -f1,2 <<<"${TAG#v}") + if [ -n "$alias" ] && [ "$alias" != "$line" ]; then + echo "::error::branch-alias is ${alias}.x-dev but the tag is on the ${line} line." + exit 1 + fi + + # The notes are the tagged version's CHANGELOG section, never a bare stub, and entries still + # under [Unreleased] would ship in this release without being described by it. + - name: Extract release notes from CHANGELOG + run: | + version="${TAG#v}" + awk -v v="$version" ' + $0 ~ ("^## \\[" v "\\]") {f=1; next} + f && /^## \[/ {exit} + f {print} + ' CHANGELOG.md | sed '/./,$!d' > release-notes.md + if [ ! -s release-notes.md ]; then + echo "::error::CHANGELOG.md has no '## [${version}]' section." + exit 1 + fi + if awk '/^## \[Unreleased\]/{f=1;next} /^## \[/{f=0} f && /^- /{found=1} END{exit !found}' CHANGELOG.md; then + echo "::error::[Unreleased] still has entries; cut them into ${version} or a later release first." + exit 1 + fi + + - name: Setup PHP + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: '8.5' + extensions: dom, curl, libxml, mbstring, zip, fileinfo + coverage: none + + - name: Install runtime dependencies + run: composer install --no-dev --prefer-dist --no-interaction --no-progress + + - name: Generate CycloneDX SBOM + uses: anchore/sbom-action@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # v0.24.3 + with: + path: . + format: cyclonedx-json + output-file: toolkit.cdx.json + upload-artifact: false + upload-release-assets: false + + # Updates the release in place when it already exists (a hand-made one, or a re-run). + - name: Create GitHub release + uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 + with: + tag_name: ${{ env.TAG }} + name: ${{ env.TAG }} + generate_release_notes: true + body_path: release-notes.md + files: toolkit.cdx.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 07fe9f9..c4cf1d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to `laranail/toolkit` are documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- `.github/workflows/release.yml`: a pushed `vX.Y.Z` tag now publishes the GitHub Release with that version's CHANGELOG section as its body and a CycloneDX SBOM attached, after checking the branch-alias line and that `[Unreleased]` is empty. `docs/release.md` already described this workflow, but it did not exist, so releases were made by hand and `v0.2.1` never got one. It can also be run by hand for an existing tag. + +### Changed + +- `docs/release.md` describes the release-branch process (`release/vX.Y.Z`, merged by pull request, tag on the merge commit). It used to say to push to `main` and that CI runs on every push; CI runs on pull requests. + ## [0.2.2] - 2026-10-05 ### Changed diff --git a/docs/release.md b/docs/release.md index cf7b08b..6555b31 100644 --- a/docs/release.md +++ b/docs/release.md @@ -10,16 +10,28 @@ Pre-1.0, the package follows SemVer's 0.x convention: breaking changes bump the ## Cutting a release -1. Land everything on `main` with the full suite green (`vendor/bin/pest`) and PHPStan clean — CI runs both on every push. -2. Add the `## [0.X.Y]` block to `CHANGELOG.md` (Keep a Changelog). -3. Commit, push, wait for CI green. -4. Tag and release with the CHANGELOG block as the body (never a bare stub): +Everything reaches `main` through a pull request, the release included. + +1. Land the changes on `main` by pull request, with every required check green. Each one adds its + entry under `## [Unreleased]` in `CHANGELOG.md` (Keep a Changelog). +2. Branch `release/v0.X.Y` from `main` and make one commit, `Release 0.X.Y`, that renames + `## [Unreleased]` to `## [0.X.Y] - `. +3. Open a pull request for it, wait for the checks, and merge it with a merge commit. +4. Tag the merge commit and push the tag: ```bash + git switch main && git pull --ff-only git tag v0.X.Y && git push origin v0.X.Y - gh release create v0.X.Y --title "v0.X.Y" --notes-file <(awk '/^## \[0.X.Y\]/{f=1;next} /^## \[/{f=0} f' CHANGELOG.md) --generate-notes ``` +The tag starts `.github/workflows/release.yml`, which checks that `extra.branch-alias` is on the +tag's line, refuses a tag while `[Unreleased]` still has entries, publishes the GitHub Release with +the version's `CHANGELOG.md` section as its body (never a bare stub) and attaches a CycloneDX SBOM. +To publish or refresh the release for an existing tag, run the workflow by hand with that tag. + +Never move a published tag: consumers who already resolved it would receive different code under +the same name. Cut the next patch instead. + The repo is GitHub-only (not on Packagist); consumers resolve tags via a `vcs` repository entry, so the tag IS the release channel. ---