ci: split prepare and publish into separate release pipelines
All checks were successful
Push Validation / validate (push) Successful in 54s

- Remove publish steps (release creation, binary build/upload) from the
  Prepare Release workflow; it now stops after committing and pushing the
  tag.
- Add Do Release workflow triggered on v*.*.* tag pushes; reads release
  notes from the tagged changelog section, creates or updates the release,
  builds linux/amd64 and linux/arm64 binaries, uploads assets, then
  smoke-tests both binaries in a follow-on validate job.
- Remove the standalone Action Validation workflow; binary validation now
  runs as a second job in Do Release after the release job succeeds, using
  the exact tag and version just published.
- Update README to document the two-workflow release model and add split
  prepare/publish usage examples for both the composite action and the
  reusable workflows.
- Update changelog unreleased section to reflect the new pipeline split
  and corrected artifact scope (linux/amd64 and linux/arm64 only).
This commit is contained in:
Micheal Wilkinson
2026-03-20 19:55:03 +00:00
parent 26b197299f
commit c859a3fccb
5 changed files with 413 additions and 243 deletions

View File

@@ -1,102 +0,0 @@
name: Action Validation
on:
push:
branches:
- "**"
tags-ignore:
- "*"
workflow_dispatch:
jobs:
validate-released-binary:
runs-on: ubuntu-latest
container: docker.io/catthehacker/ubuntu:act-latest
strategy:
fail-fast: false
matrix:
include:
- runner_arch: X64
asset_arch: amd64
run_command: ./vociferate
- runner_arch: ARM64
asset_arch: arm64
run_command: qemu-aarch64-static ./vociferate
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install arm64 emulator
if: ${{ matrix.runner_arch == 'ARM64' }}
run: |
set -euo pipefail
apt-get update
apt-get install -y qemu-user-static
- name: Resolve latest released binary
id: resolve-binary
env:
API_URL: ${{ github.api_url }}
SERVER_URL: ${{ github.server_url }}
TOKEN: ${{ github.token }}
ASSET_ARCH: ${{ matrix.asset_arch }}
run: |
set -euo pipefail
release_tag="$(curl -fsSL \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${API_URL}/repos/aether/vociferate/releases/latest" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1)"
if [[ -z "$release_tag" ]]; then
echo "Unable to resolve latest vociferate release" >&2
exit 1
fi
normalized_version="${release_tag#v}"
asset_name="vociferate_${normalized_version}_linux_${ASSET_ARCH}"
asset_url="${SERVER_URL}/aether/vociferate/releases/download/${release_tag}/${asset_name}"
echo "release_tag=$release_tag" >> "$GITHUB_OUTPUT"
echo "asset_name=$asset_name" >> "$GITHUB_OUTPUT"
echo "asset_url=$asset_url" >> "$GITHUB_OUTPUT"
- name: Download released binary
env:
TOKEN: ${{ github.token }}
ASSET_URL: ${{ steps.resolve-binary.outputs.asset_url }}
run: |
set -euo pipefail
curl --fail --location \
-H "Authorization: token ${TOKEN}" \
-o vociferate \
"$ASSET_URL"
chmod +x vociferate
- name: Smoke test released binary
env:
RUN_COMMAND: ${{ matrix.run_command }}
run: |
set -euo pipefail
recommended_tag="$($RUN_COMMAND --recommend --root .)"
case "$recommended_tag" in
v*.*.*)
;;
*)
echo "Unexpected recommended tag: $recommended_tag" >&2
exit 1
;;
esac
{
echo "## Released Binary Validation"
echo
echo "- Release tag: ${{ steps.resolve-binary.outputs.release_tag }}"
echo "- Asset: ${{ steps.resolve-binary.outputs.asset_name }}"
echo "- Recommended tag: ${recommended_tag}"
} >> "$GITHUB_STEP_SUMMARY"

View File

@@ -0,0 +1,301 @@
name: Do Release
on:
push:
tags:
- 'v*.*.*'
workflow_dispatch:
inputs:
tag:
description: Semantic version tag to publish, with or without leading v. Defaults to the current tag ref when dispatched from a tag.
required: false
workflow_call:
inputs:
tag:
description: Semantic version tag to publish, with or without leading v. When omitted, the current tag ref is used.
required: false
default: ''
type: string
jobs:
release:
runs-on: ubuntu-latest
container: docker.io/catthehacker/ubuntu:act-latest
outputs:
tag: ${{ steps.resolve-tag.outputs.tag }}
version: ${{ steps.resolve-tag.outputs.version }}
defaults:
run:
shell: bash
env:
RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout tagged revision
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.ref }}
- name: Resolve release tag
id: resolve-tag
env:
INPUT_TAG: ${{ inputs.tag }}
GITHUB_REF_VALUE: ${{ github.ref }}
run: |
set -euo pipefail
provided_tag="$(printf '%s' "${INPUT_TAG:-}" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
if [[ -n "$provided_tag" ]]; then
normalized_version="${provided_tag#v}"
tag="v${normalized_version}"
elif [[ "${GITHUB_REF_VALUE}" == refs/tags/* ]]; then
tag="${GITHUB_REF_VALUE#refs/tags/}"
normalized_version="${tag#v}"
else
echo "A tag input is required when the workflow is not running from a tag push" >&2
exit 1
fi
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "version=$normalized_version" >> "$GITHUB_OUTPUT"
- name: Checkout requested tag
if: ${{ inputs.tag != '' }}
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: refs/tags/${{ steps.resolve-tag.outputs.tag }}
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.26.1'
check-latest: true
cache: true
cache-dependency-path: go.sum
- name: Extract release notes from changelog
id: release-notes
env:
RELEASE_VERSION: ${{ steps.resolve-tag.outputs.version }}
run: |
set -euo pipefail
release_notes="$(awk -v version="$RELEASE_VERSION" '
$0 ~ "^## \\[" version "\\] - " {capture=1}
capture {
if ($0 ~ "^## \\[" && $0 !~ "^## \\[" version "\\] - ") exit
print
}
' changelog.md)"
if [[ -z "${release_notes//[[:space:]]/}" ]]; then
echo "Release notes section for ${RELEASE_VERSION} was not found in changelog.md" >&2
exit 1
fi
notes_file="$RUNNER_TEMP/release-notes.md"
printf '%s\n' "$release_notes" > "$notes_file"
echo "notes_file=$notes_file" >> "$GITHUB_OUTPUT"
- name: Create or update release
id: release
env:
TAG_NAME: ${{ steps.resolve-tag.outputs.tag }}
RELEASE_NOTES_FILE: ${{ steps.release-notes.outputs.notes_file }}
run: |
set -euo pipefail
release_notes="$(cat "$RELEASE_NOTES_FILE")"
escaped_release_notes="$(printf '%s' "$release_notes" | sed 's/\\/\\\\/g; s/"/\\"/g; :a;N;$!ba;s/\n/\\n/g')"
release_api="${GITHUB_API_URL:-${GITHUB_SERVER_URL%/}/api/v1}/repos/${GITHUB_REPOSITORY}/releases"
release_by_tag_api="${release_api}/tags/${TAG_NAME}"
status_code="$(curl -sS -o release-existing.json -w '%{http_code}' \
-H "Authorization: token ${RELEASE_TOKEN}" \
-H "Content-Type: application/json" \
"${release_by_tag_api}")"
if [[ "$status_code" == "200" ]]; then
existing_release_id="$(sed -n 's/.*"id"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' release-existing.json | head -n 1)"
if [[ -z "$existing_release_id" ]]; then
echo "Failed to parse existing release id for ${TAG_NAME}" >&2
cat release-existing.json >&2
exit 1
fi
curl --fail-with-body \
-X PATCH \
-H "Authorization: token ${RELEASE_TOKEN}" \
-H "Content-Type: application/json" \
"${release_api}/${existing_release_id}" \
--data "{\"tag_name\":\"${TAG_NAME}\",\"target\":\"${GITHUB_SHA}\",\"name\":\"${TAG_NAME}\",\"body\":\"${escaped_release_notes}\",\"draft\":false,\"prerelease\":false}" \
--output release.json
elif [[ "$status_code" != "404" ]]; then
echo "Unexpected response while checking release ${TAG_NAME}: HTTP ${status_code}" >&2
cat release-existing.json >&2
exit 1
else
curl --fail-with-body \
-X POST \
-H "Authorization: token ${RELEASE_TOKEN}" \
-H "Content-Type: application/json" \
"${release_api}" \
--data "{\"tag_name\":\"${TAG_NAME}\",\"target\":\"${GITHUB_SHA}\",\"name\":\"${TAG_NAME}\",\"body\":\"${escaped_release_notes}\",\"draft\":false,\"prerelease\":false}" \
--output release.json
fi
release_id="$(sed -n 's/.*"id"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' release.json | head -n 1)"
if [[ -z "$release_id" ]]; then
echo "Failed to parse release id from API response" >&2
cat release.json >&2
exit 1
fi
echo "id=$release_id" >> "$GITHUB_OUTPUT"
- name: Build release binaries
env:
RELEASE_VERSION: ${{ steps.resolve-tag.outputs.version }}
run: |
set -euo pipefail
mkdir -p dist
for target in linux/amd64 linux/arm64; do
os="${target%/*}"
arch="${target#*/}"
bin="vociferate_${RELEASE_VERSION}_${os}_${arch}"
GOOS="$os" GOARCH="$arch" go build -trimpath -ldflags="-s -w" -o "dist/${bin}" ./cmd/vociferate
done
(
cd dist
shasum -a 256 * > checksums.txt
)
- name: Upload release binaries
env:
RELEASE_ID: ${{ steps.release.outputs.id }}
run: |
set -euo pipefail
release_api="${GITHUB_API_URL:-${GITHUB_SERVER_URL%/}/api/v1}/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets"
for asset in dist/*; do
name="$(basename "$asset")"
assets_json="$(curl -sS --fail-with-body \
-H "Authorization: token ${RELEASE_TOKEN}" \
-H "Content-Type: application/json" \
"${release_api}")"
escaped_name="$(printf '%s' "$name" | sed 's/[][(){}.^$*+?|\\/]/\\&/g')"
existing_asset_id="$(printf '%s' "$assets_json" | tr -d '\n' | sed -n "s/.*{\"id\":\([0-9][0-9]*\)[^}]*\"name\":\"${escaped_name}\".*/\1/p")"
if [[ -n "$existing_asset_id" ]]; then
curl --fail-with-body \
-X DELETE \
-H "Authorization: token ${RELEASE_TOKEN}" \
-H "Content-Type: application/json" \
"${release_api}/${existing_asset_id}"
fi
curl --fail-with-body \
-X POST \
-H "Authorization: token ${RELEASE_TOKEN}" \
-H "Content-Type: application/octet-stream" \
"${release_api}?name=${name}" \
--data-binary "@${asset}"
done
- name: Summarize published release
env:
TAG_NAME: ${{ steps.resolve-tag.outputs.tag }}
RELEASE_VERSION: ${{ steps.resolve-tag.outputs.version }}
run: |
set -euo pipefail
{
echo "## Release Published"
echo
echo "- Tag: ${TAG_NAME}"
echo "- Release notes sourced from changelog entry ${RELEASE_VERSION}."
echo "- Published assets: vociferate_${RELEASE_VERSION}_linux_amd64, vociferate_${RELEASE_VERSION}_linux_arm64, checksums.txt"
} >> "$GITHUB_STEP_SUMMARY"
validate:
runs-on: ubuntu-latest
container: docker.io/catthehacker/ubuntu:act-latest
needs: release
strategy:
fail-fast: false
matrix:
include:
- asset_arch: amd64
run_command: ./vociferate
- asset_arch: arm64
run_command: qemu-aarch64-static ./vociferate
defaults:
run:
shell: bash
steps:
- name: Checkout tagged revision
uses: actions/checkout@v4
with:
ref: refs/tags/${{ needs.release.outputs.tag }}
- name: Install arm64 emulator
if: ${{ matrix.asset_arch == 'arm64' }}
run: |
set -euo pipefail
apt-get update
apt-get install -y qemu-user-static
- name: Download released binary
env:
TOKEN: ${{ github.token }}
TAG_NAME: ${{ needs.release.outputs.tag }}
RELEASE_VERSION: ${{ needs.release.outputs.version }}
ASSET_ARCH: ${{ matrix.asset_arch }}
SERVER_URL: ${{ github.server_url }}
run: |
set -euo pipefail
asset_name="vociferate_${RELEASE_VERSION}_linux_${ASSET_ARCH}"
asset_url="${SERVER_URL}/aether/vociferate/releases/download/${TAG_NAME}/${asset_name}"
curl --fail --location \
-H "Authorization: token ${TOKEN}" \
-o vociferate \
"$asset_url"
chmod +x vociferate
echo "asset_name=${asset_name}" >> "$GITHUB_ENV"
- name: Smoke test released binary
env:
RUN_COMMAND: ${{ matrix.run_command }}
TAG_NAME: ${{ needs.release.outputs.tag }}
run: |
set -euo pipefail
recommended_tag="$(${RUN_COMMAND} --recommend --root .)"
case "$recommended_tag" in
v*.*.*)
;;
*)
echo "Unexpected recommended tag: $recommended_tag" >&2
exit 1
;;
esac
{
echo "## Released Binary Validation (${{ matrix.asset_arch }})"
echo
echo "- Release tag: ${TAG_NAME}"
echo "- Asset: ${asset_name}"
echo "- Recommended tag: ${recommended_tag}"
} >> "$GITHUB_STEP_SUMMARY"

View File

@@ -111,127 +111,17 @@ jobs:
git push origin HEAD git push origin HEAD
git push origin "$tag" git push origin "$tag"
- name: Create release with changelog notes - name: Summarize prepared release
run: | run: |
set -euo pipefail set -euo pipefail
normalized_version="${RELEASE_VERSION#v}" normalized_version="${RELEASE_VERSION#v}"
tag="v${normalized_version}" tag="v${normalized_version}"
release_notes="$(awk -v version="$normalized_version" ' {
$0 ~ "^## \\\\[" version "\\\\] - " {capture=1} echo "## Release Prepared"
capture { echo
if ($0 ~ "^## \\\\[" && $0 !~ "^## \\\\[" version "\\\\] - ") exit echo "- Updated files were committed to main."
print echo "- Tag pushed: ${tag}"
} echo "- The tag-triggered Do Release workflow will create or update the release and publish binaries."
' changelog.md)" } >> "$GITHUB_STEP_SUMMARY"
if [[ -z "${release_notes//[[:space:]]/}" ]]; then
echo "Release notes section for ${normalized_version} was not found in changelog.md" >&2
exit 1
fi
escaped_release_notes="$(printf '%s' "$release_notes" | sed 's/\\/\\\\/g; s/"/\\"/g; :a;N;$!ba;s/\n/\\n/g')"
release_api="${GITHUB_API_URL:-${GITHUB_SERVER_URL%/}/api/v1}/repos/${GITHUB_REPOSITORY}/releases"
release_by_tag_api="${release_api}/tags/${tag}"
status_code="$(curl -sS -o release-existing.json -w '%{http_code}' \
-H "Authorization: token ${RELEASE_TOKEN}" \
-H "Content-Type: application/json" \
"${release_by_tag_api}")"
if [[ "$status_code" == "200" ]]; then
existing_release_id="$(sed -n 's/.*"id"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' release-existing.json | head -n 1)"
if [[ -z "$existing_release_id" ]]; then
echo "Failed to parse existing release id for ${tag}" >&2
cat release-existing.json >&2
exit 1
fi
curl --fail-with-body \
-X PATCH \
-H "Authorization: token ${RELEASE_TOKEN}" \
-H "Content-Type: application/json" \
"${release_api}/${existing_release_id}" \
--data "{\"tag_name\":\"${tag}\",\"target\":\"${GITHUB_SHA}\",\"name\":\"${tag}\",\"body\":\"${escaped_release_notes}\",\"draft\":false,\"prerelease\":false}" \
--output release.json
elif [[ "$status_code" != "404" ]]; then
echo "Unexpected response while checking release ${tag}: HTTP ${status_code}" >&2
cat release-existing.json >&2
exit 1
else
curl --fail-with-body \
-X POST \
-H "Authorization: token ${RELEASE_TOKEN}" \
-H "Content-Type: application/json" \
"${release_api}" \
--data "{\"tag_name\":\"${tag}\",\"target\":\"${GITHUB_SHA}\",\"name\":\"${tag}\",\"body\":\"${escaped_release_notes}\",\"draft\":false,\"prerelease\":false}" \
--output release.json
fi
release_id="$(sed -n 's/.*"id"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' release.json | head -n 1)"
if [[ -z "$release_id" ]]; then
echo "Failed to parse release id from API response" >&2
cat release.json >&2
exit 1
fi
echo "RELEASE_ID=$release_id" >> "$GITHUB_ENV"
- name: Build release binaries
run: |
set -euo pipefail
normalized_version="${RELEASE_VERSION#v}"
mkdir -p dist
for target in linux/amd64 linux/arm64; do
os="${target%/*}"
arch="${target#*/}"
bin="vociferate_${normalized_version}_${os}_${arch}"
GOOS="$os" GOARCH="$arch" go build -trimpath -ldflags="-s -w" -o "dist/${bin}" ./cmd/vociferate
done
(
cd dist
shasum -a 256 * > checksums.txt
)
- name: Upload release binaries
run: |
set -euo pipefail
if [[ -z "${RELEASE_ID:-}" ]]; then
echo "RELEASE_ID is not available for asset upload" >&2
exit 1
fi
release_api="${GITHUB_API_URL:-${GITHUB_SERVER_URL%/}/api/v1}/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets"
for asset in dist/*; do
name="$(basename "$asset")"
assets_json="$(curl -sS --fail-with-body \
-H "Authorization: token ${RELEASE_TOKEN}" \
-H "Content-Type: application/json" \
"${release_api}")"
escaped_name="$(printf '%s' "$name" | sed 's/[][(){}.^$*+?|\\/]/\\&/g')"
existing_asset_id="$(printf '%s' "$assets_json" | tr -d '\n' | sed -n "s/.*{\"id\":\([0-9][0-9]*\)[^}]*\"name\":\"${escaped_name}\".*/\1/p")"
if [[ -n "$existing_asset_id" ]]; then
curl --fail-with-body \
-X DELETE \
-H "Authorization: token ${RELEASE_TOKEN}" \
-H "Content-Type: application/json" \
"${release_api}/${existing_asset_id}"
fi
curl --fail-with-body \
-X POST \
-H "Authorization: token ${RELEASE_TOKEN}" \
-H "Content-Type: application/octet-stream" \
"${release_api}?name=${name}" \
--data-binary "@${asset}"
done

118
README.md
View File

@@ -56,9 +56,18 @@ By default, `vociferate` reads and writes the release version as the sole conten
just go-test just go-test
``` ```
## Release Flow
Releases use two workflows:
- `Prepare Release` runs on demand, updates `release-version` and `changelog.md`, commits those changes back to `main`, and pushes the release tag.
- `Do Release` runs from the pushed tag, reads the matching changelog section from that tagged revision, creates or updates the release, and uploads prebuilt binaries.
This split matters because release notes must be generated from the tagged commit that already contains the promoted changelog section.
## Release Artifacts ## Release Artifacts
Releases are prepared through the `Prepare Release` workflow. The workflow creates a release and uploads prebuilt `vociferate` binaries for: The tag-driven `Do Release` workflow publishes prebuilt `vociferate` binaries for:
- `linux/amd64` - `linux/amd64`
- `linux/arm64` - `linux/arm64`
@@ -70,37 +79,108 @@ If a release already exists for the same tag, the workflow updates its release n
You can reuse vociferate in two ways. You can reuse vociferate in two ways.
Use the composite action directly: Use the composite action directly in your prepare workflow:
```yaml ```yaml
- name: Prepare release files - name: Prepare release files
uses: git.hrafn.xyz/aether/vociferate@v1.0.0 uses: git.hrafn.xyz/aether/vociferate@v1.0.0
with: with:
version-file: internal/myapp/version/version.go version-file: internal/myapp/version/version.go
version-pattern: 'const Version = "([^"]+)"' version-pattern: 'const Version = "([^"]+)"'
changelog: changelog.md changelog: changelog.md
``` ```
Set `version` only when you want to override the recommended version. Set `version` only when you want to override the recommended version.
Pin the composite action to a released tag. It downloads a prebuilt Linux binary from vociferate releases and caches it on the runner, so it does not require installing Go. Pin the composite action to a released tag. It downloads a prebuilt Linux binary from vociferate releases and caches it on the runner, so it does not require installing Go.
If your repository uses the default plain-text `release-version` file, you can omit `version-file` and `version-pattern` entirely. If your repository uses the default plain-text `release-version` file, you can omit `version-file` and `version-pattern` entirely.
Call the reusable release workflow: A complete release setup should also split preparation from publication. For example:
```yaml ```yaml
name: Release name: Prepare Release
on: on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version: version:
description: Optional semantic version override. description: Optional semantic version override.
required: false required: false
jobs: jobs:
release: prepare:
uses: aether/vociferate/.gitea/workflows/prepare-release.yml@main runs-on: ubuntu-latest
with: steps:
version: ${{ inputs.version }} - uses: actions/checkout@v4
secrets: inherit with:
fetch-depth: 0
- name: Prepare release files
id: prepare
uses: git.hrafn.xyz/aether/vociferate@v1.0.0
with:
version: ${{ inputs.version }}
- name: Commit and push prepared release
run: |
set -euo pipefail
tag="${{ steps.prepare.outputs.version }}"
git config user.name "gitea-actions[bot]"
git config user.email "gitea-actions[bot]@users.noreply.local"
git add changelog.md release-version
git commit -m "release: prepare ${tag}"
git tag "$tag"
git push origin HEAD
git push origin "$tag"
```
Then use a separate tag workflow to publish the release from the tagged revision:
```yaml
name: Do Release
on:
push:
tags:
- "v*.*.*"
jobs:
release:
uses: aether/vociferate/.gitea/workflows/do-release.yml@main
secrets: inherit
```
Call the reusable prepare workflow:
```yaml
name: Prepare Release
on:
workflow_dispatch:
inputs:
version:
description: Optional semantic version override.
required: false
jobs:
release:
uses: aether/vociferate/.gitea/workflows/prepare-release.yml@main
with:
version: ${{ inputs.version }}
secrets: inherit
```
And publish from tags with:
```yaml
name: Do Release
on:
push:
tags:
- "v*.*.*"
jobs:
release:
uses: aether/vociferate/.gitea/workflows/do-release.yml@main
secrets: inherit
``` ```

View File

@@ -11,20 +11,21 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect
### Breaking ### Breaking
### Changed ### Changed
- Release automation is now split into a prepare workflow that updates and tags `main`, and a tag-driven publish workflow that creates the release from the tagged revision.
- The CLI entrypoint, internal package paths, build outputs, and automation references now use the `vociferate` name instead of the earlier `releaseprep` naming. - The CLI entrypoint, internal package paths, build outputs, and automation references now use the `vociferate` name instead of the earlier `releaseprep` naming.
- Configurable version source and parser via `--version-file` and `--version-pattern`. - Configurable version source and parser via `--version-file` and `--version-pattern`.
- Configurable changelog path via `--changelog`. - Configurable changelog path via `--changelog`.
- The release workflow and composite action now treat a provided `version` as an override and otherwise fall back to the recommended next version automatically. - The release workflow and composite action now treat a provided `version` as an override and otherwise fall back to the recommended next version automatically.
- Release preparation now runs directly in the release workflow; the repository-local helper script and just recipe were removed. - Release preparation now runs directly in the prepare workflow; the repository-local helper script and just recipe were removed.
- Release creation is now idempotent: existing releases for the same tag are updated in place instead of recreated. - Release creation is now idempotent: existing releases for the same tag are updated in place instead of recreated.
- Release asset uploads now replace existing assets with matching filenames so reruns stay synchronized. - Release asset uploads now replace existing assets with matching filenames so reruns stay synchronized.
- Automated release artifact publishing in the release workflow for `darwin`, `linux`, and `windows` binaries plus `checksums.txt`. - Automated release artifact publishing in the tag-driven release workflow for `linux/amd64`, `linux/arm64`, and `checksums.txt`.
- Release recommendation now forces a major version bump whenever a `### Breaking` heading is present in `## [Unreleased]`, even if the section has no bullet entries yet. - Release recommendation now forces a major version bump whenever a `### Breaking` heading is present in `## [Unreleased]`, even if the section has no bullet entries yet.
- The composite action now downloads and caches released `vociferate` binaries on both `amd64` and `arm64` platforms instead of installing Go and running the module source directly. - The composite action now downloads and caches released `vociferate` binaries on both `amd64` and `arm64` platforms instead of installing Go and running the module source directly.
- Reusable `workflow_call` support for the `Prepare Release` workflow, enabling other repositories to invoke it directly. - Reusable `workflow_call` support for the `Prepare Release` workflow, enabling other repositories to invoke it directly.
- Reusable `workflow_call` support for the tag-driven `Do Release` workflow, enabling other repositories to publish from pushed tags without reimplementing release note or asset logic.
- Composite action (`action.yml`) for release preparation and recommendation flows. - Composite action (`action.yml`) for release preparation and recommendation flows.
- Gitea workflows for push validation and manual release preparation. - Gitea workflows for push validation, manual release preparation, and tag-driven release publishing.
- README guidance for release artifacts and examples for reusing vociferate as a composite action or reusable workflow. - README guidance for release artifacts and examples for reusing vociferate as a composite action or reusable workflow.