Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 17 additions & 5 deletions docs/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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] - <date>`.
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.

---
Expand Down
Loading