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
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ jobs:
- run: make test-ts
- run: make build-ts

release-scripts:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: make test-release

integration:
runs-on: ubuntu-latest
needs: [go]
Expand Down
15 changes: 9 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,16 @@ jobs:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Bump patch version
# Rules: docs/release-versioning.md
- name: Bump version from commit types
id: bump
run: |
LATEST=$(git tag --list 'v*' --sort=-v:refname | head -1 || echo "v0.0.0")
MAJOR=$(echo "$LATEST" | sed 's/^v//' | cut -d. -f1)
MINOR=$(echo "$LATEST" | sed 's/^v//' | cut -d. -f2)
PATCH=$(echo "$LATEST" | sed 's/^v//' | cut -d. -f3)
echo "tag=v${MAJOR}.${MINOR}.$((PATCH + 1))" >> "$GITHUB_OUTPUT"
set -euo pipefail
LATEST=$(git tag --list --merged HEAD --sort=-v:refname | grep -E -m1 '^v[0-9]+\.[0-9]+\.[0-9]+$' || true)
RANGE=${LATEST:+$LATEST..}HEAD
echo "Latest tag: ${LATEST:-none}; range: $RANGE"
git log -z --format=%B "$RANGE" | scripts/release-version.sh "$LATEST" | tee -a "$GITHUB_OUTPUT"
git log -z --format=%B "$RANGE" | scripts/release-notes.sh > "$RUNNER_TEMP/release-notes.md"
- name: Create tag
run: |
git config user.name "github-actions[bot]"
Expand All @@ -64,6 +66,7 @@ jobs:
run: |
gh release create "${{ steps.bump.outputs.tag }}" \
--title "${{ steps.bump.outputs.tag }}" \
--notes-file "$RUNNER_TEMP/release-notes.md" \
--generate-notes \
--draft \
--prerelease
Expand Down
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,10 @@ substitute.
its issue closed by hand.
4. **Commit with Conventional Commits** (`feat:`, `fix:`, `docs:`, `spec:`,
`chore:`), optionally scoped — `fix(release):`. The release pipeline derives
version bumps from these, so the type is not cosmetic. Mark breaking changes
with `!` (`feat!:`) or a `BREAKING CHANGE:` footer.
version bumps from the commits since the last tag (see
[docs/release-versioning.md](docs/release-versioning.md)), so the type is not
cosmetic. Mark breaking changes with `!` (`feat!:`) or a `BREAKING CHANGE:`
footer; a `Release-As: vX.Y.Z` footer sets the version exactly.
5. **Open the PR with a closing keyword** so the issue auto-closes on merge:
`Closes #123` in the body. Fill in `.github/PULL_REQUEST_TEMPLATE.md` honestly —
only tick test boxes for suites actually run, and paste the evidence.
Expand Down
7 changes: 6 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ LISTENER_SPEC ?= spec/listener.qnt
ROUTER_SPEC := spec/router.qnt
BACKEND ?=

.PHONY: build clean test lint verify typecheck test-spec validate ci-verify release-verify
.PHONY: build clean test lint verify typecheck test-spec validate ci-verify release-verify test-release
.PHONY: build-go test-go lint-go build-rs test-rs build-ts test-ts

# ─── Go ──────────────────────────────────────────────
Expand Down Expand Up @@ -142,6 +142,11 @@ test-integration-sock-ts:

validate: typecheck verify lint-go test-go

# ─── Release scripts ─────────────────────────────────

test-release:
bash scripts/release-version_test.sh

# ─── Reproducible build verification ─────────────────

.PHONY: verify-reproducible-go verify-reproducible-rs verify-reproducible-ts verify-reproducible-all
Expand Down
97 changes: 97 additions & 0 deletions docs/release-versioning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Release Versioning

Every push to `main` cuts a release. The release workflow (`.github/workflows/release.yml`, `version` job) derives the next version from the commit messages since the latest release tag. This document defines the rule.

## The Rule

The workflow scans every commit in `<latest tag>..HEAD`. It finds the bump for each commit, and the highest bump wins.

| Row | The commits in the range include | Below 1.0.0 | 1.0.0 and above |
|---|---|---|---|
| `release-as` | a `Release-As: vX.Y.Z` line (the newest one decides) | exactly that version | exactly that version |
| `breaking` | a subject `type!:` / `type(scope)!:`, or a line starting `BREAKING CHANGE:` / `BREAKING-CHANGE:` | minor | major |
| `feature` | a subject `feat:` / `feat(scope):` | minor | minor |
| `other` | anything else (`fix`, `docs`, `chore`, `test`, `spec`, `ci`, non-conventional) | patch | patch |
| `no-tag` | there is no tag matching `vX.Y.Z` | start from `v0.0.0`, then apply the rows above | — |

- A minor bump resets the patch number. A major bump resets the minor and patch numbers.
- The latest tag is the highest version among tags that match `vX.Y.Z` exactly and are reachable from the commit being released (`git tag --merged HEAD`). Other tags are ignored. Versions are compared as numbers, so `v0.10.0` is greater than `v0.9.0`.
- If the range is empty (HEAD is already tagged), the release is still a patch.

## What Counts

| Signal | Where it must be | Example |
|---|---|---|
| Commit type (`feat`, `fix!`, …) | subject line (first line) only | `feat(ts): add flag` |
| `BREAKING CHANGE:` / `BREAKING-CHANGE:` | start of any line in the message | footer in the squash body |
| `Release-As:` | start of any line in the message | footer in the squash body |

Types are matched case-insensitively (`Feat!:` counts as breaking), as Conventional Commits allows. The `BREAKING CHANGE:` / `BREAKING-CHANGE:` footer token is case-sensitive, as Conventional Commits requires.

Only the subject decides the commit type because GitHub's default squash body lists the branch commits as `* …` bullets. A `* feat: x` bullet under a `fix:` subject is therefore history, not a feature, and it does not count. A breaking-change footer is a deliberate statement, so it counts wherever it starts a line.

## Why the Whole Range

One release can cover several merges. v0.2.25 covers #56 and #58, because the release run for `a654ff8` (#56) never reached the version job. If the workflow looked only at the last commit, a `feat` or breaking change in an earlier merge would be lost.

## `Release-As`

A `Release-As` footer sets the version exactly and overrides the computed bump.

| Item | Rule |
|---|---|
| Syntax | `Release-As: vX.Y.Z`. The key is case-insensitive, the `v` is optional (`0.3.0` and `v0.3.0` are the same value), and whitespace around the value is ignored. |
| Several in the range | only the newest one decides; older ones are ignored. Within one message, the last `Release-As` line decides. |
| Valid value | three numeric parts, strictly greater than the latest tag |
| Newest value malformed (for example, `Release-As: 0.3`) | the `version` job's bump step fails |
| Newest value not greater than the latest tag | the `version` job's bump step fails |
| Scope | applies only to the release whose range contains it; the next release computes normally |

The bump step runs before the tag is created, so when it fails no tag or release exists. To recover, merge a commit whose message carries a corrected `Release-As` footer: it becomes the newest one and decides. (Once the bump step has passed, a later failure, for example in `gh release create` or an artifact job, leaves the tag and draft behind; that is unchanged by this rule.)

## Release Notes

If the range contains breaking commits, the workflow generates a **⚠️ Breaking changes** section. A commit is breaking for the notes by exactly the `breaking` row's rule (a `!` subject or a `BREAKING CHANGE:` / `BREAKING-CHANGE:` line), so a `* feat!: …` bullet in a squash body does not add an entry. A footer's text runs from `BREAKING CHANGE:` to the next blank line, the next footer-token line (any `Word: …` line, for example `Release-As:` or `Note:`), or the end of the message; continuation lines are kept and indented under the entry. Put migration notes directly under the footer, before any other token line. The section lists the subject of each breaking commit and its `BREAKING CHANGE:` text. It is passed to `gh release create --notes-file … --generate-notes`, which prepends it to GitHub's generated notes. If there are no breaking commits, the notes are GitHub's generated notes only. You no longer have to add the breaking-change section by hand; impact or migration detail beyond the footer text still needs a human.

## Guidance for Whoever Squash-Merges

| Part | What goes there |
|---|---|
| Squash title | the Conventional Commit subject, which sets the type: `fix(release): … (#54)`, `feat!: … (#47)` |
| Squash body | footers: `BREAKING CHANGE: <what breaks and how to migrate>`, `Release-As: vX.Y.Z` |

Check the pre-filled title and body before you merge: a wrong type gives a wrong version.

## Worked Examples

| Change | Shipped as | With this rule |
|---|---|---|
| #47 `feat!: dockerd-parity listening socket` | v0.2.22 (patch) | v0.3.0 (`breaking`, below 1.0) |
| #53 `fix!: deny percent-encoded request paths` | v0.2.26 (patch, notes written by hand) | v0.3.0 (`breaking`), with a generated breaking-changes section |
| #56 + #58 (both `fix`) | v0.2.25 | v0.2.25 (`other`) |
| #54 (this change), squash body `Release-As: v0.3.0` | — | v0.3.0 (`release-as`). It corrects the version for the breaking changes in #47 and #53. |

Other examples: `fix!: x` on v1.4.2 gives v2.0.0. `feat: x` on v1.4.2 gives v1.5.0. `fix: x` with no tag gives v0.0.1.

## What Is Unchanged

- Every push to `main` still releases.
- Each release is still created as a draft prerelease and published automatically by the `publish-release` job once every artifact has uploaded.
- Pre-release tags, CHANGELOG files, and the release concurrency setting are out of scope.

## Rejected Alternatives

| Alternative | Why it was rejected |
|---|---|
| release-please / semantic-release | Heavier. They take over changelogs and release PRs, which this repo does not use. |
| Correct only AGENTS.md (say that every release is a patch) | Breaking changes would still ship as patches with hand-written notes, as #47 and #53 did. |
| `feat` gives a patch below 1.0 | Then a feature and a fix would look the same. Below 1.0, minor is the only signal left for "new behaviour". |

## Implementation and Tests

| File | Purpose |
|---|---|
| `scripts/release-version.sh` | Reads NUL-separated commit messages on stdin. Takes the latest tag (or empty) as its argument. Prints `bump=…` and `tag=vX.Y.Z`. |
| `scripts/release-notes.sh` | Reads the same input and prints the breaking-changes section, or nothing. |
| `scripts/release-version_test.sh` | One test case per row of the rule table, plus edge cases. |
| `make test-release` | Runs the tests. The tests also run in CI (`release-scripts` job). |
1 change: 1 addition & 0 deletions docs/repo-standard.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ Build verification steps must be documented in the project README or a dedicated
## Versioning

- SemVer (`vMAJOR.MINOR.PATCH`)
- In this repo, the bump is derived from Conventional Commit types since the last tag; see [release-versioning.md](release-versioning.md)
- CHANGELOG per Keep a Changelog
- Pre-release tags (e.g. `v1.0.0-rc.1`) publish with `--prerelease`

Expand Down
53 changes: 53 additions & 0 deletions scripts/release-notes.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Prints a breaking-changes section for the release notes, or nothing if there are none.
# Usage: git log -z --format=%B <tag>..HEAD | release-notes.sh
# A commit is breaking by the `breaking` row of docs/release-versioning.md.
set -euo pipefail

BREAKING_SUBJECT='^[A-Za-z]+(\([^)]*\))?!:'
FOOTER='^BREAKING[ -]CHANGE:'
TOKEN='^[A-Za-z-]+: '

# footers MSG — prints each BREAKING CHANGE footer as a " - " bullet. A footer runs until
# a blank line, the next footer token line, or the end of the message.
footers() {
local line text in_footer=0 started=0
while IFS= read -r line; do
line=${line%$'\r'}
if [[ $line =~ $FOOTER ]]; then
in_footer=1
started=0
text=${line#BREAKING?CHANGE:}
elif [ "$in_footer" -eq 1 ] && [ -n "$line" ] && ! [[ $line =~ $TOKEN ]]; then
text=$line
else
in_footer=0
continue
fi
text=${text#"${text%%[![:space:]]*}"}
[ -n "$text" ] || continue
if [ "$started" -eq 0 ]; then
echo " - $text"
started=1
else
echo " $text"
fi
done <<<"$1"
}

entries=""
while IFS= read -r -d '' msg || [ -n "$msg" ]; do
subject=${msg%%$'\n'*}
subject=${subject%$'\r'}
if [[ $subject =~ $BREAKING_SUBJECT ]] || grep -Eq "$FOOTER" <<<"$msg"; then
entries+="- $subject"$'\n'
body=$(footers "$msg")
if [ -n "$body" ]; then
entries+="$body"$'\n'
fi
fi
done

if [ -n "$entries" ]; then
printf '## ⚠️ Breaking changes\n\n%s' "$entries"
fi
68 changes: 68 additions & 0 deletions scripts/release-version.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# Derives the next release version from the commit messages since the latest tag.
# Usage: git log -z --format=%B <tag>..HEAD | release-version.sh <tag-or-empty>
# Prints bump=<major|minor|patch|release-as> and tag=vX.Y.Z. Rules: docs/release-versioning.md.
set -euo pipefail

SEMVER='^v?([0-9]+)\.([0-9]+)\.([0-9]+)$'
BREAKING_SUBJECT='^[A-Za-z]+(\([^)]*\))?!:'
FEATURE_SUBJECT='^[Ff][Ee][Aa][Tt](\([^)]*\))?:'

die() {
echo "release-version: $*" >&2
exit 1
}

latest=${1:-v0.0.0}
[[ $latest =~ $SEMVER ]] || die "latest tag '$latest' is not vX.Y.Z"
major=$((10#${BASH_REMATCH[1]}))
minor=$((10#${BASH_REMATCH[2]}))
patch=$((10#${BASH_REMATCH[3]}))

# 0 = patch, 1 = minor, 2 = major (breaking)
level=0
release_as=""
release_as_found=0

while IFS= read -r -d '' msg || [ -n "$msg" ]; do
subject=${msg%%$'\n'*}
if [[ $subject =~ $BREAKING_SUBJECT ]] || grep -Eq '^BREAKING[ -]CHANGE:' <<<"$msg"; then
level=2
elif [[ $subject =~ $FEATURE_SUBJECT ]] && [ "$level" -lt 1 ]; then
level=1
fi
# Input is newest first, so the first message with a Release-As line decides.
if [ "$release_as_found" -eq 0 ]; then
line=$(grep -i '^release-as:' <<<"$msg" | tail -n 1 || true)
if [ -n "$line" ]; then
release_as_found=1
release_as=$(sed -e 's/^[^:]*://' -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' <<<"$line")
fi
fi
done

if [ "$release_as_found" -eq 1 ]; then
[[ $release_as =~ $SEMVER ]] || die "Release-As value '$release_as' is not vX.Y.Z"
ra_major=$((10#${BASH_REMATCH[1]}))
ra_minor=$((10#${BASH_REMATCH[2]}))
ra_patch=$((10#${BASH_REMATCH[3]}))
if [ "$ra_major" -gt "$major" ] ||
{ [ "$ra_major" -eq "$major" ] && [ "$ra_minor" -gt "$minor" ]; } ||
{ [ "$ra_major" -eq "$major" ] && [ "$ra_minor" -eq "$minor" ] && [ "$ra_patch" -gt "$patch" ]; }; then
echo "bump=release-as"
echo "tag=v$ra_major.$ra_minor.$ra_patch"
exit 0
fi
die "Release-As v$ra_major.$ra_minor.$ra_patch is not greater than the latest tag v$major.$minor.$patch"
fi

if [ "$level" -eq 2 ] && [ "$major" -ge 1 ]; then
echo "bump=major"
echo "tag=v$((major + 1)).0.0"
elif [ "$level" -ge 1 ]; then
echo "bump=minor"
echo "tag=v$major.$((minor + 1)).0"
else
echo "bump=patch"
echo "tag=v$major.$minor.$((patch + 1))"
fi
Loading
Loading