Compare commits
41 Commits
ddb99c482c
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5ecfeebde | ||
|
|
6bcc027076 | ||
|
|
fd23a8b238 | ||
|
|
3f7edea46e | ||
|
|
0234df7aa1 | ||
|
|
501ac71147 | ||
|
|
60bbc7409b | ||
|
|
4c5a49d685 | ||
|
|
87059d21fd | ||
|
|
3ea4af158e | ||
|
|
7ea5f05297 | ||
|
|
71c1b81426 | ||
|
|
68e4211fbf | ||
|
|
50e5f25329 | ||
|
|
8ea9acdebc | ||
|
|
be4f3833a1 | ||
|
|
63aa7376cc | ||
|
|
e8e1dc9695 | ||
|
|
d63bfca291 | ||
|
|
c079bf766f | ||
|
|
d6f178ede9 | ||
|
|
d6fa61dc7c | ||
|
|
bab7b74da8 | ||
|
|
1b7281c168 | ||
|
|
011cca2334 | ||
|
|
dda898868f | ||
|
|
b793e1b289 | ||
|
|
55a067973e | ||
|
|
647d8cf76f | ||
|
|
4ae6f34931 | ||
|
|
8c2835fe2e | ||
|
|
c859a3fccb | ||
|
|
26b197299f | ||
|
|
4a3db8c08c | ||
|
|
2646d42523 | ||
|
|
a413385c4e | ||
|
|
5cb0010531 | ||
|
|
fd7660721a | ||
|
|
8fefbf1997 | ||
|
|
7a5b371539 | ||
|
|
cb6723818b |
205
.gitea/workflows/do-release.yml
Normal file
205
.gitea/workflows/do-release.yml
Normal file
@@ -0,0 +1,205 @@
|
||||
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.publish.outputs.tag }}
|
||||
version: ${{ steps.publish.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: Checkout requested tag
|
||||
if: ${{ inputs.tag != '' }}
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
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
|
||||
with:
|
||||
go-version: '1.26.1'
|
||||
check-latest: true
|
||||
cache: true
|
||||
cache-dependency-path: go.sum
|
||||
|
||||
- name: Create or update release
|
||||
id: publish
|
||||
uses: ./publish
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
version: ${{ inputs.tag }}
|
||||
|
||||
- name: Build release binaries
|
||||
env:
|
||||
RELEASE_VERSION: ${{ steps.publish.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.publish.outputs.release-id }}
|
||||
RELEASE_VERSION: ${{ steps.publish.outputs.version }}
|
||||
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.publish.outputs.tag }}
|
||||
RELEASE_VERSION: ${{ steps.publish.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"
|
||||
@@ -4,24 +4,25 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: Semantic version to release, with or without leading v.
|
||||
required: true
|
||||
description: Optional semantic version override, with or without leading v. When omitted, the recommended version is used.
|
||||
required: false
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
description: Semantic version to release, with or without leading v.
|
||||
required: true
|
||||
description: Optional semantic version override, with or without leading v. When omitted, the recommended version is used.
|
||||
required: false
|
||||
default: ''
|
||||
type: string
|
||||
|
||||
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
|
||||
@@ -36,187 +37,35 @@ jobs:
|
||||
cache: true
|
||||
cache-dependency-path: go.sum
|
||||
|
||||
- name: Prepare release files
|
||||
env:
|
||||
RELEASE_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
./script/prepare-release.sh "$RELEASE_VERSION"
|
||||
|
||||
- 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
|
||||
- name: Prepare and tag release
|
||||
id: prepare
|
||||
uses: ./prepare
|
||||
env:
|
||||
RELEASE_VERSION: ${{ inputs.version }}
|
||||
VOCIFERATE_CACHE_TOKEN: ${{ steps.cache-token.outputs.value }}
|
||||
with:
|
||||
version: ${{ inputs.version }}
|
||||
|
||||
- name: Summarize prepared release
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="${{ steps.prepare.outputs.version }}"
|
||||
{
|
||||
echo "## Release Prepared"
|
||||
echo
|
||||
echo "- Tag pushed: ${tag}"
|
||||
echo "- Calling Do Release workflow for ${tag}."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
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 internal/releaseprep/version/version.go
|
||||
git commit -m "release: prepare ${tag}"
|
||||
git tag "$tag"
|
||||
git push origin HEAD
|
||||
git push origin "$tag"
|
||||
|
||||
- name: Create release with changelog notes
|
||||
env:
|
||||
RELEASE_VERSION: ${{ inputs.version }}
|
||||
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
|
||||
env:
|
||||
RELEASE_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
normalized_version="${RELEASE_VERSION#v}"
|
||||
mkdir -p dist
|
||||
|
||||
for target in darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64 windows/arm64; do
|
||||
os="${target%/*}"
|
||||
arch="${target#*/}"
|
||||
ext=""
|
||||
if [[ "$os" == "windows" ]]; then
|
||||
ext=".exe"
|
||||
fi
|
||||
|
||||
bin="vociferate_${normalized_version}_${os}_${arch}${ext}"
|
||||
GOOS="$os" GOARCH="$arch" go build -trimpath -ldflags="-s -w" -o "dist/${bin}" ./cmd/releaseprep
|
||||
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
|
||||
publish:
|
||||
needs: prepare
|
||||
uses: ./.gitea/workflows/do-release.yml
|
||||
with:
|
||||
tag: ${{ needs.prepare.outputs.tag }}
|
||||
secrets: inherit
|
||||
|
||||
@@ -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,18 +34,96 @@ 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' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if recommended_tag="$(go run ./cmd/releaseprep --recommend --root . 2>release-recommendation.err)"; then
|
||||
if recommended_tag="$(go run ./cmd/vociferate --recommend --root . 2>release-recommendation.err)"; then
|
||||
{
|
||||
echo
|
||||
echo '## Release Recommendation'
|
||||
|
||||
191
README.md
191
README.md
@@ -1,6 +1,123 @@
|
||||
# vociferate
|
||||
|
||||
A reusable release preparation tool for Go repositories.
|
||||
[](https://git.hrafn.xyz/aether/vociferate/actions/runs/latest?workflow=push-validation.yml&branch=main&event=push)
|
||||
[](https://git.hrafn.xyz/aether/vociferate/actions/runs/latest?workflow=prepare-release.yml)
|
||||
[](https://git.hrafn.xyz/aether/vociferate/actions/runs/latest?workflow=do-release.yml)
|
||||
[](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
|
||||
|
||||
@@ -13,7 +130,7 @@ just go-build
|
||||
Or directly with Go:
|
||||
|
||||
```bash
|
||||
go build -o dist/releaseprep ./cmd/releaseprep
|
||||
go build -o dist/vociferate ./cmd/vociferate
|
||||
```
|
||||
|
||||
## Usage
|
||||
@@ -21,13 +138,15 @@ go build -o dist/releaseprep ./cmd/releaseprep
|
||||
Prepare release files:
|
||||
|
||||
```bash
|
||||
go run ./cmd/releaseprep --version v1.2.3 --date 2026-03-20 --root .
|
||||
go run ./cmd/vociferate --version v1.2.3 --date 2026-03-20 --root .
|
||||
```
|
||||
|
||||
In the provided workflow and composite action, `version` is optional. When it is omitted, vociferate computes and uses the recommended next version automatically.
|
||||
|
||||
Recommend next release tag from changelog content:
|
||||
|
||||
```bash
|
||||
go run ./cmd/releaseprep --recommend --root .
|
||||
go run ./cmd/vociferate --recommend --root .
|
||||
```
|
||||
|
||||
### Flags
|
||||
@@ -42,62 +161,40 @@ go run ./cmd/releaseprep --recommend --root .
|
||||
|
||||
Defaults:
|
||||
|
||||
- `version-file`: `internal/releaseprep/version/version.go`
|
||||
- `version-pattern`: `const String = "([^"]+)"`
|
||||
- `version-file`: `release-version`
|
||||
- `version-pattern`: `^\s*([^\r\n]+)\s*$`
|
||||
- `changelog`: `changelog.md`
|
||||
|
||||
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.
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
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.
|
||||
- `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.
|
||||
|
||||
Calling `Do Release` directly avoids environments where tag pushes from workflow tokens do not emit a follow-up workflow trigger event.
|
||||
|
||||
## Release Artifacts
|
||||
|
||||
The `Prepare Release` workflow creates a release and uploads prebuilt `vociferate` binaries for:
|
||||
The tag-driven `Do Release` workflow publishes prebuilt `vociferate` binaries for:
|
||||
|
||||
- `darwin/amd64`
|
||||
- `darwin/arm64`
|
||||
- `linux/amd64`
|
||||
- `linux/arm64`
|
||||
- `windows/amd64`
|
||||
- `windows/arm64`
|
||||
|
||||
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:
|
||||
|
||||
```yaml
|
||||
- name: Prepare release files
|
||||
uses: git.hrafn.xyz/aether/vociferate@main
|
||||
with:
|
||||
version: v1.2.3
|
||||
version-file: internal/myapp/version/version.go
|
||||
version-pattern: 'const Version = "([^"]+)"'
|
||||
changelog: changelog.md
|
||||
```
|
||||
|
||||
Call the reusable release workflow:
|
||||
|
||||
```yaml
|
||||
name: Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: Semantic version to release.
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
release:
|
||||
uses: aether/vociferate/.gitea/workflows/prepare-release.yml@main
|
||||
with:
|
||||
version: ${{ inputs.version }}
|
||||
secrets: inherit
|
||||
```
|
||||
|
||||
144
action.yml
144
action.yml
@@ -1,16 +1,16 @@
|
||||
name: releaseprep
|
||||
name: vociferate
|
||||
description: Prepare release files or recommend a next semantic version tag.
|
||||
|
||||
inputs:
|
||||
version:
|
||||
description: Semantic version to release.
|
||||
description: Optional semantic version override. When omitted, the recommended version is used.
|
||||
required: false
|
||||
version-file:
|
||||
description: Path to version file relative to repository root.
|
||||
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 for current version.
|
||||
description: Regular expression with one capture group for current version. Only required when version-file is set.
|
||||
required: false
|
||||
default: ''
|
||||
changelog:
|
||||
@@ -22,42 +22,144 @@ inputs:
|
||||
required: false
|
||||
default: 'false'
|
||||
|
||||
outputs:
|
||||
version:
|
||||
description: Resolved version used for prepare mode, or the emitted recommended version for recommend mode.
|
||||
value: ${{ steps.run-vociferate.outputs.version }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.26.1'
|
||||
|
||||
- name: Run releaseprep
|
||||
- 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
|
||||
|
||||
args=(--root .)
|
||||
case "$RUNNER_ARCH" in
|
||||
X64)
|
||||
arch="amd64"
|
||||
;;
|
||||
ARM64)
|
||||
arch="arm64"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported runner architecture: $RUNNER_ARCH" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ "${{ inputs.recommend }}" == "true" ]]; then
|
||||
args+=(--recommend)
|
||||
else
|
||||
if [[ -z "${{ inputs.version }}" ]]; then
|
||||
echo "input 'version' is required when recommend is false" >&2
|
||||
exit 2
|
||||
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
|
||||
args+=(--version "${{ inputs.version }}" --date "$(date -u +%F)")
|
||||
|
||||
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: 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.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 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
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
|
||||
args+=(--version-file "${{ inputs.version-file }}")
|
||||
common_args+=(--version-file "${{ inputs.version-file }}")
|
||||
fi
|
||||
|
||||
if [[ -n "${{ inputs.version-pattern }}" ]]; then
|
||||
args+=(--version-pattern "${{ inputs.version-pattern }}")
|
||||
common_args+=(--version-pattern "${{ inputs.version-pattern }}")
|
||||
fi
|
||||
|
||||
if [[ -n "${{ inputs.changelog }}" ]]; then
|
||||
args+=(--changelog "${{ inputs.changelog }}")
|
||||
common_args+=(--changelog "${{ inputs.changelog }}")
|
||||
fi
|
||||
|
||||
go run git.hrafn.xyz/aether/vociferate/cmd/releaseprep@latest "${args[@]}"
|
||||
if [[ "${{ inputs.recommend }}" == "true" ]]; then
|
||||
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="$(run_vociferate "${common_args[@]}" --recommend)"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "version=$resolved_version" >> "$GITHUB_OUTPUT"
|
||||
run_vociferate "${common_args[@]}" --version "$resolved_version" --date "$(date -u +%F)"
|
||||
|
||||
52
changelog.md
52
changelog.md
@@ -9,41 +9,39 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.0] - 2026-03-20
|
||||
|
||||
### Breaking
|
||||
|
||||
### Added
|
||||
|
||||
- Reusable `workflow_call` support for the `Prepare Release` workflow, enabling other repositories to invoke it directly.
|
||||
- Automated release artifact publishing in the release workflow for `darwin`, `linux`, and `windows` binaries plus `checksums.txt`.
|
||||
- README guidance for release artifacts and examples for reusing vociferate as a composite action or reusable workflow.
|
||||
## [0.1.0] - 2026-03-20
|
||||
|
||||
### Changed
|
||||
|
||||
- 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.
|
||||
- README workflow badges now link to the latest workflow run pages instead of the workflow definition pages.
|
||||
|
||||
### Fixed
|
||||
|
||||
### Removed
|
||||
|
||||
## [1.0.0] - 2026-03-20
|
||||
|
||||
### Breaking
|
||||
- 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
|
||||
|
||||
- Initial standalone releaseprep migration into vociferate.
|
||||
- Configurable version source and parser via `--version-file` and `--version-pattern`.
|
||||
- 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`.
|
||||
- Composite action (`action.yml`) for release preparation and recommendation flows.
|
||||
- Gitea workflows for push validation and manual release preparation.
|
||||
- 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.
|
||||
|
||||
### Changed
|
||||
|
||||
- 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.
|
||||
|
||||
### Fixed
|
||||
|
||||
### Removed
|
||||
[Unreleased]: http://teapot:3000/aether/vociferate/src/branch/main
|
||||
[0.1.0]: http://teapot:3000/aether/vociferate/releases/tag/v0.1.0
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"git.hrafn.xyz/aether/vociferate/internal/releaseprep"
|
||||
"git.hrafn.xyz/aether/vociferate/internal/vociferate"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -25,14 +25,14 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
opts := releaseprep.Options{
|
||||
opts := vociferate.Options{
|
||||
VersionFile: *versionFile,
|
||||
VersionPattern: *versionPattern,
|
||||
Changelog: *changelog,
|
||||
}
|
||||
|
||||
if *recommend {
|
||||
tag, err := releaseprep.RecommendedTag(absRoot, opts)
|
||||
tag, err := vociferate.RecommendedTag(absRoot, opts)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "recommend release: %v\n", err)
|
||||
os.Exit(1)
|
||||
@@ -42,11 +42,11 @@ func main() {
|
||||
}
|
||||
|
||||
if *version == "" || *date == "" {
|
||||
fmt.Fprintln(os.Stderr, "usage: releaseprep --version <version> --date <YYYY-MM-DD> [--root <dir>] [--version-file <path>] [--version-pattern <regexp>] [--changelog <path>] | --recommend [--root <dir>] [--version-file <path>] [--version-pattern <regexp>] [--changelog <path>]")
|
||||
fmt.Fprintln(os.Stderr, "usage: vociferate --version <version> --date <YYYY-MM-DD> [--root <dir>] [--version-file <path>] [--version-pattern <regexp>] [--changelog <path>] | --recommend [--root <dir>] [--version-file <path>] [--version-pattern <regexp>] [--changelog <path>]")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
if err := releaseprep.Prepare(absRoot, *version, *date, opts); err != nil {
|
||||
if err := vociferate.Prepare(absRoot, *version, *date, opts); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "prepare release: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
120
cmd/vociferate/main_test.go
Normal file
120
cmd/vociferate/main_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
package releaseprep
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultVersionFile = "internal/releaseprep/version/version.go"
|
||||
defaultVersionExpr = `const String = "([^"]+)"`
|
||||
defaultChangelog = "changelog.md"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
VersionFile string
|
||||
VersionPattern string
|
||||
Changelog string
|
||||
}
|
||||
|
||||
type semver struct {
|
||||
major int
|
||||
minor int
|
||||
patch int
|
||||
}
|
||||
|
||||
type resolvedOptions struct {
|
||||
VersionFile string
|
||||
VersionExpr *regexp.Regexp
|
||||
Changelog string
|
||||
}
|
||||
|
||||
func Prepare(rootDir, version, releaseDate string, options Options) error {
|
||||
normalizedVersion := strings.TrimPrefix(strings.TrimSpace(version), "v")
|
||||
if normalizedVersion == "" {
|
||||
return fmt.Errorf("version must not be empty")
|
||||
}
|
||||
|
||||
releaseDate = strings.TrimSpace(releaseDate)
|
||||
if releaseDate == "" {
|
||||
return fmt.Errorf("release date must not be empty")
|
||||
}
|
||||
|
||||
resolved, err := resolveOptions(options)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := updateVersionFile(rootDir, normalizedVersion, resolved); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := updateChangelog(rootDir, normalizedVersion, releaseDate, resolved.Changelog); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func RecommendedTag(rootDir string, options Options) (string, error) {
|
||||
resolved, err := resolveOptions(options)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
currentVersion, err := readCurrentVersion(rootDir, resolved)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
unreleasedBody, err := readUnreleasedBody(rootDir, resolved.Changelog)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
parsed, err := parseSemver(currentVersion)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.Contains(unreleasedBody, "### Breaking"), sectionHasEntries(unreleasedBody, "Removed"):
|
||||
parsed.major++
|
||||
parsed.minor = 0
|
||||
parsed.patch = 0
|
||||
case sectionHasEntries(unreleasedBody, "Added"):
|
||||
parsed.minor++
|
||||
parsed.patch = 0
|
||||
default:
|
||||
parsed.patch++
|
||||
}
|
||||
|
||||
return fmt.Sprintf("v%d.%d.%d", parsed.major, parsed.minor, parsed.patch), nil
|
||||
}
|
||||
|
||||
func sectionHasEntries(unreleasedBody, sectionName string) bool {
|
||||
heading := "### " + sectionName
|
||||
sectionStart := strings.Index(unreleasedBody, heading)
|
||||
if sectionStart == -1 {
|
||||
return false
|
||||
}
|
||||
|
||||
afterHeading := unreleasedBody[sectionStart+len(heading):]
|
||||
if strings.HasPrefix(afterHeading, "\r") {
|
||||
afterHeading = afterHeading[1:]
|
||||
}
|
||||
if strings.HasPrefix(afterHeading, "\n") {
|
||||
afterHeading = afterHeading[1:]
|
||||
}
|
||||
|
||||
nextHeading := strings.Index(afterHeading, "\n### ")
|
||||
sectionBody := afterHeading
|
||||
if nextHeading != -1 {
|
||||
sectionBody = afterHeading[:nextHeading]
|
||||
}
|
||||
|
||||
return strings.TrimSpace(sectionBody) != ""
|
||||
}
|
||||
|
||||
func resolveOptions(options Options) (resolvedOptions, error) {
|
||||
versionFile := strings.TrimSpace(options.VersionFile)
|
||||
if versionFile == "" {
|
||||
versionFile = defaultVersionFile
|
||||
}
|
||||
|
||||
pattern := strings.TrimSpace(options.VersionPattern)
|
||||
if pattern == "" {
|
||||
pattern = defaultVersionExpr
|
||||
}
|
||||
versionExpr, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return resolvedOptions{}, fmt.Errorf("compile version pattern: %w", err)
|
||||
}
|
||||
if versionExpr.NumSubexp() != 1 {
|
||||
return resolvedOptions{}, fmt.Errorf("version pattern must contain exactly one capture group")
|
||||
}
|
||||
|
||||
changelog := strings.TrimSpace(options.Changelog)
|
||||
if changelog == "" {
|
||||
changelog = defaultChangelog
|
||||
}
|
||||
|
||||
return resolvedOptions{VersionFile: versionFile, VersionExpr: versionExpr, Changelog: changelog}, nil
|
||||
}
|
||||
|
||||
func updateVersionFile(rootDir, version string, options resolvedOptions) error {
|
||||
path := filepath.Join(rootDir, options.VersionFile)
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read version file: %w", err)
|
||||
}
|
||||
|
||||
match := options.VersionExpr.FindStringSubmatch(string(contents))
|
||||
if len(match) < 2 {
|
||||
return fmt.Errorf("version value not found in %s", path)
|
||||
}
|
||||
|
||||
replacement := strings.Replace(match[0], match[1], version, 1)
|
||||
updated := strings.Replace(string(contents), match[0], replacement, 1)
|
||||
if updated == string(contents) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, []byte(updated), 0o644); err != nil {
|
||||
return fmt.Errorf("write version file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateChangelog(rootDir, version, releaseDate, changelogPath string) error {
|
||||
unreleasedBody, text, afterHeader, nextSectionStart, path, err := readChangelogState(rootDir, changelogPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if strings.TrimSpace(unreleasedBody) == "" {
|
||||
return fmt.Errorf("unreleased section is empty")
|
||||
}
|
||||
|
||||
newSection := fmt.Sprintf("## [%s] - %s\n", version, releaseDate)
|
||||
newSection += "\n" + unreleasedBody
|
||||
if !strings.HasSuffix(newSection, "\n") {
|
||||
newSection += "\n"
|
||||
}
|
||||
|
||||
updated := text[:afterHeader] + "\n" + newSection + text[nextSectionStart:]
|
||||
if err := os.WriteFile(path, []byte(updated), 0o644); err != nil {
|
||||
return fmt.Errorf("write changelog: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func readCurrentVersion(rootDir string, options resolvedOptions) (string, error) {
|
||||
path := filepath.Join(rootDir, options.VersionFile)
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read version file: %w", err)
|
||||
}
|
||||
|
||||
match := options.VersionExpr.FindStringSubmatch(string(contents))
|
||||
if len(match) < 2 {
|
||||
return "", fmt.Errorf("version value not found in %s", path)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(match[1]), nil
|
||||
}
|
||||
|
||||
func readUnreleasedBody(rootDir, changelogPath string) (string, error) {
|
||||
unreleasedBody, _, _, _, _, err := readChangelogState(rootDir, changelogPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if strings.TrimSpace(unreleasedBody) == "" {
|
||||
return "", fmt.Errorf("unreleased section is empty")
|
||||
}
|
||||
|
||||
return unreleasedBody, nil
|
||||
}
|
||||
|
||||
func readChangelogState(rootDir, changelogPath string) (string, string, int, int, string, error) {
|
||||
path := filepath.Join(rootDir, changelogPath)
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", "", 0, 0, "", fmt.Errorf("read changelog: %w", err)
|
||||
}
|
||||
|
||||
text := string(contents)
|
||||
unreleasedHeader := "## [Unreleased]\n"
|
||||
start := strings.Index(text, unreleasedHeader)
|
||||
if start == -1 {
|
||||
return "", "", 0, 0, "", fmt.Errorf("unreleased section not found in changelog")
|
||||
}
|
||||
|
||||
afterHeader := start + len(unreleasedHeader)
|
||||
nextSectionRelative := strings.Index(text[afterHeader:], "\n## [")
|
||||
if nextSectionRelative == -1 {
|
||||
nextSectionRelative = len(text[afterHeader:])
|
||||
}
|
||||
nextSectionStart := afterHeader + nextSectionRelative
|
||||
unreleasedBody := strings.TrimLeft(text[afterHeader:nextSectionStart], "\n")
|
||||
|
||||
return unreleasedBody, text, afterHeader, nextSectionStart, path, nil
|
||||
}
|
||||
|
||||
func parseSemver(version string) (semver, error) {
|
||||
parts := strings.Split(strings.TrimSpace(version), ".")
|
||||
if len(parts) != 3 {
|
||||
return semver{}, fmt.Errorf("version %q is not semantic major.minor.patch", version)
|
||||
}
|
||||
|
||||
major, err := strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
return semver{}, fmt.Errorf("parse major version: %w", err)
|
||||
}
|
||||
minor, err := strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
return semver{}, fmt.Errorf("parse minor version: %w", err)
|
||||
}
|
||||
patch, err := strconv.Atoi(parts[2])
|
||||
if err != nil {
|
||||
return semver{}, fmt.Errorf("parse patch version: %w", err)
|
||||
}
|
||||
|
||||
return semver{major: major, minor: minor, patch: patch}, nil
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
package releaseprep_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.hrafn.xyz/aether/vociferate/internal/releaseprep"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
type PrepareSuite struct {
|
||||
suite.Suite
|
||||
rootDir string
|
||||
}
|
||||
|
||||
func TestPrepareSuite(t *testing.T) {
|
||||
suite.Run(t, new(PrepareSuite))
|
||||
}
|
||||
|
||||
func (s *PrepareSuite) SetupTest() {
|
||||
s.rootDir = s.T().TempDir()
|
||||
versionDir := filepath.Join(s.rootDir, "internal", "releaseprep", "version")
|
||||
require.NoError(s.T(), os.MkdirAll(versionDir, 0o755))
|
||||
|
||||
require.NoError(s.T(), os.WriteFile(
|
||||
filepath.Join(versionDir, "version.go"),
|
||||
[]byte("package version\n\nconst String = \"1.1.6\"\n"),
|
||||
0o644,
|
||||
))
|
||||
|
||||
require.NoError(s.T(), os.WriteFile(
|
||||
filepath.Join(s.rootDir, "changelog.md"),
|
||||
[]byte("# Changelog\n\n## [Unreleased]\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"),
|
||||
0o644,
|
||||
))
|
||||
}
|
||||
|
||||
func (s *PrepareSuite) TestPrepare_UpdatesVersionAndPromotesUnreleasedNotes() {
|
||||
err := releaseprep.Prepare(s.rootDir, "v1.1.7", "2026-03-20", releaseprep.Options{})
|
||||
|
||||
require.NoError(s.T(), err)
|
||||
|
||||
versionBytes, err := os.ReadFile(filepath.Join(s.rootDir, "internal", "releaseprep", "version", "version.go"))
|
||||
require.NoError(s.T(), err)
|
||||
require.Equal(s.T(), "package version\n\nconst String = \"1.1.7\"\n", string(versionBytes))
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
func (s *PrepareSuite) TestPrepare_ReturnsErrorWhenUnreleasedSectionMissing() {
|
||||
require.NoError(s.T(), os.WriteFile(
|
||||
filepath.Join(s.rootDir, "changelog.md"),
|
||||
[]byte("# Changelog\n\n## [1.1.6] - 2017-12-20\n"),
|
||||
0o644,
|
||||
))
|
||||
|
||||
err := releaseprep.Prepare(s.rootDir, "1.1.7", "2026-03-20", releaseprep.Options{})
|
||||
|
||||
require.ErrorContains(s.T(), err, "unreleased section")
|
||||
}
|
||||
|
||||
func (s *PrepareSuite) TestPrepare_ReturnsErrorWhenUnreleasedSectionIsEmpty() {
|
||||
require.NoError(s.T(), os.WriteFile(
|
||||
filepath.Join(s.rootDir, "changelog.md"),
|
||||
[]byte("# Changelog\n\n## [Unreleased]\n\n## [1.1.6] - 2017-12-20\n"),
|
||||
0o644,
|
||||
))
|
||||
|
||||
err := releaseprep.Prepare(s.rootDir, "1.1.7", "2026-03-20", releaseprep.Options{})
|
||||
|
||||
require.ErrorContains(s.T(), err, "unreleased section is empty")
|
||||
}
|
||||
|
||||
func (s *PrepareSuite) TestRecommendedTag_UsesMajorBumpWhenBreakingHeadingExists() {
|
||||
tag, err := releaseprep.RecommendedTag(s.rootDir, releaseprep.Options{})
|
||||
|
||||
require.NoError(s.T(), err)
|
||||
require.Equal(s.T(), "v2.0.0", tag)
|
||||
}
|
||||
|
||||
func (s *PrepareSuite) TestRecommendedTag_UsesPatchBumpForFixOnlyChanges() {
|
||||
require.NoError(s.T(), os.WriteFile(
|
||||
filepath.Join(s.rootDir, "changelog.md"),
|
||||
[]byte("# Changelog\n\n## [Unreleased]\n\n### Fixed\n\n- Patch note.\n\n## [1.1.6] - 2017-12-20\n"),
|
||||
0o644,
|
||||
))
|
||||
|
||||
tag, err := releaseprep.RecommendedTag(s.rootDir, releaseprep.Options{})
|
||||
|
||||
require.NoError(s.T(), err)
|
||||
require.Equal(s.T(), "v1.1.7", tag)
|
||||
}
|
||||
|
||||
func (s *PrepareSuite) TestRecommendedTag_UsesMajorBumpWhenRemovedEntriesExist() {
|
||||
require.NoError(s.T(), os.WriteFile(
|
||||
filepath.Join(s.rootDir, "changelog.md"),
|
||||
[]byte("# Changelog\n\n## [Unreleased]\n\n### Removed\n\n- Breaking removal.\n\n## [1.1.6] - 2017-12-20\n"),
|
||||
0o644,
|
||||
))
|
||||
|
||||
tag, err := releaseprep.RecommendedTag(s.rootDir, releaseprep.Options{})
|
||||
|
||||
require.NoError(s.T(), err)
|
||||
require.Equal(s.T(), "v2.0.0", tag)
|
||||
}
|
||||
|
||||
func (s *PrepareSuite) TestRecommendedTag_UsesMajorBumpWhenBreakingEntriesExist() {
|
||||
require.NoError(s.T(), os.WriteFile(
|
||||
filepath.Join(s.rootDir, "changelog.md"),
|
||||
[]byte("# Changelog\n\n## [Unreleased]\n\n### Breaking\n\n- Changed API contract.\n\n### Changed\n\n- Updated defaults.\n\n## [1.1.6] - 2017-12-20\n"),
|
||||
0o644,
|
||||
))
|
||||
|
||||
tag, err := releaseprep.RecommendedTag(s.rootDir, releaseprep.Options{})
|
||||
|
||||
require.NoError(s.T(), err)
|
||||
require.Equal(s.T(), "v2.0.0", tag)
|
||||
}
|
||||
|
||||
func (s *PrepareSuite) TestPrepare_UsesCustomVersionFileAndPattern() {
|
||||
customVersionFile := filepath.Join("custom", "VERSION.txt")
|
||||
require.NoError(s.T(), os.MkdirAll(filepath.Join(s.rootDir, "custom"), 0o755))
|
||||
require.NoError(s.T(), os.WriteFile(
|
||||
filepath.Join(s.rootDir, customVersionFile),
|
||||
[]byte("VERSION=1.1.6\n"),
|
||||
0o644,
|
||||
))
|
||||
|
||||
err := releaseprep.Prepare(s.rootDir, "1.1.8", "2026-03-20", releaseprep.Options{
|
||||
VersionFile: customVersionFile,
|
||||
VersionPattern: `VERSION=([^\n]+)`,
|
||||
})
|
||||
|
||||
require.NoError(s.T(), err)
|
||||
|
||||
versionBytes, err := os.ReadFile(filepath.Join(s.rootDir, customVersionFile))
|
||||
require.NoError(s.T(), err)
|
||||
require.Equal(s.T(), "VERSION=1.1.8\n", string(versionBytes))
|
||||
}
|
||||
|
||||
func (s *PrepareSuite) TestPrepare_AllowsUnchangedVersionValue() {
|
||||
require.NoError(s.T(), os.WriteFile(
|
||||
filepath.Join(s.rootDir, "internal", "releaseprep", "version", "version.go"),
|
||||
[]byte("package version\n\nconst String = \"1.1.6\"\n"),
|
||||
0o644,
|
||||
))
|
||||
|
||||
err := releaseprep.Prepare(s.rootDir, "1.1.6", "2026-03-20", releaseprep.Options{})
|
||||
|
||||
require.NoError(s.T(), err)
|
||||
|
||||
versionBytes, readErr := os.ReadFile(filepath.Join(s.rootDir, "internal", "releaseprep", "version", "version.go"))
|
||||
require.NoError(s.T(), readErr)
|
||||
require.Equal(s.T(), "package version\n\nconst String = \"1.1.6\"\n", string(versionBytes))
|
||||
}
|
||||
|
||||
func (s *PrepareSuite) TestRecommendedTag_UsesCustomVersionFileAndPattern() {
|
||||
customVersionFile := filepath.Join("custom", "VERSION.txt")
|
||||
require.NoError(s.T(), os.MkdirAll(filepath.Join(s.rootDir, "custom"), 0o755))
|
||||
require.NoError(s.T(), os.WriteFile(
|
||||
filepath.Join(s.rootDir, customVersionFile),
|
||||
[]byte("VERSION=2.3.4\n"),
|
||||
0o644,
|
||||
))
|
||||
require.NoError(s.T(), os.WriteFile(
|
||||
filepath.Join(s.rootDir, "changelog.md"),
|
||||
[]byte("# Changelog\n\n## [Unreleased]\n\n### Added\n\n- Feature.\n\n## [2.3.4] - 2026-03-10\n"),
|
||||
0o644,
|
||||
))
|
||||
|
||||
tag, err := releaseprep.RecommendedTag(s.rootDir, releaseprep.Options{
|
||||
VersionFile: customVersionFile,
|
||||
VersionPattern: `VERSION=([^\n]+)`,
|
||||
})
|
||||
|
||||
require.NoError(s.T(), err)
|
||||
require.Equal(s.T(), "v2.4.0", tag)
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
package version
|
||||
|
||||
const String = "1.0.0"
|
||||
470
internal/vociferate/vociferate.go
Normal file
470
internal/vociferate/vociferate.go
Normal file
@@ -0,0 +1,470 @@
|
||||
// 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 (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultVersionFile = "release-version"
|
||||
defaultVersionExpr = `^\s*([^\r\n]+)\s*$`
|
||||
defaultChangelog = "changelog.md"
|
||||
)
|
||||
|
||||
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 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 is the path to the changelog file, relative to the repository
|
||||
// root. When empty, changelog.md is used.
|
||||
Changelog string
|
||||
}
|
||||
|
||||
type semver struct {
|
||||
major int
|
||||
minor int
|
||||
patch int
|
||||
}
|
||||
|
||||
type resolvedOptions struct {
|
||||
VersionFile string
|
||||
VersionExpr *regexp.Regexp
|
||||
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 == "" {
|
||||
return fmt.Errorf("version must not be empty")
|
||||
}
|
||||
|
||||
releaseDate = strings.TrimSpace(releaseDate)
|
||||
if releaseDate == "" {
|
||||
return fmt.Errorf("release date must not be empty")
|
||||
}
|
||||
|
||||
resolved, err := resolveOptions(options)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := updateVersionFile(rootDir, normalizedVersion, resolved); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := updateChangelog(rootDir, normalizedVersion, releaseDate, resolved.Changelog); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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 {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var currentVersion string
|
||||
if options.VersionFile != "" {
|
||||
currentVersion, err = readCurrentVersion(rootDir, resolved)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
version, found, err := readLatestChangelogVersion(rootDir, resolved.Changelog)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !found {
|
||||
currentVersion = "0.0.0"
|
||||
} else {
|
||||
currentVersion = version
|
||||
}
|
||||
}
|
||||
|
||||
unreleasedBody, err := readUnreleasedBody(rootDir, resolved.Changelog)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
parsed, err := parseSemver(currentVersion)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.Contains(unreleasedBody, "### Breaking"), sectionHasEntries(unreleasedBody, "Removed"):
|
||||
parsed.major++
|
||||
parsed.minor = 0
|
||||
parsed.patch = 0
|
||||
case sectionHasEntries(unreleasedBody, "Added"):
|
||||
parsed.minor++
|
||||
parsed.patch = 0
|
||||
default:
|
||||
parsed.patch++
|
||||
}
|
||||
|
||||
return fmt.Sprintf("v%d.%d.%d", parsed.major, parsed.minor, parsed.patch), nil
|
||||
}
|
||||
|
||||
func sectionHasEntries(unreleasedBody, sectionName string) bool {
|
||||
heading := "### " + sectionName
|
||||
sectionStart := strings.Index(unreleasedBody, heading)
|
||||
if sectionStart == -1 {
|
||||
return false
|
||||
}
|
||||
|
||||
afterHeading := unreleasedBody[sectionStart+len(heading):]
|
||||
afterHeading = strings.TrimPrefix(afterHeading, "\r")
|
||||
afterHeading = strings.TrimPrefix(afterHeading, "\n")
|
||||
|
||||
nextHeading := strings.Index(afterHeading, "\n### ")
|
||||
sectionBody := afterHeading
|
||||
if nextHeading != -1 {
|
||||
sectionBody = afterHeading[:nextHeading]
|
||||
}
|
||||
|
||||
return strings.TrimSpace(sectionBody) != ""
|
||||
}
|
||||
|
||||
func resolveOptions(options Options) (resolvedOptions, error) {
|
||||
versionFile := strings.TrimSpace(options.VersionFile)
|
||||
if versionFile == "" {
|
||||
versionFile = defaultVersionFile
|
||||
}
|
||||
|
||||
pattern := strings.TrimSpace(options.VersionPattern)
|
||||
if pattern == "" {
|
||||
pattern = defaultVersionExpr
|
||||
}
|
||||
versionExpr, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return resolvedOptions{}, fmt.Errorf("compile version pattern: %w", err)
|
||||
}
|
||||
if versionExpr.NumSubexp() != 1 {
|
||||
return resolvedOptions{}, fmt.Errorf("version pattern must contain exactly one capture group")
|
||||
}
|
||||
|
||||
changelog := strings.TrimSpace(options.Changelog)
|
||||
if changelog == "" {
|
||||
changelog = defaultChangelog
|
||||
}
|
||||
|
||||
return resolvedOptions{VersionFile: versionFile, VersionExpr: versionExpr, Changelog: changelog}, nil
|
||||
}
|
||||
|
||||
func updateVersionFile(rootDir, version string, options resolvedOptions) error {
|
||||
path := filepath.Join(rootDir, options.VersionFile)
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return os.WriteFile(path, []byte(version+"\n"), 0o644)
|
||||
}
|
||||
return fmt.Errorf("read version file: %w", err)
|
||||
}
|
||||
|
||||
match := options.VersionExpr.FindStringSubmatch(string(contents))
|
||||
if len(match) < 2 {
|
||||
return fmt.Errorf("version value not found in %s", path)
|
||||
}
|
||||
|
||||
replacement := strings.Replace(match[0], match[1], version, 1)
|
||||
updated := strings.Replace(string(contents), match[0], replacement, 1)
|
||||
if updated == string(contents) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, []byte(updated), 0o644); err != nil {
|
||||
return fmt.Errorf("write version file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateChangelog(rootDir, version, releaseDate, changelogPath string) error {
|
||||
unreleasedBody, text, afterHeader, nextSectionStart, path, err := readChangelogState(rootDir, changelogPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if strings.TrimSpace(unreleasedBody) == "" {
|
||||
return fmt.Errorf("unreleased section is empty")
|
||||
}
|
||||
|
||||
newSection := fmt.Sprintf("## [%s] - %s\n", version, releaseDate)
|
||||
newSection += "\n" + unreleasedBody
|
||||
if !strings.HasSuffix(newSection, "\n") {
|
||||
newSection += "\n"
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func readCurrentVersion(rootDir string, options resolvedOptions) (string, error) {
|
||||
path := filepath.Join(rootDir, options.VersionFile)
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read version file: %w", err)
|
||||
}
|
||||
|
||||
match := options.VersionExpr.FindStringSubmatch(string(contents))
|
||||
if len(match) < 2 {
|
||||
return "", fmt.Errorf("version value not found in %s", path)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(match[1]), nil
|
||||
}
|
||||
|
||||
func readUnreleasedBody(rootDir, changelogPath string) (string, error) {
|
||||
unreleasedBody, _, _, _, _, err := readChangelogState(rootDir, changelogPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if strings.TrimSpace(unreleasedBody) == "" {
|
||||
return "", fmt.Errorf("unreleased section is empty")
|
||||
}
|
||||
|
||||
return unreleasedBody, nil
|
||||
}
|
||||
|
||||
func readChangelogState(rootDir, changelogPath string) (string, string, int, int, string, error) {
|
||||
path := filepath.Join(rootDir, changelogPath)
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", "", 0, 0, "", fmt.Errorf("read changelog: %w", err)
|
||||
}
|
||||
|
||||
text := string(contents)
|
||||
headerLoc := unreleasedHeadingRe.FindStringIndex(text)
|
||||
if headerLoc == nil {
|
||||
return "", "", 0, 0, "", fmt.Errorf("unreleased section not found in changelog")
|
||||
}
|
||||
|
||||
afterHeader := headerLoc[1]
|
||||
nextSectionRelative := strings.Index(text[afterHeader:], "\n## [")
|
||||
if nextSectionRelative == -1 {
|
||||
nextSectionRelative = len(text[afterHeader:])
|
||||
}
|
||||
nextSectionStart := afterHeader + nextSectionRelative
|
||||
unreleasedBody := strings.TrimLeft(text[afterHeader:nextSectionStart], "\n")
|
||||
|
||||
return unreleasedBody, text, afterHeader, nextSectionStart, path, nil
|
||||
}
|
||||
|
||||
func readLatestChangelogVersion(rootDir, changelogPath string) (string, bool, error) {
|
||||
path := filepath.Join(rootDir, changelogPath)
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("read changelog: %w", err)
|
||||
}
|
||||
|
||||
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 {
|
||||
return semver{}, fmt.Errorf("version %q is not semantic major.minor.patch", version)
|
||||
}
|
||||
|
||||
major, err := strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
return semver{}, fmt.Errorf("parse major version: %w", err)
|
||||
}
|
||||
minor, err := strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
return semver{}, fmt.Errorf("parse minor version: %w", err)
|
||||
}
|
||||
patch, err := strconv.Atoi(parts[2])
|
||||
if err != nil {
|
||||
return semver{}, fmt.Errorf("parse patch version: %w", err)
|
||||
}
|
||||
|
||||
return semver{major: major, minor: minor, patch: patch}, nil
|
||||
}
|
||||
123
internal/vociferate/vociferate_internal_test.go
Normal file
123
internal/vociferate/vociferate_internal_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
272
internal/vociferate/vociferate_test.go
Normal file
272
internal/vociferate/vociferate_test.go
Normal file
@@ -0,0 +1,272 @@
|
||||
package vociferate_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.hrafn.xyz/aether/vociferate/internal/vociferate"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
type PrepareSuite struct {
|
||||
suite.Suite
|
||||
rootDir string
|
||||
}
|
||||
|
||||
func TestPrepareSuite(t *testing.T) {
|
||||
suite.Run(t, new(PrepareSuite))
|
||||
}
|
||||
|
||||
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"),
|
||||
0o644,
|
||||
))
|
||||
|
||||
require.NoError(s.T(), os.WriteFile(
|
||||
filepath.Join(s.rootDir, "changelog.md"),
|
||||
[]byte("# Changelog\n\n## [Unreleased]\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"),
|
||||
0o644,
|
||||
))
|
||||
}
|
||||
|
||||
func (s *PrepareSuite) TestPrepare_UpdatesVersionAndPromotesUnreleasedNotes() {
|
||||
err := vociferate.Prepare(s.rootDir, "v1.1.7", "2026-03-20", vociferate.Options{})
|
||||
|
||||
require.NoError(s.T(), err)
|
||||
|
||||
versionBytes, err := os.ReadFile(filepath.Join(s.rootDir, "release-version"))
|
||||
require.NoError(s.T(), err)
|
||||
require.Equal(s.T(), "1.1.7\n", string(versionBytes))
|
||||
|
||||
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\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() {
|
||||
require.NoError(s.T(), os.WriteFile(
|
||||
filepath.Join(s.rootDir, "changelog.md"),
|
||||
[]byte("# Changelog\n\n## [1.1.6] - 2017-12-20\n"),
|
||||
0o644,
|
||||
))
|
||||
|
||||
err := vociferate.Prepare(s.rootDir, "1.1.7", "2026-03-20", vociferate.Options{})
|
||||
|
||||
require.ErrorContains(s.T(), err, "unreleased section")
|
||||
}
|
||||
|
||||
func (s *PrepareSuite) TestPrepare_ReturnsErrorWhenUnreleasedSectionIsEmpty() {
|
||||
require.NoError(s.T(), os.WriteFile(
|
||||
filepath.Join(s.rootDir, "changelog.md"),
|
||||
[]byte("# Changelog\n\n## [Unreleased]\n\n## [1.1.6] - 2017-12-20\n"),
|
||||
0o644,
|
||||
))
|
||||
|
||||
err := vociferate.Prepare(s.rootDir, "1.1.7", "2026-03-20", vociferate.Options{})
|
||||
|
||||
require.ErrorContains(s.T(), err, "unreleased section is empty")
|
||||
}
|
||||
|
||||
func (s *PrepareSuite) TestRecommendedTag_UsesMajorBumpWhenBreakingHeadingExists() {
|
||||
tag, err := vociferate.RecommendedTag(s.rootDir, vociferate.Options{})
|
||||
|
||||
require.NoError(s.T(), err)
|
||||
require.Equal(s.T(), "v2.0.0", tag)
|
||||
}
|
||||
|
||||
func (s *PrepareSuite) TestRecommendedTag_UsesPatchBumpForFixOnlyChanges() {
|
||||
require.NoError(s.T(), os.WriteFile(
|
||||
filepath.Join(s.rootDir, "changelog.md"),
|
||||
[]byte("# Changelog\n\n## [Unreleased]\n\n### Fixed\n\n- Patch note.\n\n## [1.1.6] - 2017-12-20\n"),
|
||||
0o644,
|
||||
))
|
||||
|
||||
tag, err := vociferate.RecommendedTag(s.rootDir, vociferate.Options{})
|
||||
|
||||
require.NoError(s.T(), err)
|
||||
require.Equal(s.T(), "v1.1.7", tag)
|
||||
}
|
||||
|
||||
func (s *PrepareSuite) TestRecommendedTag_UsesMajorBumpWhenRemovedEntriesExist() {
|
||||
require.NoError(s.T(), os.WriteFile(
|
||||
filepath.Join(s.rootDir, "changelog.md"),
|
||||
[]byte("# Changelog\n\n## [Unreleased]\n\n### Removed\n\n- Breaking removal.\n\n## [1.1.6] - 2017-12-20\n"),
|
||||
0o644,
|
||||
))
|
||||
|
||||
tag, err := vociferate.RecommendedTag(s.rootDir, vociferate.Options{})
|
||||
|
||||
require.NoError(s.T(), err)
|
||||
require.Equal(s.T(), "v2.0.0", tag)
|
||||
}
|
||||
|
||||
func (s *PrepareSuite) TestRecommendedTag_UsesMajorBumpWhenBreakingEntriesExist() {
|
||||
require.NoError(s.T(), os.WriteFile(
|
||||
filepath.Join(s.rootDir, "changelog.md"),
|
||||
[]byte("# Changelog\n\n## [Unreleased]\n\n### Breaking\n\n- Changed API contract.\n\n### Changed\n\n- Updated defaults.\n\n## [1.1.6] - 2017-12-20\n"),
|
||||
0o644,
|
||||
))
|
||||
|
||||
tag, err := vociferate.RecommendedTag(s.rootDir, vociferate.Options{})
|
||||
|
||||
require.NoError(s.T(), err)
|
||||
require.Equal(s.T(), "v2.0.0", tag)
|
||||
}
|
||||
|
||||
func (s *PrepareSuite) TestPrepare_UsesCustomVersionFileAndPattern() {
|
||||
customVersionFile := filepath.Join("custom", "VERSION.txt")
|
||||
require.NoError(s.T(), os.MkdirAll(filepath.Join(s.rootDir, "custom"), 0o755))
|
||||
require.NoError(s.T(), os.WriteFile(
|
||||
filepath.Join(s.rootDir, customVersionFile),
|
||||
[]byte("VERSION=1.1.6\n"),
|
||||
0o644,
|
||||
))
|
||||
|
||||
err := vociferate.Prepare(s.rootDir, "1.1.8", "2026-03-20", vociferate.Options{
|
||||
VersionFile: customVersionFile,
|
||||
VersionPattern: `VERSION=([^\n]+)`,
|
||||
})
|
||||
|
||||
require.NoError(s.T(), err)
|
||||
|
||||
versionBytes, err := os.ReadFile(filepath.Join(s.rootDir, customVersionFile))
|
||||
require.NoError(s.T(), err)
|
||||
require.Equal(s.T(), "VERSION=1.1.8\n", string(versionBytes))
|
||||
}
|
||||
|
||||
func (s *PrepareSuite) TestPrepare_AllowsUnchangedVersionValue() {
|
||||
require.NoError(s.T(), os.WriteFile(
|
||||
filepath.Join(s.rootDir, "release-version"),
|
||||
[]byte("1.1.6\n"),
|
||||
0o644,
|
||||
))
|
||||
|
||||
err := vociferate.Prepare(s.rootDir, "1.1.6", "2026-03-20", vociferate.Options{})
|
||||
|
||||
require.NoError(s.T(), err)
|
||||
|
||||
versionBytes, readErr := os.ReadFile(filepath.Join(s.rootDir, "release-version"))
|
||||
require.NoError(s.T(), readErr)
|
||||
require.Equal(s.T(), "1.1.6\n", string(versionBytes))
|
||||
}
|
||||
|
||||
func (s *PrepareSuite) TestRecommendedTag_UsesChangelogVersionWhenNoVersionFileConfigured() {
|
||||
// The default release-version file is present from SetupTest but should be ignored;
|
||||
// the current version must be read from the changelog, not the file.
|
||||
require.NoError(s.T(), os.WriteFile(
|
||||
filepath.Join(s.rootDir, "release-version"),
|
||||
[]byte("99.99.99\n"), // deliberately wrong value
|
||||
0o644,
|
||||
))
|
||||
require.NoError(s.T(), os.WriteFile(
|
||||
filepath.Join(s.rootDir, "changelog.md"),
|
||||
[]byte("# Changelog\n\n## [Unreleased]\n\n### Fixed\n\n- A fix.\n\n## [3.0.0] - 2026-01-01\n\n### Fixed\n\n- Historical.\n"),
|
||||
0o644,
|
||||
))
|
||||
|
||||
tag, err := vociferate.RecommendedTag(s.rootDir, vociferate.Options{})
|
||||
|
||||
require.NoError(s.T(), err)
|
||||
require.Equal(s.T(), "v3.0.1", tag)
|
||||
}
|
||||
|
||||
func (s *PrepareSuite) TestRecommendedTag_DefaultsToV1WhenNoPriorReleasesInChangelog() {
|
||||
require.NoError(s.T(), os.Remove(filepath.Join(s.rootDir, "release-version")))
|
||||
require.NoError(s.T(), os.WriteFile(
|
||||
filepath.Join(s.rootDir, "changelog.md"),
|
||||
[]byte("# Changelog\n\n## [Unreleased]\n\n### Breaking\n\n### Added\n\n- First feature.\n"),
|
||||
0o644,
|
||||
))
|
||||
|
||||
tag, err := vociferate.RecommendedTag(s.rootDir, vociferate.Options{})
|
||||
|
||||
require.NoError(s.T(), err)
|
||||
require.Equal(s.T(), "v1.0.0", tag)
|
||||
}
|
||||
|
||||
func (s *PrepareSuite) TestPrepare_CreatesVersionFileWhenNotPresent() {
|
||||
require.NoError(s.T(), os.Remove(filepath.Join(s.rootDir, "release-version")))
|
||||
|
||||
err := vociferate.Prepare(s.rootDir, "2.0.0", "2026-03-20", vociferate.Options{})
|
||||
|
||||
require.NoError(s.T(), err)
|
||||
|
||||
versionBytes, readErr := os.ReadFile(filepath.Join(s.rootDir, "release-version"))
|
||||
require.NoError(s.T(), readErr)
|
||||
require.Equal(s.T(), "2.0.0\n", string(versionBytes))
|
||||
}
|
||||
|
||||
func (s *PrepareSuite) TestRecommendedTag_UsesCustomVersionFileAndPattern() {
|
||||
customVersionFile := filepath.Join("custom", "VERSION.txt")
|
||||
require.NoError(s.T(), os.MkdirAll(filepath.Join(s.rootDir, "custom"), 0o755))
|
||||
require.NoError(s.T(), os.WriteFile(
|
||||
filepath.Join(s.rootDir, customVersionFile),
|
||||
[]byte("VERSION=2.3.4\n"),
|
||||
0o644,
|
||||
))
|
||||
require.NoError(s.T(), os.WriteFile(
|
||||
filepath.Join(s.rootDir, "changelog.md"),
|
||||
[]byte("# Changelog\n\n## [Unreleased]\n\n### Added\n\n- Feature.\n\n## [2.3.4] - 2026-03-10\n"),
|
||||
0o644,
|
||||
))
|
||||
|
||||
tag, err := vociferate.RecommendedTag(s.rootDir, vociferate.Options{
|
||||
VersionFile: customVersionFile,
|
||||
VersionPattern: `VERSION=([^\n]+)`,
|
||||
})
|
||||
|
||||
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")
|
||||
}
|
||||
5
justfile
5
justfile
@@ -5,10 +5,7 @@ default:
|
||||
|
||||
go-build:
|
||||
@mkdir -p dist
|
||||
go build -o dist/releaseprep ./cmd/releaseprep
|
||||
go build -o dist/vociferate ./cmd/vociferate
|
||||
|
||||
go-test:
|
||||
go test ./...
|
||||
|
||||
prepare-release version:
|
||||
./script/prepare-release.sh "{{version}}"
|
||||
|
||||
229
prepare/action.yml
Normal file
229
prepare/action.yml
Normal 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
155
publish/action.yml
Normal 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
1
release-version
Normal file
@@ -0,0 +1 @@
|
||||
0.1.0
|
||||
@@ -1,18 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -ne 1 ]]; then
|
||||
echo "usage: $0 <version>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
release_date="$(date -u +%F)"
|
||||
|
||||
go run ./cmd/releaseprep \
|
||||
--root "$repo_root" \
|
||||
--version "$1" \
|
||||
--date "$release_date" \
|
||||
--version-file internal/releaseprep/version/version.go \
|
||||
--version-pattern 'const String = "([^"]+)"' \
|
||||
--changelog changelog.md
|
||||
Reference in New Issue
Block a user