Compare commits
99 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
995e397bff | ||
|
|
8bf7184479 | ||
|
|
41918cd5de | ||
|
|
0cec30c9bb | ||
|
|
24dd65da67 | ||
|
|
1ab56b0536 | ||
|
|
6919061240 | ||
|
|
7b739e04c8 | ||
|
|
98ea91f2df | ||
|
|
532f6a98d8 | ||
|
|
a3e2b4e44e | ||
|
|
f82dace4b2 | ||
|
|
81dced6ada | ||
|
|
62693935d0 | ||
|
|
c0b5ec385c | ||
|
|
84f6fbcfc8 | ||
|
|
4a2d234ba3 | ||
|
|
4841b04076 | ||
|
|
993768ae9b | ||
|
|
624b9d154c | ||
|
|
53a097784e | ||
|
|
511110f466 | ||
|
|
d5170b6874 | ||
|
|
925c99bb9e | ||
|
|
d65af508a3 | ||
|
|
eae70bb20f | ||
|
|
c96cab58ff | ||
|
|
a6d57e4048 | ||
|
|
cb52dd909d | ||
|
|
acca6adacc | ||
|
|
dc4aeb1e51 | ||
|
|
ea1b333da3 | ||
|
|
eb8bd80d48 | ||
|
|
cddcf99873 | ||
|
|
bef39120d3 | ||
|
|
ad3d657db9 | ||
|
|
27a058a3ce | ||
|
|
0d4310184e | ||
|
|
0fbd7641c0 | ||
|
|
60a0e82587 | ||
|
|
1a67d8b0e1 | ||
|
|
1a78209408 | ||
|
|
c05a1c48cb | ||
|
|
32327c6d72 | ||
|
|
72abf37b2d | ||
|
|
5bea62b8cf | ||
|
|
dd86944e64 | ||
|
|
38afdeffa0 | ||
|
|
f9c57f34d0 | ||
|
|
5793a58888 | ||
|
|
2177dae15f | ||
|
|
76508355be | ||
|
|
f069c116a1 | ||
|
|
32a6ded499 | ||
|
|
b7c62634f4 | ||
|
|
224ba03ca4 | ||
|
|
3f555fb894 | ||
|
|
ee274602a8 | ||
|
|
1306f07003 | ||
|
|
58e29aca0c | ||
|
|
f04df719e2 | ||
|
|
9a91c70e5d | ||
|
|
3eb814a3d5 | ||
|
|
92f76fd19f | ||
|
|
9dc28e8229 | ||
|
|
e625d475a5 | ||
|
|
b7d1760beb | ||
|
|
64a7b6d86b | ||
|
|
c8365e39da | ||
|
|
4a47580ea8 | ||
|
|
5a207e7d5d | ||
|
|
5c903c98be | ||
|
|
383aad48be | ||
|
|
f31141702d | ||
|
|
7cb7b050db | ||
|
|
3c60be8587 | ||
|
|
830e623fa9 | ||
|
|
d4d911e6c7 | ||
|
|
4b9372079b | ||
|
|
b5530d0c48 | ||
|
|
b1aaff9f3b | ||
|
|
3e03382781 | ||
|
|
43018ae9ac | ||
|
|
3e384dd8a3 | ||
|
|
821802c0c4 | ||
|
|
2810d93b89 | ||
|
|
02db91114d | ||
|
|
c27b042bb1 | ||
|
|
59ce683813 | ||
|
|
d653f632d1 | ||
|
|
8e5d05fce6 | ||
|
|
5dad65cc3b | ||
|
|
e99527f68b | ||
|
|
f314d7da1b | ||
|
|
21a68647f3 | ||
|
|
ba715d9965 | ||
|
|
62f637614d | ||
|
|
7d6ae6f486 | ||
|
|
16274ea1e5 |
@@ -1,245 +0,0 @@
|
|||||||
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 }}
|
|
||||||
SUMMARY_FILE: ${{ runner.temp }}/do-release-summary.md
|
|
||||||
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"
|
|
||||||
} >> "$SUMMARY_FILE"
|
|
||||||
|
|
||||||
- name: Summary
|
|
||||||
if: ${{ always() }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
echo 'Summary'
|
|
||||||
echo
|
|
||||||
|
|
||||||
if [[ -s "$SUMMARY_FILE" ]]; then
|
|
||||||
cat "$SUMMARY_FILE"
|
|
||||||
else
|
|
||||||
echo 'No summary generated.'
|
|
||||||
fi
|
|
||||||
|
|
||||||
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
|
|
||||||
env:
|
|
||||||
SUMMARY_FILE: ${{ runner.temp }}/do-release-validate-summary.md
|
|
||||||
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
|
|
||||||
|
|
||||||
${RUN_COMMAND} --help >/dev/null
|
|
||||||
|
|
||||||
recommend_stderr="$(mktemp)"
|
|
||||||
if ${RUN_COMMAND} --recommend --root . >/dev/null 2>"${recommend_stderr}"; then
|
|
||||||
echo "Expected --recommend to fail on the tagged release checkout" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
recommend_error="$(cat "${recommend_stderr}")"
|
|
||||||
case "${recommend_error}" in
|
|
||||||
*"unreleased section is empty"*)
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Unexpected recommend failure output: ${recommend_error}" >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
{
|
|
||||||
echo "## Released Binary Validation (${{ matrix.asset_arch }})"
|
|
||||||
echo
|
|
||||||
echo "- Release tag: ${TAG_NAME}"
|
|
||||||
echo "- Asset: ${asset_name}"
|
|
||||||
echo "- Binary executed successfully via --help."
|
|
||||||
echo "- --recommend failed as expected on the tagged checkout because Unreleased is empty."
|
|
||||||
} >> "$SUMMARY_FILE"
|
|
||||||
|
|
||||||
- name: Summary
|
|
||||||
if: ${{ always() }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
echo 'Summary'
|
|
||||||
echo
|
|
||||||
|
|
||||||
if [[ -s "$SUMMARY_FILE" ]]; then
|
|
||||||
cat "$SUMMARY_FILE"
|
|
||||||
else
|
|
||||||
echo 'No summary generated.'
|
|
||||||
fi
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
name: Prepare Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
description: Optional semantic version override, with or without leading v. When omitted, the recommended version is used.
|
|
||||||
required: false
|
|
||||||
workflow_call:
|
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
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:
|
|
||||||
SUMMARY_FILE: ${{ runner.temp }}/prepare-release-summary.md
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- 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: Run tests
|
|
||||||
run: go test ./...
|
|
||||||
|
|
||||||
- name: Resolve cache token
|
|
||||||
id: cache-token
|
|
||||||
run: echo "value=${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Prepare and tag release
|
|
||||||
id: prepare
|
|
||||||
uses: ./prepare
|
|
||||||
env:
|
|
||||||
VOCIFERATE_CACHE_TOKEN: ${{ steps.cache-token.outputs.value }}
|
|
||||||
with:
|
|
||||||
version: ${{ inputs.version }}
|
|
||||||
|
|
||||||
- name: Summarize prepared release
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
tag="${{ steps.prepare.outputs.version }}"
|
|
||||||
{
|
|
||||||
echo "## Release Prepared"
|
|
||||||
echo
|
|
||||||
echo "- Tag pushed: ${tag}"
|
|
||||||
echo "- Calling Do Release workflow for ${tag}."
|
|
||||||
} >> "$SUMMARY_FILE"
|
|
||||||
|
|
||||||
- name: Summary
|
|
||||||
if: ${{ always() }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
echo 'Summary'
|
|
||||||
echo
|
|
||||||
|
|
||||||
if [[ -s "$SUMMARY_FILE" ]]; then
|
|
||||||
cat "$SUMMARY_FILE"
|
|
||||||
else
|
|
||||||
echo 'No summary generated.'
|
|
||||||
fi
|
|
||||||
|
|
||||||
publish:
|
|
||||||
needs: prepare
|
|
||||||
uses: ./.gitea/workflows/do-release.yml
|
|
||||||
with:
|
|
||||||
tag: ${{ needs.prepare.outputs.tag }}
|
|
||||||
secrets: inherit
|
|
||||||
@@ -8,7 +8,7 @@ on:
|
|||||||
- "*"
|
- "*"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
validate:
|
coverage-badge:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: docker.io/catthehacker/ubuntu:act-latest
|
container: docker.io/catthehacker/ubuntu:act-latest
|
||||||
defaults:
|
defaults:
|
||||||
@@ -30,101 +30,106 @@ jobs:
|
|||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.26.1'
|
go-version-file: go.mod
|
||||||
check-latest: true
|
check-latest: false
|
||||||
cache: true
|
cache: true
|
||||||
cache-dependency-path: go.sum
|
cache-dependency-path: go.sum
|
||||||
|
|
||||||
- name: Install AWS CLI v2
|
- name: Validate formatting
|
||||||
uses: ankurk91/install-aws-cli-action@v1
|
run: test -z "$(gofmt -l .)"
|
||||||
|
|
||||||
- name: Verify AWS CLI
|
- name: Module hygiene
|
||||||
run: aws --version
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
go mod tidy
|
||||||
|
|
||||||
|
if ! go mod verify; then
|
||||||
|
echo "go mod verify failed; refreshing module cache and retrying" >&2
|
||||||
|
go clean -modcache
|
||||||
|
go mod download
|
||||||
|
go mod verify
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Restore cached gosec binary
|
||||||
|
id: cache-gosec
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ${{ runner.temp }}/gosec-bin
|
||||||
|
key: gosec-v2.22.4-${{ runner.os }}-${{ runner.arch }}
|
||||||
|
|
||||||
|
- name: Install gosec binary
|
||||||
|
if: steps.cache-gosec.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p "${RUNNER_TEMP}/gosec-bin"
|
||||||
|
GOBIN="${RUNNER_TEMP}/gosec-bin" go install github.com/securego/gosec/v2/cmd/gosec@v2.22.4
|
||||||
|
|
||||||
|
- name: Run gosec security analysis
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
"${RUNNER_TEMP}/gosec-bin/gosec" ./...
|
||||||
|
|
||||||
|
- name: Run govulncheck
|
||||||
|
uses: golang/govulncheck-action@v1.0.4
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
check-latest: false
|
||||||
|
go-package: ./...
|
||||||
|
cache: true
|
||||||
|
cache-dependency-path: go.sum
|
||||||
|
|
||||||
- name: Run full unit test suite with coverage
|
- name: Run full unit test suite with coverage
|
||||||
id: coverage
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
go test -covermode=atomic -coverprofile=coverage.out ./...
|
go test -covermode=atomic -coverprofile=coverage.out ./...
|
||||||
go tool cover -html=coverage.out -o coverage.html
|
|
||||||
|
|
||||||
total="$(go tool cover -func=coverage.out | awk '/^total:/ {sub(/%/, "", $3); print $3}')"
|
- name: Publish coverage badge artefacts
|
||||||
printf '{\n "total": "%s"\n}\n' "$total" > coverage-summary.json
|
id: coverage
|
||||||
printf 'total=%s\n' "$total" >> "$GITHUB_OUTPUT"
|
uses: ./coverage-badge
|
||||||
|
with:
|
||||||
|
artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }}
|
||||||
|
artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }}
|
||||||
|
summary-file: ${{ env.SUMMARY_FILE }}
|
||||||
|
|
||||||
- name: Generate coverage badge
|
- name: Summary
|
||||||
env:
|
if: ${{ always() }}
|
||||||
COVERAGE_TOTAL: ${{ steps.coverage.outputs.total }}
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
color="$(awk -v total="$COVERAGE_TOTAL" 'BEGIN {
|
echo 'Summary'
|
||||||
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}"
|
|
||||||
display_endpoint="${ARTEFACT_BUCKET_ENDPONT#https://}"
|
|
||||||
display_endpoint="${display_endpoint#http://}"
|
|
||||||
report_url="//${display_endpoint%/}/${ARTEFACT_BUCKET_NAME}/${prefix}/coverage.html"
|
|
||||||
badge_url="//${display_endpoint%/}/${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: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
{
|
|
||||||
echo '## Coverage'
|
|
||||||
echo
|
echo
|
||||||
echo '- Total: `${{ steps.coverage.outputs.total }}%`'
|
|
||||||
echo '- Report: ${{ steps.upload.outputs.report_url }}'
|
if [[ -s "$SUMMARY_FILE" ]]; then
|
||||||
echo '- Badge: ${{ steps.upload.outputs.badge_url }}'
|
cat "$SUMMARY_FILE"
|
||||||
} >> "$SUMMARY_FILE"
|
else
|
||||||
|
echo 'No summary generated.'
|
||||||
|
fi
|
||||||
|
|
||||||
|
recommend-release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: docker.io/catthehacker/ubuntu:act-latest
|
||||||
|
needs: coverage-badge
|
||||||
|
if: ${{ github.ref == 'refs/heads/main' }}
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
SUMMARY_FILE: ${{ runner.temp }}/push-validation-recommend-summary.md
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
check-latest: false
|
||||||
|
cache: true
|
||||||
|
cache-dependency-path: go.sum
|
||||||
|
|
||||||
- name: Recommend next release tag on main pushes
|
- name: Recommend next release tag on main pushes
|
||||||
if: ${{ github.ref == 'refs/heads/main' }}
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
|||||||
466
.gitea/workflows/release.yml
Normal file
466
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: Optional semantic version override, with or without leading v. When omitted, the recommended version is used.
|
||||||
|
required: false
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
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:
|
||||||
|
SUMMARY_FILE: ${{ runner.temp }}/release-prepare-summary.md
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
check-latest: false
|
||||||
|
cache: true
|
||||||
|
cache-dependency-path: go.sum
|
||||||
|
|
||||||
|
- name: Install UPX
|
||||||
|
uses: crazy-max/ghaction-upx@v3
|
||||||
|
with:
|
||||||
|
install-only: true
|
||||||
|
|
||||||
|
- name: Validate formatting
|
||||||
|
run: test -z "$(gofmt -l .)"
|
||||||
|
|
||||||
|
- name: Module hygiene
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
go mod tidy
|
||||||
|
|
||||||
|
if ! go mod verify; then
|
||||||
|
echo "go mod verify failed; refreshing module cache and retrying" >&2
|
||||||
|
go clean -modcache
|
||||||
|
go mod download
|
||||||
|
go mod verify
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Restore cached gosec binary
|
||||||
|
id: cache-gosec
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ${{ runner.temp }}/gosec-bin
|
||||||
|
key: gosec-v2.22.4-${{ runner.os }}-${{ runner.arch }}
|
||||||
|
|
||||||
|
- name: Install gosec binary
|
||||||
|
if: steps.cache-gosec.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p "${RUNNER_TEMP}/gosec-bin"
|
||||||
|
GOBIN="${RUNNER_TEMP}/gosec-bin" go install github.com/securego/gosec/v2/cmd/gosec@v2.22.4
|
||||||
|
|
||||||
|
- name: Run gosec security analysis
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
"${RUNNER_TEMP}/gosec-bin/gosec" ./...
|
||||||
|
|
||||||
|
- name: Run govulncheck
|
||||||
|
uses: golang/govulncheck-action@v1.0.4
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
check-latest: false
|
||||||
|
go-package: ./...
|
||||||
|
cache: true
|
||||||
|
cache-dependency-path: go.sum
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
- name: Resolve cache token
|
||||||
|
id: cache-token
|
||||||
|
run: echo "value=${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Resolve release tag
|
||||||
|
id: resolve-version
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
provided_version="$(printf '%s' "${{ inputs.version }}" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
|
||||||
|
if [[ -z "$provided_version" ]]; then
|
||||||
|
release_tag="$(go run ./cmd/vociferate --recommend --root .)"
|
||||||
|
elif [[ "$provided_version" == v* ]]; then
|
||||||
|
release_tag="$provided_version"
|
||||||
|
else
|
||||||
|
release_tag="v${provided_version}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "tag=${release_tag}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Update agent docs action tags
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
release_tag="${{ steps.resolve-version.outputs.tag }}"
|
||||||
|
for file in README.md AGENTS.md; do
|
||||||
|
sed -E -i "s/@v[0-9]+\.[0-9]+\.[0-9]+/@${release_tag}/g" "$file"
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Prepare and tag release
|
||||||
|
id: prepare
|
||||||
|
uses: ./prepare
|
||||||
|
env:
|
||||||
|
VOCIFERATE_CACHE_TOKEN: ${{ steps.cache-token.outputs.value }}
|
||||||
|
with:
|
||||||
|
version: ${{ steps.resolve-version.outputs.tag }}
|
||||||
|
token: ${{ secrets.RELEASE_PAT }}
|
||||||
|
git-add-files: CHANGELOG.md release-version README.md AGENTS.md
|
||||||
|
|
||||||
|
- name: Summary
|
||||||
|
if: ${{ always() }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
tag="${{ steps.prepare.outputs.version }}"
|
||||||
|
{
|
||||||
|
echo "## Release Prepared"
|
||||||
|
echo
|
||||||
|
echo "- Tag pushed: ${tag}"
|
||||||
|
} >> "$SUMMARY_FILE"
|
||||||
|
|
||||||
|
echo 'Summary'
|
||||||
|
echo
|
||||||
|
|
||||||
|
if [[ -s "$SUMMARY_FILE" ]]; then
|
||||||
|
cat "$SUMMARY_FILE"
|
||||||
|
else
|
||||||
|
echo 'No summary generated.'
|
||||||
|
fi
|
||||||
|
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: docker.io/catthehacker/ubuntu:act-latest
|
||||||
|
needs: prepare
|
||||||
|
outputs:
|
||||||
|
tag: ${{ steps.publish.outputs.tag }}
|
||||||
|
version: ${{ steps.publish.outputs.version }}
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
RELEASE_TOKEN: ${{ secrets.RELEASE_PAT }}
|
||||||
|
SUMMARY_FILE: ${{ runner.temp }}/release-summary.md
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Resolve release version
|
||||||
|
id: resolve-version
|
||||||
|
env:
|
||||||
|
PREPARE_TAG: ${{ needs.prepare.outputs.tag }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
tag="$(printf '%s' "${PREPARE_TAG}" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
|
||||||
|
|
||||||
|
# Unwrap Teacup expression strings if present.
|
||||||
|
if [[ "${tag}" =~ ^%\!t\(string=(.*)\)$ ]]; then
|
||||||
|
tag="${BASH_REMATCH[1]}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
normalized="${tag#v}"
|
||||||
|
tag="v${normalized}"
|
||||||
|
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version=${normalized}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Checkout release tag
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
ref: refs/tags/${{ steps.resolve-version.outputs.tag }}
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
check-latest: false
|
||||||
|
cache: true
|
||||||
|
cache-dependency-path: go.sum
|
||||||
|
|
||||||
|
- name: Preflight release API access
|
||||||
|
env:
|
||||||
|
TAG_NAME: ${{ steps.resolve-version.outputs.tag }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ -z "${RELEASE_TOKEN:-}" ]]; then
|
||||||
|
echo "No release token available. Set secrets.RELEASE_PAT." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
api_base="${GITHUB_API_URL:-${GITHUB_SERVER_URL%/}/api/v1}"
|
||||||
|
repo_api="${api_base}/repos/${GITHUB_REPOSITORY}"
|
||||||
|
|
||||||
|
curl --fail-with-body -sS \
|
||||||
|
-H "Authorization: token ${RELEASE_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"${repo_api}" >/dev/null
|
||||||
|
|
||||||
|
curl --fail-with-body -sS \
|
||||||
|
-H "Authorization: token ${RELEASE_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"${repo_api}/releases?limit=1" >/dev/null
|
||||||
|
|
||||||
|
if ! git rev-parse --verify --quiet "refs/tags/${TAG_NAME}" >/dev/null; then
|
||||||
|
echo "Tag ${TAG_NAME} was not found in the checked out repository." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Create or update release
|
||||||
|
id: publish
|
||||||
|
uses: ./publish
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.RELEASE_PAT }}
|
||||||
|
version: ${{ steps.resolve-version.outputs.version }}
|
||||||
|
|
||||||
|
- name: Build release binaries
|
||||||
|
env:
|
||||||
|
RELEASE_VERSION: ${{ steps.publish.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
upx_cmd=""
|
||||||
|
if command -v upx >/dev/null 2>&1; then
|
||||||
|
upx_cmd=upx
|
||||||
|
elif command -v upx-ucl >/dev/null 2>&1; then
|
||||||
|
upx_cmd=upx-ucl
|
||||||
|
else
|
||||||
|
echo "UPX is not available on PATH after install step; continuing without binary compression." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
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
|
||||||
|
if [[ -n "${upx_cmd}" ]]; then
|
||||||
|
"${upx_cmd}" --best --lzma "dist/${bin}"
|
||||||
|
fi
|
||||||
|
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
|
||||||
|
|
||||||
|
raw_release_id="$(printf '%s' "${RELEASE_ID:-}" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
|
||||||
|
if [[ "$raw_release_id" =~ ^%\!t\(string=(.*)\)$ ]]; then
|
||||||
|
raw_release_id="${BASH_REMATCH[1]}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
release_id="$(printf '%s' "$raw_release_id" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
|
||||||
|
if ! [[ "$release_id" =~ ^[0-9]+$ ]]; then
|
||||||
|
echo "Invalid release id from publish step: '${RELEASE_ID}'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
release_detail_api="${GITHUB_API_URL:-${GITHUB_SERVER_URL%/}/api/v1}/repos/${GITHUB_REPOSITORY}/releases/${release_id}"
|
||||||
|
if ! curl --fail-with-body -sS \
|
||||||
|
-H "Authorization: token ${RELEASE_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"$release_detail_api" >/dev/null; then
|
||||||
|
echo "Resolved release endpoint is not accessible: ${release_detail_api}" >&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
|
||||||
|
|
||||||
|
- name: Summary
|
||||||
|
if: ${{ always() }}
|
||||||
|
env:
|
||||||
|
TAG_NAME: ${{ steps.publish.outputs.tag }}
|
||||||
|
RELEASE_VERSION: ${{ steps.publish.outputs.version }}
|
||||||
|
PUBLISH_OUTCOME: ${{ steps.publish.outcome }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ "${PUBLISH_OUTCOME}" == "success" ]]; then
|
||||||
|
{
|
||||||
|
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"
|
||||||
|
echo "- Release binaries are compressed with UPX from crazy-max/ghaction-upx@v3 when available, otherwise uploaded uncompressed."
|
||||||
|
} >> "$SUMMARY_FILE"
|
||||||
|
else
|
||||||
|
{
|
||||||
|
echo "## Release Failed"
|
||||||
|
echo
|
||||||
|
echo "- Tag: ${TAG_NAME:-unknown}"
|
||||||
|
echo "- Create or update release step did not complete successfully."
|
||||||
|
} >> "$SUMMARY_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo 'Summary'
|
||||||
|
echo
|
||||||
|
|
||||||
|
if [[ -s "$SUMMARY_FILE" ]]; then
|
||||||
|
cat "$SUMMARY_FILE"
|
||||||
|
else
|
||||||
|
echo 'No summary generated.'
|
||||||
|
fi
|
||||||
|
|
||||||
|
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
|
||||||
|
env:
|
||||||
|
SUMMARY_FILE: ${{ runner.temp }}/validate-summary.md
|
||||||
|
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: ${{ secrets.RELEASE_PAT }}
|
||||||
|
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
|
||||||
|
|
||||||
|
${RUN_COMMAND} --help >/dev/null
|
||||||
|
|
||||||
|
recommend_stderr="$(mktemp)"
|
||||||
|
if ${RUN_COMMAND} --recommend --root . >/dev/null 2>"${recommend_stderr}"; then
|
||||||
|
echo "Expected --recommend to fail on the tagged release checkout" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
recommend_error="$(cat "${recommend_stderr}")"
|
||||||
|
case "${recommend_error}" in
|
||||||
|
*"unreleased section is empty"*)
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unexpected recommend failure output: ${recommend_error}" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "## Released Binary Validation (${{ matrix.asset_arch }})"
|
||||||
|
echo
|
||||||
|
echo "- Release tag: ${TAG_NAME}"
|
||||||
|
echo "- Asset: ${asset_name}"
|
||||||
|
echo "- Binary executed successfully via --help."
|
||||||
|
echo "- --recommend failed as expected on the tagged checkout because Unreleased is empty."
|
||||||
|
} >> "$SUMMARY_FILE"
|
||||||
|
|
||||||
|
- name: Summary
|
||||||
|
if: ${{ always() }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo 'Summary'
|
||||||
|
echo
|
||||||
|
|
||||||
|
if [[ -s "$SUMMARY_FILE" ]]; then
|
||||||
|
cat "$SUMMARY_FILE"
|
||||||
|
else
|
||||||
|
echo 'No summary generated.'
|
||||||
|
fi
|
||||||
388
.gitea/workflows/update-release.yml
Normal file
388
.gitea/workflows/update-release.yml
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
name: Update 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.RELEASE_PAT }}
|
||||||
|
SUMMARY_FILE: ${{ runner.temp }}/update-release-summary.md
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Fetch and detect release tag
|
||||||
|
id: detect-tag
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Fetch all tags from origin first
|
||||||
|
git fetch origin --tags --force --quiet 2>/dev/null || true
|
||||||
|
|
||||||
|
# Check if HEAD is at a tag (prepare-release may have just tagged it)
|
||||||
|
if head_tag="$(git describe --exact-match --tags HEAD 2>/dev/null)" && [[ -n "$head_tag" ]]; then
|
||||||
|
echo "detected_tag=$head_tag" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fall back to finding the most recent tag
|
||||||
|
if latest_tag="$(git describe --tags --abbrev=0 2>/dev/null)" && [[ -n "$latest_tag" ]]; then
|
||||||
|
echo "detected_tag=$latest_tag" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Resolve release version
|
||||||
|
id: resolve-version
|
||||||
|
env:
|
||||||
|
INPUT_TAG: ${{ inputs.tag }}
|
||||||
|
DETECTED_TAG: ${{ steps.detect-tag.outputs.detected_tag }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
normalize_candidate() {
|
||||||
|
local raw="$1"
|
||||||
|
|
||||||
|
raw="$(printf '%s' "$raw" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
|
||||||
|
|
||||||
|
# Teacup can surface expression strings as %!t(string=value); unwrap it.
|
||||||
|
if [[ "$raw" =~ ^%\!t\(string=(.*)\)$ ]]; then
|
||||||
|
raw="${BASH_REMATCH[1]}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
raw="$(printf '%s' "$raw" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
|
||||||
|
printf '%s' "$raw"
|
||||||
|
}
|
||||||
|
|
||||||
|
input_tag="$(normalize_candidate "${INPUT_TAG}")"
|
||||||
|
detected_tag="$(normalize_candidate "${DETECTED_TAG}")"
|
||||||
|
|
||||||
|
# Try explicit input first.
|
||||||
|
requested_tag="$input_tag"
|
||||||
|
|
||||||
|
# Fall back to detected tag if neither input nor caller tag is available.
|
||||||
|
if [[ -z "$requested_tag" && -n "$detected_tag" ]]; then
|
||||||
|
requested_tag="$detected_tag"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Try GITHUB_REF if still empty
|
||||||
|
if [[ -z "$requested_tag" && "$GITHUB_REF" == refs/tags/* ]]; then
|
||||||
|
requested_tag="${GITHUB_REF#refs/tags/}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$requested_tag" ]]; then
|
||||||
|
# Normalize to v-prefixed format
|
||||||
|
normalized="${requested_tag#v}"
|
||||||
|
tag="v${normalized}"
|
||||||
|
else
|
||||||
|
echo "Error: Could not resolve release version" >&2
|
||||||
|
echo " - inputs.tag(raw): '$INPUT_TAG'" >&2
|
||||||
|
echo " - detected_tag(raw): '${DETECTED_TAG}'" >&2
|
||||||
|
echo " - GITHUB_REF: '$GITHUB_REF'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version=${normalized}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Checkout release tag
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
ref: refs/tags/${{ steps.resolve-version.outputs.tag }}
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
check-latest: false
|
||||||
|
cache: true
|
||||||
|
cache-dependency-path: go.sum
|
||||||
|
|
||||||
|
- name: Install UPX
|
||||||
|
uses: crazy-max/ghaction-upx@v3
|
||||||
|
with:
|
||||||
|
install-only: true
|
||||||
|
|
||||||
|
- name: Preflight release API access
|
||||||
|
env:
|
||||||
|
TAG_NAME: ${{ steps.resolve-version.outputs.tag }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ -z "${RELEASE_TOKEN:-}" ]]; then
|
||||||
|
echo "No release token available. Set secrets.RELEASE_PAT." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
api_base="${GITHUB_API_URL:-${GITHUB_SERVER_URL%/}/api/v1}"
|
||||||
|
repo_api="${api_base}/repos/${GITHUB_REPOSITORY}"
|
||||||
|
|
||||||
|
curl --fail-with-body -sS \
|
||||||
|
-H "Authorization: token ${RELEASE_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"${repo_api}" >/dev/null
|
||||||
|
|
||||||
|
curl --fail-with-body -sS \
|
||||||
|
-H "Authorization: token ${RELEASE_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"${repo_api}/releases?limit=1" >/dev/null
|
||||||
|
|
||||||
|
if ! git rev-parse --verify --quiet "refs/tags/${TAG_NAME}" >/dev/null; then
|
||||||
|
echo "Tag ${TAG_NAME} was not found in the checked out repository." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Create or update release
|
||||||
|
id: publish
|
||||||
|
uses: ./publish
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.RELEASE_PAT }}
|
||||||
|
version: ${{ steps.resolve-version.outputs.version }}
|
||||||
|
|
||||||
|
- name: Build release binaries
|
||||||
|
env:
|
||||||
|
RELEASE_VERSION: ${{ steps.publish.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
upx_cmd=""
|
||||||
|
if command -v upx >/dev/null 2>&1; then
|
||||||
|
upx_cmd=upx
|
||||||
|
elif command -v upx-ucl >/dev/null 2>&1; then
|
||||||
|
upx_cmd=upx-ucl
|
||||||
|
else
|
||||||
|
echo "UPX is not available on PATH after install step; continuing without binary compression." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
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
|
||||||
|
if [[ -n "${upx_cmd}" ]]; then
|
||||||
|
"${upx_cmd}" --best --lzma "dist/${bin}"
|
||||||
|
fi
|
||||||
|
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
|
||||||
|
|
||||||
|
raw_release_id="$(printf '%s' "${RELEASE_ID:-}" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
|
||||||
|
if [[ "$raw_release_id" =~ ^%\!t\(string=(.*)\)$ ]]; then
|
||||||
|
raw_release_id="${BASH_REMATCH[1]}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
release_id="$(printf '%s' "$raw_release_id" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
|
||||||
|
if ! [[ "$release_id" =~ ^[0-9]+$ ]]; then
|
||||||
|
echo "Invalid release id from publish step: '${RELEASE_ID}'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
release_detail_api="${GITHUB_API_URL:-${GITHUB_SERVER_URL%/}/api/v1}/repos/${GITHUB_REPOSITORY}/releases/${release_id}"
|
||||||
|
if ! curl --fail-with-body -sS \
|
||||||
|
-H "Authorization: token ${RELEASE_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"$release_detail_api" >/dev/null; then
|
||||||
|
echo "Resolved release endpoint is not accessible: ${release_detail_api}" >&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
|
||||||
|
|
||||||
|
- name: Summary
|
||||||
|
if: ${{ always() }}
|
||||||
|
env:
|
||||||
|
TAG_NAME: ${{ steps.publish.outputs.tag }}
|
||||||
|
RELEASE_VERSION: ${{ steps.publish.outputs.version }}
|
||||||
|
PUBLISH_OUTCOME: ${{ steps.publish.outcome }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ "${PUBLISH_OUTCOME}" == "success" ]]; then
|
||||||
|
{
|
||||||
|
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"
|
||||||
|
echo "- Release binaries are compressed with UPX from crazy-max/ghaction-upx@v3 when available, otherwise uploaded uncompressed."
|
||||||
|
} >> "$SUMMARY_FILE"
|
||||||
|
else
|
||||||
|
{
|
||||||
|
echo "## Release Failed"
|
||||||
|
echo
|
||||||
|
echo "- Tag: ${TAG_NAME:-unknown}"
|
||||||
|
echo "- Create or update release step did not complete successfully."
|
||||||
|
} >> "$SUMMARY_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo 'Summary'
|
||||||
|
echo
|
||||||
|
|
||||||
|
if [[ -s "$SUMMARY_FILE" ]]; then
|
||||||
|
cat "$SUMMARY_FILE"
|
||||||
|
else
|
||||||
|
echo 'No summary generated.'
|
||||||
|
fi
|
||||||
|
|
||||||
|
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
|
||||||
|
env:
|
||||||
|
SUMMARY_FILE: ${{ runner.temp }}/update-release-validate-summary.md
|
||||||
|
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: ${{ secrets.RELEASE_PAT }}
|
||||||
|
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
|
||||||
|
|
||||||
|
${RUN_COMMAND} --help >/dev/null
|
||||||
|
|
||||||
|
recommend_stderr="$(mktemp)"
|
||||||
|
if ${RUN_COMMAND} --recommend --root . >/dev/null 2>"${recommend_stderr}"; then
|
||||||
|
echo "Expected --recommend to fail on the tagged release checkout" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
recommend_error="$(cat "${recommend_stderr}")"
|
||||||
|
case "${recommend_error}" in
|
||||||
|
*"unreleased section is empty"*)
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unexpected recommend failure output: ${recommend_error}" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "## Released Binary Validation (${{ matrix.asset_arch }})"
|
||||||
|
echo
|
||||||
|
echo "- Release tag: ${TAG_NAME}"
|
||||||
|
echo "- Asset: ${asset_name}"
|
||||||
|
echo "- Binary executed successfully via --help."
|
||||||
|
echo "- --recommend failed as expected on the tagged checkout because Unreleased is empty."
|
||||||
|
} >> "$SUMMARY_FILE"
|
||||||
|
|
||||||
|
- name: Summary
|
||||||
|
if: ${{ always() }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo 'Summary'
|
||||||
|
echo
|
||||||
|
|
||||||
|
if [[ -s "$SUMMARY_FILE" ]]; then
|
||||||
|
cat "$SUMMARY_FILE"
|
||||||
|
else
|
||||||
|
echo 'No summary generated.'
|
||||||
|
fi
|
||||||
440
.github/copilot-instructions.md
vendored
Normal file
440
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
# Æther Go Project Workflow Instructions
|
||||||
|
|
||||||
|
These instructions apply to all coding work in Æther Go repositories.
|
||||||
|
|
||||||
|
This file is self-contained. Do not assume access to this Guides repository or any other Æther repository when following these instructions.
|
||||||
|
|
||||||
|
## Engineering Process: Strict TDD
|
||||||
|
|
||||||
|
Follow TDD (Red, Green, Refactor) for all feature and bug-fix work:
|
||||||
|
|
||||||
|
- **Red**: Write or update a test first. Confirm the test fails for the expected reason.
|
||||||
|
- **Green**: Implement the minimum code needed to pass the test.
|
||||||
|
- **Refactor**: Improve code only after tests are green.
|
||||||
|
|
||||||
|
Do not implement behavior before a failing test exists, unless the user explicitly asks to skip TDD.
|
||||||
|
|
||||||
|
## Testing Requirements
|
||||||
|
|
||||||
|
- Always create and update tests in `*_test.go` files (Go language standard).
|
||||||
|
- Use the repository's established test framework (prefer testify suites for new coverage where appropriate).
|
||||||
|
- Run focused tests for touched packages first using `go test -run <TestName> ./path/to/package`.
|
||||||
|
- Run broader package or module suites as needed.
|
||||||
|
- Run full project validation when requested or when change risk warrants it.
|
||||||
|
- Preserve behavior validated by the repository's behavior/integration suites unless a behavioral change is explicitly requested.
|
||||||
|
|
||||||
|
## Coverage Standards
|
||||||
|
|
||||||
|
Apply coverage gates per package/module, not repository-wide aggregate:
|
||||||
|
|
||||||
|
- **Target**: 80%+ coverage per module.
|
||||||
|
- **Warning zone**: 65% to 79.99% (below target, improve during normal engineering work).
|
||||||
|
- **High risk**: 50% to 64.99% (requires explicit justification and follow-up).
|
||||||
|
- **Fail gate**: Below 50% (unacceptable).
|
||||||
|
|
||||||
|
Exclude generated code from coverage calculations. Run coverage analysis for changed modules:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test -covermode=atomic -coverprofile=coverage.out ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commit Checkpoints
|
||||||
|
|
||||||
|
Create commits at these checkpoints unless the user explicitly asks not to commit:
|
||||||
|
|
||||||
|
- After writing failing tests (red commit).
|
||||||
|
- After implementation passes tests (green commit).
|
||||||
|
- After refactoring (if substantial refactoring occurred).
|
||||||
|
- After changelog updates (separate docs-only commit).
|
||||||
|
|
||||||
|
For each functional change block, create at least one code commit before moving to unrelated work. Keep commits small and scoped to one change unit. Use non-interactive git commands.
|
||||||
|
|
||||||
|
### Changelog Commits
|
||||||
|
|
||||||
|
After completing a change block:
|
||||||
|
|
||||||
|
1. Update the changelog (typically `CHANGELOG.md`).
|
||||||
|
2. Create a separate docs-only commit for changelog updates.
|
||||||
|
3. Keep changelog commits scoped to documentation changes only—do not mix code edits into that commit.
|
||||||
|
|
||||||
|
### Commit Message Guidance
|
||||||
|
|
||||||
|
Prefer conventional commits with clear scopes and concise summaries.
|
||||||
|
|
||||||
|
- Preferred format for Go maintenance and tooling changes: `chore(go): <summary>`
|
||||||
|
- Preferred format for documentation updates: `docs: <summary>`
|
||||||
|
- Keep summaries lowercase, imperative, and under 72 characters when possible.
|
||||||
|
- Use one commit per logical change.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- `chore(go): update dependency injection guidance`
|
||||||
|
- `docs: clarify security scanning requirements`
|
||||||
|
|
||||||
|
## Go Conventions
|
||||||
|
|
||||||
|
- **Go version**: Target the repository's standard Go toolchain (typically `1.26.1`) and maintain compatibility with declared repository settings.
|
||||||
|
- **Test files**: Keep tests in `*_test.go` files in the same package as the code being tested.
|
||||||
|
- **Test suites**: Prefer testify suites for new Go test coverage where appropriate.
|
||||||
|
- **Focused testing**: Run package-specific tests first, then broader validation when requested.
|
||||||
|
- **Behavior parity**: Use the repository behavior suite for behavior parity validation when relevant.
|
||||||
|
|
||||||
|
## Dependency Injection (DI) Pattern
|
||||||
|
|
||||||
|
Dependency Injection is a required architectural pattern in Æther Go projects. Use the standards below directly:
|
||||||
|
|
||||||
|
- **Interfaces as contracts**: Define interfaces to represent dependencies; place interfaces in the same package as consumers.
|
||||||
|
- **Concrete structs**: Implement concrete types as structs that satisfy interfaces.
|
||||||
|
- **Constructor functions**: Use `New<TypeName>` constructor functions to wire dependencies and validate inputs.
|
||||||
|
- **No globals or singletons**: Accept dependencies as parameters; avoid hidden global state.
|
||||||
|
- **Testing with DI**: Create concrete mock implementations of interfaces for testing; avoid reflection-based mocking.
|
||||||
|
|
||||||
|
Example pattern:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Interface defines a contract.
|
||||||
|
type UserRepository interface {
|
||||||
|
GetUser(ctx context.Context, id string) (*User, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Concrete implementation satisfies the interface.
|
||||||
|
type PostgresUserRepository struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PostgresUserRepository) GetUser(ctx context.Context, id string) (*User, error) {
|
||||||
|
// Implementation
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constructor function injects dependencies.
|
||||||
|
func NewPostgresUserRepository(db *sql.DB) (*PostgresUserRepository, error) {
|
||||||
|
if db == nil {
|
||||||
|
return nil, errors.New("database connection cannot be nil")
|
||||||
|
}
|
||||||
|
return &PostgresUserRepository{db: db}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consumer type accepts interface dependency.
|
||||||
|
type UserService struct {
|
||||||
|
repo UserRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserService(repo UserRepository) (*UserService, error) {
|
||||||
|
if repo == nil {
|
||||||
|
return nil, errors.New("UserRepository cannot be nil")
|
||||||
|
}
|
||||||
|
return &UserService{repo: repo}, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Key rules:
|
||||||
|
|
||||||
|
- Validate injected dependencies in constructors; return error if validation fails.
|
||||||
|
- Keep interfaces minimal and focused on the consumer's contract (Interface Segregation Principle).
|
||||||
|
- Organize packages by domain, not by layer (avoid `service`, `handler`, `repository` top-level packages).
|
||||||
|
- Break circular dependencies using interfaces; define the interface in the consuming package.
|
||||||
|
|
||||||
|
## Additional Go Conventions
|
||||||
|
|
||||||
|
Beyond dependency injection, follow these Go-specific conventions:
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
- Return errors as the last return value.
|
||||||
|
- Use `errors.New()` or `fmt.Errorf()` for simple errors; use custom error types for complex cases.
|
||||||
|
- Wrap errors with context using `fmt.Errorf("%w", err)` in Go 1.13+.
|
||||||
|
- Do not log and return errors; let the caller decide how to handle.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
|
||||||
|
if id == "" {
|
||||||
|
return nil, fmt.Errorf("GetUser: invalid user id")
|
||||||
|
}
|
||||||
|
user, err := s.repo.GetUser(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("GetUser: %w", err)
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Package Organization
|
||||||
|
|
||||||
|
- Use domain-driven design principles; packages represent domain entities or use cases.
|
||||||
|
- Keep package dependencies acyclic; use interfaces to break circular dependencies.
|
||||||
|
- Place interfaces in consumer packages, not separate `interfaces` packages.
|
||||||
|
- Consider a `cmd/` directory for application entry points and `internal/` for domain logic.
|
||||||
|
|
||||||
|
Example structure:
|
||||||
|
|
||||||
|
```
|
||||||
|
myapp/
|
||||||
|
cmd/
|
||||||
|
myapp/
|
||||||
|
main.go # Dependency wiring
|
||||||
|
internal/
|
||||||
|
user/
|
||||||
|
user.go # Domain model
|
||||||
|
service.go # UserService and UserRepository interface
|
||||||
|
repository.go # PostgresUserRepository
|
||||||
|
auth/
|
||||||
|
auth.go # Domain model
|
||||||
|
service.go # AuthService and AuthProvider interface
|
||||||
|
```
|
||||||
|
|
||||||
|
### Concurrency Patterns
|
||||||
|
|
||||||
|
- Use goroutines and channels for concurrent work.
|
||||||
|
- Prefer message passing (channels) over shared memory.
|
||||||
|
- Use `context.Context` for cancellation and timeouts.
|
||||||
|
- Protect shared state with mutexes only when channels are not practical.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *UserService) ProcessUsers(ctx context.Context, ids []string) error {
|
||||||
|
workers := 5
|
||||||
|
jobs := make(chan string, len(ids))
|
||||||
|
errors := make(chan error, len(ids))
|
||||||
|
|
||||||
|
for i := 0; i < workers; i++ {
|
||||||
|
go func() {
|
||||||
|
for id := range jobs {
|
||||||
|
if err := s.processUser(ctx, id); err != nil {
|
||||||
|
errors <- err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, id := range ids {
|
||||||
|
jobs <- id
|
||||||
|
}
|
||||||
|
close(jobs)
|
||||||
|
|
||||||
|
for i := 0; i < len(ids); i++ {
|
||||||
|
select {
|
||||||
|
case err := <-errors:
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Code Organization and Naming
|
||||||
|
|
||||||
|
- Use clear, descriptive names for types and functions.
|
||||||
|
- Avoid `util`, `helper`, or `common` packages; prefer domain-specific package names.
|
||||||
|
- Keep files focused; one struct per file is reasonable if the struct is substantial.
|
||||||
|
- Use `context.Context` as the first parameter in functions that can block or make external calls.
|
||||||
|
|
||||||
|
## Workflow and Release Standards
|
||||||
|
|
||||||
|
When updating CI workflows or release logic:
|
||||||
|
|
||||||
|
- Use the repository's standard Go setup (typically `actions/setup-go@v5` with pinned version).
|
||||||
|
- Enforce Go dependency/build caching in every Go CI job to reduce repeated module and build downloads.
|
||||||
|
- Require `actions/setup-go@v5` caching with `cache: true` and `cache-dependency-path: go.sum`.
|
||||||
|
- For workflows that split jobs across multiple Go-related steps (test/lint/security), ensure caches are restored in each job.
|
||||||
|
- Enforce formatting in local and CI workflows:
|
||||||
|
- Require `go fmt ./...` before commit.
|
||||||
|
- Require formatting validation in CI (for example `test -z "$(gofmt -l .)"`), or use a standard formatter action that provides equivalent enforcement.
|
||||||
|
- Enforce module hygiene in local and CI workflows:
|
||||||
|
- Require `go mod tidy` and `go mod verify` as part of validation.
|
||||||
|
- CI may use standard actions/automation that perform equivalent module tidy and verification checks.
|
||||||
|
- Enforce changelog gate in PR validation workflows:
|
||||||
|
- Fail PR validation when no entry is added under `## [Unreleased]` in `CHANGELOG.md` for code, behavior, security, workflow, or tooling changes.
|
||||||
|
- Repository policy may allow explicit docs-only/metadata-only exceptions.
|
||||||
|
- Keep workflow summary output using the summary-file pattern:
|
||||||
|
- Define `SUMMARY_FILE` environment variable per job.
|
||||||
|
- Append markdown output from steps to the summary file.
|
||||||
|
- Print the summary in a final `Summary` step with `if: ${{ always() }}` condition.
|
||||||
|
- Badge URLs must use `https://{HOST}/{OWNER}/{REPO}/actions/workflows/{WORKFLOW_FILE}.yml/badge.svg{?CONTEXT_PARAMS}`.
|
||||||
|
- Badge-link targets must use `https://{HOST}/{OWNER}/{REPO}/actions/runs/latest?workflow={WORKFLOW_FILE}.yml{&CONTEXT_PARAMS}`.
|
||||||
|
- `CONTEXT_PARAMS` is optional; available params are `branch`, `event`, `style` for badge URLs and `branch`, `event` for badge-link targets. Prefer `branch` and `event` when filtering run context; if `style` is used, place it last.
|
||||||
|
- Prefer latest-run pages for badge links for fast status triage.
|
||||||
|
|
||||||
|
### Required Go Security Actions and Caching Pattern (GitHub Actions)
|
||||||
|
|
||||||
|
When using GitHub Actions for Go repositories, explicitly use these actions in CI:
|
||||||
|
|
||||||
|
- `securego/gosec@v2`
|
||||||
|
- `golang/govulncheck-action@v1`
|
||||||
|
|
||||||
|
Minimum recommended pattern:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
jobs:
|
||||||
|
security:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Go with cache
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
cache: true
|
||||||
|
cache-dependency-path: go.sum
|
||||||
|
|
||||||
|
- name: Validate formatting
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
test -z "$(gofmt -l .)"
|
||||||
|
|
||||||
|
- name: Module hygiene
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
go mod tidy
|
||||||
|
go mod verify
|
||||||
|
|
||||||
|
- name: Run gosec
|
||||||
|
uses: securego/gosec@v2
|
||||||
|
with:
|
||||||
|
args: ./...
|
||||||
|
|
||||||
|
- name: Run govulncheck
|
||||||
|
id: govulncheck
|
||||||
|
uses: golang/govulncheck-action@v1
|
||||||
|
with:
|
||||||
|
go-package: ./...
|
||||||
|
cache: true
|
||||||
|
cache-dependency-path: go.sum
|
||||||
|
```
|
||||||
|
|
||||||
|
### Composite Actions and Release Orchestration
|
||||||
|
|
||||||
|
Use `https://git.hrafn.xyz/aether/vociferate` as the default release-management tool when integrating Æther composite actions:
|
||||||
|
|
||||||
|
- **Always use full `https://` URLs** in `uses:` references for all vociferate actions (for example `uses: https://git.hrafn.xyz/aether/vociferate/prepare@v1.0.2`). This ensures correct action resolution on both GitHub and self-hosted Gitea instances. Never use shorthand coordinates like `aether/vociferate` without the full URL.
|
||||||
|
- Pin all action references to released tags (for example `@v1.0.2`).
|
||||||
|
- Keep all vociferate references on the same tag within a workflow.
|
||||||
|
- Use `prepare` action to update changelog/version and create release tags.
|
||||||
|
- Use `publish` action to create/update release notes and assets from existing tags.
|
||||||
|
- Do not mix alternate release actions unless a repository-local policy explicitly documents an override.
|
||||||
|
- Use `coverage-badge` action after tests produce `coverage.out` for coverage artifact uploads.
|
||||||
|
- For pre-conditions: checkout with full history (`fetch-depth: 0`), valid credentials, and required bucket variables/secrets.
|
||||||
|
|
||||||
|
Minimal standalone release workflow example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
prepare:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Vociferate prepare
|
||||||
|
uses: https://git.hrafn.xyz/aether/vociferate/prepare@v1.0.0
|
||||||
|
|
||||||
|
publish:
|
||||||
|
needs: prepare
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Vociferate publish
|
||||||
|
uses: https://git.hrafn.xyz/aether/vociferate/publish@v1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Conventions
|
||||||
|
|
||||||
|
- **Automation**: Prefer `justfile` for task automation; mirror core CI operations locally.
|
||||||
|
- **Dependency management**: Use `go.mod` and `go.sum` for version tracking.
|
||||||
|
- **Code formatting**: Run `go fmt ./...` before committing changes.
|
||||||
|
- **Module hygiene**: Run `go mod tidy` and `go mod verify` during local validation.
|
||||||
|
- **Structure**: Keep code organized in logical packages; avoid deep nesting.
|
||||||
|
|
||||||
|
## Security Standards
|
||||||
|
|
||||||
|
Security standards (self-contained):
|
||||||
|
|
||||||
|
- **gosec**: Run static security analysis for Go code.
|
||||||
|
- Command: `gosec ./...`
|
||||||
|
- Purpose: Detect common security issues (hard-coded secrets, SQL injection, weak crypto, etc.)
|
||||||
|
- Suppress: Use `#nosec` comments only with documented justification.
|
||||||
|
|
||||||
|
- **govulncheck**: Check Go code and dependencies for known vulnerabilities.
|
||||||
|
- Command: `govulncheck ./...`
|
||||||
|
- Purpose: Detect vulnerabilities in direct and transitive dependencies.
|
||||||
|
- Address: Update vulnerable dependencies to patched versions.
|
||||||
|
|
||||||
|
- **GitHub Actions enforcement** (for GitHub-hosted CI):
|
||||||
|
- Use `securego/gosec@v2` in CI workflows.
|
||||||
|
- Use `golang/govulncheck-action@v1` in CI workflows.
|
||||||
|
- Enable caching in these workflows (`actions/setup-go@v5` with `cache: true` and `cache-dependency-path`).
|
||||||
|
|
||||||
|
- **Dependency hygiene**: Keep `go.mod` and `go.sum` clean; run `go mod tidy` and `go mod verify` regularly.
|
||||||
|
|
||||||
|
Integrate both tools into CI workflows; fail builds on high/critical findings.
|
||||||
|
|
||||||
|
## Validation Sequence
|
||||||
|
|
||||||
|
Execute validation in this order (unless repository policy specifies otherwise):
|
||||||
|
|
||||||
|
1. Run `go fmt ./...` for code formatting.
|
||||||
|
2. Validate formatting (for example `test -z "$(gofmt -l .)"`) before or within CI.
|
||||||
|
3. Run `go mod tidy` and `go mod verify` (or equivalent standard automation).
|
||||||
|
4. Run focused package tests that directly cover the changed code.
|
||||||
|
5. Run broader package or module test suites as needed.
|
||||||
|
6. Run `gosec ./...` for security analysis.
|
||||||
|
7. Run `govulncheck ./...` for vulnerability scanning.
|
||||||
|
8. Run full project or behavior/integration suites when change scope or risk warrants it.
|
||||||
|
9. Verify coverage gates per changed module/class (target 80%, low bound 65%, fail below 50%).
|
||||||
|
|
||||||
|
## Safety and Scope
|
||||||
|
|
||||||
|
- Do not revert unrelated local changes.
|
||||||
|
- Avoid broad refactors outside the requested scope.
|
||||||
|
- Keep implementation minimal and aimed only at passing the failing test.
|
||||||
|
- Do not add code that the test does not exercise.
|
||||||
|
|
||||||
|
## Disambiguation and Decision-Making
|
||||||
|
|
||||||
|
If blocked by ambiguity:
|
||||||
|
|
||||||
|
- Ask one concise clarifying question rather than guessing.
|
||||||
|
- Proceed with the most reasonable interpretation based on context.
|
||||||
|
- Document any assumption in a commit message or code comment if relevant.
|
||||||
|
|
||||||
|
## Checklist: Feature or Bug-Fix Completion
|
||||||
|
|
||||||
|
Before considering a task done:
|
||||||
|
|
||||||
|
- ✓ A failing test existed before implementation (or skip-TDD was explicitly requested).
|
||||||
|
- ✓ Implementation was minimal and aimed at passing the test only.
|
||||||
|
- ✓ Refactoring happened only after tests were green.
|
||||||
|
- ✓ Focused tests passed for all changed packages.
|
||||||
|
- ✓ Broader validation was run when risk or scope justified it.
|
||||||
|
- ✓ Code was formatted with `go fmt ./...` and formatting validation passed.
|
||||||
|
- ✓ Module hygiene checks passed (`go mod tidy` and `go mod verify`, or equivalent standard automation).
|
||||||
|
- ✓ PR validation changelog gate passed (`CHANGELOG.md` has required addition under `## [Unreleased]` when policy applies).
|
||||||
|
- ✓ Coverage gates were evaluated per changed module/class (target 80%, low bound 65%, fail below 50%).
|
||||||
|
- ✓ Behavioral parity expectations were preserved unless change was explicitly requested.
|
||||||
|
- ✓ Security scanning passed: `gosec ./...` and `govulncheck ./...` without unacknowledged findings.
|
||||||
|
- ✓ Dependency injection properly applied: dependencies injected via constructors, not globals; interfaces define contracts; concrete implementations satisfy interfaces.
|
||||||
|
- ✓ Commits were created at checkpoints (red test, green implementation, changelog).
|
||||||
|
- ✓ Changelog was updated with a separate docs-only commit.
|
||||||
240
AGENTS.md
Normal file
240
AGENTS.md
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
# Agent Integration Guide
|
||||||
|
|
||||||
|
This guide is for agentic coding partners that need to integrate the composite actions published by this repository.
|
||||||
|
|
||||||
|
## Source Of Truth
|
||||||
|
|
||||||
|
Pin all action references to a released tag (for example `@v1.1.0`) and keep all vociferate references on the same tag in a workflow.
|
||||||
|
|
||||||
|
Published composite actions:
|
||||||
|
|
||||||
|
- `https://git.hrafn.xyz/aether/vociferate@v1.1.0` (root action)
|
||||||
|
- `https://git.hrafn.xyz/aether/vociferate/prepare@v1.1.0`
|
||||||
|
- `https://git.hrafn.xyz/aether/vociferate/publish@v1.1.0`
|
||||||
|
- `https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.1.0`
|
||||||
|
- `https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.1.0`
|
||||||
|
|
||||||
|
## Action Selection Matrix
|
||||||
|
|
||||||
|
Use this when deciding which action to call:
|
||||||
|
|
||||||
|
- Choose `prepare` when you need to update changelog/version files, commit, and push a release tag.
|
||||||
|
- Choose `publish` when a tag already exists and you need to create or update release notes/assets.
|
||||||
|
- Choose `coverage-badge` after tests have produced `coverage.out` and you need coverage artefacts uploaded.
|
||||||
|
- Choose `decorate-pr` to annotate pull requests with coverage information and unreleased changelog entries.
|
||||||
|
- Choose root `vociferate` for direct recommend/prepare logic without commit/tag/push behavior.
|
||||||
|
|
||||||
|
## Preconditions
|
||||||
|
|
||||||
|
Apply these checks before invoking actions:
|
||||||
|
|
||||||
|
- Checkout repository first.
|
||||||
|
- For prepare/publish flows that depend on tags/history, use full history checkout (`fetch-depth: 0`).
|
||||||
|
- Use `secrets.RELEASE_PAT` for release/tag/update operations (`prepare`, `publish`, `release`, `update-release`) so authenticated release changes can be pushed and published reliably.
|
||||||
|
- `release`, `update-release`, and `decorate-pr` run preflight API checks and fail fast when token credentials are missing or insufficient.
|
||||||
|
- Set required vars/secrets for coverage uploads:
|
||||||
|
- `vars.ARTEFACT_BUCKET_NAME`
|
||||||
|
- `vars.ARTEFACT_BUCKET_ENDPONT`
|
||||||
|
- `secrets.ARTEFACT_BUCKET_WRITE_ACCESS_KEY`
|
||||||
|
- `secrets.ARTEFACT_BUCKET_WRITE_ACCESS_SECRET`
|
||||||
|
- For externally visible changelog links, set `vars.VOCIFERATE_REPOSITORY_URL` to the server/base URL only.
|
||||||
|
|
||||||
|
## Changelog Format Guidance
|
||||||
|
|
||||||
|
Agents should keep `CHANGELOG.md` in a Keep a Changelog compatible structure because vociferate derives versions and release notes from headings.
|
||||||
|
|
||||||
|
Required conventions:
|
||||||
|
|
||||||
|
- Keep the top-level heading as `# Changelog`.
|
||||||
|
- Maintain an `## [Unreleased]` section.
|
||||||
|
- Keep the standard subsections under `Unreleased` in this order:
|
||||||
|
- `### Breaking`
|
||||||
|
- `### Added`
|
||||||
|
- `### Changed`
|
||||||
|
- `### Removed`
|
||||||
|
- `### Fixed`
|
||||||
|
- Record releases with headings like `## [1.2.3] - YYYY-MM-DD`.
|
||||||
|
- Use bullet entries under subsections (for example `- Added new publish output`).
|
||||||
|
- Preserve or regenerate bottom reference links (`[Unreleased]: ...`, `[1.2.3]: ...`) instead of mixing inline heading links.
|
||||||
|
|
||||||
|
Semver behavior used by recommendation logic:
|
||||||
|
|
||||||
|
- `Breaking` or `Removed` entries trigger a major bump.
|
||||||
|
- `Added` entries trigger a minor bump.
|
||||||
|
- Otherwise recommendation falls back to a patch bump.
|
||||||
|
|
||||||
|
Minimal template:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Breaking
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Minimal Integration Patterns
|
||||||
|
|
||||||
|
### 1. Full Release Workflow
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
uses: https://git.hrafn.xyz/aether/vociferate/.gitea/workflows/release.yml@v1.1.0
|
||||||
|
with:
|
||||||
|
version: ${{ inputs.version }}
|
||||||
|
secrets: inherit
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Update Existing Release Tag
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
uses: https://git.hrafn.xyz/aether/vociferate/.gitea/workflows/update-release.yml@v1.1.0
|
||||||
|
with:
|
||||||
|
tag: v1.2.3
|
||||||
|
secrets: inherit
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Coverage Badge Publication
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
jobs:
|
||||||
|
coverage:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
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:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Run tests with coverage
|
||||||
|
run: go test -covermode=atomic -coverprofile=coverage.out ./...
|
||||||
|
- id: badge
|
||||||
|
uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.1.0
|
||||||
|
with:
|
||||||
|
artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }}
|
||||||
|
artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Decorate Pull Request With Coverage and Changes
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
jobs:
|
||||||
|
coverage:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
env:
|
||||||
|
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:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Run tests with coverage
|
||||||
|
run: go test -covermode=atomic -coverprofile=coverage.out ./...
|
||||||
|
- id: badge
|
||||||
|
uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.1.0
|
||||||
|
with:
|
||||||
|
artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }}
|
||||||
|
artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }}
|
||||||
|
- name: Decorate PR
|
||||||
|
uses: https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.1.0
|
||||||
|
with:
|
||||||
|
coverage-percentage: ${{ steps.badge.outputs.total }}
|
||||||
|
badge-url: ${{ steps.badge.outputs.badge-url }}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Inputs And Outputs Cheatsheet
|
||||||
|
|
||||||
|
### prepare
|
||||||
|
|
||||||
|
Common inputs:
|
||||||
|
|
||||||
|
- `version` (optional override)
|
||||||
|
- `version-file` (optional)
|
||||||
|
- `version-pattern` (optional)
|
||||||
|
- `changelog` (optional)
|
||||||
|
|
||||||
|
Primary output:
|
||||||
|
|
||||||
|
- `version` (resolved tag, for example `v1.2.3`)
|
||||||
|
|
||||||
|
### publish
|
||||||
|
|
||||||
|
Common inputs:
|
||||||
|
|
||||||
|
- `token` (optional, defaults to workflow token)
|
||||||
|
- `version` (optional if running from tag ref)
|
||||||
|
- `changelog` (optional)
|
||||||
|
|
||||||
|
Primary outputs:
|
||||||
|
|
||||||
|
- `release-id`
|
||||||
|
- `tag`
|
||||||
|
- `version`
|
||||||
|
|
||||||
|
### coverage-badge
|
||||||
|
|
||||||
|
Required inputs:
|
||||||
|
|
||||||
|
- `artefact-bucket-name`
|
||||||
|
- `artefact-bucket-endpoint`
|
||||||
|
|
||||||
|
Useful optional inputs:
|
||||||
|
|
||||||
|
- `coverage-profile` (default `coverage.out`)
|
||||||
|
- `summary-file` (append markdown summary)
|
||||||
|
|
||||||
|
Primary outputs:
|
||||||
|
|
||||||
|
- `total`
|
||||||
|
- `report-url`
|
||||||
|
- `badge-url`
|
||||||
|
|
||||||
|
### decorate-pr
|
||||||
|
|
||||||
|
Required inputs:
|
||||||
|
|
||||||
|
- `coverage-percentage` (0-100, typically from coverage-badge action)
|
||||||
|
- `badge-url` (SVG badge URL, typically from coverage-badge action)
|
||||||
|
|
||||||
|
Useful optional inputs:
|
||||||
|
|
||||||
|
- `changelog` (default `CHANGELOG.md`)
|
||||||
|
- `comment-title` (default `Vociferate Review`)
|
||||||
|
- `token` (defaults to workflow token)
|
||||||
|
- `enable-changelog-gate` (default `false`) - Enable changelog validation gate
|
||||||
|
- `changelog-gate-mode` (default `soft`) - `strict` or `soft` mode for gate
|
||||||
|
- `changelog-gate-required-for` (default `code,behavior,security,workflow,tooling`) - Change types requiring entries
|
||||||
|
- `changelog-gate-allow-docs-only` (default `true`) - Skip requirement for docs-only PRs
|
||||||
|
- `changelog-gate-docs-globs` (default `docs/**,**.md,**.txt,**.rst`) - Docs file patterns
|
||||||
|
- `changelog-gate-skip-labels` (default empty) - PR labels that bypass requirement
|
||||||
|
|
||||||
|
Primary outputs:
|
||||||
|
|
||||||
|
- `comment-id` - Comment ID
|
||||||
|
- `comment-url` - Comment URL
|
||||||
|
- `gate-passed` - Whether changelog gate validation passed
|
||||||
|
- `docs-only` - Whether PR is docs-only
|
||||||
|
- `unreleased-additions-count` - Number of Unreleased additions detected
|
||||||
|
- `gate-failure-reason` - Reason for gate failure, if applicable
|
||||||
|
|
||||||
|
## Guardrails For Agents
|
||||||
|
|
||||||
|
Use these rules to avoid common automation mistakes:
|
||||||
|
|
||||||
|
- Do not mix action tags in one workflow update.
|
||||||
|
- Do not assume a release workflow will run from a tag push in all environments; reusable workflow call paths are supported.
|
||||||
|
- Do not treat `VOCIFERATE_REPOSITORY_URL` as a full repository URL; it must be a base URL.
|
||||||
|
- Do not bypass preflight failures with broad retry loops; fix token scope/secret wiring first.
|
||||||
187
CHANGELOG.md
Normal file
187
CHANGELOG.md
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
A `### Breaking` section is used in addition to Keep a Changelog's standard sections to explicitly document changes that are backwards-incompatible but would otherwise appear under `### Changed`. Entries under `### Breaking` trigger a major version bump in automated release recommendation logic.
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Breaking
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extracted `coverage-gate` action and tool from Cue for reuse across Æther projects.
|
||||||
|
- Coverage gate now available as reusable composite action with JSON metrics output (`passes`, `total_coverage`, `packages_checked`, `packages_failed`).
|
||||||
|
- Support for per-package coverage threshold policy via JSON configuration in `coverage-gate` tool.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Hardened `coverage-gate` file input handling by validating and normalizing policy/profile paths before opening files, resolving `G304` findings in `coverage-gate/parse.go`.
|
||||||
|
- Made release binary builds resilient by installing UPX via `crazy-max/ghaction-upx@v3` and falling back to uncompressed artifacts when UPX is still unavailable in both `release.yml` and `update-release.yml`.
|
||||||
|
|
||||||
|
## [1.1.0] - 2026-03-21
|
||||||
|
|
||||||
|
### Breaking
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added changelog gate validation to `decorate-pr` action for enforcing changelog updates on qualifying code changes.
|
||||||
|
- Changelog gate modes: `strict` (fails job on violation) and `soft` (warns via PR comment).
|
||||||
|
- Docs-only PR exemption with customizable glob patterns for documentation files.
|
||||||
|
- PR label-based exemptions for changelog gate (example: `skip-changelog`).
|
||||||
|
- Precise diff parsing: validates only added lines within the Unreleased section.
|
||||||
|
- Gate decision outputs: `gate-passed`, `docs-only`, `unreleased-additions-count`, `gate-failure-reason` for reuse downstream.
|
||||||
|
- Integrated remediation guidance in PR comments showing how to add changelog entries.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Refactored `internal/vociferate` to use a constructor-backed service with injected filesystem, environment, and git dependencies while preserving the existing package-level API.
|
||||||
|
- Hardened `prepare-release` validation to enforce formatting checks, module hygiene, `gosec`, and `govulncheck` before preparing a release.
|
||||||
|
- Added matching local validation targets in `justfile` for formatting, module hygiene, tests, and security checks.
|
||||||
|
- `decorate-pr` now reads Unreleased changelog content through the `vociferate` Go CLI instead of maintaining separate shell parsing logic in the composite action.
|
||||||
|
- `publish` now extracts tagged release notes through the `vociferate` Go CLI instead of duplicating changelog section parsing in shell.
|
||||||
|
- Composite actions now share a centralized `run-vociferate` orchestration flow, with binary-versus-source execution delegated through shared composite actions and single-use runtime/download logic folded back into `run-vociferate.binary`.
|
||||||
|
- `run-vociferate` now contains both binary and source execution flows directly in a single action implementation, removing nested local action wrappers for better runner compatibility.
|
||||||
|
- Release automation now requires `secrets.RELEASE_PAT` for prepare/publish/do-release operations instead of defaulting to `GITHUB_TOKEN`/`GITEA_TOKEN`.
|
||||||
|
- Renamed the reusable Gitea workflows to `release.yml` and `update-release.yml`, and inlined release publication into the main `release` workflow for clearer per-step job output.
|
||||||
|
- Release binary builds now compress published linux artifacts with UPX before checksum generation and upload.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Prevented `govulncheck-action` from defaulting to `setup-go` version `stable` by explicitly setting `go-version-file` and disabling `check-latest`, avoiding unauthenticated GitHub API rate-limit failures on self-hosted/act-style runners.
|
||||||
|
- Made `do-release` version resolution resilient to `workflow_call` input passing issues by adding a separate tag detection step that fetches and discovers the latest tag from origin as a fallback when `inputs.tag` is empty, enabling proper operation even when Gitea's workflow_call doesn't pass inputs through correctly.
|
||||||
|
- Fixed version resolution in `do-release` workflow by moving version calculation before checkout, resolving from inputs/git tags, and always passing explicit version to `publish` action.
|
||||||
|
- Fixed tag detection in `do-release` to prioritize the tag at current HEAD (created by `prepare-release`) over the globally latest tag, ensuring correct version is detected when called from `prepare-release` workflow.
|
||||||
|
- Fixed `do-release` workflow_call resolution on Teacup runners by explicitly falling back to `needs.prepare.outputs.tag` and normalizing `%!t(string=...)` wrapped values before choosing a release tag.
|
||||||
|
- Fixed release-chain triggering by using a PAT for release commit/tag pushes so downstream release workflows are triggered reliably.
|
||||||
|
- Made `publish` action version resolution more robust with clearer error messages when version input is missing and workflow is not running from a tag push.
|
||||||
|
- Fixed `do-release` workflow to always checkout the resolved release tag, eliminating conditional checkout logic that could skip the checkout when called from `prepare-release` workflow.
|
||||||
|
- Pinned `securego/gosec` and `golang/govulncheck-action` to concrete version tags (`v2.22.4` and `v1.0.4`) so self-hosted Gitea runners can resolve them via direct git clone without relying on the GitHub Actions floating-tag API.
|
||||||
|
- Restored explicit gosec caching by storing a pinned `v2.22.4` binary under `${{ runner.temp }}/gosec-bin` with `actions/cache@v4`, so CI keeps fast security scans while still using the Go 1.26 toolchain from `setup-go`.
|
||||||
|
- Replaced `securego/gosec` composite action with a direct `go install github.com/securego/gosec/v2/cmd/gosec@v2.22.4 && gosec ./...` run step so gosec uses the Go 1.26 toolchain installed by `setup-go` rather than the action's bundled Go 1.24 binary which ignores `GOTOOLCHAIN=auto`.
|
||||||
|
- Fixed nested local composite-action references to use repository-local `./run-vociferate` paths so strict runners do not misparse parent-directory (`../`) action references as malformed remote coordinates.
|
||||||
|
- Consolidated `run-vociferate` binary and source execution flows directly into the main `run-vociferate` action to avoid nested local-action path resolution issues on strict runners.
|
||||||
|
- Hardened workflow module hygiene by retrying `go mod verify` after a module-cache refresh (`go clean -modcache` + `go mod download`) when runners report modified cached dependency directories.
|
||||||
|
- Synced `update-release.yml` with the active release pipeline fixes for Teacup-wrapped outputs, release-id normalization, upload endpoint validation, and accurate success or failure summaries.
|
||||||
|
|
||||||
|
## [1.0.2] - 2026-03-21
|
||||||
|
|
||||||
|
### Breaking
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Documented release/PR-decoration preflight token and API-access checks, including `GITHUB_TOKEN`/`GITEA_TOKEN` behavior for self-hosted Gitea.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
## [1.0.1] - 2026-03-21
|
||||||
|
|
||||||
|
### Breaking
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Enforced explicit `https://` changelog reference links in prepare output for browser-safe markdown links.
|
||||||
|
|
||||||
|
## [1.0.0] - 2026-03-21
|
||||||
|
|
||||||
|
### Breaking
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Canonical changelog filename is now `CHANGELOG.md`, and action/code defaults were updated to match.
|
||||||
|
- README now uses `Æther` stylization in prose and corrects released-tag guidance wording.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
## [0.2.0] - 2026-03-21
|
||||||
|
|
||||||
|
### Breaking
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a project LICENSE file.
|
||||||
|
- Root and prepare actions now read `${{ vars.VOCIFERATE_REPOSITORY_URL }}` and forward it to `VOCIFERATE_REPOSITORY_URL` for repository URL override.
|
||||||
|
- Added a published `coverage-badge` composite action for generating and uploading coverage report/badge artefacts for reuse across repositories.
|
||||||
|
- Added `AGENTS.md`, an explicit integration guide for agentic coding partners using vociferate composite actions.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Push validation now handles coverage artefact and badge generation in a dedicated `coverage-badge` job, with release recommendation isolated in a separate dependent job.
|
||||||
|
- Push validation now calls the reusable `./coverage-badge` composite action for coverage badge generation and publication.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Browser-facing URLs emitted in generated changelog links, workflow summaries, and markdown now use explicit `https://` forms.
|
||||||
|
- Release workflows now collect summary markdown into portable temp files and print it in explicit `Summary` steps instead of relying on unsupported `GITHUB_STEP_SUMMARY` output.
|
||||||
|
- Prepare now recreates the standard `Unreleased` section headers after promoting notes into a tagged release entry.
|
||||||
|
- First-release recommendation remains `v1.0.0` when no prior releases exist in the changelog.
|
||||||
|
- Do Release smoke validation now expects `--recommend` to fail on tagged release checkouts where `Unreleased` is intentionally empty.
|
||||||
|
- Changelog reference links now use compare URLs (`previous...current` for releases and `latest...main` for Unreleased), with first release links comparing from the repository's first commit short hash.
|
||||||
|
- Repository URL derivation now supports `VOCIFERATE_REPOSITORY_URL` as the highest-priority base-URL override for changelog link generation.
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-03-20
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- README workflow badges now link to the latest workflow run pages instead of the workflow definition pages.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Publish action falls back to `git describe` when `inputs.version` is empty and `GITHUB_REF` is not a tag ref, resolving `workflow_call` input propagation failures in act runner v0.3.0.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Coverage badge in README linked to S3-hosted main-branch report.
|
||||||
|
- S3 coverage artefact publishing (HTML report, badge, JSON summary) in push validation pipeline.
|
||||||
|
- CLI tests and internal helper tests raising total coverage to 84%.
|
||||||
|
- Test suite isolation against ambient CI environment variables for changelog link generation tests.
|
||||||
|
- Go CLI for changelog-driven release preparation and semantic version recommendation.
|
||||||
|
- Version recommendation from changelog release headings, including first-release support (`0.0.0` base -> `v1.0.0`).
|
||||||
|
- Automatic `release-version` creation/update during release preparation.
|
||||||
|
- Configurable version source/parsing via `--version-file` and `--version-pattern`.
|
||||||
|
- Configurable changelog path via `--changelog`.
|
||||||
|
- Recommended-version fallback when `version` is omitted in CLI and action flows.
|
||||||
|
- Major-version recommendation trigger from `Unreleased` `### Breaking`.
|
||||||
|
- Root composite action (`action.yml`) for recommend/prepare flows.
|
||||||
|
- Subdirectory composite actions: `prepare/action.yml` (prepare/commit/tag/push) and `publish/action.yml` (extract notes/create-or-update release).
|
||||||
|
- `publish` outputs for downstream automation: `release-id`, `tag`, and `version`.
|
||||||
|
- Dual execution mode for actions: `go run` from source on `@main`, prebuilt binaries on tagged refs.
|
||||||
|
- Repository-scoped binary cache keys with workflow-defined fixed token support via `VOCIFERATE_CACHE_TOKEN`.
|
||||||
|
- Tag-driven release publication with idempotent release updates and asset replacement on reruns.
|
||||||
|
- Release artifacts for `linux/amd64`, `linux/arm64`, and `checksums.txt`.
|
||||||
|
- Reusable Gitea workflows (`prepare-release.yml`, `do-release.yml`) with `workflow_call` support.
|
||||||
|
- Project/automation rename from `releaseprep` to `vociferate` (entrypoint, package paths, outputs).
|
||||||
|
- README guidance focused on primary cross-repository reuse workflows.
|
||||||
|
|
||||||
|
[Unreleased]: https://git.hrafn.xyz/aether/vociferate/compare/v1.1.0...main
|
||||||
|
[1.1.0]: https://git.hrafn.xyz/aether/vociferate/compare/v1.0.2...v1.1.0
|
||||||
|
[1.0.2]: https://git.hrafn.xyz/aether/vociferate/compare/v1.0.1...v1.0.2
|
||||||
|
[1.0.1]: https://git.hrafn.xyz/aether/vociferate/compare/v1.0.0...v1.0.1
|
||||||
|
[1.0.0]: https://git.hrafn.xyz/aether/vociferate/compare/v0.2.0...v1.0.0
|
||||||
|
[0.2.0]: https://git.hrafn.xyz/aether/vociferate/compare/v0.1.0...v0.2.0
|
||||||
|
[0.1.0]: https://git.hrafn.xyz/aether/vociferate/compare/81dced6...v0.1.0
|
||||||
371
COMPLIANCE_ANALYSIS.md
Normal file
371
COMPLIANCE_ANALYSIS.md
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
# Vociferate Standards Compliance Analysis
|
||||||
|
|
||||||
|
**Date:** March 21, 2026
|
||||||
|
**Repository:** git.hrafn.xyz/aether/vociferate
|
||||||
|
**Analysis Scope:** Go codebase, CI workflows, and engineering practices
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
The vociferate codebase demonstrates **solid fundamentals** in testing, error handling, and package organization but **lacks critical CI/CD workflow validation** steps documented in the project standards. The main gaps are:
|
||||||
|
|
||||||
|
- ✅ **Strong:** Test structure (testify suites), coverage (80%+), error handling (proper wrapping)
|
||||||
|
- ⚠️ **Acceptable:** Dependency injection patterns (functional options pattern used appropriately)
|
||||||
|
- ❌ **Critical Gaps:** Missing `go fmt`, `go mod tidy/verify`, `gosec`, `govulncheck` in CI workflows
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Testing Structure
|
||||||
|
|
||||||
|
### ✅ Status: COMPLIANT
|
||||||
|
|
||||||
|
**Findings:**
|
||||||
|
|
||||||
|
- **Test file format:** Properly organized in `*_test.go` files
|
||||||
|
- [cmd/vociferate/main_test.go](cmd/vociferate/main_test.go)
|
||||||
|
- [internal/vociferate/vociferate_test.go](internal/vociferate/vociferate_test.go)
|
||||||
|
- [internal/vociferate/vociferate_internal_test.go](internal/vociferate/vociferate_internal_test.go)
|
||||||
|
|
||||||
|
- **Testify suite usage:** ✅ Yes, properly implemented
|
||||||
|
- `PrepareSuite` in [vociferate_test.go](internal/vociferate/vociferate_test.go#L12) uses `suite.Suite`
|
||||||
|
- Tests use `require` assertions from testify
|
||||||
|
- Setup/teardown via `SetupTest()` method
|
||||||
|
- **Coverage analysis:**
|
||||||
|
- **cmd/vociferate:** 84.6% ✅ (exceeds 80% target)
|
||||||
|
- **internal/vociferate:** 80.9% ✅ (meets 80% target)
|
||||||
|
- **Total:** Both packages meet or exceed target
|
||||||
|
- Coverage methodology: `go test -covermode=atomic -coverprofile=coverage.out ./...`
|
||||||
|
|
||||||
|
**Compliance:** ✅ Full compliance with testing standards
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Dependency Injection
|
||||||
|
|
||||||
|
### ⚠️ Status: PARTIAL COMPLIANCE
|
||||||
|
|
||||||
|
**Findings:**
|
||||||
|
|
||||||
|
**What's Good:**
|
||||||
|
|
||||||
|
- ✅ No global singletons or hidden state
|
||||||
|
- ✅ Package state is minimal and functions are stateless
|
||||||
|
- ✅ Functional options pattern used (`vociferate.Options` struct):
|
||||||
|
```go
|
||||||
|
type Options struct {
|
||||||
|
VersionFile string
|
||||||
|
VersionPattern string
|
||||||
|
Changelog string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- ✅ Functions accept options explicitly (not constructor-injected, but appropriate for this use case)
|
||||||
|
|
||||||
|
**What Needs Attention:**
|
||||||
|
|
||||||
|
- ⚠️ **No explicit `New*` constructor functions** — This is acceptable for a utility library, but pattern not followed
|
||||||
|
- ⚠️ **Global regex variables** (4 instances, should be const or lazy-initialized):
|
||||||
|
|
||||||
|
```go
|
||||||
|
var releasedSectionRe = regexp.MustCompile(`(?m)^## \[(\d+\.\d+\.\d+)\] - `)
|
||||||
|
var linkedReleasedSectionRe = regexp.MustCompile(...)
|
||||||
|
var unreleasedHeadingRe = regexp.MustCompile(...)
|
||||||
|
var releaseHeadingRe = regexp.MustCompile(...)
|
||||||
|
var refLinkLineRe = regexp.MustCompile(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Issue:** Mutable global state; should be const or initialized once
|
||||||
|
- **Low risk** for this codebase (single-use CLI), but violates best practices
|
||||||
|
|
||||||
|
**Compliance:** ⚠️ Acceptable for library code; regex vars could be improved
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Error Handling
|
||||||
|
|
||||||
|
### ✅ Status: EXCELLENT
|
||||||
|
|
||||||
|
**Findings:**
|
||||||
|
|
||||||
|
- ✅ All errors wrapped with context using `fmt.Errorf("%w", err)`
|
||||||
|
- ✅ Consistent error wrapping throughout codebase:
|
||||||
|
- [vociferate.go lines 68, 73, 81, 87, 104](internal/vociferate/vociferate.go#L68-L87)
|
||||||
|
- `"version must not be empty"` → `fmt.Errorf("version must not be empty")`
|
||||||
|
- `"compile version pattern: %w"` → wraps underlying error
|
||||||
|
- `"read version file: %w"` → proper context wrapping
|
||||||
|
- `"write changelog: %w"` → proper context wrapping
|
||||||
|
|
||||||
|
- ✅ No log-and-return anti-pattern observed
|
||||||
|
- ✅ Error propagation allows callers to decide handling
|
||||||
|
|
||||||
|
**Examples of proper error handling:**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// From updateVersionFile
|
||||||
|
if err := os.ReadFile(path); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return os.WriteFile(...)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("read version file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// From resolveOptions
|
||||||
|
versionExpr, err := regexp.Compile(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return resolvedOptions{}, fmt.Errorf("compile version pattern: %w", err)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Compliance:** ✅ Full compliance with error handling standards
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Package Organization
|
||||||
|
|
||||||
|
### ✅ Status: COMPLIANT
|
||||||
|
|
||||||
|
**Findings:**
|
||||||
|
|
||||||
|
- ✅ **Domain-driven structure:**
|
||||||
|
- `internal/vociferate/` — Core domain logic
|
||||||
|
- `cmd/vociferate/` — CLI entry point
|
||||||
|
- No layer-based top-level packages (no `service/`, `handler/`, `repository/`)
|
||||||
|
|
||||||
|
- ✅ **Clear separation of concerns:**
|
||||||
|
- CLI parsing and execution in `cmd/vociferate/main.go`
|
||||||
|
- Domain logic in `internal/vociferate/vociferate.go`
|
||||||
|
- Tests colocated with implementations
|
||||||
|
|
||||||
|
- ✅ **Version placeholder package** (empty, future-ready):
|
||||||
|
- `internal/vociferate/version/` — Prepared for versioning but not yet populated
|
||||||
|
|
||||||
|
- ✅ **Minimal, focused code organization:**
|
||||||
|
- No unnecessary intermediate packages
|
||||||
|
- Clear domain boundaries
|
||||||
|
|
||||||
|
**Compliance:** ✅ Full compliance with package organization standards
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. CI/CD Workflows
|
||||||
|
|
||||||
|
### ✅ Status: COMPLIANT
|
||||||
|
|
||||||
|
**Workflows analyzed:**
|
||||||
|
|
||||||
|
- [push-validation.yml](.gitea/workflows/push-validation.yml)
|
||||||
|
- [release.yml](.gitea/workflows/release.yml)
|
||||||
|
- [update-release.yml](.gitea/workflows/update-release.yml)
|
||||||
|
|
||||||
|
#### What's Implemented
|
||||||
|
|
||||||
|
**push-validation.yml:**
|
||||||
|
|
||||||
|
- ✅ Go 1.26.1 setup with `actions/setup-go@v5`
|
||||||
|
- ✅ Caching enabled (`cache: true`, `cache-dependency-path: go.sum`)
|
||||||
|
- ✅ Code formatting validation (`go fmt` check)
|
||||||
|
- ✅ Module hygiene checks (`go mod tidy` and `go mod verify`)
|
||||||
|
- ✅ Security analysis with `gosec`
|
||||||
|
- ✅ Vulnerability scanning with `govulncheck`
|
||||||
|
- ✅ Full unit test suite with coverage (`go test -covermode=atomic -coverprofile=coverage.out`)
|
||||||
|
- ✅ Coverage badge publication
|
||||||
|
- ✅ Release tag recommendation on `main` branch
|
||||||
|
|
||||||
|
**release.yml:**
|
||||||
|
|
||||||
|
- ✅ Go setup and caching
|
||||||
|
- ✅ Tests run before release preparation
|
||||||
|
- ✅ Version and changelog updates
|
||||||
|
- ✅ Tag creation
|
||||||
|
|
||||||
|
#### What's Fixed
|
||||||
|
|
||||||
|
| Step | Documented Requirement | Push Validation | Status |
|
||||||
|
| --------------------- | ----------------------------------------- | --------------- | -------- |
|
||||||
|
| **go fmt validation** | Required | ✅ YES | Enforced |
|
||||||
|
| **go mod tidy** | Required | ✅ YES | Enforced |
|
||||||
|
| **go mod verify** | Required | ✅ YES | Enforced |
|
||||||
|
| **gosec** | Required (`securego/gosec@v2`) | ✅ YES | Enforced |
|
||||||
|
| **govulncheck** | Required (`golang/govulncheck-action@v1`) | ✅ YES | Enforced |
|
||||||
|
|
||||||
|
**Implemented Actions (commit 7cb7b05):**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Now in push-validation.yml:
|
||||||
|
- name: Validate formatting
|
||||||
|
run: test -z "$(gofmt -l .)"
|
||||||
|
|
||||||
|
- name: Module hygiene
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
go mod tidy
|
||||||
|
go mod verify
|
||||||
|
|
||||||
|
- name: Run gosec security analysis
|
||||||
|
uses: securego/gosec@v2
|
||||||
|
with:
|
||||||
|
args: ./...
|
||||||
|
|
||||||
|
- name: Run govulncheck
|
||||||
|
uses: golang/govulncheck-action@v1
|
||||||
|
with:
|
||||||
|
go-package: ./...
|
||||||
|
cache: true
|
||||||
|
cache-dependency-path: go.sum
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** Changelog gate is a PR-level feature implemented in the `decorate-pr` action, not a push validation check.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Validation Sequence
|
||||||
|
|
||||||
|
### ✅ Status: NOW FOLLOWING DOCUMENTED STANDARD
|
||||||
|
|
||||||
|
**Documented sequence (from copilot-instructions.md):**
|
||||||
|
|
||||||
|
1. ✅ Run `go fmt ./...` for code formatting
|
||||||
|
2. ✅ **Validate formatting** — **NOW IMPLEMENTED**
|
||||||
|
3. ✅ **Run `go mod tidy` and `go mod verify`** — **NOW IMPLEMENTED**
|
||||||
|
4. ✅ Run focused package tests
|
||||||
|
5. ✅ Run broader test suites
|
||||||
|
6. ✅ **Run `gosec ./...`** — **NOW IMPLEMENTED**
|
||||||
|
7. ✅ **Run `govulncheck ./...`** — **NOW IMPLEMENTED**
|
||||||
|
8. ✅ Run full project validation (coverage checks)
|
||||||
|
9. ✅ Verify coverage gates per module (target 80%)
|
||||||
|
|
||||||
|
**Current workflow sequence (after commit 7cb7b05):**
|
||||||
|
|
||||||
|
1. Setup Go environment with caching ✅
|
||||||
|
2. Validate code formatting ✅
|
||||||
|
3. Check module hygiene (tidy + verify) ✅
|
||||||
|
4. Run security analysis (gosec) ✅
|
||||||
|
5. Run vulnerability scanning (govulncheck) ✅
|
||||||
|
6. Run full unit test suite with coverage ✅
|
||||||
|
7. Publish coverage badge ✅
|
||||||
|
8. (On main) Recommend next release tag ✅
|
||||||
|
|
||||||
|
**Impact:** All security, formatting, and module checks now run in CI, preventing:
|
||||||
|
|
||||||
|
- Inconsistent code formatting from merging ✅
|
||||||
|
- Stale/incorrect `go.mod` from merging ✅
|
||||||
|
- Known vulnerabilities from going undetected ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Additional Observations
|
||||||
|
|
||||||
|
### Code Quality Improvements (commit 7cb7b05)
|
||||||
|
|
||||||
|
**Regex Variables in `internal/vociferate/vociferate.go`:**
|
||||||
|
|
||||||
|
- ✅ Grouped into `var (...)` block for clarity
|
||||||
|
- ✅ Added clarifying comment about read-only nature
|
||||||
|
- Maintains Go idioms while signaling immutability intent
|
||||||
|
- No functional changes; improves code organization
|
||||||
|
|
||||||
|
### Justfile (Local Automation)
|
||||||
|
|
||||||
|
**Current state:** Aligned with CI baseline for local validation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go-build
|
||||||
|
go-test
|
||||||
|
validate-fmt
|
||||||
|
validate-mod
|
||||||
|
security
|
||||||
|
validate
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implemented locally (commit 383aad4):**
|
||||||
|
|
||||||
|
- ✅ `validate-fmt` runs `go fmt ./...` and verifies `gofmt -l .` is clean
|
||||||
|
- ✅ `validate-mod` runs `go mod tidy` and `go mod verify`
|
||||||
|
- ✅ `security` runs `gosec ./...` and `govulncheck ./...`
|
||||||
|
- ✅ `validate` composes formatting, module hygiene, tests, and security checks
|
||||||
|
|
||||||
|
### Go Module Configuration
|
||||||
|
|
||||||
|
✅ **go.mod** is properly configured:
|
||||||
|
|
||||||
|
- Go 1.26 with toolchain 1.26.1
|
||||||
|
- Dependencies: `github.com/stretchr/testify v1.10.0` (for test suites)
|
||||||
|
- No extraneous dependencies
|
||||||
|
|
||||||
|
### Code Formatting
|
||||||
|
|
||||||
|
✅ **Code appears to follow Go conventions:**
|
||||||
|
|
||||||
|
- Consistent naming (camelCase for exported names)
|
||||||
|
- Proper error returns
|
||||||
|
- Clear package documentation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommendations (Priority Order)
|
||||||
|
|
||||||
|
### ✅ COMPLETED (commit 7cb7b05)
|
||||||
|
|
||||||
|
1. ✅ **`gosec` security scanning** — Now implemented in `push-validation.yml`
|
||||||
|
2. ✅ **`govulncheck` vulnerability scanning** — Now implemented in `push-validation.yml`
|
||||||
|
3. ✅ **`go fmt` validation** — Now implemented in `push-validation.yml`
|
||||||
|
4. ✅ **Module hygiene checks** (`go mod tidy` + `go mod verify`) — Now implemented in `push-validation.yml`
|
||||||
|
5. ✅ **Regex variable organization** — Grouped with clarifying comments in `vociferate.go`
|
||||||
|
6. ✅ **DI service boundary** — `internal/vociferate` now uses a constructor-backed service with injected filesystem, environment, and git dependencies (commit 383aad4)
|
||||||
|
7. ✅ **Local validation parity** — `justfile` now mirrors CI checks for format, modules, tests, and security (commit 383aad4)
|
||||||
|
|
||||||
|
### 🟡 FUTURE (Lower Priority)
|
||||||
|
|
||||||
|
8. **Implement changelog gate in PR workflows** — The `decorate-pr` action has changelog gate support; consider enabling `changelog-gate-mode: soft` in workflow if desired for future enhancement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary Table
|
||||||
|
|
||||||
|
| Category | Standard | Status | Details |
|
||||||
|
| ------------------------ | ------------------------------------ | ------- | ------------------------------------------------------ |
|
||||||
|
| **Testing** | `*_test.go` + testify suites | ✅ PASS | 80%+ coverage in all packages |
|
||||||
|
| **DI Pattern** | Constructor functions, no singletons | ✅ PASS | Constructor-backed service with injected collaborators |
|
||||||
|
| **Error Handling** | fmt.Errorf with `%w` wrapping | ✅ PASS | Consistent throughout codebase |
|
||||||
|
| **Package Organization** | Domain-driven, no layer-based | ✅ PASS | Clean structure, no over-engineering |
|
||||||
|
| **go fmt validation** | Fail if formatting inconsistent | ✅ PASS | Enforced in workflows and local automation |
|
||||||
|
| **go mod checks** | tidy + verify | ✅ PASS | Enforced in workflows and local automation |
|
||||||
|
| **gosec** | Static security analysis | ✅ PASS | Enforced in workflows and local automation |
|
||||||
|
| **govulncheck** | Vulnerability scanning | ✅ PASS | Enforced in workflows and local automation |
|
||||||
|
| **Coverage gates** | 80% target per module | ✅ PASS | Both packages exceed/meet target |
|
||||||
|
| **Changelog gate** | Enforce changelog entries | ❌ FAIL | Not implemented |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
**Current State (Updated):** The codebase now demonstrates strong engineering fundamentals in testing, error handling, structure, **and CI/CD validation**.
|
||||||
|
|
||||||
|
✅ **All critical standards gaps have been addressed** across commits 7cb7b05 and 383aad4:
|
||||||
|
|
||||||
|
- Security scanning (`gosec` + `govulncheck`) now enforced
|
||||||
|
- Code formatting validation now required
|
||||||
|
- Module hygiene checks (`go mod tidy`/`verify`) now enforced
|
||||||
|
- Regex variable organization clarified
|
||||||
|
- Dependency injection implemented through a constructor-backed service
|
||||||
|
- Local `justfile` validation now mirrors CI checks
|
||||||
|
|
||||||
|
**Validation Sequence:** The workflow now follows the documented 8-step validation sequence from copilot-instructions.md:
|
||||||
|
|
||||||
|
1. Format validation
|
||||||
|
2. Module hygiene
|
||||||
|
3. Security analysis
|
||||||
|
4. Vulnerability scanning
|
||||||
|
5. Full test suite
|
||||||
|
6. Coverage analysis
|
||||||
|
|
||||||
|
**Effort Invested:**
|
||||||
|
|
||||||
|
- CI/CD improvements: workflow hardening in `push-validation.yml` and `release.yml`
|
||||||
|
- Code organization: injected service boundaries for filesystem, environment, and git access
|
||||||
|
- Local automation: `justfile` validation parity for format, modules, tests, and security
|
||||||
|
- **Primary commits:** 7cb7b05, 383aad4, 5c903c9
|
||||||
|
|
||||||
|
**Next Steps (Optional):**
|
||||||
|
|
||||||
|
- Consider enabling changelog gate in PR workflows for future enhancement
|
||||||
150
README.md
150
README.md
@@ -1,11 +1,11 @@
|
|||||||
# vociferate
|
# vociferate
|
||||||
|
|
||||||
[](//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=push-validation.yml&branch=main&event=push)
|
||||||
[](//git.hrafn.xyz/aether/vociferate/actions/runs/latest?workflow=prepare-release.yml)
|
[](https://git.hrafn.xyz/aether/vociferate/actions/runs/latest?workflow=release.yml)
|
||||||
[](//git.hrafn.xyz/aether/vociferate/actions/runs/latest?workflow=do-release.yml)
|
[](https://git.hrafn.xyz/aether/vociferate/actions/runs/latest?workflow=update-release.yml)
|
||||||
[](//s3.hrafn.xyz/aether-workflow-report-artefacts/vociferate/branch/main/coverage.html)
|
[](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
|
`vociferate` is an `Æther` release orchestration tool written in Go for repositories that
|
||||||
want changelog-driven versioning, automated release preparation, and repeatable
|
want changelog-driven versioning, automated release preparation, and repeatable
|
||||||
tag publication.
|
tag publication.
|
||||||
|
|
||||||
@@ -16,13 +16,15 @@ revision.
|
|||||||
|
|
||||||
## Use In Other Repositories
|
## Use In Other Repositories
|
||||||
|
|
||||||
Vociferate ships two composite actions that together cover the full release flow.
|
Vociferate ships composite actions covering release preparation, release publication, coverage badge publishing, and pull request decoration.
|
||||||
Until release tags are created, reference `@main`. Once tags exist again, pin both actions to the same released tag.
|
Release tags now exist; pin all action and reusable-workflow references to the same released tag (for example, `@v1.1.0`) instead of `@main`.
|
||||||
|
|
||||||
|
For agentic coding partners, see [`AGENTS.md`](AGENTS.md) for a direct integration playbook, selection matrix, and copy-paste workflow patterns.
|
||||||
|
|
||||||
### `prepare` — update files, commit, and push tag
|
### `prepare` — update files, commit, and push tag
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
name: Prepare Release
|
name: Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
@@ -39,19 +41,19 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- uses: git.hrafn.xyz/aether/vociferate/prepare@main
|
- uses: https://git.hrafn.xyz/aether/vociferate/prepare@v1.1.0
|
||||||
with:
|
with:
|
||||||
version: ${{ inputs.version }}
|
version: ${{ inputs.version }}
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
needs: prepare
|
needs: prepare
|
||||||
uses: aether/vociferate/.gitea/workflows/do-release.yml@main
|
uses: https://git.hrafn.xyz/aether/vociferate/.gitea/workflows/update-release.yml@v1.1.0
|
||||||
with:
|
with:
|
||||||
tag: ${{ needs.prepare.outputs.version }}
|
tag: ${{ needs.prepare.outputs.version }}
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
```
|
```
|
||||||
|
|
||||||
Downloads a prebuilt vociferate binary, runs it to update `changelog.md` and
|
Downloads a prebuilt vociferate binary, runs it to update `CHANGELOG.md` and
|
||||||
`release-version`, then commits those changes to the default branch and pushes
|
`release-version`, then commits those changes to the default branch and pushes
|
||||||
the release tag. Does not require Go on the runner.
|
the release tag. Does not require Go on the runner.
|
||||||
|
|
||||||
@@ -59,20 +61,21 @@ For repositories that embed the version inside source code, pass `version-file`
|
|||||||
and `version-pattern`:
|
and `version-pattern`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- uses: git.hrafn.xyz/aether/vociferate/prepare@main
|
- uses: https://git.hrafn.xyz/aether/vociferate/prepare@v1.1.0
|
||||||
with:
|
with:
|
||||||
|
token: ${{ secrets.RELEASE_PAT }}
|
||||||
version-file: internal/myapp/version/version.go
|
version-file: internal/myapp/version/version.go
|
||||||
version-pattern: 'const Version = "([^"]+)"'
|
version-pattern: 'const Version = "([^"]+)"'
|
||||||
git-add-files: changelog.md internal/myapp/version/version.go
|
git-add-files: CHANGELOG.md internal/myapp/version/version.go
|
||||||
```
|
```
|
||||||
|
|
||||||
`prepare` uses `github.token` internally for authenticated fetch/push operations,
|
`prepare` requires a PAT input for authenticated commit/push/tag operations.
|
||||||
so no token input is required.
|
Pass `token: ${{ secrets.RELEASE_PAT }}` when invoking the action.
|
||||||
|
|
||||||
### `publish` — create release with changelog notes
|
### `publish` — create release with changelog notes
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
name: Do Release
|
name: Update Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
@@ -83,32 +86,135 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
uses: aether/vociferate/.gitea/workflows/do-release.yml@main
|
uses: https://git.hrafn.xyz/aether/vociferate/.gitea/workflows/update-release.yml@v1.1.0
|
||||||
with:
|
with:
|
||||||
tag: ${{ inputs.tag }}
|
tag: ${{ inputs.tag }}
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
```
|
```
|
||||||
|
|
||||||
Reads the matching section from `changelog.md` and creates or updates the
|
Reads the matching section from `CHANGELOG.md` and creates or updates the
|
||||||
Gitea/GitHub release with those notes. The `version` input is optional — when
|
Gitea/GitHub release with those notes. The `version` input is optional — when
|
||||||
omitted it is derived from the current tag ref automatically.
|
omitted it is derived from the current tag ref automatically.
|
||||||
|
|
||||||
|
The reusable `Update Release` workflow now runs preflight checks before publish to
|
||||||
|
fail fast when the release token is missing or lacks API access. Set
|
||||||
|
`secrets.RELEASE_PAT` and use it for prepare/publish release operations.
|
||||||
|
|
||||||
The `publish` action outputs `release-id` so you can upload additional release
|
The `publish` action outputs `release-id` so you can upload additional release
|
||||||
assets after it runs:
|
assets after it runs:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- id: publish
|
- id: publish
|
||||||
uses: git.hrafn.xyz/aether/vociferate/publish@main
|
uses: https://git.hrafn.xyz/aether/vociferate/publish@v1.1.0
|
||||||
|
|
||||||
- name: Upload my binary
|
- name: Upload my binary
|
||||||
run: |
|
run: |
|
||||||
curl --fail-with-body -X POST \
|
curl --fail-with-body -X POST \
|
||||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
-H "Authorization: token ${{ secrets.RELEASE_PAT }}" \
|
||||||
-H "Content-Type: application/octet-stream" \
|
-H "Content-Type: application/octet-stream" \
|
||||||
"${{ github.api_url }}/repos/${{ github.repository }}/releases/${{ steps.publish.outputs.release-id }}/assets?name=myapp" \
|
"${{ github.api_url }}/repos/${{ github.repository }}/releases/${{ steps.publish.outputs.release-id }}/assets?name=myapp" \
|
||||||
--data-binary "@dist/myapp"
|
--data-binary "@dist/myapp"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `coverage-badge` - publish coverage report and badge
|
||||||
|
|
||||||
|
Run your coverage tests first, then call the action to generate `coverage.html`, `coverage-badge.svg`, and `coverage-summary.json`, upload them to S3-compatible storage, and emit output URLs.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Run tests with coverage
|
||||||
|
run: go test -covermode=atomic -coverprofile=coverage.out ./...
|
||||||
|
|
||||||
|
- id: coverage
|
||||||
|
uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.1.0
|
||||||
|
with:
|
||||||
|
artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }}
|
||||||
|
artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }}
|
||||||
|
|
||||||
|
- name: Print coverage links
|
||||||
|
run: |
|
||||||
|
echo "Report: ${{ steps.coverage.outputs.report-url }}"
|
||||||
|
echo "Badge: ${{ steps.coverage.outputs.badge-url }}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### `decorate-pr` - annotate pull requests with coverage and changes
|
||||||
|
|
||||||
|
Decorate pull requests with coverage badges, coverage percentages, and unreleased changelog entries. The action creates a new comment or updates an existing one on each run.
|
||||||
|
|
||||||
|
`decorate-pr` also runs a preflight comment API check so workflows fail early
|
||||||
|
with a clear message when token permissions are insufficient.
|
||||||
|
|
||||||
|
#### Basic Usage
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Run tests with coverage
|
||||||
|
run: go test -covermode=atomic -coverprofile=coverage.out ./...
|
||||||
|
|
||||||
|
- id: coverage
|
||||||
|
uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.1.0
|
||||||
|
with:
|
||||||
|
artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }}
|
||||||
|
artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }}
|
||||||
|
|
||||||
|
- name: Decorate pull request
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
uses: https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.1.0
|
||||||
|
with:
|
||||||
|
coverage-percentage: ${{ steps.coverage.outputs.total }}
|
||||||
|
badge-url: ${{ steps.coverage.outputs.badge-url }}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Changelog Gate (Strict Mode)
|
||||||
|
|
||||||
|
Enable changelog validation to enforce that code changes include `Unreleased` changelog entries. The gate fails the workflow in strict mode, or warns in soft mode:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Decorate pull request with changelog gate
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
uses: https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.1.0
|
||||||
|
with:
|
||||||
|
coverage-percentage: ${{ steps.coverage.outputs.total }}
|
||||||
|
badge-url: ${{ steps.coverage.outputs.badge-url }}
|
||||||
|
enable-changelog-gate: true
|
||||||
|
changelog-gate-mode: strict
|
||||||
|
changelog-gate-required-for: "code,behavior,security,workflow,tooling"
|
||||||
|
changelog-gate-allow-docs-only: true
|
||||||
|
changelog-gate-docs-globs: "docs/**,**.md,**.txt,**.rst"
|
||||||
|
changelog-gate-skip-labels: "skip-changelog"
|
||||||
|
```
|
||||||
|
|
||||||
|
The gate automatically:
|
||||||
|
|
||||||
|
- Parses diffs to detect docs-only PRs (skips requirement for doc-only changes)
|
||||||
|
- Counts `Unreleased` additions using section-aware parsing (ignores edits outside the section)
|
||||||
|
- Checks PR labels for skip exemptions (for example, `skip-changelog`)
|
||||||
|
- Outputs decision status and remediation guidance in the PR comment
|
||||||
|
- Handles both strict (fail) and soft (warn) modes
|
||||||
|
|
||||||
|
Decision outputs enable downstream workflow logic:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Decorate PR and check gate
|
||||||
|
id: decorate
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
uses: https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.1.0
|
||||||
|
with:
|
||||||
|
coverage-percentage: ${{ steps.coverage.outputs.total }}
|
||||||
|
badge-url: ${{ steps.coverage.outputs.badge-url }}
|
||||||
|
enable-changelog-gate: true
|
||||||
|
changelog-gate-mode: soft
|
||||||
|
|
||||||
|
- name: Gate decision
|
||||||
|
run: |
|
||||||
|
echo "Gate passed: ${{ steps.decorate.outputs.gate-passed }}"
|
||||||
|
echo "Is docs-only PR: ${{ steps.decorate.outputs.docs-only }}"
|
||||||
|
echo "Unreleased additions: ${{ steps.decorate.outputs.unreleased-additions-count }}"
|
||||||
|
if [[ "${{ steps.decorate.outputs.gate-passed }}" == "false" ]]; then
|
||||||
|
echo "Gate failure reason: ${{ steps.decorate.outputs.gate-failure-reason }}"
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
The action automatically finds existing vociferate comments by their marker and updates them instead of creating duplicates. This keeps PR timelines clean while keeping review information current.
|
||||||
|
|
||||||
## Why The Name
|
## Why The Name
|
||||||
|
|
||||||
> **vociferate** _(verb)_: to cry out loudly or forcefully.
|
> **vociferate** _(verb)_: to cry out loudly or forcefully.
|
||||||
@@ -163,7 +269,7 @@ Defaults:
|
|||||||
|
|
||||||
- `version-file`: `release-version`
|
- `version-file`: `release-version`
|
||||||
- `version-pattern`: `^\s*([^\r\n]+)\s*$`
|
- `version-pattern`: `^\s*([^\r\n]+)\s*$`
|
||||||
- `changelog`: `changelog.md`
|
- `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.
|
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.
|
||||||
|
|
||||||
@@ -183,7 +289,7 @@ just go-test
|
|||||||
|
|
||||||
Releases use two workflows:
|
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` 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.
|
- `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.
|
- `Do Release` reads the matching changelog section from that tagged revision, creates or updates the release, and uploads prebuilt binaries.
|
||||||
|
|
||||||
|
|||||||
157
action.yml
157
action.yml
@@ -16,7 +16,7 @@ inputs:
|
|||||||
changelog:
|
changelog:
|
||||||
description: Path to changelog file relative to repository root.
|
description: Path to changelog file relative to repository root.
|
||||||
required: false
|
required: false
|
||||||
default: changelog.md
|
default: CHANGELOG.md
|
||||||
recommend:
|
recommend:
|
||||||
description: If true, print recommended next release tag.
|
description: If true, print recommended next release tag.
|
||||||
required: false
|
required: false
|
||||||
@@ -25,142 +25,71 @@ inputs:
|
|||||||
outputs:
|
outputs:
|
||||||
version:
|
version:
|
||||||
description: Resolved version used for prepare mode, or the emitted recommended version for recommend mode.
|
description: Resolved version used for prepare mode, or the emitted recommended version for recommend mode.
|
||||||
value: ${{ steps.run-vociferate.outputs.version }}
|
value: ${{ steps.finalize-version.outputs.version }}
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: composite
|
using: composite
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Resolve vociferate binary metadata
|
- name: Resolve release date
|
||||||
id: resolve-binary
|
id: resolve-date
|
||||||
shell: bash
|
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: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
case "$RUNNER_ARCH" in
|
printf 'value=%s\n' "$(date -u +%F)" >> "$GITHUB_OUTPUT"
|
||||||
X64)
|
|
||||||
arch="amd64"
|
|
||||||
;;
|
|
||||||
ARM64)
|
|
||||||
arch="arm64"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Unsupported runner architecture: $RUNNER_ARCH" >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
if [[ "$ACTION_REF" == v* ]]; then
|
- name: Normalize version input
|
||||||
release_tag="$ACTION_REF"
|
id: normalize-version
|
||||||
normalized_version="${release_tag#v}"
|
shell: bash
|
||||||
asset_name="vociferate_${normalized_version}_linux_${arch}"
|
env:
|
||||||
cache_dir="${RUNNER_TEMP}/vociferate/${release_tag}/linux-${arch}"
|
INPUT_VERSION: ${{ inputs.version }}
|
||||||
binary_path="${cache_dir}/vociferate"
|
run: |
|
||||||
asset_url="${SERVER_URL}/aether/vociferate/releases/download/${release_tag}/${asset_name}"
|
set -euo pipefail
|
||||||
|
|
||||||
provided_cache_token="$(printf '%s' "${CACHE_TOKEN:-}" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
|
resolved_version="$(printf '%s' "${INPUT_VERSION:-}" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
|
||||||
if [[ -n "$provided_cache_token" ]]; then
|
printf 'value=%s\n' "$resolved_version" >> "$GITHUB_OUTPUT"
|
||||||
cache_token="$provided_cache_token"
|
|
||||||
else
|
|
||||||
cache_token="${ACTION_REPOSITORY:-aether/vociferate}-${release_tag}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p "$cache_dir"
|
- name: Recommend version
|
||||||
|
id: recommend-version
|
||||||
echo "use_binary=true" >> "$GITHUB_OUTPUT"
|
if: inputs.recommend == 'true' || steps.normalize-version.outputs.value == ''
|
||||||
echo "release_tag=$release_tag" >> "$GITHUB_OUTPUT"
|
uses: ./run-vociferate
|
||||||
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:
|
with:
|
||||||
go-version: '1.26.1'
|
root: ${{ github.workspace }}
|
||||||
cache: true
|
version-file: ${{ inputs.version-file }}
|
||||||
cache-dependency-path: ${{ github.action_path }}/go.sum
|
version-pattern: ${{ inputs.version-pattern }}
|
||||||
|
changelog: ${{ inputs.changelog }}
|
||||||
|
recommend: 'true'
|
||||||
|
|
||||||
- name: Restore cached vociferate binary
|
- name: Finalize version
|
||||||
id: cache-vociferate
|
id: finalize-version
|
||||||
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
|
shell: bash
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ github.token }}
|
PROVIDED_VERSION: ${{ steps.normalize-version.outputs.value }}
|
||||||
ASSET_URL: ${{ steps.resolve-binary.outputs.asset_url }}
|
RECOMMENDED_VERSION: ${{ steps.recommend-version.outputs.stdout }}
|
||||||
BINARY_PATH: ${{ steps.resolve-binary.outputs.binary_path }}
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
curl --fail --location \
|
if [[ -n "$PROVIDED_VERSION" ]] && [[ "${{ inputs.recommend }}" != 'true' ]]; then
|
||||||
-H "Authorization: token ${TOKEN}" \
|
resolved_version="$PROVIDED_VERSION"
|
||||||
-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 }}
|
|
||||||
VOCIFERATE_REPOSITORY_URL: ${{ vars.VOCIFERATE_REPOSITORY_URL }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
if [[ "$USE_BINARY" == "true" ]]; then
|
|
||||||
run_vociferate() { "$VOCIFERATE_BIN" "$@"; }
|
|
||||||
else
|
else
|
||||||
run_vociferate() { (cd "$GITHUB_ACTION_PATH" && go run ./cmd/vociferate "$@"); }
|
resolved_version="$RECOMMENDED_VERSION"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
common_args=(--root "$GITHUB_WORKSPACE")
|
if [[ "${{ inputs.recommend }}" == 'true' ]]; then
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
if [[ "${{ inputs.recommend }}" == "true" ]]; then
|
|
||||||
resolved_version="$(run_vociferate "${common_args[@]}" --recommend)"
|
|
||||||
echo "$resolved_version"
|
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
|
fi
|
||||||
|
|
||||||
echo "version=$resolved_version" >> "$GITHUB_OUTPUT"
|
printf 'version=%s\n' "$resolved_version" >> "$GITHUB_OUTPUT"
|
||||||
run_vociferate "${common_args[@]}" --version "$resolved_version" --date "$(date -u +%F)"
|
|
||||||
|
- name: Prepare release files
|
||||||
|
if: inputs.recommend != 'true'
|
||||||
|
uses: ./run-vociferate
|
||||||
|
with:
|
||||||
|
root: ${{ github.workspace }}
|
||||||
|
version-file: ${{ inputs.version-file }}
|
||||||
|
version-pattern: ${{ inputs.version-pattern }}
|
||||||
|
changelog: ${{ inputs.changelog }}
|
||||||
|
version: ${{ steps.finalize-version.outputs.version }}
|
||||||
|
date: ${{ steps.resolve-date.outputs.value }}
|
||||||
|
|||||||
81
changelog.md
81
changelog.md
@@ -1,81 +0,0 @@
|
|||||||
# Changelog
|
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
|
||||||
|
|
||||||
The format is based on [Keep a Changelog](//keepachangelog.com/en/1.1.0/),
|
|
||||||
and this project adheres to [Semantic Versioning](//semver.org/spec/v2.0.0.html).
|
|
||||||
|
|
||||||
A `### Breaking` section is used in addition to Keep a Changelog's standard sections to explicitly document changes that are backwards-incompatible but would otherwise appear under `### Changed`. Entries under `### Breaking` trigger a major version bump in automated release recommendation logic.
|
|
||||||
|
|
||||||
## [Unreleased]
|
|
||||||
|
|
||||||
### Breaking
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
## [0.2.0] - 2026-03-21
|
|
||||||
|
|
||||||
### Breaking
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Added a project LICENSE file.
|
|
||||||
- Root and prepare actions now read `${{ vars.VOCIFERATE_REPOSITORY_URL }}` and forward it to `VOCIFERATE_REPOSITORY_URL` for repository URL override.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Browser-facing URLs emitted in generated changelog links, workflow summaries, and markdown now use protocol-relative `//` forms.
|
|
||||||
- Release workflows now collect summary markdown into portable temp files and print it in explicit `Summary` steps instead of relying on unsupported `GITHUB_STEP_SUMMARY` output.
|
|
||||||
- Prepare now recreates the standard `Unreleased` section headers after promoting notes into a tagged release entry.
|
|
||||||
- First-release recommendation remains `v1.0.0` when no prior releases exist in the changelog.
|
|
||||||
- Do Release smoke validation now expects `--recommend` to fail on tagged release checkouts where `Unreleased` is intentionally empty.
|
|
||||||
- Changelog reference links now use compare URLs (`previous...current` for releases and `latest...main` for Unreleased), with first release links comparing from the repository's first commit short hash.
|
|
||||||
- Repository URL derivation now supports `VOCIFERATE_REPOSITORY_URL` as the highest-priority base-URL override for changelog link generation.
|
|
||||||
|
|
||||||
## [0.1.0] - 2026-03-20
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- README workflow badges now link to the latest workflow run pages instead of the workflow definition pages.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Publish action falls back to `git describe` when `inputs.version` is empty and `GITHUB_REF` is not a tag ref, resolving `workflow_call` input propagation failures in act runner v0.3.0.
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Coverage badge in README linked to S3-hosted main-branch report.
|
|
||||||
- S3 coverage artefact publishing (HTML report, badge, JSON summary) in push validation pipeline.
|
|
||||||
- CLI tests and internal helper tests raising total coverage to 84%.
|
|
||||||
- Test suite isolation against ambient CI environment variables for changelog link generation tests.
|
|
||||||
- Go CLI for changelog-driven release preparation and semantic version recommendation.
|
|
||||||
- Version recommendation from changelog release headings, including first-release support (`0.0.0` base -> `v1.0.0`).
|
|
||||||
- Automatic `release-version` creation/update during release preparation.
|
|
||||||
- Configurable version source/parsing via `--version-file` and `--version-pattern`.
|
|
||||||
- Configurable changelog path via `--changelog`.
|
|
||||||
- Recommended-version fallback when `version` is omitted in CLI and action flows.
|
|
||||||
- Major-version recommendation trigger from `Unreleased` `### Breaking`.
|
|
||||||
- Root composite action (`action.yml`) for recommend/prepare flows.
|
|
||||||
- Subdirectory composite actions: `prepare/action.yml` (prepare/commit/tag/push) and `publish/action.yml` (extract notes/create-or-update release).
|
|
||||||
- `publish` outputs for downstream automation: `release-id`, `tag`, and `version`.
|
|
||||||
- Dual execution mode for actions: `go run` from source on `@main`, prebuilt binaries on tagged refs.
|
|
||||||
- Repository-scoped binary cache keys with workflow-defined fixed token support via `VOCIFERATE_CACHE_TOKEN`.
|
|
||||||
- Tag-driven release publication with idempotent release updates and asset replacement on reruns.
|
|
||||||
- Release artifacts for `linux/amd64`, `linux/arm64`, and `checksums.txt`.
|
|
||||||
- Reusable Gitea workflows (`prepare-release.yml`, `do-release.yml`) with `workflow_call` support.
|
|
||||||
- Project/automation rename from `releaseprep` to `vociferate` (entrypoint, package paths, outputs).
|
|
||||||
- README guidance focused on primary cross-repository reuse workflows.
|
|
||||||
|
|
||||||
[Unreleased]: //git.hrafn.xyz/aether/vociferate/compare/v0.2.0...main
|
|
||||||
[0.2.0]: //git.hrafn.xyz/aether/vociferate/compare/v0.1.0...v0.2.0
|
|
||||||
[0.1.0]: //git.hrafn.xyz/aether/vociferate/compare/2060af6...v0.1.0
|
|
||||||
@@ -13,6 +13,8 @@ func main() {
|
|||||||
version := flag.String("version", "", "semantic version to release, with or without leading v")
|
version := flag.String("version", "", "semantic version to release, with or without leading v")
|
||||||
date := flag.String("date", "", "release date in YYYY-MM-DD format")
|
date := flag.String("date", "", "release date in YYYY-MM-DD format")
|
||||||
recommend := flag.Bool("recommend", false, "print the recommended next release tag based on the changelog")
|
recommend := flag.Bool("recommend", false, "print the recommended next release tag based on the changelog")
|
||||||
|
printUnreleased := flag.Bool("print-unreleased", false, "print the current Unreleased changelog body")
|
||||||
|
printReleaseNotes := flag.Bool("print-release-notes", false, "print the release notes section for --version")
|
||||||
root := flag.String("root", ".", "repository root to update")
|
root := flag.String("root", ".", "repository root to update")
|
||||||
versionFile := flag.String("version-file", "", "path to the version file, relative to --root")
|
versionFile := flag.String("version-file", "", "path to the version file, relative to --root")
|
||||||
versionPattern := flag.String("version-pattern", "", "regexp with one capture group for the version value")
|
versionPattern := flag.String("version-pattern", "", "regexp with one capture group for the version value")
|
||||||
@@ -41,8 +43,28 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if *printUnreleased {
|
||||||
|
body, err := vociferate.UnreleasedBody(absRoot, opts)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "print unreleased: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Print(body)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if *printReleaseNotes {
|
||||||
|
body, err := vociferate.ReleaseNotes(absRoot, *version, opts)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "print release notes: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Print(body)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if *version == "" || *date == "" {
|
if *version == "" || *date == "" {
|
||||||
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>]")
|
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>] | --print-unreleased [--root <dir>] [--changelog <path>] | --print-release-notes --version <version> [--root <dir>] [--changelog <path>]")
|
||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
func TestMainRecommendPrintsTag(t *testing.T) {
|
func TestMainRecommendPrintsTag(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
writeFile(t, filepath.Join(root, "release-version"), "1.1.6\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### Added\n\n- Feature.\n\n## [1.1.6] - 2017-12-20\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)
|
stdout, stderr, code := runMain(t, "--recommend", "--root", root)
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
@@ -23,6 +23,32 @@ func TestMainRecommendPrintsTag(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMainPrintUnreleasedWritesPendingNotes(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
writeFile(t, filepath.Join(root, "CHANGELOG.md"), "# Changelog\n\n## [Unreleased]\n\n### Added\n\n- Feature.\n\n### Fixed\n\n- Bugfix.\n\n## [1.1.6] - 2017-12-20\n")
|
||||||
|
|
||||||
|
stdout, stderr, code := runMain(t, "--print-unreleased", "--root", root)
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("expected exit 0, got %d (stderr: %s)", code, stderr)
|
||||||
|
}
|
||||||
|
if stdout != "### Added\n\n- Feature.\n\n### Fixed\n\n- Bugfix.\n" {
|
||||||
|
t.Fatalf("unexpected unreleased output: %q", stdout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMainPrintReleaseNotesWritesTaggedSection(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
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\n### Fixed\n\n- Historical note.\n")
|
||||||
|
|
||||||
|
stdout, stderr, code := runMain(t, "--print-release-notes", "--version", "1.1.6", "--root", root)
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("expected exit 0, got %d (stderr: %s)", code, stderr)
|
||||||
|
}
|
||||||
|
if stdout != "## [1.1.6] - 2017-12-20\n\n### Fixed\n\n- Historical note.\n" {
|
||||||
|
t.Fatalf("unexpected release notes output: %q", stdout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMainUsageExitWhenRequiredFlagsMissing(t *testing.T) {
|
func TestMainUsageExitWhenRequiredFlagsMissing(t *testing.T) {
|
||||||
_, stderr, code := runMain(t)
|
_, stderr, code := runMain(t)
|
||||||
if code != 2 {
|
if code != 2 {
|
||||||
@@ -37,7 +63,7 @@ func TestMainPrepareUpdatesFiles(t *testing.T) {
|
|||||||
root := t.TempDir()
|
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, ".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, "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")
|
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)
|
_, stderr, code := runMain(t, "--version", "v1.1.7", "--date", "2026-03-20", "--root", root)
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
|
|||||||
168
coverage-badge/action.yml
Normal file
168
coverage-badge/action.yml
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
name: vociferate/coverage-badge
|
||||||
|
description: >
|
||||||
|
Generate coverage report artefacts, publish them to object storage,
|
||||||
|
and expose report URLs for workflow summaries.
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
coverage-profile:
|
||||||
|
description: Path to the Go coverage profile file.
|
||||||
|
required: false
|
||||||
|
default: coverage.out
|
||||||
|
coverage-html:
|
||||||
|
description: Output path for the rendered HTML coverage report.
|
||||||
|
required: false
|
||||||
|
default: coverage.html
|
||||||
|
coverage-badge:
|
||||||
|
description: Output path for the generated SVG badge.
|
||||||
|
required: false
|
||||||
|
default: coverage-badge.svg
|
||||||
|
coverage-summary:
|
||||||
|
description: Output path for the generated coverage summary JSON.
|
||||||
|
required: false
|
||||||
|
default: coverage-summary.json
|
||||||
|
artefact-bucket-name:
|
||||||
|
description: S3 bucket name for published coverage artefacts.
|
||||||
|
required: true
|
||||||
|
artefact-bucket-endpoint:
|
||||||
|
description: Endpoint URL used for S3-compatible uploads.
|
||||||
|
required: true
|
||||||
|
branch-name:
|
||||||
|
description: Branch name used in the publication path.
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
repository-name:
|
||||||
|
description: Repository name used in the publication path.
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
summary-file:
|
||||||
|
description: Optional file path to append markdown summary output.
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
|
||||||
|
outputs:
|
||||||
|
total:
|
||||||
|
description: Computed coverage percentage.
|
||||||
|
value: ${{ steps.generate.outputs.total }}
|
||||||
|
report-url:
|
||||||
|
description: Browser-facing URL for the published coverage report.
|
||||||
|
value: ${{ steps.upload.outputs.report_url }}
|
||||||
|
badge-url:
|
||||||
|
description: Browser-facing URL for the published coverage badge.
|
||||||
|
value: ${{ steps.upload.outputs.badge_url }}
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- name: Install AWS CLI v2
|
||||||
|
uses: ankurk91/install-aws-cli-action@v1
|
||||||
|
|
||||||
|
- name: Verify AWS CLI
|
||||||
|
shell: bash
|
||||||
|
run: aws --version
|
||||||
|
|
||||||
|
- name: Generate coverage artefacts
|
||||||
|
id: generate
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
COVERAGE_PROFILE: ${{ inputs.coverage-profile }}
|
||||||
|
COVERAGE_HTML: ${{ inputs.coverage-html }}
|
||||||
|
COVERAGE_BADGE: ${{ inputs.coverage-badge }}
|
||||||
|
COVERAGE_SUMMARY: ${{ inputs.coverage-summary }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
go tool cover -html="$COVERAGE_PROFILE" -o "$COVERAGE_HTML"
|
||||||
|
|
||||||
|
total="$(go tool cover -func="$COVERAGE_PROFILE" | awk '/^total:/ {sub(/%/, "", $3); print $3}')"
|
||||||
|
printf '{\n "total": "%s"\n}\n' "$total" > "$COVERAGE_SUMMARY"
|
||||||
|
printf 'total=%s\n' "$total" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
color="$(awk -v total="$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" <<EOF
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="126" height="20" role="img" aria-label="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">${total}%</text>
|
||||||
|
<text x="93.5" y="14">${total}%</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Upload coverage artefacts
|
||||||
|
id: upload
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
ARTEFACT_BUCKET_NAME: ${{ inputs.artefact-bucket-name }}
|
||||||
|
ARTEFACT_BUCKET_ENDPONT: ${{ inputs.artefact-bucket-endpoint }}
|
||||||
|
INPUT_BRANCH_NAME: ${{ inputs.branch-name }}
|
||||||
|
INPUT_REPOSITORY_NAME: ${{ inputs.repository-name }}
|
||||||
|
COVERAGE_HTML: ${{ inputs.coverage-html }}
|
||||||
|
COVERAGE_BADGE: ${{ inputs.coverage-badge }}
|
||||||
|
COVERAGE_SUMMARY: ${{ inputs.coverage-summary }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
aws configure set default.s3.addressing_style path
|
||||||
|
|
||||||
|
branch_name="$(printf '%s' "$INPUT_BRANCH_NAME" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
|
||||||
|
if [[ -z "$branch_name" ]]; then
|
||||||
|
branch_name="${GITHUB_REF_NAME}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
repo_name="$(printf '%s' "$INPUT_REPOSITORY_NAME" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
|
||||||
|
if [[ -z "$repo_name" ]]; then
|
||||||
|
repo_name="${GITHUB_REPOSITORY##*/}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
prefix="${repo_name}/branch/${branch_name}"
|
||||||
|
|
||||||
|
display_endpoint="${ARTEFACT_BUCKET_ENDPONT#https://}"
|
||||||
|
display_endpoint="${display_endpoint#http://}"
|
||||||
|
report_url="https://${display_endpoint%/}/${ARTEFACT_BUCKET_NAME}/${prefix}/coverage.html"
|
||||||
|
badge_url="https://${display_endpoint%/}/${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" "s3://${ARTEFACT_BUCKET_NAME}/${prefix}/coverage-badge.svg" --content-type image/svg+xml
|
||||||
|
aws --endpoint-url "${ARTEFACT_BUCKET_ENDPONT}" s3 cp "$COVERAGE_SUMMARY" "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: Append coverage summary
|
||||||
|
if: ${{ inputs.summary-file != '' }}
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
SUMMARY_FILE: ${{ inputs.summary-file }}
|
||||||
|
TOTAL: ${{ steps.generate.outputs.total }}
|
||||||
|
REPORT_URL: ${{ steps.upload.outputs.report_url }}
|
||||||
|
BADGE_URL: ${{ steps.upload.outputs.badge_url }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
{
|
||||||
|
echo '## Coverage'
|
||||||
|
echo
|
||||||
|
echo "- Total: \`${TOTAL}%\`"
|
||||||
|
echo "- Report: ${REPORT_URL}"
|
||||||
|
echo "- Badge: ${BADGE_URL}"
|
||||||
|
} >> "$SUMMARY_FILE"
|
||||||
85
coverage-gate/README.md
Normal file
85
coverage-gate/README.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# coveragegate
|
||||||
|
|
||||||
|
Standalone coverage-threshold enforcement tool for this repository.
|
||||||
|
|
||||||
|
This tool is a quality gate. It is not part of Cue runtime orchestration.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
- Reads a Go coverage profile (for example `_build/coverage.out`).
|
||||||
|
- Loads package coverage policy from JSON.
|
||||||
|
- Discovers packages under a source root using `go list ./...`.
|
||||||
|
- Evaluates per-package statement coverage against policy thresholds.
|
||||||
|
- Prints a package table and returns a non-zero exit code when any package fails.
|
||||||
|
|
||||||
|
## Repository integration
|
||||||
|
|
||||||
|
Primary repository flow:
|
||||||
|
|
||||||
|
1. `just test-coverage` runs tests in `src/` and writes `_build/coverage.out`.
|
||||||
|
2. `scripts/check-core-coverage.sh` runs this tool from `tools/coveragegate/`.
|
||||||
|
3. The script currently passes:
|
||||||
|
- `--profile $ROOT_DIR/_build/coverage.out`
|
||||||
|
- `--policy $ROOT_DIR/docs/coverage-thresholds.json`
|
||||||
|
- `--src-root $ROOT_DIR/src`
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
From repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tools/coveragegate
|
||||||
|
go run . \
|
||||||
|
--profile ../../_build/coverage.out \
|
||||||
|
--policy ../../docs/coverage-thresholds.json \
|
||||||
|
--src-root ../../src
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the repository wrapper:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/check-core-coverage.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Flags
|
||||||
|
|
||||||
|
- `--profile`: Path to Go coverage profile.
|
||||||
|
- `--policy`: Path to JSON policy file.
|
||||||
|
- `--src-root`: Directory where packages are discovered with `go list ./...`.
|
||||||
|
|
||||||
|
## Exit codes
|
||||||
|
|
||||||
|
- `0`: All in-scope packages meet threshold.
|
||||||
|
- `1`: Policy/profile/load failure or one or more packages below threshold.
|
||||||
|
- `2`: Invalid CLI arguments.
|
||||||
|
|
||||||
|
## Policy model (current)
|
||||||
|
|
||||||
|
The tool expects a JSON object with at least:
|
||||||
|
|
||||||
|
- `minimum_statement_coverage` (number)
|
||||||
|
- `critical_packages` (array)
|
||||||
|
|
||||||
|
Each critical package may include:
|
||||||
|
|
||||||
|
- `package` (string)
|
||||||
|
- `minimum_statement_coverage` (number)
|
||||||
|
- `include` (boolean)
|
||||||
|
- `exclusions` (array of strings)
|
||||||
|
|
||||||
|
Behavior notes:
|
||||||
|
|
||||||
|
- If a package has no policy override, the global minimum is used.
|
||||||
|
- Generated/composition files are excluded by built-in rules.
|
||||||
|
- Packages with no statements are treated as passing.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Run tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tools/coveragegate
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep code `gofmt` and `go vet` clean.
|
||||||
91
coverage-gate/action.yml
Normal file
91
coverage-gate/action.yml
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
name: vociferate/coverage-gate
|
||||||
|
description: >
|
||||||
|
Enforce per-package code coverage thresholds against Go coverage profiles.
|
||||||
|
Supports JSON policy files with per-package overrides and global minimums.
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
profile:
|
||||||
|
description: Path to Go coverage profile file (output from `go test -coverprofile=...`).
|
||||||
|
required: false
|
||||||
|
default: coverage.out
|
||||||
|
policy:
|
||||||
|
description: Path to JSON file defining coverage thresholds and per-package overrides.
|
||||||
|
required: false
|
||||||
|
default: docs/coverage-thresholds.json
|
||||||
|
src-root:
|
||||||
|
description: Source root directory for package discovery (passed to `go list ./...`).
|
||||||
|
required: false
|
||||||
|
default: .
|
||||||
|
summary-file:
|
||||||
|
description: Optional file path to append markdown summary of coverage results.
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
|
||||||
|
outputs:
|
||||||
|
passed:
|
||||||
|
description: 'Boolean: true if all packages meet threshold, false if any failed.'
|
||||||
|
value: ${{ steps.gate.outputs.passed }}
|
||||||
|
total-coverage:
|
||||||
|
description: Repository-wide statement coverage percentage.
|
||||||
|
value: ${{ steps.gate.outputs.total_coverage }}
|
||||||
|
packages-checked:
|
||||||
|
description: Number of packages evaluated against policy.
|
||||||
|
value: ${{ steps.gate.outputs.packages_checked }}
|
||||||
|
packages-failed:
|
||||||
|
description: Number of packages below threshold.
|
||||||
|
value: ${{ steps.gate.outputs.packages_failed }}
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- id: gate
|
||||||
|
shell: bash
|
||||||
|
working-directory: ${{ github.action_path }}
|
||||||
|
env:
|
||||||
|
PROFILE: ${{ inputs.profile }}
|
||||||
|
POLICY: ${{ inputs.policy }}
|
||||||
|
SRC_ROOT: ${{ inputs.src-root }}
|
||||||
|
SUMMARY_FILE: ${{ inputs.summary-file }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Run coverage gate and capture output
|
||||||
|
EXIT_CODE=0
|
||||||
|
OUTPUT=$(go run . \
|
||||||
|
--profile "$PROFILE" \
|
||||||
|
--policy "$POLICY" \
|
||||||
|
--src-root "$SRC_ROOT" \
|
||||||
|
) || EXIT_CODE=$?
|
||||||
|
|
||||||
|
echo "$OUTPUT"
|
||||||
|
|
||||||
|
# Parse summary from output (tool prints JSON stats on last line)
|
||||||
|
SUMMARY_LINE=$(echo "$OUTPUT" | tail -1)
|
||||||
|
|
||||||
|
# Determine pass/fail
|
||||||
|
if [[ $EXIT_CODE -eq 0 ]]; then
|
||||||
|
echo "passed=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "passed=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Extract metrics (tool outputs: packages_checked, packages_failed, total_coverage on summary line)
|
||||||
|
if echo "$SUMMARY_LINE" | jq . &>/dev/null; then
|
||||||
|
echo "total_coverage=$(echo "$SUMMARY_LINE" | jq -r '.total_coverage')" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "packages_checked=$(echo "$SUMMARY_LINE" | jq -r '.packages_checked')" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "packages_failed=$(echo "$SUMMARY_LINE" | jq -r '.packages_failed')" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# Append to summary file if provided
|
||||||
|
if [[ -n "$SUMMARY_FILE" ]]; then
|
||||||
|
{
|
||||||
|
echo "## Coverage Gate Results"
|
||||||
|
echo
|
||||||
|
echo "- **Passed:** $([ "$EXIT_CODE" -eq 0 ] && echo '✓ Yes' || echo '✗ No')"
|
||||||
|
echo "- **Total Coverage:** $(echo "$SUMMARY_LINE" | jq -r '.total_coverage')%"
|
||||||
|
echo "- **Packages Checked:** $(echo "$SUMMARY_LINE" | jq -r '.packages_checked')"
|
||||||
|
echo "- **Packages Failed:** $(echo "$SUMMARY_LINE" | jq -r '.packages_failed')"
|
||||||
|
} >> "$SUMMARY_FILE"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit $EXIT_CODE
|
||||||
3
coverage-gate/go.mod
Normal file
3
coverage-gate/go.mod
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module git.hrafn.xyz/aether/vociferate/coverage-gate
|
||||||
|
|
||||||
|
go 1.26.1
|
||||||
26
coverage-gate/integration_test.go
Normal file
26
coverage-gate/integration_test.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRun_ExitCodeOnInvalidProfile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tmp := t.TempDir()
|
||||||
|
policyPath := filepath.Join(tmp, "policy.json")
|
||||||
|
if err := os.WriteFile(policyPath, []byte(`{"minimum_statement_coverage":80,"critical_packages":[]}`), 0600); err != nil {
|
||||||
|
t.Fatalf("write policy: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
exit := run(
|
||||||
|
[]string{"--profile", filepath.Join(tmp, "missing.out"), "--policy", policyPath, "--src-root", "."},
|
||||||
|
os.Stdout,
|
||||||
|
os.Stderr,
|
||||||
|
func(_ string) ([]string, error) { return []string{"git.hrafn.xyz/aether/cue/service/llm"}, nil },
|
||||||
|
)
|
||||||
|
if exit != 1 {
|
||||||
|
t.Fatalf("expected exit 1 for missing profile, got %d", exit)
|
||||||
|
}
|
||||||
|
}
|
||||||
118
coverage-gate/main.go
Normal file
118
coverage-gate/main.go
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type discoverPackagesFunc func(srcRoot string) ([]string, error)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr, discoverPackages))
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(args []string, stdout io.Writer, stderr io.Writer, discover discoverPackagesFunc) int {
|
||||||
|
fs := flag.NewFlagSet("coveragegate", flag.ContinueOnError)
|
||||||
|
fs.SetOutput(stderr)
|
||||||
|
|
||||||
|
profilePath := fs.String("profile", "../_build/coverage.out", "path to go coverprofile")
|
||||||
|
policyPath := fs.String("policy", "../specs/003-testing-time/contracts/coverage-thresholds.json", "path to coverage policy json")
|
||||||
|
srcRoot := fs.String("src-root", ".", "path to src workspace root")
|
||||||
|
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
policy, err := LoadPolicy(*policyPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "coverage gate: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
aggByPkg, err := ParseCoverProfile(*profilePath, policy)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "coverage gate: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
pkgs, err := discover(*srcRoot)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "coverage gate: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
results := EvaluateCoverage(pkgs, aggByPkg, policy)
|
||||||
|
if len(results) == 0 {
|
||||||
|
fmt.Fprintln(stderr, "coverage gate: no in-scope packages found")
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(stdout, "Package coverage results:")
|
||||||
|
fmt.Fprintln(stdout, "PACKAGE\tCOVERAGE\tTHRESHOLD\tSTATUS")
|
||||||
|
|
||||||
|
failed := false
|
||||||
|
totalCoverage := 0.0
|
||||||
|
for _, r := range results {
|
||||||
|
status := "PASS"
|
||||||
|
if !r.Pass {
|
||||||
|
status = "FAIL"
|
||||||
|
failed = true
|
||||||
|
}
|
||||||
|
fmt.Fprintf(stdout, "%s\t%.2f%%\t%.2f%%\t%s\n", r.Package, r.Percent, r.Threshold, status)
|
||||||
|
totalCoverage += r.Percent
|
||||||
|
}
|
||||||
|
if len(results) > 0 {
|
||||||
|
totalCoverage /= float64(len(results))
|
||||||
|
}
|
||||||
|
|
||||||
|
packagesFailed := 0
|
||||||
|
for _, r := range results {
|
||||||
|
if !r.Pass {
|
||||||
|
packagesFailed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output JSON metrics for CI consumption
|
||||||
|
metrics := map[string]interface{}{
|
||||||
|
"passed": !failed,
|
||||||
|
"total_coverage": fmt.Sprintf("%.2f", totalCoverage),
|
||||||
|
"packages_checked": len(results),
|
||||||
|
"packages_failed": packagesFailed,
|
||||||
|
}
|
||||||
|
metricsJSON, _ := json.Marshal(metrics)
|
||||||
|
fmt.Fprintln(stdout, string(metricsJSON))
|
||||||
|
|
||||||
|
if failed {
|
||||||
|
fmt.Fprintln(stderr, "coverage gate: one or more packages are below threshold")
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fmt.Fprintln(stdout, "coverage gate: all in-scope packages meet threshold")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func discoverPackages(srcRoot string) ([]string, error) {
|
||||||
|
cmd := exec.Command("go", "list", "./...")
|
||||||
|
cmd.Dir = srcRoot
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("discover packages with go list: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||||
|
pkgs := make([]string, 0, len(lines))
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pkgs = append(pkgs, line)
|
||||||
|
}
|
||||||
|
sort.Strings(pkgs)
|
||||||
|
return pkgs, nil
|
||||||
|
}
|
||||||
85
coverage-gate/main_test.go
Normal file
85
coverage-gate/main_test.go
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRun_FailsWhenBelowThreshold(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tmp := t.TempDir()
|
||||||
|
policyPath := filepath.Join(tmp, "policy.json")
|
||||||
|
profilePath := filepath.Join(tmp, "coverage.out")
|
||||||
|
|
||||||
|
policy := `{
|
||||||
|
"minimum_statement_coverage": 80,
|
||||||
|
"critical_packages": []
|
||||||
|
}`
|
||||||
|
if err := os.WriteFile(policyPath, []byte(policy), 0600); err != nil {
|
||||||
|
t.Fatalf("write policy: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
profile := "mode: set\n" +
|
||||||
|
"git.hrafn.xyz/aether/cue/service/llm/validator.go:1.1,2.1 10 0\n"
|
||||||
|
if err := os.WriteFile(profilePath, []byte(profile), 0600); err != nil {
|
||||||
|
t.Fatalf("write profile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
var errOut bytes.Buffer
|
||||||
|
exit := run(
|
||||||
|
[]string{"--profile", profilePath, "--policy", policyPath, "--src-root", "."},
|
||||||
|
&out,
|
||||||
|
&errOut,
|
||||||
|
func(_ string) ([]string, error) {
|
||||||
|
return []string{"git.hrafn.xyz/aether/cue/service/llm"}, nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if exit != 1 {
|
||||||
|
t.Fatalf("expected exit 1, got %d\nstdout: %s\nstderr: %s", exit, out.String(), errOut.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(errOut.String(), "below threshold") {
|
||||||
|
t.Fatalf("expected threshold error, got: %s", errOut.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRun_PassesWhenAllMeetThreshold(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tmp := t.TempDir()
|
||||||
|
policyPath := filepath.Join(tmp, "policy.json")
|
||||||
|
profilePath := filepath.Join(tmp, "coverage.out")
|
||||||
|
|
||||||
|
policy := `{
|
||||||
|
"minimum_statement_coverage": 80,
|
||||||
|
"critical_packages": []
|
||||||
|
}`
|
||||||
|
if err := os.WriteFile(policyPath, []byte(policy), 0600); err != nil {
|
||||||
|
t.Fatalf("write policy: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
profile := "mode: set\n" +
|
||||||
|
"git.hrafn.xyz/aether/cue/service/llm/validator.go:1.1,2.1 10 1\n"
|
||||||
|
if err := os.WriteFile(profilePath, []byte(profile), 0600); err != nil {
|
||||||
|
t.Fatalf("write profile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
var errOut bytes.Buffer
|
||||||
|
exit := run(
|
||||||
|
[]string{"--profile", profilePath, "--policy", policyPath, "--src-root", "."},
|
||||||
|
&out,
|
||||||
|
&errOut,
|
||||||
|
func(_ string) ([]string, error) {
|
||||||
|
return []string{"git.hrafn.xyz/aether/cue/service/llm"}, nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if exit != 0 {
|
||||||
|
t.Fatalf("expected exit 0, got %d\nstdout: %s\nstderr: %s", exit, out.String(), errOut.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(out.String(), "all in-scope packages meet threshold") {
|
||||||
|
t.Fatalf("expected success summary, got: %s", out.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
235
coverage-gate/parse.go
Normal file
235
coverage-gate/parse.go
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Policy describes coverage threshold configuration.
|
||||||
|
type Policy struct {
|
||||||
|
MinimumStatementCoverage float64 `json:"minimum_statement_coverage"`
|
||||||
|
CriticalPackages []PackagePolicy `json:"critical_packages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PackagePolicy overrides defaults for a specific package.
|
||||||
|
type PackagePolicy struct {
|
||||||
|
Package string `json:"package"`
|
||||||
|
MinimumStatementCoverage float64 `json:"minimum_statement_coverage"`
|
||||||
|
Include bool `json:"include"`
|
||||||
|
Exclusions []string `json:"exclusions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coverage aggregates covered and total statements for a package.
|
||||||
|
type Coverage struct {
|
||||||
|
Covered int64
|
||||||
|
Total int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// PackageResult is the policy-evaluated coverage result for one package.
|
||||||
|
type PackageResult struct {
|
||||||
|
Package string
|
||||||
|
Covered int64
|
||||||
|
Total int64
|
||||||
|
Percent float64
|
||||||
|
Threshold float64
|
||||||
|
Pass bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadPolicy reads policy JSON from disk.
|
||||||
|
func LoadPolicy(path string) (Policy, error) {
|
||||||
|
f, err := openValidatedReadOnlyFile(path, ".json", "policy")
|
||||||
|
if err != nil {
|
||||||
|
return Policy{}, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
var p Policy
|
||||||
|
if err := json.NewDecoder(f).Decode(&p); err != nil {
|
||||||
|
return Policy{}, fmt.Errorf("decode policy: %w", err)
|
||||||
|
}
|
||||||
|
if p.MinimumStatementCoverage <= 0 {
|
||||||
|
p.MinimumStatementCoverage = 80.0
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseCoverProfile parses a Go coverprofile and aggregates package coverage.
|
||||||
|
func ParseCoverProfile(profilePath string, policy Policy) (map[string]Coverage, error) {
|
||||||
|
f, err := openValidatedReadOnlyFile(profilePath, "", "coverage profile")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
coverage := make(map[string]Coverage)
|
||||||
|
s := bufio.NewScanner(f)
|
||||||
|
lineNo := 0
|
||||||
|
for s.Scan() {
|
||||||
|
lineNo++
|
||||||
|
line := strings.TrimSpace(s.Text())
|
||||||
|
if lineNo == 1 {
|
||||||
|
if !strings.HasPrefix(line, "mode:") {
|
||||||
|
return nil, fmt.Errorf("invalid coverage profile header")
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Fields(line)
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return nil, fmt.Errorf("invalid coverage line %d", lineNo)
|
||||||
|
}
|
||||||
|
fileAndRange := parts[0]
|
||||||
|
numStmts, err := strconv.ParseInt(parts[1], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid statements count at line %d: %w", lineNo, err)
|
||||||
|
}
|
||||||
|
execCount, err := strconv.ParseInt(parts[2], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid execution count at line %d: %w", lineNo, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
idx := strings.Index(fileAndRange, ":")
|
||||||
|
if idx < 0 {
|
||||||
|
return nil, fmt.Errorf("invalid file segment at line %d", lineNo)
|
||||||
|
}
|
||||||
|
filePath := fileAndRange[:idx]
|
||||||
|
pkg := filepath.ToSlash(filepath.Dir(filePath))
|
||||||
|
if isExcludedFile(pkg, filePath, policy) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
agg := coverage[pkg]
|
||||||
|
agg.Total += numStmts
|
||||||
|
if execCount > 0 {
|
||||||
|
agg.Covered += numStmts
|
||||||
|
}
|
||||||
|
coverage[pkg] = agg
|
||||||
|
}
|
||||||
|
if err := s.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan coverage profile: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return coverage, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func openValidatedReadOnlyFile(path string, requiredExt string, label string) (*os.File, error) {
|
||||||
|
cleaned := filepath.Clean(strings.TrimSpace(path))
|
||||||
|
if cleaned == "" || cleaned == "." {
|
||||||
|
return nil, fmt.Errorf("invalid %s path", label)
|
||||||
|
}
|
||||||
|
|
||||||
|
if requiredExt != "" {
|
||||||
|
ext := strings.ToLower(filepath.Ext(cleaned))
|
||||||
|
if ext != strings.ToLower(requiredExt) {
|
||||||
|
return nil, fmt.Errorf("invalid %s file extension: got %q, want %q", label, ext, requiredExt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
absPath, err := filepath.Abs(cleaned)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("resolve %s path: %w", label, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("stat %s: %w", label, err)
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil, fmt.Errorf("%s path must be a file, got directory", label)
|
||||||
|
}
|
||||||
|
|
||||||
|
// #nosec G304 -- path is explicitly cleaned, normalized, and pre-validated as an existing file.
|
||||||
|
f, err := os.Open(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open %s: %w", label, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvaluateCoverage evaluates package coverage against policy thresholds.
|
||||||
|
func EvaluateCoverage(packages []string, byPackage map[string]Coverage, policy Policy) []PackageResult {
|
||||||
|
results := make([]PackageResult, 0, len(packages))
|
||||||
|
for _, pkg := range packages {
|
||||||
|
if !isPackageIncluded(pkg, policy) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
agg := byPackage[pkg]
|
||||||
|
percent := 100.0
|
||||||
|
if agg.Total > 0 {
|
||||||
|
percent = float64(agg.Covered) * 100.0 / float64(agg.Total)
|
||||||
|
}
|
||||||
|
threshold := thresholdForPackage(pkg, policy)
|
||||||
|
pass := agg.Total == 0 || percent >= threshold
|
||||||
|
results = append(results, PackageResult{
|
||||||
|
Package: pkg,
|
||||||
|
Covered: agg.Covered,
|
||||||
|
Total: agg.Total,
|
||||||
|
Percent: percent,
|
||||||
|
Threshold: threshold,
|
||||||
|
Pass: pass,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Slice(results, func(i, j int) bool {
|
||||||
|
return results[i].Package < results[j].Package
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
func thresholdForPackage(pkg string, policy Policy) float64 {
|
||||||
|
for _, entry := range policy.CriticalPackages {
|
||||||
|
if entry.Package == pkg && entry.MinimumStatementCoverage > 0 {
|
||||||
|
return entry.MinimumStatementCoverage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if policy.MinimumStatementCoverage > 0 {
|
||||||
|
return policy.MinimumStatementCoverage
|
||||||
|
}
|
||||||
|
return 80.0
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPackageIncluded(pkg string, policy Policy) bool {
|
||||||
|
for _, entry := range policy.CriticalPackages {
|
||||||
|
if entry.Package == pkg {
|
||||||
|
return entry.Include
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isExcludedFile(pkg string, filePath string, policy Policy) bool {
|
||||||
|
base := filepath.Base(filePath)
|
||||||
|
|
||||||
|
// Exclude known generated artifacts and thin composition wiring.
|
||||||
|
if strings.HasSuffix(base, "_gen.go") ||
|
||||||
|
base == "generated.go" ||
|
||||||
|
base == "models_gen.go" ||
|
||||||
|
base == "schema.resolvers.go" ||
|
||||||
|
base == "main.go" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range policy.CriticalPackages {
|
||||||
|
if entry.Package != pkg {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, ex := range entry.Exclusions {
|
||||||
|
if ex == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(filePath, ex) || strings.Contains(filePath, "/"+ex) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
119
coverage-gate/parse_test.go
Normal file
119
coverage-gate/parse_test.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseCoverProfile_AppliesExclusions(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tmp := t.TempDir()
|
||||||
|
profile := filepath.Join(tmp, "coverage.out")
|
||||||
|
content := "mode: set\n" +
|
||||||
|
"git.hrafn.xyz/aether/cue/api/graph/generated.go:1.1,2.1 2 1\n" +
|
||||||
|
"git.hrafn.xyz/aether/cue/api/graph/resolver.go:1.1,2.1 2 1\n" +
|
||||||
|
"git.hrafn.xyz/aether/cue/service/llm/validator.go:1.1,2.1 2 0\n"
|
||||||
|
if err := os.WriteFile(profile, []byte(content), 0600); err != nil {
|
||||||
|
t.Fatalf("write profile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
policy := Policy{
|
||||||
|
MinimumStatementCoverage: 80,
|
||||||
|
CriticalPackages: []PackagePolicy{
|
||||||
|
{Package: "git.hrafn.xyz/aether/cue/api/graph", Include: true, Exclusions: []string{"generated.go"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := ParseCoverProfile(profile, policy)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseCoverProfile() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
api := got["git.hrafn.xyz/aether/cue/api/graph"]
|
||||||
|
if api.Total != 2 || api.Covered != 2 {
|
||||||
|
t.Fatalf("api coverage mismatch: got %+v", api)
|
||||||
|
}
|
||||||
|
llm := got["git.hrafn.xyz/aether/cue/service/llm"]
|
||||||
|
if llm.Total != 2 || llm.Covered != 0 {
|
||||||
|
t.Fatalf("llm coverage mismatch: got %+v", llm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateCoverage_UsesPolicyThresholds(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
pkgs := []string{
|
||||||
|
"git.hrafn.xyz/aether/cue/service/llm",
|
||||||
|
"git.hrafn.xyz/aether/cue/service/orchestrator",
|
||||||
|
}
|
||||||
|
byPkg := map[string]Coverage{
|
||||||
|
"git.hrafn.xyz/aether/cue/service/llm": {Covered: 8, Total: 10},
|
||||||
|
"git.hrafn.xyz/aether/cue/service/orchestrator": {Covered: 3, Total: 10},
|
||||||
|
}
|
||||||
|
policy := Policy{
|
||||||
|
MinimumStatementCoverage: 80,
|
||||||
|
CriticalPackages: []PackagePolicy{
|
||||||
|
{Package: "git.hrafn.xyz/aether/cue/service/orchestrator", MinimumStatementCoverage: 30, Include: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
results := EvaluateCoverage(pkgs, byPkg, policy)
|
||||||
|
if len(results) != 2 {
|
||||||
|
t.Fatalf("expected 2 results, got %d", len(results))
|
||||||
|
}
|
||||||
|
if !results[0].Pass {
|
||||||
|
t.Fatalf("expected llm to pass at default threshold: %+v", results[0])
|
||||||
|
}
|
||||||
|
if !results[1].Pass {
|
||||||
|
t.Fatalf("expected orchestrator to pass at overridden threshold: %+v", results[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateCoverage_NoStatementsPasses(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
pkg := "git.hrafn.xyz/aether/cue/repository/vector"
|
||||||
|
results := EvaluateCoverage(
|
||||||
|
[]string{pkg},
|
||||||
|
map[string]Coverage{pkg: {Covered: 0, Total: 0}},
|
||||||
|
Policy{MinimumStatementCoverage: 80},
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(results) != 1 {
|
||||||
|
t.Fatalf("expected 1 result, got %d", len(results))
|
||||||
|
}
|
||||||
|
if !results[0].Pass {
|
||||||
|
t.Fatalf("expected pass for no-statement package, got %+v", results[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadPolicy_RejectsNonJSONPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tmp := t.TempDir()
|
||||||
|
policyPath := filepath.Join(tmp, "policy.yaml")
|
||||||
|
if err := os.WriteFile(policyPath, []byte("minimum_statement_coverage: 80\n"), 0600); err != nil {
|
||||||
|
t.Fatalf("write policy file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := LoadPolicy(policyPath)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected LoadPolicy to fail for non-json extension")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "invalid policy file extension") {
|
||||||
|
t.Fatalf("expected extension error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCoverProfile_RejectsDirectoryPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := ParseCoverProfile(t.TempDir(), Policy{MinimumStatementCoverage: 80})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected ParseCoverProfile to fail for directory path")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "coverage profile path must be a file") {
|
||||||
|
t.Fatalf("expected directory path error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
395
decorate-pr/action.yml
Normal file
395
decorate-pr/action.yml
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
name: vociferate/decorate-pr
|
||||||
|
description: >
|
||||||
|
Decorate pull requests with coverage badges, unreleased changelog entries,
|
||||||
|
and other useful review information. Updates existing comment or creates a
|
||||||
|
new one if it doesn't exist.
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
coverage-percentage:
|
||||||
|
description: >
|
||||||
|
Computed coverage percentage (0-100). Typically from coverage-badge
|
||||||
|
action outputs.
|
||||||
|
required: true
|
||||||
|
badge-url:
|
||||||
|
description: >
|
||||||
|
Browser-facing URL for the coverage badge image (SVG). Typically from
|
||||||
|
coverage-badge action outputs.
|
||||||
|
required: true
|
||||||
|
changelog:
|
||||||
|
description: Path to changelog file relative to repository root.
|
||||||
|
required: false
|
||||||
|
default: CHANGELOG.md
|
||||||
|
comment-title:
|
||||||
|
description: >
|
||||||
|
Title/identifier for the PR comment. Used to locate existing comment
|
||||||
|
for updates. Defaults to 'Vociferate Review'.
|
||||||
|
required: false
|
||||||
|
default: 'Vociferate Review'
|
||||||
|
token:
|
||||||
|
description: >
|
||||||
|
GitHub or Gitea token for posting PR comments. Defaults to the
|
||||||
|
workflow token.
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
enable-changelog-gate:
|
||||||
|
description: >
|
||||||
|
Enable changelog gate validation. Validates that qualifying changes
|
||||||
|
include entries in the Unreleased section of CHANGELOG.
|
||||||
|
required: false
|
||||||
|
default: 'false'
|
||||||
|
changelog-gate-mode:
|
||||||
|
description: >
|
||||||
|
Gate mode: 'strict' fails the job if validation fails; 'soft' warns
|
||||||
|
via PR comment only.
|
||||||
|
required: false
|
||||||
|
default: 'soft'
|
||||||
|
changelog-gate-required-for:
|
||||||
|
description: >
|
||||||
|
Comma-separated types of changes that require changelog entries.
|
||||||
|
Valid: code, behavior, security, workflow, tooling.
|
||||||
|
required: false
|
||||||
|
default: 'code,behavior,security,workflow,tooling'
|
||||||
|
changelog-gate-allow-docs-only:
|
||||||
|
description: >
|
||||||
|
Allow PRs that only modify documentation files to skip changelog
|
||||||
|
requirement.
|
||||||
|
required: false
|
||||||
|
default: 'true'
|
||||||
|
changelog-gate-docs-globs:
|
||||||
|
description: >
|
||||||
|
Comma-separated glob patterns for files that are considered
|
||||||
|
documentation (case-insensitive).
|
||||||
|
required: false
|
||||||
|
default: 'docs/**,**.md,**.txt,**.rst'
|
||||||
|
changelog-gate-skip-labels:
|
||||||
|
description: >
|
||||||
|
Comma-separated PR labels that exempt a PR from changelog requirement.
|
||||||
|
Example: skip-changelog.
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
|
||||||
|
outputs:
|
||||||
|
comment-id:
|
||||||
|
description: Numeric ID of the posted or updated PR comment.
|
||||||
|
value: ${{ steps.post-comment.outputs.comment_id }}
|
||||||
|
comment-url:
|
||||||
|
description: URL to the posted or updated PR comment.
|
||||||
|
value: ${{ steps.post-comment.outputs.comment_url }}
|
||||||
|
gate-passed:
|
||||||
|
description: >
|
||||||
|
Whether changelog gate validation passed (true/false). Only set when
|
||||||
|
gate is enabled.
|
||||||
|
value: ${{ steps.changelog-gate.outputs.gate_passed }}
|
||||||
|
docs-only:
|
||||||
|
description: >
|
||||||
|
Whether PR is docs-only (true/false). Only set when gate is enabled.
|
||||||
|
value: ${{ steps.changelog-gate.outputs.docs_only }}
|
||||||
|
unreleased-additions-count:
|
||||||
|
description: >
|
||||||
|
Number of lines added to Unreleased section in this PR. Only set when
|
||||||
|
gate is enabled.
|
||||||
|
value: ${{ steps.changelog-gate.outputs.unreleased_additions_count }}
|
||||||
|
gate-failure-reason:
|
||||||
|
description: >
|
||||||
|
Human-readable reason for gate failure, if applicable. Only set when
|
||||||
|
gate is enabled and failed.
|
||||||
|
value: ${{ steps.changelog-gate.outputs.failure_reason }}
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- name: Validate PR context
|
||||||
|
id: validate
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
GITHUB_EVENT_NAME: ${{ github.event_name }}
|
||||||
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ "$GITHUB_EVENT_NAME" != "pull_request" ]]; then
|
||||||
|
echo "This action only works on pull_request events" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$PR_NUMBER" ]]; then
|
||||||
|
echo "Could not determine PR number from context" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'pr_number=%s\n' "$PR_NUMBER" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Preflight comment API access
|
||||||
|
id: preflight
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
AUTH_TOKEN: ${{ inputs.token != '' && inputs.token || github.token }}
|
||||||
|
PR_NUMBER: ${{ steps.validate.outputs.pr_number }}
|
||||||
|
SERVER_URL: ${{ github.server_url }}
|
||||||
|
REPOSITORY: ${{ github.repository }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ -z "$AUTH_TOKEN" ]]; then
|
||||||
|
echo "No token available for PR comment API calls. Set input token or provide workflow token." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
api_url="${SERVER_URL}/api/v1"
|
||||||
|
if [[ "$SERVER_URL" == *"github.com"* ]]; then
|
||||||
|
api_url="https://api.github.com"
|
||||||
|
fi
|
||||||
|
|
||||||
|
comments_url="${api_url}/repos/${REPOSITORY}/issues/${PR_NUMBER}/comments"
|
||||||
|
|
||||||
|
curl --fail-with-body -sS \
|
||||||
|
-H "Authorization: Bearer ${AUTH_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"$comments_url" >/dev/null
|
||||||
|
|
||||||
|
- name: Detect changelog file
|
||||||
|
id: changelog-file
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
CHANGELOG: ${{ inputs.changelog }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ ! -f "$CHANGELOG" ]]; then
|
||||||
|
printf 'exists=false\n' >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
printf 'exists=true\n' >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Extract changelog unreleased entries
|
||||||
|
id: extract-changelog
|
||||||
|
if: steps.changelog-file.outputs.exists == 'true'
|
||||||
|
uses: ./run-vociferate
|
||||||
|
with:
|
||||||
|
root: ${{ github.workspace }}
|
||||||
|
changelog: ${{ inputs.changelog }}
|
||||||
|
print-unreleased: 'true'
|
||||||
|
|
||||||
|
- name: Validate changelog gate
|
||||||
|
id: changelog-gate
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
ENABLE_GATE: ${{ inputs.enable-changelog-gate }}
|
||||||
|
GATE_MODE: ${{ inputs.changelog-gate-mode }}
|
||||||
|
REQUIRED_FOR: ${{ inputs.changelog-gate-required-for }}
|
||||||
|
ALLOW_DOCS_ONLY: ${{ inputs.changelog-gate-allow-docs-only }}
|
||||||
|
DOCS_GLOBS: ${{ inputs.changelog-gate-docs-globs }}
|
||||||
|
SKIP_LABELS: ${{ inputs.changelog-gate-skip-labels }}
|
||||||
|
CHANGELOG: ${{ inputs.changelog }}
|
||||||
|
PR_NUMBER: ${{ steps.validate.outputs.pr_number }}
|
||||||
|
SERVER_URL: ${{ github.server_url }}
|
||||||
|
REPOSITORY: ${{ github.repository }}
|
||||||
|
AUTH_TOKEN: ${{ inputs.token != '' && inputs.token || github.token }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# If gate is disabled, skip
|
||||||
|
if [[ "$ENABLE_GATE" != "true" ]]; then
|
||||||
|
echo "gate_enabled=false" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
api_url="${SERVER_URL}/api/v1"
|
||||||
|
if [[ "$SERVER_URL" == *"github.com"* ]]; then
|
||||||
|
api_url="https://api.github.com"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get PR labels
|
||||||
|
pr_url="${api_url}/repos/${REPOSITORY}/pulls/${PR_NUMBER}"
|
||||||
|
pr_data=$(curl -sS -H "Authorization: Bearer ${AUTH_TOKEN}" -H "Content-Type: application/json" "$pr_url")
|
||||||
|
pr_labels=$(printf '%s' "$pr_data" | jq -r '.labels[].name' 2>/dev/null | tr '\n' ',' || echo "")
|
||||||
|
|
||||||
|
# Check skip labels
|
||||||
|
if [[ -n "$SKIP_LABELS" ]]; then
|
||||||
|
IFS=',' read -ra skip_array <<< "$SKIP_LABELS"
|
||||||
|
for skip_label in "${skip_array[@]}"; do
|
||||||
|
skip_label="${skip_label// /}"
|
||||||
|
if [[ ",$pr_labels," == *",$skip_label,"* ]]; then
|
||||||
|
printf 'gate_passed=true\n' >> "$GITHUB_OUTPUT"
|
||||||
|
printf 'docs_only=false\n' >> "$GITHUB_OUTPUT"
|
||||||
|
printf 'unreleased_additions_count=0\n' >> "$GITHUB_OUTPUT"
|
||||||
|
printf 'skip_reason=skip_label_detected\n' >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get PR diff
|
||||||
|
diff_url="${api_url}/repos/${REPOSITORY}/pulls/${PR_NUMBER}/files"
|
||||||
|
diff_data=$(curl -sS -H "Authorization: Bearer ${AUTH_TOKEN}" -H "Content-Type: application/json" "$diff_url")
|
||||||
|
|
||||||
|
# Determine if PR only modifies docs
|
||||||
|
total_files=$(printf '%s' "$diff_data" | jq 'length' 2>/dev/null || echo 0)
|
||||||
|
docs_only_true=true
|
||||||
|
has_qualifying_changes=false
|
||||||
|
|
||||||
|
if [[ "$total_files" -gt 0 ]]; then
|
||||||
|
while IFS= read -r filename; do
|
||||||
|
# Check if file matches docs globs
|
||||||
|
is_doc=false
|
||||||
|
IFS=',' read -ra glob_array <<< "$DOCS_GLOBS"
|
||||||
|
for glob in "${glob_array[@]}"; do
|
||||||
|
glob="${glob// /}"
|
||||||
|
# Simple glob matching (case-insensitive)
|
||||||
|
if [[ "${filename,,}" == ${glob,,} ]] || [[ "${filename,,}" == *"/${glob,,}" ]]; then
|
||||||
|
is_doc=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# .md files are always docs
|
||||||
|
if [[ "$filename" == *.md ]]; then
|
||||||
|
is_doc=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$is_doc" != "true" ]]; then
|
||||||
|
docs_only_true=false
|
||||||
|
fi
|
||||||
|
done < <(printf '%s' "$diff_data" | jq -r '.[].filename' 2>/dev/null)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get changeset for changelog file
|
||||||
|
changelog_diff=""
|
||||||
|
if [[ -f "$CHANGELOG" ]]; then
|
||||||
|
changelog_diff=$(printf '%s' "$diff_data" | jq -r ".[] | select(.filename == \"$CHANGELOG\") | .patch" 2>/dev/null || echo "")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Count additions to Unreleased section in changelog diff
|
||||||
|
unreleased_additions=0
|
||||||
|
if [[ -n "$changelog_diff" ]]; then
|
||||||
|
# Find lines added within Unreleased section
|
||||||
|
in_unreleased=false
|
||||||
|
while IFS= read -r line; do
|
||||||
|
# Skip processing metadata lines
|
||||||
|
[[ "$line" =~ ^(\+\+\+|---|@@) ]] && continue
|
||||||
|
|
||||||
|
# Check if entering Unreleased section
|
||||||
|
if [[ "$line" == "+## [Unreleased]"* ]] || [[ "$line" == " ## [Unreleased]"* ]]; then
|
||||||
|
in_unreleased=true
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if exiting Unreleased section
|
||||||
|
if [[ "$line" == "+## ["* ]] || [[ "$line" == " ## ["* ]]; then
|
||||||
|
if [[ "$line" != "+## [Unreleased]"* ]] && [[ "$line" != " ## [Unreleased]"* ]]; then
|
||||||
|
in_unreleased=false
|
||||||
|
fi
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Count non-empty additions in Unreleased
|
||||||
|
if $in_unreleased && [[ "$line" == "+"* ]] && [[ ! "$line" =~ ^(\+\+\+|---|@@) ]]; then
|
||||||
|
content="${line:1}" # Remove the '+' prefix
|
||||||
|
if [[ -n "${content// /}" ]]; then # Check if not just whitespace
|
||||||
|
unreleased_additions=$((unreleased_additions + 1))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done <<< "$changelog_diff"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Evaluate gate
|
||||||
|
gate_passed=true
|
||||||
|
failure_reason=""
|
||||||
|
|
||||||
|
if [[ "$docs_only_true" == "true" ]] && [[ "$ALLOW_DOCS_ONLY" == "true" ]]; then
|
||||||
|
gate_passed=true
|
||||||
|
docs_only=true
|
||||||
|
else
|
||||||
|
docs_only=false
|
||||||
|
# Check if code requires changelog entry
|
||||||
|
if [[ "$total_files" -gt 0 ]] && [[ "$unreleased_additions" -eq 0 ]]; then
|
||||||
|
gate_passed=false
|
||||||
|
failure_reason="Code changes detected but no entries added to Unreleased section of CHANGELOG.md"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'gate_enabled=true\n' >> "$GITHUB_OUTPUT"
|
||||||
|
printf 'gate_passed=%s\n' "$gate_passed" >> "$GITHUB_OUTPUT"
|
||||||
|
printf 'docs_only=%s\n' "$docs_only" >> "$GITHUB_OUTPUT"
|
||||||
|
printf 'unreleased_additions_count=%s\n' "$unreleased_additions" >> "$GITHUB_OUTPUT"
|
||||||
|
printf 'failure_reason=%s\n' "$failure_reason" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# Fail job if strict mode and gate failed
|
||||||
|
if [[ "$GATE_MODE" == "strict" ]] && [[ "$gate_passed" != "true" ]]; then
|
||||||
|
echo "$failure_reason" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build PR comment markdown
|
||||||
|
id: build-comment
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
COMMENT_TITLE: ${{ inputs.comment-title }}
|
||||||
|
COVERAGE_PCT: ${{ inputs.coverage-percentage }}
|
||||||
|
BADGE_URL: ${{ inputs.badge-url }}
|
||||||
|
UNRELEASED: ${{ steps.extract-changelog.outputs.stdout }}
|
||||||
|
GATE_ENABLED: ${{ steps.changelog-gate.outputs.gate_enabled }}
|
||||||
|
GATE_PASSED: ${{ steps.changelog-gate.outputs.gate_passed }}
|
||||||
|
GATE_MODE: ${{ inputs.changelog-gate-mode }}
|
||||||
|
DOCS_ONLY: ${{ steps.changelog-gate.outputs.docs_only }}
|
||||||
|
ADDITIONS_COUNT: ${{ steps.changelog-gate.outputs.unreleased_additions_count }}
|
||||||
|
FAILURE_REASON: ${{ steps.changelog-gate.outputs.failure_reason }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
bash "$GITHUB_ACTION_PATH/build-comment.sh"
|
||||||
|
|
||||||
|
- name: Find and update/post PR comment
|
||||||
|
id: post-comment
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ inputs.token != '' && inputs.token || github.token }}
|
||||||
|
PR_NUMBER: ${{ steps.validate.outputs.pr_number }}
|
||||||
|
COMMENT_BODY: ${{ steps.build-comment.outputs.comment_body }}
|
||||||
|
SERVER_URL: ${{ github.server_url }}
|
||||||
|
REPOSITORY: ${{ github.repository }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
API_URL="${SERVER_URL}/api/v1"
|
||||||
|
if [[ "$SERVER_URL" == *"github.com"* ]]; then
|
||||||
|
API_URL="https://api.github.com"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# List existing comments to find vociferate comment
|
||||||
|
comments_url="${API_URL}/repos/${REPOSITORY}/issues/${PR_NUMBER}/comments"
|
||||||
|
|
||||||
|
response=$(curl -s -H "Authorization: Bearer ${GITHUB_TOKEN}" "$comments_url")
|
||||||
|
|
||||||
|
# Find existing vociferate comment by checking for the marker
|
||||||
|
existing_comment_id=$(printf '%s' "$response" | \
|
||||||
|
jq -r '.[] | select(.body | contains("vociferate-pr-review")) | .id' 2>/dev/null | \
|
||||||
|
head -1 || echo "")
|
||||||
|
|
||||||
|
if [[ -n "$existing_comment_id" ]]; then
|
||||||
|
# Update existing comment
|
||||||
|
update_url="${API_URL}/repos/${REPOSITORY}/issues/comments/${existing_comment_id}"
|
||||||
|
curl -s -X PATCH \
|
||||||
|
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$(printf '{"body": %s}' "$(printf '%s' "$COMMENT_BODY" | jq -Rs .)")" \
|
||||||
|
"$update_url" > /dev/null
|
||||||
|
|
||||||
|
printf 'comment_id=%s\n' "$existing_comment_id" >> "$GITHUB_OUTPUT"
|
||||||
|
printf 'comment_url=%s/repos/%s/issues/%s#issuecomment-%s\n' \
|
||||||
|
"$SERVER_URL" "$REPOSITORY" "$PR_NUMBER" "$existing_comment_id" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
# Create new comment
|
||||||
|
create_url="${API_URL}/repos/${REPOSITORY}/issues/${PR_NUMBER}/comments"
|
||||||
|
response=$(curl -s -X POST \
|
||||||
|
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$(printf '{"body": %s}' "$(printf '%s' "$COMMENT_BODY" | jq -Rs .)")" \
|
||||||
|
"$create_url")
|
||||||
|
|
||||||
|
comment_id=$(printf '%s' "$response" | jq -r '.id' 2>/dev/null)
|
||||||
|
|
||||||
|
printf 'comment_id=%s\n' "$comment_id" >> "$GITHUB_OUTPUT"
|
||||||
|
printf 'comment_url=%s/repos/%s/issues/%s#issuecomment-%s\n' \
|
||||||
|
"$SERVER_URL" "$REPOSITORY" "$PR_NUMBER" "$comment_id" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
71
decorate-pr/build-comment.sh
Normal file
71
decorate-pr/build-comment.sh
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
tmp_file=$(mktemp)
|
||||||
|
trap 'rm -f "$tmp_file"' EXIT
|
||||||
|
|
||||||
|
{
|
||||||
|
printf '%s\n' '<!-- vociferate-pr-review -->'
|
||||||
|
printf '\n## %s\n\n' "$COMMENT_TITLE"
|
||||||
|
printf '### Coverage\n'
|
||||||
|
printf '\n\n' "$BADGE_URL"
|
||||||
|
printf '**Coverage:** %s%%\n' "$COVERAGE_PCT"
|
||||||
|
} > "$tmp_file"
|
||||||
|
|
||||||
|
if [[ "$GATE_ENABLED" == "true" ]]; then
|
||||||
|
gate_status="Pass"
|
||||||
|
if [[ "$GATE_PASSED" != "true" ]]; then
|
||||||
|
if [[ "$GATE_MODE" == "strict" ]]; then
|
||||||
|
gate_status="Fail"
|
||||||
|
else
|
||||||
|
gate_status="Warning"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
{
|
||||||
|
printf '\n### Changelog Gate\n'
|
||||||
|
printf '**Status:** %s\n\n' "$gate_status"
|
||||||
|
} >> "$tmp_file"
|
||||||
|
|
||||||
|
if [[ "$DOCS_ONLY" == "true" ]]; then
|
||||||
|
printf '%s\n\n' 'This PR only modifies documentation; changelog entry not required.' >> "$tmp_file"
|
||||||
|
elif [[ "$GATE_PASSED" == "true" ]]; then
|
||||||
|
printf 'Found %s line(s) added to Unreleased section.\n\n' "$ADDITIONS_COUNT" >> "$tmp_file"
|
||||||
|
else
|
||||||
|
printf '**Issue:** %s\n\n' "$FAILURE_REASON" >> "$tmp_file"
|
||||||
|
cat >> "$tmp_file" <<'EOF'
|
||||||
|
**How to fix:** Add an entry under the appropriate subsection in the `## [Unreleased]` section of `CHANGELOG.md`. Use one of:
|
||||||
|
- `### Breaking` for backwards-incompatible changes
|
||||||
|
- `### Added` for new features
|
||||||
|
- `### Changed` for behavior changes
|
||||||
|
- `### Removed` for deprecated removals
|
||||||
|
- `### Fixed` for bug fixes
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```markdown
|
||||||
|
## [Unreleased]
|
||||||
|
### Added
|
||||||
|
- New changelog gate validation for PR decoration.
|
||||||
|
```
|
||||||
|
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$UNRELEASED" ]]; then
|
||||||
|
{
|
||||||
|
printf '### Unreleased Changes\n\n'
|
||||||
|
printf '%s\n\n' "$UNRELEASED"
|
||||||
|
} >> "$tmp_file"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat >> "$tmp_file" <<'EOF'
|
||||||
|
---
|
||||||
|
*This comment was automatically generated by [vociferate/decorate-pr](https://git.hrafn.xyz/aether/vociferate).*
|
||||||
|
EOF
|
||||||
|
|
||||||
|
delimiter="EOF_COMMENT"
|
||||||
|
printf 'comment_body<<%s\n' "$delimiter" >> "$GITHUB_OUTPUT"
|
||||||
|
cat "$tmp_file" >> "$GITHUB_OUTPUT"
|
||||||
|
printf '%s\n' "$delimiter" >> "$GITHUB_OUTPUT"
|
||||||
@@ -19,15 +19,115 @@ import (
|
|||||||
const (
|
const (
|
||||||
defaultVersionFile = "release-version"
|
defaultVersionFile = "release-version"
|
||||||
defaultVersionExpr = `^\s*([^\r\n]+)\s*$`
|
defaultVersionExpr = `^\s*([^\r\n]+)\s*$`
|
||||||
defaultChangelog = "changelog.md"
|
defaultChangelog = "CHANGELOG.md"
|
||||||
defaultUnreleasedTemplate = "### Breaking\n\n### Added\n\n### Changed\n\n### Removed\n\n### Fixed\n"
|
defaultUnreleasedTemplate = "### Breaking\n\n### Added\n\n### Changed\n\n### Removed\n\n### Fixed\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
var releasedSectionRe = regexp.MustCompile(`(?m)^## \[(\d+\.\d+\.\d+)\] - `)
|
// Pre-compiled regex patterns used for changelog parsing.
|
||||||
var linkedReleasedSectionRe = regexp.MustCompile(`(?m)^## \[(\d+\.\d+\.\d+)\](?:\([^\n)]*\))? - `)
|
// These are read-only after initialization.
|
||||||
var unreleasedHeadingRe = regexp.MustCompile(`(?m)^## \[Unreleased\](?:\([^\n)]*\))?\n`)
|
var (
|
||||||
var releaseHeadingRe = regexp.MustCompile(`(?m)^## \[(\d+\.\d+\.\d+)\](?:\([^\n)]*\))? - `)
|
releasedSectionRe = regexp.MustCompile(`(?m)^## \[(\d+\.\d+\.\d+)\] - `)
|
||||||
var refLinkLineRe = regexp.MustCompile(`^\[[^\]]+\]: \S`)
|
linkedReleasedSectionRe = regexp.MustCompile(`(?m)^## \[(\d+\.\d+\.\d+)\](?:\([^\n)]*\))? - `)
|
||||||
|
unreleasedHeadingRe = regexp.MustCompile(`(?m)^## \[Unreleased\](?:\([^\n)]*\))?\n`)
|
||||||
|
releaseHeadingRe = regexp.MustCompile(`(?m)^## \[(\d+\.\d+\.\d+)\](?:\([^\n)]*\))? - `)
|
||||||
|
refLinkLineRe = regexp.MustCompile(`^\[[^\]]+\]: \S`)
|
||||||
|
)
|
||||||
|
|
||||||
|
type fileSystem interface {
|
||||||
|
ReadFile(path string) ([]byte, error)
|
||||||
|
WriteFile(path string, data []byte, perm os.FileMode) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type environment interface {
|
||||||
|
Getenv(key string) string
|
||||||
|
}
|
||||||
|
|
||||||
|
type gitRunner interface {
|
||||||
|
FirstCommitShortHash(rootDir string) (string, bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
type osFileSystem struct{}
|
||||||
|
|
||||||
|
func (osFileSystem) ReadFile(path string) ([]byte, error) {
|
||||||
|
// #nosec G304 -- This adapter intentionally accepts caller-provided paths so the service can work against repository-relative files.
|
||||||
|
return os.ReadFile(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (osFileSystem) WriteFile(path string, data []byte, perm os.FileMode) error {
|
||||||
|
return os.WriteFile(path, data, perm)
|
||||||
|
}
|
||||||
|
|
||||||
|
type osEnvironment struct{}
|
||||||
|
|
||||||
|
func (osEnvironment) Getenv(key string) string {
|
||||||
|
return os.Getenv(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
type commandGitRunner struct{}
|
||||||
|
|
||||||
|
func (commandGitRunner) FirstCommitShortHash(rootDir string) (string, bool) {
|
||||||
|
command := exec.Command("git", "-C", rootDir, "rev-list", "--max-parents=0", "--abbrev-commit", "HEAD")
|
||||||
|
output, err := command.Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
commit := strings.TrimSpace(string(output))
|
||||||
|
if commit == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(commit, "\n") {
|
||||||
|
commit = strings.SplitN(commit, "\n", 2)[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
return commit, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dependencies defines the injected collaborators required by Service.
|
||||||
|
type Dependencies struct {
|
||||||
|
FileSystem fileSystem
|
||||||
|
Environment environment
|
||||||
|
Git gitRunner
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service coordinates changelog and version file operations using injected dependencies.
|
||||||
|
type Service struct {
|
||||||
|
fileSystem fileSystem
|
||||||
|
environment environment
|
||||||
|
git gitRunner
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService validates and wires the dependencies required by the release service.
|
||||||
|
func NewService(deps Dependencies) (*Service, error) {
|
||||||
|
if deps.FileSystem == nil {
|
||||||
|
return nil, fmt.Errorf("file system dependency must not be nil")
|
||||||
|
}
|
||||||
|
if deps.Environment == nil {
|
||||||
|
return nil, fmt.Errorf("environment dependency must not be nil")
|
||||||
|
}
|
||||||
|
if deps.Git == nil {
|
||||||
|
return nil, fmt.Errorf("git runner dependency must not be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Service{
|
||||||
|
fileSystem: deps.FileSystem,
|
||||||
|
environment: deps.Environment,
|
||||||
|
git: deps.Git,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultService() *Service {
|
||||||
|
service, err := NewService(Dependencies{
|
||||||
|
FileSystem: osFileSystem{},
|
||||||
|
Environment: osEnvironment{},
|
||||||
|
Git: commandGitRunner{},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return service
|
||||||
|
}
|
||||||
|
|
||||||
type Options struct {
|
type Options struct {
|
||||||
// VersionFile is the path to the file that stores the current version,
|
// VersionFile is the path to the file that stores the current version,
|
||||||
@@ -38,7 +138,7 @@ type Options struct {
|
|||||||
// When empty, a line-oriented default matcher is used.
|
// When empty, a line-oriented default matcher is used.
|
||||||
VersionPattern string
|
VersionPattern string
|
||||||
// Changelog is the path to the changelog file, relative to the repository
|
// Changelog is the path to the changelog file, relative to the repository
|
||||||
// root. When empty, changelog.md is used.
|
// root. When empty, CHANGELOG.md is used.
|
||||||
Changelog string
|
Changelog string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +163,12 @@ type resolvedOptions struct {
|
|||||||
// when repository metadata can be derived from CI environment variables or the
|
// when repository metadata can be derived from CI environment variables or the
|
||||||
// origin git remote.
|
// origin git remote.
|
||||||
func Prepare(rootDir, version, releaseDate string, options Options) error {
|
func Prepare(rootDir, version, releaseDate string, options Options) error {
|
||||||
|
return defaultService().Prepare(rootDir, version, releaseDate, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare updates version state and promotes the Unreleased changelog notes
|
||||||
|
// into a new release section.
|
||||||
|
func (s *Service) Prepare(rootDir, version, releaseDate string, options Options) error {
|
||||||
normalizedVersion := strings.TrimPrefix(strings.TrimSpace(version), "v")
|
normalizedVersion := strings.TrimPrefix(strings.TrimSpace(version), "v")
|
||||||
if normalizedVersion == "" {
|
if normalizedVersion == "" {
|
||||||
return fmt.Errorf("version must not be empty")
|
return fmt.Errorf("version must not be empty")
|
||||||
@@ -78,11 +184,11 @@ func Prepare(rootDir, version, releaseDate string, options Options) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := updateVersionFile(rootDir, normalizedVersion, resolved); err != nil {
|
if err := s.updateVersionFile(rootDir, normalizedVersion, resolved); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := updateChangelog(rootDir, normalizedVersion, releaseDate, resolved.Changelog); err != nil {
|
if err := s.updateChangelog(rootDir, normalizedVersion, releaseDate, resolved.Changelog); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +206,22 @@ func Prepare(rootDir, version, releaseDate string, options Options) error {
|
|||||||
// When no previous release is present in the changelog, the first
|
// When no previous release is present in the changelog, the first
|
||||||
// recommendation is always v1.0.0.
|
// recommendation is always v1.0.0.
|
||||||
func RecommendedTag(rootDir string, options Options) (string, error) {
|
func RecommendedTag(rootDir string, options Options) (string, error) {
|
||||||
|
return defaultService().RecommendedTag(rootDir, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnreleasedBody returns the current Unreleased changelog body exactly as it
|
||||||
|
// should appear in downstream tooling.
|
||||||
|
func UnreleasedBody(rootDir string, options Options) (string, error) {
|
||||||
|
return defaultService().UnreleasedBody(rootDir, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReleaseNotes returns the release section for a specific semantic version.
|
||||||
|
func ReleaseNotes(rootDir, version string, options Options) (string, error) {
|
||||||
|
return defaultService().ReleaseNotes(rootDir, version, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecommendedTag returns the next semantic release tag based on current changelog state.
|
||||||
|
func (s *Service) RecommendedTag(rootDir string, options Options) (string, error) {
|
||||||
resolved, err := resolveOptions(options)
|
resolved, err := resolveOptions(options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -108,12 +230,12 @@ func RecommendedTag(rootDir string, options Options) (string, error) {
|
|||||||
var currentVersion string
|
var currentVersion string
|
||||||
isFirstRelease := false
|
isFirstRelease := false
|
||||||
if options.VersionFile != "" {
|
if options.VersionFile != "" {
|
||||||
currentVersion, err = readCurrentVersion(rootDir, resolved)
|
currentVersion, err = s.readCurrentVersion(rootDir, resolved)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
version, found, err := readLatestChangelogVersion(rootDir, resolved.Changelog)
|
version, found, err := s.readLatestChangelogVersion(rootDir, resolved.Changelog)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -125,7 +247,7 @@ func RecommendedTag(rootDir string, options Options) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
unreleasedBody, err := readUnreleasedBody(rootDir, resolved.Changelog)
|
unreleasedBody, err := s.readUnreleasedBody(rootDir, resolved.Changelog)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -154,6 +276,26 @@ func RecommendedTag(rootDir string, options Options) (string, error) {
|
|||||||
return fmt.Sprintf("v%d.%d.%d", parsed.major, parsed.minor, parsed.patch), nil
|
return fmt.Sprintf("v%d.%d.%d", parsed.major, parsed.minor, parsed.patch), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UnreleasedBody returns the current Unreleased changelog body.
|
||||||
|
func (s *Service) UnreleasedBody(rootDir string, options Options) (string, error) {
|
||||||
|
resolved, err := resolveOptions(options)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.readUnreleasedBody(rootDir, resolved.Changelog)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReleaseNotes returns the release section for a specific semantic version.
|
||||||
|
func (s *Service) ReleaseNotes(rootDir, version string, options Options) (string, error) {
|
||||||
|
resolved, err := resolveOptions(options)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.readReleaseNotes(rootDir, strings.TrimPrefix(strings.TrimSpace(version), "v"), resolved.Changelog)
|
||||||
|
}
|
||||||
|
|
||||||
func sectionHasEntries(unreleasedBody, sectionName string) bool {
|
func sectionHasEntries(unreleasedBody, sectionName string) bool {
|
||||||
heading := "### " + sectionName
|
heading := "### " + sectionName
|
||||||
sectionStart := strings.Index(unreleasedBody, heading)
|
sectionStart := strings.Index(unreleasedBody, heading)
|
||||||
@@ -201,11 +343,15 @@ func resolveOptions(options Options) (resolvedOptions, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func updateVersionFile(rootDir, version string, options resolvedOptions) error {
|
func updateVersionFile(rootDir, version string, options resolvedOptions) error {
|
||||||
|
return defaultService().updateVersionFile(rootDir, version, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) updateVersionFile(rootDir, version string, options resolvedOptions) error {
|
||||||
path := filepath.Join(rootDir, options.VersionFile)
|
path := filepath.Join(rootDir, options.VersionFile)
|
||||||
contents, err := os.ReadFile(path)
|
contents, err := s.fileSystem.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return os.WriteFile(path, []byte(version+"\n"), 0o644)
|
return s.fileSystem.WriteFile(path, []byte(version+"\n"), 0o644)
|
||||||
}
|
}
|
||||||
return fmt.Errorf("read version file: %w", err)
|
return fmt.Errorf("read version file: %w", err)
|
||||||
}
|
}
|
||||||
@@ -221,7 +367,7 @@ func updateVersionFile(rootDir, version string, options resolvedOptions) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.WriteFile(path, []byte(updated), 0o644); err != nil {
|
if err := s.fileSystem.WriteFile(path, []byte(updated), 0o644); err != nil {
|
||||||
return fmt.Errorf("write version file: %w", err)
|
return fmt.Errorf("write version file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,7 +375,11 @@ func updateVersionFile(rootDir, version string, options resolvedOptions) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func updateChangelog(rootDir, version, releaseDate, changelogPath string) error {
|
func updateChangelog(rootDir, version, releaseDate, changelogPath string) error {
|
||||||
unreleasedBody, text, afterHeader, nextSectionStart, path, err := readChangelogState(rootDir, changelogPath)
|
return defaultService().updateChangelog(rootDir, version, releaseDate, changelogPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) updateChangelog(rootDir, version, releaseDate, changelogPath string) error {
|
||||||
|
unreleasedBody, text, afterHeader, nextSectionStart, path, err := s.readChangelogState(rootDir, changelogPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -245,11 +395,11 @@ func updateChangelog(rootDir, version, releaseDate, changelogPath string) error
|
|||||||
}
|
}
|
||||||
|
|
||||||
updated := text[:afterHeader] + "\n" + defaultUnreleasedTemplate + "\n" + newSection + text[nextSectionStart:]
|
updated := text[:afterHeader] + "\n" + defaultUnreleasedTemplate + "\n" + newSection + text[nextSectionStart:]
|
||||||
repoURL, ok := deriveRepositoryURL(rootDir)
|
repoURL, ok := s.deriveRepositoryURL(rootDir)
|
||||||
if ok {
|
if ok {
|
||||||
updated = addChangelogLinks(updated, repoURL, rootDir)
|
updated = s.addChangelogLinks(updated, repoURL, rootDir)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(path, []byte(updated), 0o644); err != nil {
|
if err := s.fileSystem.WriteFile(path, []byte(updated), 0o644); err != nil {
|
||||||
return fmt.Errorf("write changelog: %w", err)
|
return fmt.Errorf("write changelog: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,8 +407,12 @@ func updateChangelog(rootDir, version, releaseDate, changelogPath string) error
|
|||||||
}
|
}
|
||||||
|
|
||||||
func readCurrentVersion(rootDir string, options resolvedOptions) (string, error) {
|
func readCurrentVersion(rootDir string, options resolvedOptions) (string, error) {
|
||||||
|
return defaultService().readCurrentVersion(rootDir, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) readCurrentVersion(rootDir string, options resolvedOptions) (string, error) {
|
||||||
path := filepath.Join(rootDir, options.VersionFile)
|
path := filepath.Join(rootDir, options.VersionFile)
|
||||||
contents, err := os.ReadFile(path)
|
contents, err := s.fileSystem.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("read version file: %w", err)
|
return "", fmt.Errorf("read version file: %w", err)
|
||||||
}
|
}
|
||||||
@@ -272,7 +426,11 @@ func readCurrentVersion(rootDir string, options resolvedOptions) (string, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func readUnreleasedBody(rootDir, changelogPath string) (string, error) {
|
func readUnreleasedBody(rootDir, changelogPath string) (string, error) {
|
||||||
unreleasedBody, _, _, _, _, err := readChangelogState(rootDir, changelogPath)
|
return defaultService().readUnreleasedBody(rootDir, changelogPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) readUnreleasedBody(rootDir, changelogPath string) (string, error) {
|
||||||
|
unreleasedBody, _, _, _, _, err := s.readChangelogState(rootDir, changelogPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -297,8 +455,12 @@ func unreleasedHasEntries(unreleasedBody string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func readChangelogState(rootDir, changelogPath string) (string, string, int, int, string, error) {
|
func readChangelogState(rootDir, changelogPath string) (string, string, int, int, string, error) {
|
||||||
|
return defaultService().readChangelogState(rootDir, changelogPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) readChangelogState(rootDir, changelogPath string) (string, string, int, int, string, error) {
|
||||||
path := filepath.Join(rootDir, changelogPath)
|
path := filepath.Join(rootDir, changelogPath)
|
||||||
contents, err := os.ReadFile(path)
|
contents, err := s.fileSystem.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", 0, 0, "", fmt.Errorf("read changelog: %w", err)
|
return "", "", 0, 0, "", fmt.Errorf("read changelog: %w", err)
|
||||||
}
|
}
|
||||||
@@ -321,8 +483,16 @@ func readChangelogState(rootDir, changelogPath string) (string, string, int, int
|
|||||||
}
|
}
|
||||||
|
|
||||||
func readLatestChangelogVersion(rootDir, changelogPath string) (string, bool, error) {
|
func readLatestChangelogVersion(rootDir, changelogPath string) (string, bool, error) {
|
||||||
|
return defaultService().readLatestChangelogVersion(rootDir, changelogPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readReleaseNotes(rootDir, version, changelogPath string) (string, error) {
|
||||||
|
return defaultService().readReleaseNotes(rootDir, version, changelogPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) readLatestChangelogVersion(rootDir, changelogPath string) (string, bool, error) {
|
||||||
path := filepath.Join(rootDir, changelogPath)
|
path := filepath.Join(rootDir, changelogPath)
|
||||||
contents, err := os.ReadFile(path)
|
contents, err := s.fileSystem.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", false, fmt.Errorf("read changelog: %w", err)
|
return "", false, fmt.Errorf("read changelog: %w", err)
|
||||||
}
|
}
|
||||||
@@ -334,10 +504,46 @@ func readLatestChangelogVersion(rootDir, changelogPath string) (string, bool, er
|
|||||||
return match[1], true, nil
|
return match[1], true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) readReleaseNotes(rootDir, version, changelogPath string) (string, error) {
|
||||||
|
if version == "" {
|
||||||
|
return "", fmt.Errorf("release version must not be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(rootDir, changelogPath)
|
||||||
|
contents, err := s.fileSystem.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read changelog: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
text := string(contents)
|
||||||
|
headingExpr := regexp.MustCompile(`(?m)^## \[` + regexp.QuoteMeta(version) + `\](?:\([^\n)]*\))? - `)
|
||||||
|
headingLoc := headingExpr.FindStringIndex(text)
|
||||||
|
if headingLoc == nil {
|
||||||
|
return "", fmt.Errorf("release notes section for %s not found in changelog", version)
|
||||||
|
}
|
||||||
|
|
||||||
|
nextSectionRelative := strings.Index(text[headingLoc[0]+1:], "\n## [")
|
||||||
|
sectionEnd := len(text)
|
||||||
|
if nextSectionRelative != -1 {
|
||||||
|
sectionEnd = headingLoc[0] + 1 + nextSectionRelative
|
||||||
|
}
|
||||||
|
|
||||||
|
section := text[headingLoc[0]:sectionEnd]
|
||||||
|
if !strings.HasSuffix(section, "\n") {
|
||||||
|
section += "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
return section, nil
|
||||||
|
}
|
||||||
|
|
||||||
func deriveRepositoryURL(rootDir string) (string, bool) {
|
func deriveRepositoryURL(rootDir string) (string, bool) {
|
||||||
override := strings.TrimSpace(os.Getenv("VOCIFERATE_REPOSITORY_URL"))
|
return defaultService().deriveRepositoryURL(rootDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) deriveRepositoryURL(rootDir string) (string, bool) {
|
||||||
|
override := strings.TrimSpace(s.environment.Getenv("VOCIFERATE_REPOSITORY_URL"))
|
||||||
if override != "" {
|
if override != "" {
|
||||||
repositoryPath, ok := deriveRepositoryPath(rootDir)
|
repositoryPath, ok := s.deriveRepositoryPath(rootDir)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
@@ -346,14 +552,14 @@ func deriveRepositoryURL(rootDir string) (string, bool) {
|
|||||||
return baseURL + "/" + repositoryPath, true
|
return baseURL + "/" + repositoryPath, true
|
||||||
}
|
}
|
||||||
|
|
||||||
serverURL := strings.TrimSpace(os.Getenv("GITHUB_SERVER_URL"))
|
serverURL := strings.TrimSpace(s.environment.Getenv("GITHUB_SERVER_URL"))
|
||||||
repository := strings.TrimSpace(os.Getenv("GITHUB_REPOSITORY"))
|
repository := strings.TrimSpace(s.environment.Getenv("GITHUB_REPOSITORY"))
|
||||||
if serverURL != "" && repository != "" {
|
if serverURL != "" && repository != "" {
|
||||||
return strings.TrimSuffix(serverURL, "/") + "/" + strings.TrimPrefix(repository, "/"), true
|
return strings.TrimSuffix(serverURL, "/") + "/" + strings.TrimPrefix(repository, "/"), true
|
||||||
}
|
}
|
||||||
|
|
||||||
gitConfigPath := filepath.Join(rootDir, ".git", "config")
|
gitConfigPath := filepath.Join(rootDir, ".git", "config")
|
||||||
contents, err := os.ReadFile(gitConfigPath)
|
contents, err := s.fileSystem.ReadFile(gitConfigPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
@@ -372,13 +578,17 @@ func deriveRepositoryURL(rootDir string) (string, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func deriveRepositoryPath(rootDir string) (string, bool) {
|
func deriveRepositoryPath(rootDir string) (string, bool) {
|
||||||
repository := strings.TrimSpace(os.Getenv("GITHUB_REPOSITORY"))
|
return defaultService().deriveRepositoryPath(rootDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) deriveRepositoryPath(rootDir string) (string, bool) {
|
||||||
|
repository := strings.TrimSpace(s.environment.Getenv("GITHUB_REPOSITORY"))
|
||||||
if repository != "" {
|
if repository != "" {
|
||||||
return strings.TrimPrefix(repository, "/"), true
|
return strings.TrimPrefix(repository, "/"), true
|
||||||
}
|
}
|
||||||
|
|
||||||
gitConfigPath := filepath.Join(rootDir, ".git", "config")
|
gitConfigPath := filepath.Join(rootDir, ".git", "config")
|
||||||
contents, err := os.ReadFile(gitConfigPath)
|
contents, err := s.fileSystem.ReadFile(gitConfigPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
@@ -472,6 +682,10 @@ func normalizeRepoURL(remoteURL string) (string, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func addChangelogLinks(text, repoURL, rootDir string) string {
|
func addChangelogLinks(text, repoURL, rootDir string) string {
|
||||||
|
return defaultService().addChangelogLinks(text, repoURL, rootDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) addChangelogLinks(text, repoURL, rootDir string) string {
|
||||||
repoURL = strings.TrimSuffix(strings.TrimSpace(repoURL), "/")
|
repoURL = strings.TrimSuffix(strings.TrimSpace(repoURL), "/")
|
||||||
if repoURL == "" {
|
if repoURL == "" {
|
||||||
return text
|
return text
|
||||||
@@ -519,7 +733,7 @@ func addChangelogLinks(text, repoURL, rootDir string) string {
|
|||||||
linkDefs = append(linkDefs, fmt.Sprintf("[Unreleased]: %s/src/branch/main", displayRepoURL))
|
linkDefs = append(linkDefs, fmt.Sprintf("[Unreleased]: %s/src/branch/main", displayRepoURL))
|
||||||
}
|
}
|
||||||
|
|
||||||
firstCommitShort, hasFirstCommit := firstCommitShortHash(rootDir)
|
firstCommitShort, hasFirstCommit := s.firstCommitShortHash(rootDir)
|
||||||
for i, version := range releasedVersions {
|
for i, version := range releasedVersions {
|
||||||
if i+1 < len(releasedVersions) {
|
if i+1 < len(releasedVersions) {
|
||||||
previousVersion := releasedVersions[i+1]
|
previousVersion := releasedVersions[i+1]
|
||||||
@@ -541,31 +755,20 @@ func addChangelogLinks(text, repoURL, rootDir string) string {
|
|||||||
func displayURL(url string) string {
|
func displayURL(url string) string {
|
||||||
trimmed := strings.TrimSpace(url)
|
trimmed := strings.TrimSpace(url)
|
||||||
if strings.HasPrefix(trimmed, "https://") {
|
if strings.HasPrefix(trimmed, "https://") {
|
||||||
return "//" + strings.TrimPrefix(trimmed, "https://")
|
return trimmed
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(trimmed, "http://") {
|
if strings.HasPrefix(trimmed, "http://") {
|
||||||
return "//" + strings.TrimPrefix(trimmed, "http://")
|
return "https://" + strings.TrimPrefix(trimmed, "http://")
|
||||||
}
|
}
|
||||||
return trimmed
|
return trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
func firstCommitShortHash(rootDir string) (string, bool) {
|
func firstCommitShortHash(rootDir string) (string, bool) {
|
||||||
command := exec.Command("git", "-C", rootDir, "rev-list", "--max-parents=0", "--abbrev-commit", "HEAD")
|
return defaultService().firstCommitShortHash(rootDir)
|
||||||
output, err := command.Output()
|
}
|
||||||
if err != nil {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
commit := strings.TrimSpace(string(output))
|
func (s *Service) firstCommitShortHash(rootDir string) (string, bool) {
|
||||||
if commit == "" {
|
return s.git.FirstCommitShortHash(rootDir)
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Contains(commit, "\n") {
|
|
||||||
commit = strings.SplitN(commit, "\n", 2)[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
return commit, true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func compareURL(repoURL, baseRef, headRef string) string {
|
func compareURL(repoURL, baseRef, headRef string) string {
|
||||||
|
|||||||
@@ -3,9 +3,54 @@ package vociferate
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type stubFileSystem struct {
|
||||||
|
files map[string][]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func newStubFileSystem(files map[string]string) *stubFileSystem {
|
||||||
|
backing := make(map[string][]byte, len(files))
|
||||||
|
for path, contents := range files {
|
||||||
|
backing[path] = []byte(contents)
|
||||||
|
}
|
||||||
|
return &stubFileSystem{files: backing}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *stubFileSystem) ReadFile(path string) ([]byte, error) {
|
||||||
|
contents, ok := fs.files[path]
|
||||||
|
if !ok {
|
||||||
|
return nil, os.ErrNotExist
|
||||||
|
}
|
||||||
|
clone := make([]byte, len(contents))
|
||||||
|
copy(clone, contents)
|
||||||
|
return clone, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *stubFileSystem) WriteFile(path string, data []byte, _ os.FileMode) error {
|
||||||
|
clone := make([]byte, len(data))
|
||||||
|
copy(clone, data)
|
||||||
|
fs.files[path] = clone
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type stubEnvironment map[string]string
|
||||||
|
|
||||||
|
func (env stubEnvironment) Getenv(key string) string {
|
||||||
|
return env[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
type stubGitRunner struct {
|
||||||
|
commit string
|
||||||
|
ok bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (runner stubGitRunner) FirstCommitShortHash(_ string) (string, bool) {
|
||||||
|
return runner.commit, runner.ok
|
||||||
|
}
|
||||||
|
|
||||||
func TestNormalizeRepoURL(t *testing.T) {
|
func TestNormalizeRepoURL(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -146,3 +191,123 @@ func TestDeriveRepositoryURL_UsesOverrideAsHighestPriority(t *testing.T) {
|
|||||||
t.Fatalf("unexpected repository URL: %q", url)
|
t.Fatalf("unexpected repository URL: %q", url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveOptions_UsesUppercaseChangelogDefault(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
resolved, err := resolveOptions(Options{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveOptions returned unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resolved.Changelog != "CHANGELOG.md" {
|
||||||
|
t.Fatalf("resolved changelog = %q, want %q", resolved.Changelog, "CHANGELOG.md")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewService_ValidatesDependencies(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
validFS := newStubFileSystem(nil)
|
||||||
|
validEnv := stubEnvironment{}
|
||||||
|
validGit := stubGitRunner{}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
deps Dependencies
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "missing file system",
|
||||||
|
deps: Dependencies{Environment: validEnv, Git: validGit},
|
||||||
|
wantErr: "file system dependency must not be nil",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing environment",
|
||||||
|
deps: Dependencies{FileSystem: validFS, Git: validGit},
|
||||||
|
wantErr: "environment dependency must not be nil",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing git runner",
|
||||||
|
deps: Dependencies{FileSystem: validFS, Environment: validEnv},
|
||||||
|
wantErr: "git runner dependency must not be nil",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
tt := tt
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
_, err := NewService(tt.deps)
|
||||||
|
if err == nil || err.Error() != tt.wantErr {
|
||||||
|
t.Fatalf("NewService() error = %v, want %q", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServicePrepare_UsesInjectedEnvironmentForRepositoryLinks(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rootDir := "/repo"
|
||||||
|
fs := newStubFileSystem(map[string]string{
|
||||||
|
filepath.Join(rootDir, "release-version"): "1.1.6\n",
|
||||||
|
filepath.Join(rootDir, "CHANGELOG.md"): "# Changelog\n\n## [Unreleased]\n\n### Added\n\n- New thing.\n\n## [1.1.6] - 2017-12-20\n\n### Fixed\n\n- Historical note.\n",
|
||||||
|
})
|
||||||
|
|
||||||
|
svc, err := NewService(Dependencies{
|
||||||
|
FileSystem: fs,
|
||||||
|
Environment: stubEnvironment{"GITHUB_SERVER_URL": "https://git.hrafn.xyz", "GITHUB_REPOSITORY": "aether/vociferate"},
|
||||||
|
Git: stubGitRunner{commit: "deadbee", ok: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewService() unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = svc.Prepare(rootDir, "1.1.7", "2026-03-21", Options{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Prepare() unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated := string(fs.files[filepath.Join(rootDir, "CHANGELOG.md")])
|
||||||
|
if !contains(updated, "[Unreleased]: https://git.hrafn.xyz/aether/vociferate/compare/v1.1.7...main") {
|
||||||
|
t.Fatalf("Prepare() changelog missing injected environment link:\n%s", updated)
|
||||||
|
}
|
||||||
|
if !contains(updated, "[1.1.7]: https://git.hrafn.xyz/aether/vociferate/compare/v1.1.6...v1.1.7") {
|
||||||
|
t.Fatalf("Prepare() changelog missing injected release link:\n%s", updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServicePrepare_UsesInjectedGitRunnerForFirstCommitLink(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rootDir := "/repo"
|
||||||
|
fs := newStubFileSystem(map[string]string{
|
||||||
|
filepath.Join(rootDir, "release-version"): "1.1.6\n",
|
||||||
|
filepath.Join(rootDir, "CHANGELOG.md"): "# Changelog\n\n## [Unreleased]\n\n### Added\n\n- New thing.\n\n## [1.1.6] - 2017-12-20\n\n### Fixed\n\n- Historical note.\n",
|
||||||
|
filepath.Join(rootDir, ".git", "config"): "[remote \"origin\"]\n\turl = git@git.hrafn.xyz:aether/vociferate.git\n",
|
||||||
|
})
|
||||||
|
|
||||||
|
svc, err := NewService(Dependencies{
|
||||||
|
FileSystem: fs,
|
||||||
|
Environment: stubEnvironment{},
|
||||||
|
Git: stubGitRunner{commit: "abc1234", ok: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewService() unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = svc.Prepare(rootDir, "1.1.7", "2026-03-21", Options{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Prepare() unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated := string(fs.files[filepath.Join(rootDir, "CHANGELOG.md")])
|
||||||
|
if !contains(updated, "[1.1.6]: https://git.hrafn.xyz/aether/vociferate/compare/abc1234...v1.1.6") {
|
||||||
|
t.Fatalf("Prepare() changelog missing injected git link:\n%s", updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(text, fragment string) bool {
|
||||||
|
return len(fragment) > 0 && strings.Contains(text, fragment)
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ func (s *PrepareSuite) SetupTest() {
|
|||||||
))
|
))
|
||||||
|
|
||||||
require.NoError(s.T(), os.WriteFile(
|
require.NoError(s.T(), os.WriteFile(
|
||||||
filepath.Join(s.rootDir, "changelog.md"),
|
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"),
|
[]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,
|
0o644,
|
||||||
))
|
))
|
||||||
@@ -72,15 +72,15 @@ func (s *PrepareSuite) TestPrepare_UpdatesVersionAndPromotesUnreleasedNotes() {
|
|||||||
require.NoError(s.T(), err)
|
require.NoError(s.T(), err)
|
||||||
require.Equal(s.T(), "1.1.7\n", string(versionBytes))
|
require.Equal(s.T(), "1.1.7\n", string(versionBytes))
|
||||||
|
|
||||||
changelogBytes, err := os.ReadFile(filepath.Join(s.rootDir, "changelog.md"))
|
changelogBytes, err := os.ReadFile(filepath.Join(s.rootDir, "CHANGELOG.md"))
|
||||||
require.NoError(s.T(), err)
|
require.NoError(s.T(), err)
|
||||||
firstCommit := firstCommitShortHash(s.T(), s.rootDir)
|
firstCommit := firstCommitShortHash(s.T(), s.rootDir)
|
||||||
require.Equal(s.T(), "# Changelog\n\n## [Unreleased]\n\n### Breaking\n\n### Added\n\n### Changed\n\n### Removed\n\n### Fixed\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]: //git.hrafn.xyz/aether/vociferate/compare/v1.1.7...main\n[1.1.7]: //git.hrafn.xyz/aether/vociferate/compare/v1.1.6...v1.1.7\n[1.1.6]: //git.hrafn.xyz/aether/vociferate/compare/"+firstCommit+"...v1.1.6\n", string(changelogBytes))
|
require.Equal(s.T(), "# Changelog\n\n## [Unreleased]\n\n### Breaking\n\n### Added\n\n### Changed\n\n### Removed\n\n### Fixed\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/compare/v1.1.7...main\n[1.1.7]: https://git.hrafn.xyz/aether/vociferate/compare/v1.1.6...v1.1.7\n[1.1.6]: https://git.hrafn.xyz/aether/vociferate/compare/"+firstCommit+"...v1.1.6\n", string(changelogBytes))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PrepareSuite) TestPrepare_ReturnsErrorWhenUnreleasedSectionMissing() {
|
func (s *PrepareSuite) TestPrepare_ReturnsErrorWhenUnreleasedSectionMissing() {
|
||||||
require.NoError(s.T(), os.WriteFile(
|
require.NoError(s.T(), os.WriteFile(
|
||||||
filepath.Join(s.rootDir, "changelog.md"),
|
filepath.Join(s.rootDir, "CHANGELOG.md"),
|
||||||
[]byte("# Changelog\n\n## [1.1.6] - 2017-12-20\n"),
|
[]byte("# Changelog\n\n## [1.1.6] - 2017-12-20\n"),
|
||||||
0o644,
|
0o644,
|
||||||
))
|
))
|
||||||
@@ -92,7 +92,7 @@ func (s *PrepareSuite) TestPrepare_ReturnsErrorWhenUnreleasedSectionMissing() {
|
|||||||
|
|
||||||
func (s *PrepareSuite) TestPrepare_ReturnsErrorWhenUnreleasedSectionIsEmpty() {
|
func (s *PrepareSuite) TestPrepare_ReturnsErrorWhenUnreleasedSectionIsEmpty() {
|
||||||
require.NoError(s.T(), os.WriteFile(
|
require.NoError(s.T(), os.WriteFile(
|
||||||
filepath.Join(s.rootDir, "changelog.md"),
|
filepath.Join(s.rootDir, "CHANGELOG.md"),
|
||||||
[]byte("# Changelog\n\n## [Unreleased]\n\n## [1.1.6] - 2017-12-20\n"),
|
[]byte("# Changelog\n\n## [Unreleased]\n\n## [1.1.6] - 2017-12-20\n"),
|
||||||
0o644,
|
0o644,
|
||||||
))
|
))
|
||||||
@@ -109,9 +109,41 @@ func (s *PrepareSuite) TestRecommendedTag_UsesMinorBumpWhenBreakingHeadingIsEmpt
|
|||||||
require.Equal(s.T(), "v1.2.0", tag)
|
require.Equal(s.T(), "v1.2.0", tag)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *PrepareSuite) TestUnreleasedBody_ReturnsStructuredPendingReleaseNotes() {
|
||||||
|
body, err := vociferate.UnreleasedBody(s.rootDir, vociferate.Options{})
|
||||||
|
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.Equal(s.T(), "### Breaking\n\n### Added\n\n- New thing.\n\n### Fixed\n\n- Old thing.\n", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PrepareSuite) TestUnreleasedBody_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.UnreleasedBody(s.rootDir, vociferate.Options{})
|
||||||
|
|
||||||
|
require.ErrorContains(s.T(), err, "unreleased section")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PrepareSuite) TestReleaseNotes_ReturnsReleaseSectionForVersion() {
|
||||||
|
notes, err := vociferate.ReleaseNotes(s.rootDir, "1.1.6", vociferate.Options{})
|
||||||
|
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
require.Equal(s.T(), "## [1.1.6] - 2017-12-20\n\n### Fixed\n\n- Historical note.\n", notes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PrepareSuite) TestReleaseNotes_ReturnsErrorWhenVersionSectionMissing() {
|
||||||
|
_, err := vociferate.ReleaseNotes(s.rootDir, "9.9.9", vociferate.Options{})
|
||||||
|
|
||||||
|
require.ErrorContains(s.T(), err, "release notes section")
|
||||||
|
}
|
||||||
|
|
||||||
func (s *PrepareSuite) TestRecommendedTag_ReturnsErrorWhenUnreleasedHasOnlyTemplateHeadings() {
|
func (s *PrepareSuite) TestRecommendedTag_ReturnsErrorWhenUnreleasedHasOnlyTemplateHeadings() {
|
||||||
require.NoError(s.T(), os.WriteFile(
|
require.NoError(s.T(), os.WriteFile(
|
||||||
filepath.Join(s.rootDir, "changelog.md"),
|
filepath.Join(s.rootDir, "CHANGELOG.md"),
|
||||||
[]byte("# Changelog\n\n## [Unreleased]\n\n### Breaking\n\n### Added\n\n### Changed\n\n### Removed\n\n### Fixed\n\n## [1.1.6] - 2017-12-20\n"),
|
[]byte("# Changelog\n\n## [Unreleased]\n\n### Breaking\n\n### Added\n\n### Changed\n\n### Removed\n\n### Fixed\n\n## [1.1.6] - 2017-12-20\n"),
|
||||||
0o644,
|
0o644,
|
||||||
))
|
))
|
||||||
@@ -123,7 +155,7 @@ func (s *PrepareSuite) TestRecommendedTag_ReturnsErrorWhenUnreleasedHasOnlyTempl
|
|||||||
|
|
||||||
func (s *PrepareSuite) TestRecommendedTag_UsesPatchBumpForFixOnlyChanges() {
|
func (s *PrepareSuite) TestRecommendedTag_UsesPatchBumpForFixOnlyChanges() {
|
||||||
require.NoError(s.T(), os.WriteFile(
|
require.NoError(s.T(), os.WriteFile(
|
||||||
filepath.Join(s.rootDir, "changelog.md"),
|
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"),
|
[]byte("# Changelog\n\n## [Unreleased]\n\n### Fixed\n\n- Patch note.\n\n## [1.1.6] - 2017-12-20\n"),
|
||||||
0o644,
|
0o644,
|
||||||
))
|
))
|
||||||
@@ -136,7 +168,7 @@ func (s *PrepareSuite) TestRecommendedTag_UsesPatchBumpForFixOnlyChanges() {
|
|||||||
|
|
||||||
func (s *PrepareSuite) TestRecommendedTag_UsesMajorBumpWhenRemovedEntriesExist() {
|
func (s *PrepareSuite) TestRecommendedTag_UsesMajorBumpWhenRemovedEntriesExist() {
|
||||||
require.NoError(s.T(), os.WriteFile(
|
require.NoError(s.T(), os.WriteFile(
|
||||||
filepath.Join(s.rootDir, "changelog.md"),
|
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"),
|
[]byte("# Changelog\n\n## [Unreleased]\n\n### Removed\n\n- Breaking removal.\n\n## [1.1.6] - 2017-12-20\n"),
|
||||||
0o644,
|
0o644,
|
||||||
))
|
))
|
||||||
@@ -149,7 +181,7 @@ func (s *PrepareSuite) TestRecommendedTag_UsesMajorBumpWhenRemovedEntriesExist()
|
|||||||
|
|
||||||
func (s *PrepareSuite) TestRecommendedTag_UsesMajorBumpWhenBreakingEntriesExist() {
|
func (s *PrepareSuite) TestRecommendedTag_UsesMajorBumpWhenBreakingEntriesExist() {
|
||||||
require.NoError(s.T(), os.WriteFile(
|
require.NoError(s.T(), os.WriteFile(
|
||||||
filepath.Join(s.rootDir, "changelog.md"),
|
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"),
|
[]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,
|
0o644,
|
||||||
))
|
))
|
||||||
@@ -206,7 +238,7 @@ func (s *PrepareSuite) TestRecommendedTag_UsesChangelogVersionWhenNoVersionFileC
|
|||||||
0o644,
|
0o644,
|
||||||
))
|
))
|
||||||
require.NoError(s.T(), os.WriteFile(
|
require.NoError(s.T(), os.WriteFile(
|
||||||
filepath.Join(s.rootDir, "changelog.md"),
|
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"),
|
[]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,
|
0o644,
|
||||||
))
|
))
|
||||||
@@ -220,7 +252,7 @@ func (s *PrepareSuite) TestRecommendedTag_UsesChangelogVersionWhenNoVersionFileC
|
|||||||
func (s *PrepareSuite) TestRecommendedTag_DefaultsToV1WhenNoPriorReleasesInChangelog() {
|
func (s *PrepareSuite) TestRecommendedTag_DefaultsToV1WhenNoPriorReleasesInChangelog() {
|
||||||
require.NoError(s.T(), os.Remove(filepath.Join(s.rootDir, "release-version")))
|
require.NoError(s.T(), os.Remove(filepath.Join(s.rootDir, "release-version")))
|
||||||
require.NoError(s.T(), os.WriteFile(
|
require.NoError(s.T(), os.WriteFile(
|
||||||
filepath.Join(s.rootDir, "changelog.md"),
|
filepath.Join(s.rootDir, "CHANGELOG.md"),
|
||||||
[]byte("# Changelog\n\n## [Unreleased]\n\n### Breaking\n\n### Added\n\n- First feature.\n"),
|
[]byte("# Changelog\n\n## [Unreleased]\n\n### Breaking\n\n### Added\n\n- First feature.\n"),
|
||||||
0o644,
|
0o644,
|
||||||
))
|
))
|
||||||
@@ -252,7 +284,7 @@ func (s *PrepareSuite) TestRecommendedTag_UsesCustomVersionFileAndPattern() {
|
|||||||
0o644,
|
0o644,
|
||||||
))
|
))
|
||||||
require.NoError(s.T(), os.WriteFile(
|
require.NoError(s.T(), os.WriteFile(
|
||||||
filepath.Join(s.rootDir, "changelog.md"),
|
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"),
|
[]byte("# Changelog\n\n## [Unreleased]\n\n### Added\n\n- Feature.\n\n## [2.3.4] - 2026-03-10\n"),
|
||||||
0o644,
|
0o644,
|
||||||
))
|
))
|
||||||
@@ -273,7 +305,7 @@ func (s *PrepareSuite) TestPrepare_UsesGitHrafnXYZEnvironmentForChangelogLinks()
|
|||||||
err := vociferate.Prepare(s.rootDir, "1.1.7", "2026-03-20", vociferate.Options{})
|
err := vociferate.Prepare(s.rootDir, "1.1.7", "2026-03-20", vociferate.Options{})
|
||||||
require.NoError(s.T(), err)
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
changelogBytes, readErr := os.ReadFile(filepath.Join(s.rootDir, "changelog.md"))
|
changelogBytes, readErr := os.ReadFile(filepath.Join(s.rootDir, "CHANGELOG.md"))
|
||||||
require.NoError(s.T(), readErr)
|
require.NoError(s.T(), readErr)
|
||||||
changelog := string(changelogBytes)
|
changelog := string(changelogBytes)
|
||||||
firstCommit := firstCommitShortHash(s.T(), s.rootDir)
|
firstCommit := firstCommitShortHash(s.T(), s.rootDir)
|
||||||
@@ -283,9 +315,9 @@ func (s *PrepareSuite) TestPrepare_UsesGitHrafnXYZEnvironmentForChangelogLinks()
|
|||||||
require.Contains(s.T(), changelog, "### Removed\n")
|
require.Contains(s.T(), changelog, "### Removed\n")
|
||||||
require.Contains(s.T(), changelog, "## [1.1.7] - 2026-03-20")
|
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, "## [1.1.6] - 2017-12-20")
|
||||||
require.Contains(s.T(), changelog, "[Unreleased]: //git.hrafn.xyz/aether/vociferate/compare/v1.1.7...main")
|
require.Contains(s.T(), changelog, "[Unreleased]: https://git.hrafn.xyz/aether/vociferate/compare/v1.1.7...main")
|
||||||
require.Contains(s.T(), changelog, "[1.1.7]: //git.hrafn.xyz/aether/vociferate/compare/v1.1.6...v1.1.7")
|
require.Contains(s.T(), changelog, "[1.1.7]: https://git.hrafn.xyz/aether/vociferate/compare/v1.1.6...v1.1.7")
|
||||||
require.Contains(s.T(), changelog, "[1.1.6]: //git.hrafn.xyz/aether/vociferate/compare/"+firstCommit+"...v1.1.6")
|
require.Contains(s.T(), changelog, "[1.1.6]: https://git.hrafn.xyz/aether/vociferate/compare/"+firstCommit+"...v1.1.6")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PrepareSuite) TestPrepare_UsesGitHubEnvironmentForChangelogLinks() {
|
func (s *PrepareSuite) TestPrepare_UsesGitHubEnvironmentForChangelogLinks() {
|
||||||
@@ -295,7 +327,7 @@ func (s *PrepareSuite) TestPrepare_UsesGitHubEnvironmentForChangelogLinks() {
|
|||||||
err := vociferate.Prepare(s.rootDir, "1.1.7", "2026-03-20", vociferate.Options{})
|
err := vociferate.Prepare(s.rootDir, "1.1.7", "2026-03-20", vociferate.Options{})
|
||||||
require.NoError(s.T(), err)
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
changelogBytes, readErr := os.ReadFile(filepath.Join(s.rootDir, "changelog.md"))
|
changelogBytes, readErr := os.ReadFile(filepath.Join(s.rootDir, "CHANGELOG.md"))
|
||||||
require.NoError(s.T(), readErr)
|
require.NoError(s.T(), readErr)
|
||||||
changelog := string(changelogBytes)
|
changelog := string(changelogBytes)
|
||||||
firstCommit := firstCommitShortHash(s.T(), s.rootDir)
|
firstCommit := firstCommitShortHash(s.T(), s.rootDir)
|
||||||
@@ -305,7 +337,7 @@ func (s *PrepareSuite) TestPrepare_UsesGitHubEnvironmentForChangelogLinks() {
|
|||||||
require.Contains(s.T(), changelog, "### Removed\n")
|
require.Contains(s.T(), changelog, "### Removed\n")
|
||||||
require.Contains(s.T(), changelog, "## [1.1.7] - 2026-03-20")
|
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, "## [1.1.6] - 2017-12-20")
|
||||||
require.Contains(s.T(), changelog, "[Unreleased]: //github.com/aether/vociferate/compare/v1.1.7...main")
|
require.Contains(s.T(), changelog, "[Unreleased]: https://github.com/aether/vociferate/compare/v1.1.7...main")
|
||||||
require.Contains(s.T(), changelog, "[1.1.7]: //github.com/aether/vociferate/compare/v1.1.6...v1.1.7")
|
require.Contains(s.T(), changelog, "[1.1.7]: https://github.com/aether/vociferate/compare/v1.1.6...v1.1.7")
|
||||||
require.Contains(s.T(), changelog, "[1.1.6]: //github.com/aether/vociferate/compare/"+firstCommit+"...v1.1.6")
|
require.Contains(s.T(), changelog, "[1.1.6]: https://github.com/aether/vociferate/compare/"+firstCommit+"...v1.1.6")
|
||||||
}
|
}
|
||||||
|
|||||||
14
justfile
14
justfile
@@ -9,3 +9,17 @@ go-build:
|
|||||||
|
|
||||||
go-test:
|
go-test:
|
||||||
go test ./...
|
go test ./...
|
||||||
|
|
||||||
|
validate-fmt:
|
||||||
|
go fmt ./...
|
||||||
|
test -z "$(gofmt -l .)"
|
||||||
|
|
||||||
|
validate-mod:
|
||||||
|
go mod tidy
|
||||||
|
go mod verify
|
||||||
|
|
||||||
|
security:
|
||||||
|
gosec ./...
|
||||||
|
govulncheck ./...
|
||||||
|
|
||||||
|
validate: validate-fmt validate-mod go-test security
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ inputs:
|
|||||||
changelog:
|
changelog:
|
||||||
description: Path to changelog file relative to repository root.
|
description: Path to changelog file relative to repository root.
|
||||||
required: false
|
required: false
|
||||||
default: changelog.md
|
default: CHANGELOG.md
|
||||||
git-user-name:
|
git-user-name:
|
||||||
description: Name for the release commit author.
|
description: Name for the release commit author.
|
||||||
required: false
|
required: false
|
||||||
@@ -38,160 +38,102 @@ inputs:
|
|||||||
git-add-files:
|
git-add-files:
|
||||||
description: >
|
description: >
|
||||||
Space-separated list of file paths to stage for the release commit.
|
Space-separated list of file paths to stage for the release commit.
|
||||||
Defaults to changelog.md and release-version. Adjust when using a
|
Defaults to CHANGELOG.md and release-version. Adjust when using a
|
||||||
custom version-file.
|
custom version-file.
|
||||||
required: false
|
required: false
|
||||||
default: 'changelog.md release-version'
|
default: 'CHANGELOG.md release-version'
|
||||||
|
token:
|
||||||
|
description: >
|
||||||
|
Personal access token used to authenticate commit, push, and tag
|
||||||
|
operations. Required to ensure downstream workflows trigger on tag push.
|
||||||
|
required: true
|
||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
version:
|
version:
|
||||||
description: >
|
description: >
|
||||||
The resolved version tag (e.g. v1.2.3) that was committed and pushed.
|
The resolved version tag (e.g. v1.2.3) that was committed and pushed.
|
||||||
value: ${{ steps.run-vociferate.outputs.version }}
|
value: ${{ steps.finalize-version.outputs.version }}
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: composite
|
using: composite
|
||||||
steps:
|
steps:
|
||||||
- name: Resolve vociferate binary metadata
|
- name: Normalize version input
|
||||||
id: resolve-binary
|
id: normalize-version
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
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 }}
|
INPUT_VERSION: ${{ inputs.version }}
|
||||||
VOCIFERATE_REPOSITORY_URL: ${{ vars.VOCIFERATE_REPOSITORY_URL }}
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
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:]]\+$//')"
|
provided_version="$(printf '%s' "${INPUT_VERSION:-}" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
|
||||||
|
printf 'value=%s\n' "$provided_version" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Recommend version
|
||||||
|
id: recommend-version
|
||||||
|
if: steps.normalize-version.outputs.value == ''
|
||||||
|
uses: ./run-vociferate
|
||||||
|
with:
|
||||||
|
root: ${{ github.workspace }}
|
||||||
|
version-file: ${{ inputs.version-file }}
|
||||||
|
version-pattern: ${{ inputs.version-pattern }}
|
||||||
|
changelog: ${{ inputs.changelog }}
|
||||||
|
recommend: 'true'
|
||||||
|
|
||||||
|
- name: Finalize version
|
||||||
|
id: finalize-version
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
PROVIDED_VERSION: ${{ steps.normalize-version.outputs.value }}
|
||||||
|
RECOMMENDED_VERSION: ${{ steps.recommend-version.outputs.stdout }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
provided_version="$PROVIDED_VERSION"
|
||||||
if [[ -z "$provided_version" ]]; then
|
if [[ -z "$provided_version" ]]; then
|
||||||
provided_version="$(run_vociferate "${common_args[@]}" --recommend)"
|
provided_version="$RECOMMENDED_VERSION"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
normalized_version="${provided_version#v}"
|
normalized_version="${provided_version#v}"
|
||||||
tag="v${normalized_version}"
|
tag="v${normalized_version}"
|
||||||
|
|
||||||
run_vociferate "${common_args[@]}" --version "$provided_version" --date "$(date -u +%F)"
|
printf 'version=%s\n' "$tag" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
echo "version=$tag" >> "$GITHUB_OUTPUT"
|
- name: Resolve release date
|
||||||
|
id: resolve-date
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
printf 'value=%s\n' "$(date -u +%F)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Prepare release files
|
||||||
|
uses: ./run-vociferate
|
||||||
|
with:
|
||||||
|
root: ${{ github.workspace }}
|
||||||
|
version-file: ${{ inputs.version-file }}
|
||||||
|
version-pattern: ${{ inputs.version-pattern }}
|
||||||
|
changelog: ${{ inputs.changelog }}
|
||||||
|
version: ${{ steps.finalize-version.outputs.version }}
|
||||||
|
date: ${{ steps.resolve-date.outputs.value }}
|
||||||
|
|
||||||
- name: Commit and push release
|
- name: Commit and push release
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ github.token }}
|
TOKEN: ${{ inputs.token }}
|
||||||
GIT_USER_NAME: ${{ inputs.git-user-name }}
|
GIT_USER_NAME: ${{ inputs.git-user-name }}
|
||||||
GIT_USER_EMAIL: ${{ inputs.git-user-email }}
|
GIT_USER_EMAIL: ${{ inputs.git-user-email }}
|
||||||
GIT_ADD_FILES: ${{ inputs.git-add-files }}
|
GIT_ADD_FILES: ${{ inputs.git-add-files }}
|
||||||
RELEASE_TAG: ${{ steps.run-vociferate.outputs.version }}
|
RELEASE_TAG: ${{ steps.finalize-version.outputs.version }}
|
||||||
GITHUB_SERVER_URL: ${{ github.server_url }}
|
GITHUB_SERVER_URL: ${{ github.server_url }}
|
||||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ -z "${TOKEN:-}" ]]; then
|
||||||
|
echo "A release PAT is required. Provide inputs.token (for example secrets.RELEASE_PAT)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
case "$GITHUB_SERVER_URL" in
|
case "$GITHUB_SERVER_URL" in
|
||||||
https://*)
|
https://*)
|
||||||
authed_remote="https://oauth2:${TOKEN}@${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY}.git"
|
authed_remote="https://oauth2:${TOKEN}@${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY}.git"
|
||||||
|
|||||||
@@ -7,10 +7,9 @@ description: >
|
|||||||
inputs:
|
inputs:
|
||||||
token:
|
token:
|
||||||
description: >
|
description: >
|
||||||
Token used to authenticate release API calls. Defaults to the
|
Personal access token used to authenticate release API calls.
|
||||||
workflow token.
|
Required to support release updates across workflow boundaries.
|
||||||
required: false
|
required: true
|
||||||
default: ''
|
|
||||||
version:
|
version:
|
||||||
description: >
|
description: >
|
||||||
Semantic version to publish (with or without leading v). When omitted,
|
Semantic version to publish (with or without leading v). When omitted,
|
||||||
@@ -20,7 +19,7 @@ inputs:
|
|||||||
changelog:
|
changelog:
|
||||||
description: Path to changelog file relative to repository root.
|
description: Path to changelog file relative to repository root.
|
||||||
required: false
|
required: false
|
||||||
default: changelog.md
|
default: CHANGELOG.md
|
||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
release-id:
|
release-id:
|
||||||
@@ -57,78 +56,100 @@ runs:
|
|||||||
normalized="${tag#v}"
|
normalized="${tag#v}"
|
||||||
else
|
else
|
||||||
echo "A version input is required when the workflow is not running from a tag push" >&2
|
echo "A version input is required when the workflow is not running from a tag push" >&2
|
||||||
|
echo "Provide version via input or ensure HEAD is at a tagged commit." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||||
echo "version=$normalized" >> "$GITHUB_OUTPUT"
|
echo "version=$normalized" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Extract release notes from changelog
|
- name: Extract release notes
|
||||||
id: extract-notes
|
id: extract-notes
|
||||||
|
uses: ./run-vociferate
|
||||||
|
with:
|
||||||
|
root: ${{ github.workspace }}
|
||||||
|
changelog: ${{ inputs.changelog }}
|
||||||
|
version: ${{ steps.resolve-version.outputs.version }}
|
||||||
|
print-release-notes: 'true'
|
||||||
|
|
||||||
|
- name: Write release notes file
|
||||||
|
id: write-notes
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
CHANGELOG: ${{ inputs.changelog != '' && inputs.changelog || 'changelog.md' }}
|
RELEASE_NOTES: ${{ steps.extract-notes.outputs.stdout }}
|
||||||
RELEASE_VERSION: ${{ steps.resolve-version.outputs.version }}
|
|
||||||
RUNNER_TEMP: ${{ runner.temp }}
|
RUNNER_TEMP: ${{ runner.temp }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
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"
|
notes_file="${RUNNER_TEMP}/release-notes.md"
|
||||||
printf '%s\n' "$release_notes" > "$notes_file"
|
printf '%s\n' "$RELEASE_NOTES" > "$notes_file"
|
||||||
echo "notes_file=$notes_file" >> "$GITHUB_OUTPUT"
|
|
||||||
|
printf 'notes_file=%s\n' "$notes_file" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Create or update release
|
- name: Create or update release
|
||||||
id: create-release
|
id: create-release
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ inputs.token != '' && inputs.token || github.token }}
|
TOKEN: ${{ inputs.token }}
|
||||||
TAG_NAME: ${{ steps.resolve-version.outputs.tag }}
|
TAG_NAME: ${{ steps.resolve-version.outputs.tag }}
|
||||||
RELEASE_NOTES_FILE: ${{ steps.extract-notes.outputs.notes_file }}
|
RELEASE_NOTES_FILE: ${{ steps.write-notes.outputs.notes_file }}
|
||||||
GITHUB_API_URL: ${{ github.api_url }}
|
GITHUB_API_URL: ${{ github.api_url }}
|
||||||
GITHUB_SERVER_URL: ${{ github.server_url }}
|
GITHUB_SERVER_URL: ${{ github.server_url }}
|
||||||
GITHUB_SHA: ${{ github.sha }}
|
|
||||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
parse_release_id() {
|
||||||
|
local json_file="$1"
|
||||||
|
|
||||||
|
if command -v python3 >/dev/null 2>&1; then
|
||||||
|
python3 -c 'import json, sys; payload = json.load(open(sys.argv[1], encoding="utf-8")); value = payload.get("id"); print(value if isinstance(value, int) else "")' "$json_file"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fallback for environments without python3.
|
||||||
|
sed -n 's/.*"id"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' "$json_file" | head -n 1
|
||||||
|
}
|
||||||
|
|
||||||
|
raw_token="$(printf '%s' "${TOKEN:-}" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
|
||||||
|
if [[ "$raw_token" =~ ^%\!t\(string=(.*)\)$ ]]; then
|
||||||
|
raw_token="${BASH_REMATCH[1]}"
|
||||||
|
fi
|
||||||
|
api_token="$(printf '%s' "$raw_token" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
|
||||||
|
|
||||||
|
if [[ -z "$api_token" ]]; then
|
||||||
|
echo "inputs.token is required (set to secrets.RELEASE_PAT)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
release_notes="$(cat "$RELEASE_NOTES_FILE")"
|
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')"
|
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_api="${GITHUB_API_URL:-${GITHUB_SERVER_URL%/}/api/v1}/repos/${GITHUB_REPOSITORY}/releases"
|
||||||
release_by_tag_api="${release_api}/tags/${TAG_NAME}"
|
release_by_tag_api="${release_api}/tags/${TAG_NAME}"
|
||||||
|
|
||||||
status_code="$(curl -sS -o release-existing.json -w '%{http_code}' \
|
status_code="$(curl -sS -o release-existing.json -w '%{http_code}' \
|
||||||
-H "Authorization: token ${TOKEN}" \
|
-H "Authorization: token ${api_token}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
"${release_by_tag_api}")"
|
"${release_by_tag_api}")"
|
||||||
|
|
||||||
if [[ "$status_code" == "200" ]]; then
|
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)"
|
existing_release_id="$(parse_release_id release-existing.json)"
|
||||||
if [[ -z "$existing_release_id" ]]; then
|
if [[ -z "$existing_release_id" ]]; then
|
||||||
echo "Failed to parse existing release id for ${TAG_NAME}" >&2
|
echo "Failed to parse existing release id for ${TAG_NAME}" >&2
|
||||||
cat release-existing.json >&2
|
cat release-existing.json >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
curl --fail-with-body \
|
if ! curl --fail-with-body \
|
||||||
-X PATCH \
|
-X PATCH \
|
||||||
-H "Authorization: token ${TOKEN}" \
|
-H "Authorization: token ${api_token}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
"${release_api}/${existing_release_id}" \
|
"${release_api}/${existing_release_id}" \
|
||||||
--data "{\"tag_name\":\"${TAG_NAME}\",\"target\":\"${GITHUB_SHA}\",\"name\":\"${TAG_NAME}\",\"body\":\"${escaped_release_notes}\",\"draft\":false,\"prerelease\":false}" \
|
--data "{\"tag_name\":\"${TAG_NAME}\",\"name\":\"${TAG_NAME}\",\"body\":\"${escaped_release_notes}\",\"draft\":false,\"prerelease\":false}" \
|
||||||
--output release.json
|
--output release.json; then
|
||||||
|
cat release.json >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
echo "id=$existing_release_id" >> "$GITHUB_OUTPUT"
|
echo "id=$existing_release_id" >> "$GITHUB_OUTPUT"
|
||||||
elif [[ "$status_code" != "404" ]]; then
|
elif [[ "$status_code" != "404" ]]; then
|
||||||
@@ -136,15 +157,18 @@ runs:
|
|||||||
cat release-existing.json >&2
|
cat release-existing.json >&2
|
||||||
exit 1
|
exit 1
|
||||||
else
|
else
|
||||||
curl --fail-with-body \
|
if ! curl --fail-with-body \
|
||||||
-X POST \
|
-X POST \
|
||||||
-H "Authorization: token ${TOKEN}" \
|
-H "Authorization: token ${api_token}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
"${release_api}" \
|
"${release_api}" \
|
||||||
--data "{\"tag_name\":\"${TAG_NAME}\",\"target\":\"${GITHUB_SHA}\",\"name\":\"${TAG_NAME}\",\"body\":\"${escaped_release_notes}\",\"draft\":false,\"prerelease\":false}" \
|
--data "{\"tag_name\":\"${TAG_NAME}\",\"name\":\"${TAG_NAME}\",\"body\":\"${escaped_release_notes}\",\"draft\":false,\"prerelease\":false}" \
|
||||||
--output release.json
|
--output release.json; then
|
||||||
|
cat release.json >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
release_id="$(sed -n 's/.*"id"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' release.json | head -n 1)"
|
release_id="$(parse_release_id release.json)"
|
||||||
if [[ -z "$release_id" ]]; then
|
if [[ -z "$release_id" ]]; then
|
||||||
echo "Failed to parse release id from API response" >&2
|
echo "Failed to parse release id from API response" >&2
|
||||||
cat release.json >&2
|
cat release.json >&2
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
0.2.0
|
1.1.0
|
||||||
|
|||||||
276
run-vociferate/action.yml
Normal file
276
run-vociferate/action.yml
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
name: vociferate/run-vociferate
|
||||||
|
description: Resolve the preferred runtime for vociferate and execute it with a consistent output contract.
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
root:
|
||||||
|
description: Repository root to pass to vociferate.
|
||||||
|
required: true
|
||||||
|
version-file:
|
||||||
|
description: Optional version file path.
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
version-pattern:
|
||||||
|
description: Optional version pattern.
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
changelog:
|
||||||
|
description: Optional changelog path.
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
version:
|
||||||
|
description: Optional version argument.
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
date:
|
||||||
|
description: Optional date argument.
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
recommend:
|
||||||
|
description: Whether to run vociferate with --recommend.
|
||||||
|
required: false
|
||||||
|
default: 'false'
|
||||||
|
print-unreleased:
|
||||||
|
description: Whether to print the Unreleased body.
|
||||||
|
required: false
|
||||||
|
default: 'false'
|
||||||
|
print-release-notes:
|
||||||
|
description: Whether to print the release notes section for version.
|
||||||
|
required: false
|
||||||
|
default: 'false'
|
||||||
|
|
||||||
|
outputs:
|
||||||
|
stdout:
|
||||||
|
description: Captured stdout from the selected runtime.
|
||||||
|
value: ${{ steps.finalize.outputs.stdout }}
|
||||||
|
use_binary:
|
||||||
|
description: Whether the selected runtime was the released binary.
|
||||||
|
value: ${{ steps.resolve-runtime.outputs.use_binary }}
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- name: Resolve runtime
|
||||||
|
id: resolve-runtime
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
ACTION_REF: ${{ github.action_ref }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ "$ACTION_REF" == v* ]]; then
|
||||||
|
printf 'use_binary=true\n' >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
printf 'use_binary=false\n' >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Resolve binary metadata
|
||||||
|
id: resolve-binary
|
||||||
|
if: steps.resolve-runtime.outputs.use_binary == 'true'
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
ACTION_REF: ${{ github.action_ref }}
|
||||||
|
ACTION_REPOSITORY: ${{ github.action_repository }}
|
||||||
|
CACHE_TOKEN: ${{ env.VOCIFERATE_CACHE_TOKEN }}
|
||||||
|
SERVER_URL: ${{ github.server_url }}
|
||||||
|
RUNNER_ARCH: ${{ runner.arch }}
|
||||||
|
RUNNER_TEMP: ${{ runner.temp }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ "$ACTION_REF" != v* ]]; then
|
||||||
|
echo "run-vociferate binary path requires github.action_ref to be a release tag" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$RUNNER_ARCH" in
|
||||||
|
X64)
|
||||||
|
arch="amd64"
|
||||||
|
;;
|
||||||
|
ARM64)
|
||||||
|
arch="arm64"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unsupported runner architecture: $RUNNER_ARCH" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
printf 'cache_token=%s\n' "$cache_token" >> "$GITHUB_OUTPUT"
|
||||||
|
printf 'cache_dir=%s\n' "$cache_dir" >> "$GITHUB_OUTPUT"
|
||||||
|
printf 'binary_path=%s\n' "$binary_path" >> "$GITHUB_OUTPUT"
|
||||||
|
printf 'asset_url=%s\n' "$asset_url" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Restore cached binary
|
||||||
|
id: cache-vociferate
|
||||||
|
if: steps.resolve-runtime.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 binary
|
||||||
|
if: steps.resolve-runtime.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 binary
|
||||||
|
id: run-binary
|
||||||
|
if: steps.resolve-runtime.outputs.use_binary == 'true'
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
VOCIFERATE_BIN: ${{ steps.resolve-binary.outputs.binary_path }}
|
||||||
|
ROOT: ${{ inputs.root }}
|
||||||
|
VERSION_FILE: ${{ inputs.version-file }}
|
||||||
|
VERSION_PATTERN: ${{ inputs.version-pattern }}
|
||||||
|
CHANGELOG: ${{ inputs.changelog }}
|
||||||
|
VERSION: ${{ inputs.version }}
|
||||||
|
DATE: ${{ inputs.date }}
|
||||||
|
RECOMMEND: ${{ inputs.recommend }}
|
||||||
|
PRINT_UNRELEASED: ${{ inputs.print-unreleased }}
|
||||||
|
PRINT_RELEASE_NOTES: ${{ inputs.print-release-notes }}
|
||||||
|
VOCIFERATE_REPOSITORY_URL: ${{ vars.VOCIFERATE_REPOSITORY_URL }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
command=("$VOCIFERATE_BIN" --root "$ROOT")
|
||||||
|
if [[ -n "$VERSION_FILE" ]]; then
|
||||||
|
command+=(--version-file "$VERSION_FILE")
|
||||||
|
fi
|
||||||
|
if [[ -n "$VERSION_PATTERN" ]]; then
|
||||||
|
command+=(--version-pattern "$VERSION_PATTERN")
|
||||||
|
fi
|
||||||
|
if [[ -n "$CHANGELOG" ]]; then
|
||||||
|
command+=(--changelog "$CHANGELOG")
|
||||||
|
fi
|
||||||
|
if [[ -n "$VERSION" ]]; then
|
||||||
|
command+=(--version "$VERSION")
|
||||||
|
fi
|
||||||
|
if [[ -n "$DATE" ]]; then
|
||||||
|
command+=(--date "$DATE")
|
||||||
|
fi
|
||||||
|
if [[ "$RECOMMEND" == 'true' ]]; then
|
||||||
|
command+=(--recommend)
|
||||||
|
fi
|
||||||
|
if [[ "$PRINT_UNRELEASED" == 'true' ]]; then
|
||||||
|
command+=(--print-unreleased)
|
||||||
|
fi
|
||||||
|
if [[ "$PRINT_RELEASE_NOTES" == 'true' ]]; then
|
||||||
|
command+=(--print-release-notes)
|
||||||
|
fi
|
||||||
|
|
||||||
|
stdout_file="$(mktemp)"
|
||||||
|
trap 'rm -f "$stdout_file"' EXIT
|
||||||
|
"${command[@]}" > "$stdout_file"
|
||||||
|
|
||||||
|
delimiter="EOF_STDOUT"
|
||||||
|
printf 'stdout<<%s\n' "$delimiter" >> "$GITHUB_OUTPUT"
|
||||||
|
cat "$stdout_file" >> "$GITHUB_OUTPUT"
|
||||||
|
printf '%s\n' "$delimiter" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Run source
|
||||||
|
id: run-code
|
||||||
|
if: steps.resolve-runtime.outputs.use_binary != 'true'
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
ROOT: ${{ inputs.root }}
|
||||||
|
VERSION_FILE: ${{ inputs.version-file }}
|
||||||
|
VERSION_PATTERN: ${{ inputs.version-pattern }}
|
||||||
|
CHANGELOG: ${{ inputs.changelog }}
|
||||||
|
VERSION: ${{ inputs.version }}
|
||||||
|
DATE: ${{ inputs.date }}
|
||||||
|
RECOMMEND: ${{ inputs.recommend }}
|
||||||
|
PRINT_UNRELEASED: ${{ inputs.print-unreleased }}
|
||||||
|
PRINT_RELEASE_NOTES: ${{ inputs.print-release-notes }}
|
||||||
|
VOCIFERATE_REPOSITORY_URL: ${{ vars.VOCIFERATE_REPOSITORY_URL }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
source_root="$GITHUB_ACTION_PATH"
|
||||||
|
while [[ ! -f "$source_root/go.mod" ]] && [[ "$source_root" != "/" ]]; do
|
||||||
|
source_root="$(realpath "$source_root/..")"
|
||||||
|
done
|
||||||
|
if [[ ! -f "$source_root/go.mod" ]]; then
|
||||||
|
echo "Could not locate Go module root from $GITHUB_ACTION_PATH" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
command=(go run ./cmd/vociferate --root "$ROOT")
|
||||||
|
if [[ -n "$VERSION_FILE" ]]; then
|
||||||
|
command+=(--version-file "$VERSION_FILE")
|
||||||
|
fi
|
||||||
|
if [[ -n "$VERSION_PATTERN" ]]; then
|
||||||
|
command+=(--version-pattern "$VERSION_PATTERN")
|
||||||
|
fi
|
||||||
|
if [[ -n "$CHANGELOG" ]]; then
|
||||||
|
command+=(--changelog "$CHANGELOG")
|
||||||
|
fi
|
||||||
|
if [[ -n "$VERSION" ]]; then
|
||||||
|
command+=(--version "$VERSION")
|
||||||
|
fi
|
||||||
|
if [[ -n "$DATE" ]]; then
|
||||||
|
command+=(--date "$DATE")
|
||||||
|
fi
|
||||||
|
if [[ "$RECOMMEND" == 'true' ]]; then
|
||||||
|
command+=(--recommend)
|
||||||
|
fi
|
||||||
|
if [[ "$PRINT_UNRELEASED" == 'true' ]]; then
|
||||||
|
command+=(--print-unreleased)
|
||||||
|
fi
|
||||||
|
if [[ "$PRINT_RELEASE_NOTES" == 'true' ]]; then
|
||||||
|
command+=(--print-release-notes)
|
||||||
|
fi
|
||||||
|
|
||||||
|
stdout_file="$(mktemp)"
|
||||||
|
trap 'rm -f "$stdout_file"' EXIT
|
||||||
|
(cd "$source_root" && "${command[@]}") > "$stdout_file"
|
||||||
|
|
||||||
|
delimiter="EOF_STDOUT"
|
||||||
|
printf 'stdout<<%s\n' "$delimiter" >> "$GITHUB_OUTPUT"
|
||||||
|
cat "$stdout_file" >> "$GITHUB_OUTPUT"
|
||||||
|
printf '%s\n' "$delimiter" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Finalize stdout
|
||||||
|
id: finalize
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
STDOUT_BINARY: ${{ steps.run-binary.outputs.stdout }}
|
||||||
|
STDOUT_CODE: ${{ steps.run-code.outputs.stdout }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
stdout="$STDOUT_BINARY"
|
||||||
|
if [[ -z "$stdout" ]]; then
|
||||||
|
stdout="$STDOUT_CODE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
delimiter="EOF_STDOUT"
|
||||||
|
printf 'stdout<<%s\n' "$delimiter" >> "$GITHUB_OUTPUT"
|
||||||
|
printf '%s\n' "$stdout" >> "$GITHUB_OUTPUT"
|
||||||
|
printf '%s\n' "$delimiter" >> "$GITHUB_OUTPUT"
|
||||||
Reference in New Issue
Block a user