29 Commits

Author SHA1 Message Date
gitea-actions[bot]
c5ecfeebde release: prepare v0.1.0 2026-03-20 23:13:45 +00:00
Micheal Wilkinson
6bcc027076 docs: remove deleted release tag references
All checks were successful
Push Validation / validate (push) Successful in 1m48s
2026-03-20 23:12:01 +00:00
Micheal Wilkinson
fd23a8b238 docs: point README badges at latest workflow runs 2026-03-20 23:08:56 +00:00
Micheal Wilkinson
3f7edea46e feat: use reference-style links in changelog instead of inline links
Moves changelog link generation from inline heading format
  ## [1.1.0](https://...) - date
to reference-style definitions at the bottom of the file
  ## [1.1.0] - date
  ...
  [Unreleased]: https://.../src/branch/main
  [1.1.0]: https://.../releases/tag/v1.1.0

This keeps headings plain, which simplifies changelog parsing (the
awk pattern in publish/action.yml now matches without special-casing
the inline URL), and follows the canonical Keep a Changelog style.
2026-03-20 23:07:10 +00:00
Micheal Wilkinson
0234df7aa1 fix: match linked changelog headings when extracting release notes
All checks were successful
Push Validation / validate (push) Successful in 1m38s
2026-03-20 22:54:42 +00:00
gitea-actions[bot]
501ac71147 release: prepare v1.1.0 2026-03-20 22:51:47 +00:00
Micheal Wilkinson
60bbc7409b docs: changelog entries for v1.0.1
All checks were successful
Push Validation / validate (push) Successful in 1m47s
2026-03-20 22:50:13 +00:00
Micheal Wilkinson
4c5a49d685 fix: fall back to git tag on HEAD when version input is not propagated
All checks were successful
Push Validation / validate (push) Successful in 1m43s
2026-03-20 22:46:44 +00:00
Micheal Wilkinson
87059d21fd test: raise coverage with cli and internal helper tests 2026-03-20 22:41:19 +00:00
gitea-actions[bot]
3ea4af158e release: prepare v1.0.0 2026-03-20 22:34:11 +00:00
Micheal Wilkinson
7ea5f05297 docs(readme): updated badge image urls
All checks were successful
Push Validation / validate (push) Successful in 1m42s
2026-03-20 22:31:17 +00:00
Micheal Wilkinson
71c1b81426 test: harden changelog link tests for CI env vars
Some checks failed
Push Validation / validate (push) Has been cancelled
2026-03-20 22:30:46 +00:00
Micheal Wilkinson
68e4211fbf ci: publish coverage artefacts and add badge
All checks were successful
Push Validation / validate (push) Successful in 1m45s
2026-03-20 22:00:11 +00:00
Micheal Wilkinson
50e5f25329 docs: add full godoc comments for vociferate API 2026-03-20 21:49:39 +00:00
Micheal Wilkinson
8ea9acdebc feat: add changelog heading links for unreleased and releases
Some checks failed
Push Validation / validate (push) Failing after 42s
- Link Unreleased heading to repository main branch.
- Link release headings to release tag pages.
- Derive repository URL from CI metadata or origin git remote.
- Keep plain headings when repository URL cannot be resolved.
- Update tests and README usage docs for linked heading behavior.
2026-03-20 21:46:36 +00:00
Micheal Wilkinson
be4f3833a1 feat: chain do-release from prepare workflow
- Update prepare-release to call do-release via workflow_call after tag creation.
- Update README examples and release-flow docs to reflect direct invocation
  instead of relying only on tag-push triggers.
2026-03-20 21:46:36 +00:00
Micheal Wilkinson
63aa7376cc fix: fail prepare when release tag already exists
Some checks failed
Push Validation / validate (push) Has been cancelled
Prevent silent successful runs that skip tag creation. If the resolved
release tag already exists locally or remotely, fail with guidance so users
know why tag-triggered do-release will not run.
2026-03-20 21:29:54 +00:00
Micheal Wilkinson
e8e1dc9695 fix: make prepare action resilient for reruns
Some checks failed
Push Validation / validate (push) Has been cancelled
- Disable setup-go module cache path in prepare action's source-run mode
  to avoid invalid '..' cache-dependency-path patterns.
- Make commit/tag step idempotent when release tag already exists locally
  or remotely.
- Skip empty commit attempts when no release files changed while still
  allowing first-time tag creation.
2026-03-20 21:24:52 +00:00
Micheal Wilkinson
d63bfca291 fix: close prepare-release summary step block
All checks were successful
Push Validation / validate (push) Successful in 58s
2026-03-20 21:20:26 +00:00
Micheal Wilkinson
c079bf766f docs: refine changelog for initial release
All checks were successful
Push Validation / validate (push) Successful in 53s
2026-03-20 21:13:38 +00:00
Micheal Wilkinson
d6f178ede9 docs: polish README naming section formatting
All checks were successful
Push Validation / validate (push) Successful in 53s
2026-03-20 21:06:45 +00:00
Micheal Wilkinson
d6fa61dc7c docs: clarify fixed cache token behavior
All checks were successful
Push Validation / validate (push) Successful in 56s
2026-03-20 20:47:24 +00:00
Micheal Wilkinson
bab7b74da8 refactor: internalize auth and cache token wiring in prepare flow
- Remove token and cache-token from public action inputs
- Always use github.token internally for downloads/push
- Read fixed cache token from VOCIFERATE_CACHE_TOKEN env
- Add explicit 'Resolve cache token' step before prepare/tag in
  prepare-release workflow and pass it via env
2026-03-20 20:47:19 +00:00
Micheal Wilkinson
1b7281c168 docs: update changelog for cache token scoping
All checks were successful
Push Validation / validate (push) Successful in 54s
2026-03-20 20:40:59 +00:00
Micheal Wilkinson
011cca2334 feat: add repository-scoped cache token for action binaries
Add a new optional cache-token input to both published actions.

- Default cache key token is now action_repository + release_tag.
- Cache key uses this token plus runner architecture.
- prepare-release workflow passes github.sha as a fixed token.

This prevents cross-repository cache collisions when consumers pull
vociferate binaries produced by this repository.
2026-03-20 20:40:56 +00:00
Micheal Wilkinson
dda898868f docs: note @main go run behaviour in changelog
All checks were successful
Push Validation / validate (push) Successful in 53s
2026-03-20 20:34:51 +00:00
Micheal Wilkinson
b793e1b289 feat: use go run from source when action ref is @main
When github.action_ref is not a semver tag (e.g. main), skip binary
download and run vociferate directly via 'go run ./cmd/vociferate' from
the action's own source directory (GITHUB_ACTION_PATH). A conditional
Setup Go step installs the toolchain only on that path.

When pinned to a semver tag (v*), the existing prebuilt binary download
and cache behaviour is unchanged.

This makes the prepare-release workflow self-contained on main — it no
longer requires a published release to bootstrap itself.
2026-03-20 20:34:46 +00:00
Micheal Wilkinson
55a067973e docs: update changelog for prepare and publish actions
All checks were successful
Push Validation / validate (push) Successful in 52s
2026-03-20 20:27:26 +00:00
Micheal Wilkinson
647d8cf76f feat: add prepare and publish composite actions
Add two focused subdirectory composite actions:

- prepare/action.yml: downloads the vociferate binary, runs it to update
  changelog and release-version, then commits, tags, and pushes — replacing
  the boilerplate git steps consumers previously had to write inline.

- publish/action.yml: extracts the matching changelog section and creates or
  updates the Gitea/GitHub release. Outputs release-id, tag, and version so
  consumers can upload their own assets after it runs.

Simplify the vociferate workflows to use ./prepare and ./publish directly,
validating both actions in the self-release pipeline.

Update README to show the clean two-action usage pattern.
2026-03-20 20:27:22 +00:00
13 changed files with 1182 additions and 363 deletions

View File

@@ -22,8 +22,8 @@ jobs:
runs-on: ubuntu-latest
container: docker.io/catthehacker/ubuntu:act-latest
outputs:
tag: ${{ steps.resolve-tag.outputs.tag }}
version: ${{ steps.resolve-tag.outputs.version }}
tag: ${{ steps.publish.outputs.tag }}
version: ${{ steps.publish.outputs.version }}
defaults:
run:
shell: bash
@@ -36,35 +36,12 @@ jobs:
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 }}
ref: ${{ startsWith(inputs.tag, 'v') && format('refs/tags/{0}', inputs.tag) || format('refs/tags/v{0}', inputs.tag) }}
- name: Setup Go
uses: actions/setup-go@v5
@@ -74,90 +51,16 @@ jobs:
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"
id: publish
uses: ./publish
with:
token: ${{ secrets.GITHUB_TOKEN }}
version: ${{ inputs.tag }}
- name: Build release binaries
env:
RELEASE_VERSION: ${{ steps.resolve-tag.outputs.version }}
RELEASE_VERSION: ${{ steps.publish.outputs.version }}
run: |
set -euo pipefail
@@ -178,7 +81,8 @@ jobs:
- name: Upload release binaries
env:
RELEASE_ID: ${{ steps.release.outputs.id }}
RELEASE_ID: ${{ steps.publish.outputs.release-id }}
RELEASE_VERSION: ${{ steps.publish.outputs.version }}
run: |
set -euo pipefail
@@ -213,8 +117,8 @@ jobs:
- name: Summarize published release
env:
TAG_NAME: ${{ steps.resolve-tag.outputs.tag }}
RELEASE_VERSION: ${{ steps.resolve-tag.outputs.version }}
TAG_NAME: ${{ steps.publish.outputs.tag }}
RELEASE_VERSION: ${{ steps.publish.outputs.version }}
run: |
set -euo pipefail

View File

@@ -18,11 +18,11 @@ jobs:
prepare:
runs-on: ubuntu-latest
container: docker.io/catthehacker/ubuntu:act-latest
outputs:
tag: ${{ steps.prepare.outputs.version }}
defaults:
run:
shell: bash
env:
RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -37,91 +37,35 @@ jobs:
cache: true
cache-dependency-path: go.sum
- name: Resolve release version
id: resolve-version
env:
INPUT_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
provided_version="$(printf '%s' "${INPUT_VERSION:-}" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
if [[ -n "$provided_version" ]]; then
release_version="$provided_version"
else
if ! release_version="$(go run ./cmd/vociferate --recommend --root . 2>release-recommendation.err)"; then
recommendation_error="$(tr '\n' ' ' < release-recommendation.err | sed 's/[[:space:]]\+/ /g' | sed 's/^ //; s/ $//')"
echo "Resolve release version: ${recommendation_error}" >&2
exit 1
fi
fi
echo "RELEASE_VERSION=$release_version" >> "$GITHUB_ENV"
echo "version=$release_version" >> "$GITHUB_OUTPUT"
- name: Prepare release files
run: |
set -euo pipefail
go run ./cmd/vociferate \
--root . \
--version "$RELEASE_VERSION" \
--date "$(date -u +%F)" \
--changelog changelog.md
- name: Run tests
run: |
set -euo pipefail
go test ./...
run: go test ./...
- name: Configure git author
run: |
set -euo pipefail
git config user.name "gitea-actions[bot]"
git config user.email "gitea-actions[bot]@users.noreply.local"
- name: Resolve cache token
id: cache-token
run: echo "value=${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
- name: Commit release changes and push tag
run: |
set -euo pipefail
normalized_version="${RELEASE_VERSION#v}"
tag="v${normalized_version}"
if git rev-parse "$tag" >/dev/null 2>&1; then
echo "Tag ${tag} already exists" >&2
exit 1
fi
case "$GITHUB_SERVER_URL" in
https://*)
authed_remote="https://oauth2:${RELEASE_TOKEN}@${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY}.git"
;;
http://*)
authed_remote="http://oauth2:${RELEASE_TOKEN}@${GITHUB_SERVER_URL#http://}/${GITHUB_REPOSITORY}.git"
;;
*)
echo "Unsupported GITHUB_SERVER_URL: ${GITHUB_SERVER_URL}" >&2
exit 1
;;
esac
git remote set-url origin "$authed_remote"
git add changelog.md release-version
git commit -m "release: prepare ${tag}"
git tag "$tag"
git push origin HEAD
git push origin "$tag"
- name: Prepare and tag release
id: prepare
uses: ./prepare
env:
VOCIFERATE_CACHE_TOKEN: ${{ steps.cache-token.outputs.value }}
with:
version: ${{ inputs.version }}
- name: Summarize prepared release
run: |
set -euo pipefail
normalized_version="${RELEASE_VERSION#v}"
tag="v${normalized_version}"
tag="${{ steps.prepare.outputs.version }}"
{
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."
echo "- Calling Do Release workflow for ${tag}."
} >> "$GITHUB_STEP_SUMMARY"
publish:
needs: prepare
uses: ./.gitea/workflows/do-release.yml
with:
tag: ${{ needs.prepare.outputs.tag }}
secrets: inherit

View File

@@ -14,6 +14,14 @@ jobs:
defaults:
run:
shell: bash
env:
ARTEFACT_BUCKET_NAME: ${{ vars.ARTEFACT_BUCKET_NAME }}
ARTEFACT_BUCKET_ENDPONT: ${{ vars.ARTEFACT_BUCKET_ENDPONT }}
ARTEFACT_BUCKET_REGION: ${{ vars.ARTEFACT_BUCKET_REGION }}
AWS_ACCESS_KEY_ID: ${{ secrets.ARTEFACT_BUCKET_WRITE_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ARTEFACT_BUCKET_WRITE_ACCESS_SECRET }}
AWS_DEFAULT_REGION: ${{ vars.ARTEFACT_BUCKET_REGION }}
AWS_EC2_METADATA_DISABLED: true
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -26,11 +34,89 @@ jobs:
cache: true
cache-dependency-path: go.sum
- name: Install AWS CLI v2
uses: ankurk91/install-aws-cli-action@v1
- name: Verify AWS CLI
run: aws --version
- name: Run full unit test suite with coverage
id: coverage
run: |
set -euo pipefail
go test -covermode=atomic -coverprofile=coverage.out ./...
go tool cover -func=coverage.out
go tool cover -html=coverage.out -o coverage.html
total="$(go tool cover -func=coverage.out | awk '/^total:/ {sub(/%/, "", $3); print $3}')"
printf '{\n "total": "%s"\n}\n' "$total" > coverage-summary.json
printf 'total=%s\n' "$total" >> "$GITHUB_OUTPUT"
- name: Generate coverage badge
env:
COVERAGE_TOTAL: ${{ steps.coverage.outputs.total }}
run: |
set -euo pipefail
color="$(awk -v total="$COVERAGE_TOTAL" 'BEGIN {
if (total >= 80) print "brightgreen";
else if (total >= 70) print "green";
else if (total >= 60) print "yellowgreen";
else if (total >= 50) print "yellow";
else print "red";
}')"
cat > coverage-badge.svg <<EOF
<svg xmlns="http://www.w3.org/2000/svg" width="126" height="20" role="img" aria-label="coverage: ${COVERAGE_TOTAL}%">
<linearGradient id="smooth" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="round">
<rect width="126" height="20" rx="3" fill="#fff"/>
</clipPath>
<g clip-path="url(#round)">
<rect width="63" height="20" fill="#555"/>
<rect x="63" width="63" height="20" fill="${color}"/>
<rect width="126" height="20" fill="url(#smooth)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text x="32.5" y="15" fill="#010101" fill-opacity=".3">coverage</text>
<text x="32.5" y="14">coverage</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">${COVERAGE_TOTAL}%</text>
<text x="93.5" y="14">${COVERAGE_TOTAL}%</text>
</g>
</svg>
EOF
- name: Upload branch coverage artefacts
id: upload
run: |
set -euo pipefail
aws configure set default.s3.addressing_style path
repo_name="${GITHUB_REPOSITORY##*/}"
prefix="${repo_name}/branch/${GITHUB_REF_NAME}"
report_url="${ARTEFACT_BUCKET_ENDPONT%/}/${ARTEFACT_BUCKET_NAME}/${prefix}/coverage.html"
badge_url="${ARTEFACT_BUCKET_ENDPONT%/}/${ARTEFACT_BUCKET_NAME}/${prefix}/coverage-badge.svg"
aws --endpoint-url "${ARTEFACT_BUCKET_ENDPONT}" s3 cp coverage.html "s3://${ARTEFACT_BUCKET_NAME}/${prefix}/coverage.html" --content-type text/html
aws --endpoint-url "${ARTEFACT_BUCKET_ENDPONT}" s3 cp coverage-badge.svg "s3://${ARTEFACT_BUCKET_NAME}/${prefix}/coverage-badge.svg" --content-type image/svg+xml
aws --endpoint-url "${ARTEFACT_BUCKET_ENDPONT}" s3 cp coverage-summary.json "s3://${ARTEFACT_BUCKET_NAME}/${prefix}/coverage-summary.json" --content-type application/json
printf 'report_url=%s\n' "$report_url" >> "$GITHUB_OUTPUT"
printf 'badge_url=%s\n' "$badge_url" >> "$GITHUB_OUTPUT"
- name: Add coverage summary
run: |
{
echo '## Coverage'
echo
echo '- Total: `${{ steps.coverage.outputs.total }}%`'
echo '- Report: ${{ steps.upload.outputs.report_url }}'
echo '- Badge: ${{ steps.upload.outputs.badge_url }}'
} >> "$GITHUB_STEP_SUMMARY"
- name: Recommend next release tag on main pushes
if: ${{ github.ref == 'refs/heads/main' }}

236
README.md
View File

@@ -1,6 +1,123 @@
# vociferate
A reusable release preparation tool for Go repositories.
[![Main Validation](https://git.hrafn.xyz/aether/vociferate/actions/workflows/push-validation.yml/badge.svg?branch=main&event=push)](https://git.hrafn.xyz/aether/vociferate/actions/runs/latest?workflow=push-validation.yml&branch=main&event=push)
[![Prepare Release](https://git.hrafn.xyz/aether/vociferate/actions/workflows/prepare-release.yml/badge.svg?event=workflow_dispatch)](https://git.hrafn.xyz/aether/vociferate/actions/runs/latest?workflow=prepare-release.yml)
[![Do Release](https://git.hrafn.xyz/aether/vociferate/actions/workflows/do-release.yml/badge.svg?event=push)](https://git.hrafn.xyz/aether/vociferate/actions/runs/latest?workflow=do-release.yml)
[![Coverage](https://s3.hrafn.xyz/aether-workflow-report-artefacts/vociferate/branch/main/coverage-badge.svg)](https://s3.hrafn.xyz/aether-workflow-report-artefacts/vociferate/branch/main/coverage.html)
`vociferate` is an `aether` release orchestration tool written in Go for repositories that
want changelog-driven versioning, automated release preparation, and repeatable
tag publication.
It started as release preparation glue, but it now covers the full release path:
recommend the next semantic version, promote `Unreleased` changelog entries,
commit and tag a release, and publish release notes/assets from the tagged
revision.
## Use In Other Repositories
Vociferate ships two composite actions that together cover the full release flow.
Until release tags are created, reference `@main`. Once tags exist again, pin both actions to the same released tag.
### `prepare` — update files, commit, and push tag
```yaml
name: Prepare Release
on:
workflow_dispatch:
inputs:
version:
description: Optional semantic version override.
required: false
jobs:
prepare:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: git.hrafn.xyz/aether/vociferate/prepare@main
with:
version: ${{ inputs.version }}
publish:
needs: prepare
uses: aether/vociferate/.gitea/workflows/do-release.yml@main
with:
tag: ${{ needs.prepare.outputs.version }}
secrets: inherit
```
Downloads a prebuilt vociferate binary, runs it to update `changelog.md` and
`release-version`, then commits those changes to the default branch and pushes
the release tag. Does not require Go on the runner.
For repositories that embed the version inside source code, pass `version-file`
and `version-pattern`:
```yaml
- uses: git.hrafn.xyz/aether/vociferate/prepare@main
with:
version-file: internal/myapp/version/version.go
version-pattern: 'const Version = "([^"]+)"'
git-add-files: changelog.md internal/myapp/version/version.go
```
`prepare` uses `github.token` internally for authenticated fetch/push operations,
so no token input is required.
### `publish` — create release with changelog notes
```yaml
name: Do Release
on:
workflow_dispatch:
inputs:
tag:
description: Semantic version to publish (for example v1.2.3)
required: true
jobs:
release:
uses: aether/vociferate/.gitea/workflows/do-release.yml@main
with:
tag: ${{ inputs.tag }}
secrets: inherit
```
Reads the matching section from `changelog.md` and creates or updates the
Gitea/GitHub release with those notes. The `version` input is optional — when
omitted it is derived from the current tag ref automatically.
The `publish` action outputs `release-id` so you can upload additional release
assets after it runs:
```yaml
- id: publish
uses: git.hrafn.xyz/aether/vociferate/publish@main
- name: Upload my binary
run: |
curl --fail-with-body -X POST \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/octet-stream" \
"${{ github.api_url }}/repos/${{ github.repository }}/releases/${{ steps.publish.outputs.release-id }}/assets?name=myapp" \
--data-binary "@dist/myapp"
```
## Why The Name
> **vociferate** _(verb)_: to cry out loudly or forcefully.
Long story short: vociferating into the Æther is synonymous screaming into the
void.
If updating changelogs and documenting releases has ever felt like that, this
tool is for you.
## Build
@@ -50,6 +167,8 @@ Defaults:
When no `--version-file` flag is provided, `vociferate` derives the current version from the most recent released section heading in the changelog (`## [x.y.z] - ...`). If no prior releases exist, it defaults to `0.0.0` and recommends `v1.0.0` as the first tag.
During prepare, vociferate can normalize changelog heading links when it can determine the repository URL (from CI environment variables or `origin` git remote). If you prefer changelog headings to stay plain while tags are being rebuilt, leave the changelog as plain headings and avoid retaining historical release-tag links.
When running `--version`, the `release-version` file is created automatically if it does not exist, so new repositories do not need to pre-seed it.
Repositories that keep the version inside source code should pass explicit `--version-file` and `--version-pattern` values; in that case the version file is used directly instead of the changelog.
@@ -65,9 +184,10 @@ just go-test
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.
- `Prepare Release` then calls `Do Release` directly via reusable `workflow_call` with the resolved tag.
- `Do Release` 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.
Calling `Do Release` directly avoids environments where tag pushes from workflow tokens do not emit a follow-up workflow trigger event.
## Release Artifacts
@@ -78,113 +198,3 @@ The tag-driven `Do Release` workflow publishes prebuilt `vociferate` binaries fo
It also uploads `checksums.txt` for integrity verification.
If a release already exists for the same tag, the workflow updates its release notes and replaces matching asset filenames so reruns stay in sync.
## Reuse In Other Repositories
You can reuse vociferate in two ways.
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
```
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.
For repositories using changelog-based versioning (the default), omit `version-file` and `version-pattern` entirely. Only set them for repositories that embed the version inside source code.
A complete release setup should also split preparation from publication. For example:
```yaml
name: Prepare Release
on:
workflow_dispatch:
inputs:
version:
description: Optional semantic version override.
required: false
jobs:
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
```

View File

@@ -2,10 +2,6 @@ name: vociferate
description: Prepare release files or recommend a next semantic version tag.
inputs:
token:
description: Optional token used to download the cached vociferate release binary. When omitted, the workflow token is used.
required: false
default: ''
version:
description: Optional semantic version override. When omitted, the recommended version is used.
required: false
@@ -41,9 +37,11 @@ runs:
shell: bash
env:
ACTION_REF: ${{ github.action_ref }}
ACTION_REPOSITORY: ${{ github.action_repository }}
CACHE_TOKEN: ${{ env.VOCIFERATE_CACHE_TOKEN }}
SERVER_URL: ${{ github.server_url }}
API_URL: ${{ github.api_url }}
TOKEN: ${{ inputs.token != '' && inputs.token || github.token }}
TOKEN: ${{ github.token }}
RUNNER_ARCH: ${{ runner.arch }}
RUNNER_TEMP: ${{ runner.temp }}
run: |
@@ -62,45 +60,55 @@ runs:
;;
esac
release_tag="$ACTION_REF"
if [[ -z "$release_tag" || "$release_tag" == refs/* || "$release_tag" != v* ]]; then
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 [[ "$ACTION_REF" == v* ]]; then
release_tag="$ACTION_REF"
normalized_version="${release_tag#v}"
asset_name="vociferate_${normalized_version}_linux_${arch}"
cache_dir="${RUNNER_TEMP}/vociferate/${release_tag}/linux-${arch}"
binary_path="${cache_dir}/vociferate"
asset_url="${SERVER_URL}/aether/vociferate/releases/download/${release_tag}/${asset_name}"
provided_cache_token="$(printf '%s' "${CACHE_TOKEN:-}" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
if [[ -n "$provided_cache_token" ]]; then
cache_token="$provided_cache_token"
else
cache_token="${ACTION_REPOSITORY:-aether/vociferate}-${release_tag}"
fi
mkdir -p "$cache_dir"
echo "use_binary=true" >> "$GITHUB_OUTPUT"
echo "release_tag=$release_tag" >> "$GITHUB_OUTPUT"
echo "cache_token=$cache_token" >> "$GITHUB_OUTPUT"
echo "asset_name=$asset_name" >> "$GITHUB_OUTPUT"
echo "asset_url=$asset_url" >> "$GITHUB_OUTPUT"
echo "cache_dir=$cache_dir" >> "$GITHUB_OUTPUT"
echo "binary_path=$binary_path" >> "$GITHUB_OUTPUT"
else
echo "use_binary=false" >> "$GITHUB_OUTPUT"
fi
if [[ -z "$release_tag" ]]; then
echo "Unable to resolve a vociferate release tag for binary download" >&2
exit 1
fi
normalized_version="${release_tag#v}"
asset_name="vociferate_${normalized_version}_linux_${arch}"
cache_dir="${RUNNER_TEMP}/vociferate/${release_tag}/linux-${arch}"
binary_path="${cache_dir}/vociferate"
asset_url="${SERVER_URL}/aether/vociferate/releases/download/${release_tag}/${asset_name}"
mkdir -p "$cache_dir"
echo "release_tag=$release_tag" >> "$GITHUB_OUTPUT"
echo "asset_name=$asset_name" >> "$GITHUB_OUTPUT"
echo "asset_url=$asset_url" >> "$GITHUB_OUTPUT"
echo "cache_dir=$cache_dir" >> "$GITHUB_OUTPUT"
echo "binary_path=$binary_path" >> "$GITHUB_OUTPUT"
- name: Setup Go
if: steps.resolve-binary.outputs.use_binary != 'true'
uses: actions/setup-go@v5
with:
go-version: '1.26.1'
cache: true
cache-dependency-path: ${{ github.action_path }}/go.sum
- name: Restore cached vociferate binary
id: cache-vociferate
if: steps.resolve-binary.outputs.use_binary == 'true'
uses: actions/cache@v4
with:
path: ${{ steps.resolve-binary.outputs.cache_dir }}
key: vociferate-${{ steps.resolve-binary.outputs.release_tag }}-linux-${{ runner.arch }}
key: vociferate-${{ steps.resolve-binary.outputs.cache_token }}-linux-${{ runner.arch }}
- name: Download vociferate binary
if: steps.cache-vociferate.outputs.cache-hit != 'true'
if: steps.resolve-binary.outputs.use_binary == 'true' && steps.cache-vociferate.outputs.cache-hit != 'true'
shell: bash
env:
TOKEN: ${{ inputs.token != '' && inputs.token || github.token }}
TOKEN: ${{ github.token }}
ASSET_URL: ${{ steps.resolve-binary.outputs.asset_url }}
BINARY_PATH: ${{ steps.resolve-binary.outputs.binary_path }}
run: |
@@ -117,10 +125,17 @@ runs:
shell: bash
env:
VOCIFERATE_BIN: ${{ steps.resolve-binary.outputs.binary_path }}
USE_BINARY: ${{ steps.resolve-binary.outputs.use_binary }}
run: |
set -euo pipefail
common_args=(--root .)
if [[ "$USE_BINARY" == "true" ]]; then
run_vociferate() { "$VOCIFERATE_BIN" "$@"; }
else
run_vociferate() { (cd "$GITHUB_ACTION_PATH" && go run ./cmd/vociferate "$@"); }
fi
common_args=(--root "$GITHUB_WORKSPACE")
if [[ -n "${{ inputs.version-file }}" ]]; then
common_args+=(--version-file "${{ inputs.version-file }}")
@@ -135,16 +150,16 @@ runs:
fi
if [[ "${{ inputs.recommend }}" == "true" ]]; then
resolved_version="$("$VOCIFERATE_BIN" "${common_args[@]}" --recommend)"
resolved_version="$(run_vociferate "${common_args[@]}" --recommend)"
echo "$resolved_version"
echo "version=$resolved_version" >> "$GITHUB_OUTPUT"
exit 0
else
resolved_version="$(printf '%s' "${{ inputs.version }}" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
if [[ -z "$resolved_version" ]]; then
resolved_version="$("$VOCIFERATE_BIN" "${common_args[@]}" --recommend)"
resolved_version="$(run_vociferate "${common_args[@]}" --recommend)"
fi
fi
echo "version=$resolved_version" >> "$GITHUB_OUTPUT"
"$VOCIFERATE_BIN" "${common_args[@]}" --version "$resolved_version" --date "$(date -u +%F)"
run_vociferate "${common_args[@]}" --version "$resolved_version" --date "$(date -u +%F)"

View File

@@ -9,25 +9,39 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect
## [Unreleased]
### Breaking
## [0.1.0] - 2026-03-20
### Changed
- Release version recommendation now reads the current version from the most recent released section in the changelog instead of requiring a separate version file. When no prior releases exist the version defaults to `0.0.0`, yielding `v1.0.0` as the first recommended tag.
- `vociferate prepare` creates the `release-version` file if it does not already exist, removing the need to pre-seed it in new repositories.
- 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`.
- README workflow badges now link to the latest workflow run pages instead of the workflow definition pages.
### Fixed
- Publish action falls back to `git describe` when `inputs.version` is empty and `GITHUB_REF` is not a tag ref, resolving `workflow_call` input propagation failures in act runner v0.3.0.
### Added
- Coverage badge in README linked to S3-hosted main-branch report.
- S3 coverage artefact publishing (HTML report, badge, JSON summary) in push validation pipeline.
- CLI tests and internal helper tests raising total coverage to 84%.
- Test suite isolation against ambient CI environment variables for changelog link generation tests.
- Go CLI for changelog-driven release preparation and semantic version recommendation.
- Version recommendation from changelog release headings, including first-release support (`0.0.0` base -> `v1.0.0`).
- Automatic `release-version` creation/update during release preparation.
- Configurable version source/parsing 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 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 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, 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.
- Recommended-version fallback when `version` is omitted in CLI and action flows.
- Major-version recommendation trigger from `Unreleased` `### Breaking`.
- Root composite action (`action.yml`) for recommend/prepare flows.
- Subdirectory composite actions: `prepare/action.yml` (prepare/commit/tag/push) and `publish/action.yml` (extract notes/create-or-update release).
- `publish` outputs for downstream automation: `release-id`, `tag`, and `version`.
- Dual execution mode for actions: `go run` from source on `@main`, prebuilt binaries on tagged refs.
- Repository-scoped binary cache keys with workflow-defined fixed token support via `VOCIFERATE_CACHE_TOKEN`.
- Tag-driven release publication with idempotent release updates and asset replacement on reruns.
- Release artifacts for `linux/amd64`, `linux/arm64`, and `checksums.txt`.
- Reusable Gitea workflows (`prepare-release.yml`, `do-release.yml`) with `workflow_call` support.
- Project/automation rename from `releaseprep` to `vociferate` (entrypoint, package paths, outputs).
- README guidance focused on primary cross-repository reuse workflows.
[Unreleased]: http://teapot:3000/aether/vociferate/src/branch/main
[0.1.0]: http://teapot:3000/aether/vociferate/releases/tag/v0.1.0

120
cmd/vociferate/main_test.go Normal file
View File

@@ -0,0 +1,120 @@
package main
import (
"flag"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestMainRecommendPrintsTag(t *testing.T) {
root := t.TempDir()
writeFile(t, filepath.Join(root, "release-version"), "1.1.6\n")
writeFile(t, filepath.Join(root, "changelog.md"), "# Changelog\n\n## [Unreleased]\n\n### Added\n\n- Feature.\n\n## [1.1.6] - 2017-12-20\n")
stdout, stderr, code := runMain(t, "--recommend", "--root", root)
if code != 0 {
t.Fatalf("expected exit 0, got %d (stderr: %s)", code, stderr)
}
if strings.TrimSpace(stdout) != "v1.2.0" {
t.Fatalf("unexpected recommended tag: %q", strings.TrimSpace(stdout))
}
}
func TestMainUsageExitWhenRequiredFlagsMissing(t *testing.T) {
_, stderr, code := runMain(t)
if code != 2 {
t.Fatalf("expected exit 2, got %d", code)
}
if !strings.Contains(stderr, "usage: vociferate") {
t.Fatalf("expected usage text in stderr, got: %s", stderr)
}
}
func TestMainPrepareUpdatesFiles(t *testing.T) {
root := t.TempDir()
writeFile(t, filepath.Join(root, ".git", "config"), "[remote \"origin\"]\n\turl = git@git.hrafn.xyz:aether/vociferate.git\n")
writeFile(t, filepath.Join(root, "release-version"), "1.1.6\n")
writeFile(t, filepath.Join(root, "changelog.md"), "# Changelog\n\n## [Unreleased]\n\n### Fixed\n\n- Patch note.\n\n## [1.1.6] - 2017-12-20\n")
_, stderr, code := runMain(t, "--version", "v1.1.7", "--date", "2026-03-20", "--root", root)
if code != 0 {
t.Fatalf("expected exit 0, got %d (stderr: %s)", code, stderr)
}
versionBytes, err := os.ReadFile(filepath.Join(root, "release-version"))
if err != nil {
t.Fatalf("read release-version: %v", err)
}
if strings.TrimSpace(string(versionBytes)) != "1.1.7" {
t.Fatalf("unexpected version file value: %q", string(versionBytes))
}
}
func TestMainPrepareReturnsExitOneOnFailure(t *testing.T) {
root := t.TempDir()
_, stderr, code := runMain(t, "--version", "v1.1.7", "--date", "2026-03-20", "--root", root)
if code != 1 {
t.Fatalf("expected exit 1, got %d", code)
}
if !strings.Contains(stderr, "prepare release") {
t.Fatalf("expected prepare error in stderr, got: %s", stderr)
}
}
func TestHelperProcess(t *testing.T) {
if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
return
}
idx := -1
for i, arg := range os.Args {
if arg == "--" {
idx = i
break
}
}
if idx == -1 {
os.Exit(2)
}
args := append([]string{"vociferate"}, os.Args[idx+1:]...)
os.Args = args
flag.CommandLine = flag.NewFlagSet(args[0], flag.ExitOnError)
main()
os.Exit(0)
}
func runMain(t *testing.T, args ...string) (string, string, int) {
t.Helper()
cmdArgs := append([]string{"-test.run=TestHelperProcess", "--"}, args...)
cmd := exec.Command(os.Args[0], cmdArgs...)
cmd.Env = append(os.Environ(), "GO_WANT_HELPER_PROCESS=1")
out, err := cmd.CombinedOutput()
output := string(out)
if err == nil {
return output, "", 0
}
if exitErr, ok := err.(*exec.ExitError); ok {
return "", output, exitErr.ExitCode()
}
t.Fatalf("run helper process: %v", err)
return "", "", -1
}
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir for %s: %v", path, err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write file %s: %v", path, err)
}
}

View File

@@ -1,3 +1,9 @@
// Package vociferate provides changelog-driven release preparation utilities.
//
// It updates version metadata, promotes the Unreleased changelog section into a
// dated version section, recommends the next semantic version tag from pending
// changelog entries, and normalizes changelog links when repository metadata is
// available.
package vociferate
import (
@@ -16,11 +22,22 @@ const (
)
var releasedSectionRe = regexp.MustCompile(`(?m)^## \[(\d+\.\d+\.\d+)\] - `)
var linkedReleasedSectionRe = regexp.MustCompile(`(?m)^## \[(\d+\.\d+\.\d+)\](?:\([^\n)]*\))? - `)
var unreleasedHeadingRe = regexp.MustCompile(`(?m)^## \[Unreleased\](?:\([^\n)]*\))?\n`)
var releaseHeadingRe = regexp.MustCompile(`(?m)^## \[(\d+\.\d+\.\d+)\](?:\([^\n)]*\))? - `)
var refLinkLineRe = regexp.MustCompile(`^\[[^\]]+\]: \S`)
type Options struct {
VersionFile string
// VersionFile is the path to the file that stores the current version,
// relative to the repository root. When empty, release-version is used.
VersionFile string
// VersionPattern is a regular expression with exactly one capture group for
// extracting the current version from VersionFile.
// When empty, a line-oriented default matcher is used.
VersionPattern string
Changelog string
// Changelog is the path to the changelog file, relative to the repository
// root. When empty, changelog.md is used.
Changelog string
}
type semver struct {
@@ -35,6 +52,14 @@ type resolvedOptions struct {
Changelog string
}
// Prepare updates version state and promotes the Unreleased changelog notes
// into a new release section.
//
// The version may be provided with or without a leading "v" and releaseDate
// must use YYYY-MM-DD formatting. Prepare updates both the configured version
// file and changelog, and enriches changelog headings with repository links
// when repository metadata can be derived from CI environment variables or the
// origin git remote.
func Prepare(rootDir, version, releaseDate string, options Options) error {
normalizedVersion := strings.TrimPrefix(strings.TrimSpace(version), "v")
if normalizedVersion == "" {
@@ -62,6 +87,16 @@ func Prepare(rootDir, version, releaseDate string, options Options) error {
return nil
}
// RecommendedTag returns the next semantic release tag (for example, v1.2.3)
// based on the current version and Unreleased changelog content.
//
// Bump rules are:
// - major: Unreleased contains a Breaking section or Removed entries
// - minor: Unreleased contains Added entries
// - patch: all other cases
//
// When no previous release is present in the changelog, the base version is
// treated as 0.0.0.
func RecommendedTag(rootDir string, options Options) (string, error) {
resolved, err := resolveOptions(options)
if err != nil {
@@ -202,6 +237,10 @@ func updateChangelog(rootDir, version, releaseDate, changelogPath string) error
}
updated := text[:afterHeader] + "\n" + newSection + text[nextSectionStart:]
repoURL, ok := deriveRepositoryURL(rootDir)
if ok {
updated = addChangelogLinks(updated, repoURL)
}
if err := os.WriteFile(path, []byte(updated), 0o644); err != nil {
return fmt.Errorf("write changelog: %w", err)
}
@@ -245,13 +284,12 @@ func readChangelogState(rootDir, changelogPath string) (string, string, int, int
}
text := string(contents)
unreleasedHeader := "## [Unreleased]\n"
start := strings.Index(text, unreleasedHeader)
if start == -1 {
headerLoc := unreleasedHeadingRe.FindStringIndex(text)
if headerLoc == nil {
return "", "", 0, 0, "", fmt.Errorf("unreleased section not found in changelog")
}
afterHeader := start + len(unreleasedHeader)
afterHeader := headerLoc[1]
nextSectionRelative := strings.Index(text[afterHeader:], "\n## [")
if nextSectionRelative == -1 {
nextSectionRelative = len(text[afterHeader:])
@@ -269,13 +307,146 @@ func readLatestChangelogVersion(rootDir, changelogPath string) (string, bool, er
return "", false, fmt.Errorf("read changelog: %w", err)
}
match := releasedSectionRe.FindStringSubmatch(string(contents))
match := linkedReleasedSectionRe.FindStringSubmatch(string(contents))
if match == nil {
return "", false, nil
}
return match[1], true, nil
}
func deriveRepositoryURL(rootDir string) (string, bool) {
serverURL := strings.TrimSpace(os.Getenv("GITHUB_SERVER_URL"))
repository := strings.TrimSpace(os.Getenv("GITHUB_REPOSITORY"))
if serverURL != "" && repository != "" {
return strings.TrimSuffix(serverURL, "/") + "/" + strings.TrimPrefix(repository, "/"), true
}
gitConfigPath := filepath.Join(rootDir, ".git", "config")
contents, err := os.ReadFile(gitConfigPath)
if err != nil {
return "", false
}
remoteURL, ok := originRemoteURLFromGitConfig(string(contents))
if !ok {
return "", false
}
repoURL, ok := normalizeRepoURL(remoteURL)
if !ok {
return "", false
}
return repoURL, true
}
func originRemoteURLFromGitConfig(config string) (string, bool) {
inOrigin := false
for _, line := range strings.Split(config, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
inOrigin = trimmed == `[remote "origin"]`
continue
}
if !inOrigin {
continue
}
if strings.HasPrefix(trimmed, "url") {
parts := strings.SplitN(trimmed, "=", 2)
if len(parts) != 2 {
continue
}
url := strings.TrimSpace(parts[1])
if url != "" {
return url, true
}
}
}
return "", false
}
func normalizeRepoURL(remoteURL string) (string, bool) {
remoteURL = strings.TrimSpace(remoteURL)
if remoteURL == "" {
return "", false
}
if strings.HasPrefix(remoteURL, "http://") || strings.HasPrefix(remoteURL, "https://") {
return strings.TrimSuffix(remoteURL, ".git"), true
}
if strings.HasPrefix(remoteURL, "ssh://") {
withoutScheme := strings.TrimPrefix(remoteURL, "ssh://")
at := strings.Index(withoutScheme, "@")
if at == -1 {
return "", false
}
hostAndPath := withoutScheme[at+1:]
host, path, ok := strings.Cut(hostAndPath, "/")
if !ok || host == "" || path == "" {
return "", false
}
return "https://" + host + "/" + strings.TrimSuffix(path, ".git"), true
}
if strings.Contains(remoteURL, "@") && strings.Contains(remoteURL, ":") {
afterAt, ok := strings.CutPrefix(remoteURL, strings.Split(remoteURL, "@")[0]+"@")
if !ok {
return "", false
}
host, path, ok := strings.Cut(afterAt, ":")
if !ok || host == "" || path == "" {
return "", false
}
return "https://" + host + "/" + strings.TrimSuffix(path, ".git"), true
}
return "", false
}
func addChangelogLinks(text, repoURL string) string {
repoURL = strings.TrimSuffix(strings.TrimSpace(repoURL), "/")
if repoURL == "" {
return text
}
// Normalize headings to plain format, stripping any existing inline links.
text = unreleasedHeadingRe.ReplaceAllString(text, "## [Unreleased]\n")
text = releaseHeadingRe.ReplaceAllStringFunc(text, func(match string) string {
parts := releaseHeadingRe.FindStringSubmatch(match)
if len(parts) < 2 {
return match
}
version := parts[1]
return fmt.Sprintf("## [%s] - ", version)
})
// Strip any trailing reference link block (blank lines followed by ref link lines).
lines := strings.Split(strings.TrimRight(text, "\n"), "\n")
cutAt := len(lines)
for i := len(lines) - 1; i >= 0; i-- {
if strings.TrimSpace(lines[i]) == "" || refLinkLineRe.MatchString(lines[i]) {
cutAt = i
} else {
break
}
}
text = strings.Join(lines[:cutAt], "\n") + "\n"
// Build and append reference link definitions.
linkDefs := []string{fmt.Sprintf("[Unreleased]: %s/src/branch/main", repoURL)}
for _, match := range releasedSectionRe.FindAllStringSubmatch(text, -1) {
if len(match) >= 2 {
linkDefs = append(linkDefs, fmt.Sprintf("[%s]: %s/releases/tag/v%s", match[1], repoURL, match[1]))
}
}
return strings.TrimRight(text, "\n") + "\n\n" + strings.Join(linkDefs, "\n") + "\n"
}
func parseSemver(version string) (semver, error) {
parts := strings.Split(strings.TrimSpace(version), ".")
if len(parts) != 3 {

View File

@@ -0,0 +1,123 @@
package vociferate
import (
"os"
"path/filepath"
"testing"
)
func TestNormalizeRepoURL(t *testing.T) {
t.Parallel()
tests := []struct {
name string
remoteURL string
wantURL string
wantOK bool
}{
{name: "https", remoteURL: "https://git.hrafn.xyz/aether/vociferate.git", wantURL: "https://git.hrafn.xyz/aether/vociferate", wantOK: true},
{name: "http", remoteURL: "http://teapot:3000/aether/vociferate.git", wantURL: "http://teapot:3000/aether/vociferate", wantOK: true},
{name: "ssh with scheme", remoteURL: "ssh://git@git.hrafn.xyz/aether/vociferate.git", wantURL: "https://git.hrafn.xyz/aether/vociferate", wantOK: true},
{name: "scp style", remoteURL: "git@git.hrafn.xyz:aether/vociferate.git", wantURL: "https://git.hrafn.xyz/aether/vociferate", wantOK: true},
{name: "empty", remoteURL: "", wantURL: "", wantOK: false},
{name: "unsupported scheme", remoteURL: "ftp://example.com/repo.git", wantURL: "", wantOK: false},
{name: "invalid ssh missing user", remoteURL: "ssh://git.hrafn.xyz/aether/vociferate.git", wantURL: "", wantOK: false},
{name: "invalid scp style", remoteURL: "git.hrafn.xyz:aether/vociferate.git", wantURL: "", wantOK: false},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
gotURL, gotOK := normalizeRepoURL(tt.remoteURL)
if gotOK != tt.wantOK {
t.Fatalf("normalizeRepoURL(%q) ok = %v, want %v", tt.remoteURL, gotOK, tt.wantOK)
}
if gotURL != tt.wantURL {
t.Fatalf("normalizeRepoURL(%q) url = %q, want %q", tt.remoteURL, gotURL, tt.wantURL)
}
})
}
}
func TestParseSemver(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
want semver
wantErr bool
}{
{name: "valid", input: "1.2.3", want: semver{major: 1, minor: 2, patch: 3}, wantErr: false},
{name: "missing part", input: "1.2", wantErr: true},
{name: "non numeric", input: "1.two.3", wantErr: true},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := parseSemver(tt.input)
if tt.wantErr {
if err == nil {
t.Fatalf("parseSemver(%q) expected error", tt.input)
}
return
}
if err != nil {
t.Fatalf("parseSemver(%q) unexpected error: %v", tt.input, err)
}
if got != tt.want {
t.Fatalf("parseSemver(%q) = %+v, want %+v", tt.input, got, tt.want)
}
})
}
}
func TestOriginRemoteURLFromGitConfig(t *testing.T) {
t.Parallel()
t.Run("origin exists", func(t *testing.T) {
t.Parallel()
config := "[core]\n\trepositoryformatversion = 0\n[remote \"origin\"]\n\turl = git@git.hrafn.xyz:aether/vociferate.git\n"
url, ok := originRemoteURLFromGitConfig(config)
if !ok {
t.Fatal("expected origin url to be found")
}
if url != "git@git.hrafn.xyz:aether/vociferate.git" {
t.Fatalf("unexpected url: %q", url)
}
})
t.Run("origin missing", func(t *testing.T) {
t.Parallel()
config := "[core]\n\trepositoryformatversion = 0\n[remote \"upstream\"]\n\turl = git@git.hrafn.xyz:aether/vociferate.git\n"
_, ok := originRemoteURLFromGitConfig(config)
if ok {
t.Fatal("expected origin url to be absent")
}
})
}
func TestDeriveRepositoryURLFromGitConfigFallback(t *testing.T) {
t.Setenv("GITHUB_SERVER_URL", "")
t.Setenv("GITHUB_REPOSITORY", "")
root := t.TempDir()
configPath := filepath.Join(root, ".git", "config")
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
t.Fatalf("mkdir .git: %v", err)
}
if err := os.WriteFile(configPath, []byte("[remote \"origin\"]\n\turl = git@git.hrafn.xyz:aether/vociferate.git\n"), 0o644); err != nil {
t.Fatalf("write git config: %v", err)
}
url, ok := deriveRepositoryURL(root)
if !ok {
t.Fatal("expected repository URL from git config")
}
if url != "https://git.hrafn.xyz/aether/vociferate" {
t.Fatalf("unexpected repository URL: %q", url)
}
}

View File

@@ -21,6 +21,15 @@ func TestPrepareSuite(t *testing.T) {
func (s *PrepareSuite) SetupTest() {
s.rootDir = s.T().TempDir()
s.T().Setenv("GITHUB_SERVER_URL", "")
s.T().Setenv("GITHUB_REPOSITORY", "")
require.NoError(s.T(), os.MkdirAll(filepath.Join(s.rootDir, ".git"), 0o755))
require.NoError(s.T(), os.WriteFile(
filepath.Join(s.rootDir, ".git", "config"),
[]byte("[remote \"origin\"]\n\turl = git@git.hrafn.xyz:aether/vociferate.git\n"),
0o644,
))
require.NoError(s.T(), os.WriteFile(
filepath.Join(s.rootDir, "release-version"),
[]byte("1.1.6\n"),
@@ -45,7 +54,7 @@ func (s *PrepareSuite) TestPrepare_UpdatesVersionAndPromotesUnreleasedNotes() {
changelogBytes, err := os.ReadFile(filepath.Join(s.rootDir, "changelog.md"))
require.NoError(s.T(), err)
require.Equal(s.T(), "# Changelog\n\n## [Unreleased]\n\n## [1.1.7] - 2026-03-20\n\n### Breaking\n\n### Added\n\n- New thing.\n\n### Fixed\n\n- Old thing.\n\n## [1.1.6] - 2017-12-20\n\n### Fixed\n\n- Historical note.\n", string(changelogBytes))
require.Equal(s.T(), "# Changelog\n\n## [Unreleased]\n\n## [1.1.7] - 2026-03-20\n\n### Breaking\n\n### Added\n\n- New thing.\n\n### Fixed\n\n- Old thing.\n\n## [1.1.6] - 2017-12-20\n\n### Fixed\n\n- Historical note.\n\n[Unreleased]: https://git.hrafn.xyz/aether/vociferate/src/branch/main\n[1.1.7]: https://git.hrafn.xyz/aether/vociferate/releases/tag/v1.1.7\n[1.1.6]: https://git.hrafn.xyz/aether/vociferate/releases/tag/v1.1.6\n", string(changelogBytes))
}
func (s *PrepareSuite) TestPrepare_ReturnsErrorWhenUnreleasedSectionMissing() {
@@ -223,3 +232,41 @@ func (s *PrepareSuite) TestRecommendedTag_UsesCustomVersionFileAndPattern() {
require.NoError(s.T(), err)
require.Equal(s.T(), "v2.4.0", tag)
}
func (s *PrepareSuite) TestPrepare_UsesGitHrafnXYZEnvironmentForChangelogLinks() {
s.T().Setenv("GITHUB_SERVER_URL", "https://git.hrafn.xyz")
s.T().Setenv("GITHUB_REPOSITORY", "aether/vociferate")
err := vociferate.Prepare(s.rootDir, "1.1.7", "2026-03-20", vociferate.Options{})
require.NoError(s.T(), err)
changelogBytes, readErr := os.ReadFile(filepath.Join(s.rootDir, "changelog.md"))
require.NoError(s.T(), readErr)
changelog := string(changelogBytes)
require.Contains(s.T(), changelog, "## [Unreleased]\n")
require.Contains(s.T(), changelog, "## [1.1.7] - 2026-03-20")
require.Contains(s.T(), changelog, "## [1.1.6] - 2017-12-20")
require.Contains(s.T(), changelog, "[Unreleased]: https://git.hrafn.xyz/aether/vociferate/src/branch/main")
require.Contains(s.T(), changelog, "[1.1.7]: https://git.hrafn.xyz/aether/vociferate/releases/tag/v1.1.7")
require.Contains(s.T(), changelog, "[1.1.6]: https://git.hrafn.xyz/aether/vociferate/releases/tag/v1.1.6")
}
func (s *PrepareSuite) TestPrepare_UsesGitHubEnvironmentForChangelogLinks() {
s.T().Setenv("GITHUB_SERVER_URL", "https://github.com")
s.T().Setenv("GITHUB_REPOSITORY", "aether/vociferate")
err := vociferate.Prepare(s.rootDir, "1.1.7", "2026-03-20", vociferate.Options{})
require.NoError(s.T(), err)
changelogBytes, readErr := os.ReadFile(filepath.Join(s.rootDir, "changelog.md"))
require.NoError(s.T(), readErr)
changelog := string(changelogBytes)
require.Contains(s.T(), changelog, "## [Unreleased]\n")
require.Contains(s.T(), changelog, "## [1.1.7] - 2026-03-20")
require.Contains(s.T(), changelog, "## [1.1.6] - 2017-12-20")
require.Contains(s.T(), changelog, "[Unreleased]: https://github.com/aether/vociferate/src/branch/main")
require.Contains(s.T(), changelog, "[1.1.7]: https://github.com/aether/vociferate/releases/tag/v1.1.7")
require.Contains(s.T(), changelog, "[1.1.6]: https://github.com/aether/vociferate/releases/tag/v1.1.6")
}

229
prepare/action.yml Normal file
View File

@@ -0,0 +1,229 @@
name: vociferate/prepare
description: >
Download vociferate, prepare release files, then commit, tag, and push.
The repository must be checked out before this action runs.
inputs:
version:
description: >
Optional semantic version override (with or without leading v). When
omitted, the recommended next version is derived from the changelog.
required: false
default: ''
version-file:
description: >
Path to version file relative to repository root. When omitted, the
current version is derived from the most recent released section in
the changelog.
required: false
default: ''
version-pattern:
description: >
Regular expression with one capture group containing the version value.
Only required when version-file is set.
required: false
default: ''
changelog:
description: Path to changelog file relative to repository root.
required: false
default: changelog.md
git-user-name:
description: Name for the release commit author.
required: false
default: 'gitea-actions[bot]'
git-user-email:
description: Email for the release commit author.
required: false
default: 'gitea-actions[bot]@users.noreply.local'
git-add-files:
description: >
Space-separated list of file paths to stage for the release commit.
Defaults to changelog.md and release-version. Adjust when using a
custom version-file.
required: false
default: 'changelog.md release-version'
outputs:
version:
description: >
The resolved version tag (e.g. v1.2.3) that was committed and pushed.
value: ${{ steps.run-vociferate.outputs.version }}
runs:
using: composite
steps:
- name: Resolve vociferate binary metadata
id: resolve-binary
shell: bash
env:
ACTION_REF: ${{ github.action_ref }}
ACTION_REPOSITORY: ${{ github.action_repository }}
CACHE_TOKEN: ${{ env.VOCIFERATE_CACHE_TOKEN }}
SERVER_URL: ${{ github.server_url }}
API_URL: ${{ github.api_url }}
TOKEN: ${{ github.token }}
RUNNER_ARCH: ${{ runner.arch }}
RUNNER_TEMP: ${{ runner.temp }}
run: |
set -euo pipefail
case "$RUNNER_ARCH" in
X64) arch="amd64" ;;
ARM64) arch="arm64" ;;
*)
echo "Unsupported runner architecture: $RUNNER_ARCH" >&2
exit 1
;;
esac
if [[ "$ACTION_REF" == v* ]]; then
release_tag="$ACTION_REF"
normalized_version="${release_tag#v}"
asset_name="vociferate_${normalized_version}_linux_${arch}"
cache_dir="${RUNNER_TEMP}/vociferate/${release_tag}/linux-${arch}"
binary_path="${cache_dir}/vociferate"
asset_url="${SERVER_URL}/aether/vociferate/releases/download/${release_tag}/${asset_name}"
provided_cache_token="$(printf '%s' "${CACHE_TOKEN:-}" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
if [[ -n "$provided_cache_token" ]]; then
cache_token="$provided_cache_token"
else
cache_token="${ACTION_REPOSITORY:-aether/vociferate}-${release_tag}"
fi
mkdir -p "$cache_dir"
echo "use_binary=true" >> "$GITHUB_OUTPUT"
echo "release_tag=$release_tag" >> "$GITHUB_OUTPUT"
echo "cache_token=$cache_token" >> "$GITHUB_OUTPUT"
echo "asset_name=$asset_name" >> "$GITHUB_OUTPUT"
echo "asset_url=$asset_url" >> "$GITHUB_OUTPUT"
echo "cache_dir=$cache_dir" >> "$GITHUB_OUTPUT"
echo "binary_path=$binary_path" >> "$GITHUB_OUTPUT"
else
echo "use_binary=false" >> "$GITHUB_OUTPUT"
fi
- name: Setup Go
if: steps.resolve-binary.outputs.use_binary != 'true'
uses: actions/setup-go@v5
with:
go-version: '1.26.1'
cache: false
- name: Restore cached vociferate binary
id: cache-vociferate
if: steps.resolve-binary.outputs.use_binary == 'true'
uses: actions/cache@v4
with:
path: ${{ steps.resolve-binary.outputs.cache_dir }}
key: vociferate-${{ steps.resolve-binary.outputs.cache_token }}-linux-${{ runner.arch }}
- name: Download vociferate binary
if: steps.resolve-binary.outputs.use_binary == 'true' && steps.cache-vociferate.outputs.cache-hit != 'true'
shell: bash
env:
TOKEN: ${{ github.token }}
ASSET_URL: ${{ steps.resolve-binary.outputs.asset_url }}
BINARY_PATH: ${{ steps.resolve-binary.outputs.binary_path }}
run: |
set -euo pipefail
curl --fail --location \
-H "Authorization: token ${TOKEN}" \
-o "$BINARY_PATH" \
"$ASSET_URL"
chmod +x "$BINARY_PATH"
- name: Run vociferate
id: run-vociferate
shell: bash
env:
VOCIFERATE_BIN: ${{ steps.resolve-binary.outputs.binary_path }}
USE_BINARY: ${{ steps.resolve-binary.outputs.use_binary }}
INPUT_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
if [[ "$USE_BINARY" == "true" ]]; then
run_vociferate() { "$VOCIFERATE_BIN" "$@"; }
else
action_root="$(realpath "$GITHUB_ACTION_PATH/..")"
run_vociferate() { (cd "$action_root" && go run ./cmd/vociferate "$@"); }
fi
common_args=(--root "$GITHUB_WORKSPACE")
if [[ -n "${{ inputs.version-file }}" ]]; then
common_args+=(--version-file "${{ inputs.version-file }}")
fi
if [[ -n "${{ inputs.version-pattern }}" ]]; then
common_args+=(--version-pattern "${{ inputs.version-pattern }}")
fi
if [[ -n "${{ inputs.changelog }}" ]]; then
common_args+=(--changelog "${{ inputs.changelog }}")
fi
provided_version="$(printf '%s' "${INPUT_VERSION:-}" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
if [[ -z "$provided_version" ]]; then
provided_version="$(run_vociferate "${common_args[@]}" --recommend)"
fi
normalized_version="${provided_version#v}"
tag="v${normalized_version}"
run_vociferate "${common_args[@]}" --version "$provided_version" --date "$(date -u +%F)"
echo "version=$tag" >> "$GITHUB_OUTPUT"
- name: Commit and push release
shell: bash
env:
TOKEN: ${{ github.token }}
GIT_USER_NAME: ${{ inputs.git-user-name }}
GIT_USER_EMAIL: ${{ inputs.git-user-email }}
GIT_ADD_FILES: ${{ inputs.git-add-files }}
RELEASE_TAG: ${{ steps.run-vociferate.outputs.version }}
GITHUB_SERVER_URL: ${{ github.server_url }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: |
set -euo pipefail
case "$GITHUB_SERVER_URL" in
https://*)
authed_remote="https://oauth2:${TOKEN}@${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY}.git"
;;
http://*)
authed_remote="http://oauth2:${TOKEN}@${GITHUB_SERVER_URL#http://}/${GITHUB_REPOSITORY}.git"
;;
*)
echo "Unsupported GITHUB_SERVER_URL: ${GITHUB_SERVER_URL}" >&2
exit 1
;;
esac
git config user.name "$GIT_USER_NAME"
git config user.email "$GIT_USER_EMAIL"
git remote set-url origin "$authed_remote"
if git rev-parse "$RELEASE_TAG" >/dev/null 2>&1 || git ls-remote --exit-code --tags origin "refs/tags/${RELEASE_TAG}" >/dev/null 2>&1; then
echo "Tag ${RELEASE_TAG} already exists; no new tag can be pushed, so tag-triggered release publication will not run." >&2
echo "Choose a new version (or update changelog content so recommendation advances) and run Prepare Release again." >&2
exit 1
fi
for f in $GIT_ADD_FILES; do
git add "$f"
done
if git diff --cached --quiet; then
echo "No staged release file changes; tagging current HEAD as ${RELEASE_TAG}."
else
git commit -m "release: prepare ${RELEASE_TAG}"
fi
git tag "$RELEASE_TAG"
git push origin HEAD
git push origin "$RELEASE_TAG"

155
publish/action.yml Normal file
View File

@@ -0,0 +1,155 @@
name: vociferate/publish
description: >
Extract release notes from the changelog and create or update a
Gitea/GitHub release. The repository must be checked out at the release
tag before this action runs.
inputs:
token:
description: >
Token used to authenticate release API calls. Defaults to the
workflow token.
required: false
default: ''
version:
description: >
Semantic version to publish (with or without leading v). When omitted,
derived from the current git tag ref.
required: false
default: ''
changelog:
description: Path to changelog file relative to repository root.
required: false
default: changelog.md
outputs:
release-id:
description: Numeric ID of the created or updated release.
value: ${{ steps.create-release.outputs.id }}
tag:
description: The tag used for the release (e.g. v1.2.3).
value: ${{ steps.resolve-version.outputs.tag }}
version:
description: The bare version string without leading v (e.g. 1.2.3).
value: ${{ steps.resolve-version.outputs.version }}
runs:
using: composite
steps:
- name: Resolve release version
id: resolve-version
shell: bash
env:
INPUT_VERSION: ${{ inputs.version }}
GITHUB_REF_VALUE: ${{ github.ref }}
run: |
set -euo pipefail
provided="$(printf '%s' "${INPUT_VERSION:-}" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
if [[ -n "$provided" ]]; then
normalized="${provided#v}"
tag="v${normalized}"
elif [[ "${GITHUB_REF_VALUE}" == refs/tags/* ]]; then
tag="${GITHUB_REF_VALUE#refs/tags/}"
normalized="${tag#v}"
elif head_tag="$(git describe --exact-match --tags HEAD 2>/dev/null)" && [[ -n "$head_tag" ]]; then
tag="$head_tag"
normalized="${tag#v}"
else
echo "A version 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" >> "$GITHUB_OUTPUT"
- name: Extract release notes from changelog
id: extract-notes
shell: bash
env:
CHANGELOG: ${{ inputs.changelog != '' && inputs.changelog || 'changelog.md' }}
RELEASE_VERSION: ${{ steps.resolve-version.outputs.version }}
RUNNER_TEMP: ${{ runner.temp }}
run: |
set -euo pipefail
release_notes="$(awk -v version="$RELEASE_VERSION" '
$0 ~ "^## \\[" version "\\]" {capture=1}
capture {
if ($0 ~ "^## \\[" && $0 !~ "^## \\[" version "\\]") exit
print
}
' "$CHANGELOG")"
if [[ -z "${release_notes//[[:space:]]/}" ]]; then
echo "Release notes section for ${RELEASE_VERSION} was not found in ${CHANGELOG}" >&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: create-release
shell: bash
env:
TOKEN: ${{ inputs.token != '' && inputs.token || github.token }}
TAG_NAME: ${{ steps.resolve-version.outputs.tag }}
RELEASE_NOTES_FILE: ${{ steps.extract-notes.outputs.notes_file }}
GITHUB_API_URL: ${{ github.api_url }}
GITHUB_SERVER_URL: ${{ github.server_url }}
GITHUB_SHA: ${{ github.sha }}
GITHUB_REPOSITORY: ${{ github.repository }}
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 ${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 ${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
echo "id=$existing_release_id" >> "$GITHUB_OUTPUT"
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 ${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
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"
fi

1
release-version Normal file
View File

@@ -0,0 +1 @@
0.1.0