diff --git a/.gitea/workflows/action-validation.yml b/.gitea/workflows/action-validation.yml deleted file mode 100644 index 2c5f504..0000000 --- a/.gitea/workflows/action-validation.yml +++ /dev/null @@ -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" \ No newline at end of file diff --git a/.gitea/workflows/do-release.yml b/.gitea/workflows/do-release.yml new file mode 100644 index 0000000..1fd573a --- /dev/null +++ b/.gitea/workflows/do-release.yml @@ -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" diff --git a/.gitea/workflows/prepare-release.yml b/.gitea/workflows/prepare-release.yml index e4c8c1b..f9b239e 100644 --- a/.gitea/workflows/prepare-release.yml +++ b/.gitea/workflows/prepare-release.yml @@ -111,127 +111,17 @@ jobs: git push origin HEAD git push origin "$tag" - - name: Create release with changelog notes + - name: Summarize prepared release run: | set -euo pipefail normalized_version="${RELEASE_VERSION#v}" tag="v${normalized_version}" - release_notes="$(awk -v version="$normalized_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 ${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 + { + echo "## Release Prepared" + echo + echo "- Updated files were committed to main." + echo "- Tag pushed: ${tag}" + echo "- The tag-triggered Do Release workflow will create or update the release and publish binaries." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/README.md b/README.md index 99092eb..83b8098 100644 --- a/README.md +++ b/README.md @@ -56,9 +56,18 @@ By default, `vociferate` reads and writes the release version as the sole conten 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 -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/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. -Use the composite action directly: +Use the composite action directly in your prepare workflow: ```yaml - name: Prepare release files - uses: git.hrafn.xyz/aether/vociferate@v1.0.0 - with: - version-file: internal/myapp/version/version.go - version-pattern: 'const Version = "([^"]+)"' - changelog: changelog.md + uses: git.hrafn.xyz/aether/vociferate@v1.0.0 + with: + version-file: internal/myapp/version/version.go + version-pattern: 'const Version = "([^"]+)"' + changelog: changelog.md ``` 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. 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 -name: Release +name: Prepare Release on: - workflow_dispatch: - inputs: - version: - description: Optional semantic version override. - required: false + 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 + prepare: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + 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 ``` diff --git a/changelog.md b/changelog.md index 0436fa1..37e7e1d 100644 --- a/changelog.md +++ b/changelog.md @@ -11,20 +11,21 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect ### Breaking - ### 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. - Configurable version source and parser via `--version-file` and `--version-pattern`. - 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. -- 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 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. - 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 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. -- 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.