3 Commits

Author SHA1 Message Date
Micheal Wilkinson
44f499dc52 docs: record local action path syntax fix
All checks were successful
Push Validation / coverage-badge (push) Successful in 1m14s
Push Validation / recommend-release (push) Successful in 28s
2026-03-21 15:20:38 +00:00
Micheal Wilkinson
43827593e7 fix(actions): mark nested run-vociferate refs as local paths 2026-03-21 15:20:38 +00:00
gitea-actions[bot]
4714bfe272 release: prepare v1.1.0 2026-03-21 15:18:34 +00:00
23 changed files with 606 additions and 1732 deletions

View File

@@ -1,4 +1,4 @@
name: Update Release name: Do Release
on: on:
push: push:
@@ -28,113 +28,38 @@ jobs:
run: run:
shell: bash shell: bash
env: env:
RELEASE_TOKEN: ${{ secrets.RELEASE_PAT }} RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN || secrets.GITEA_TOKEN }}
SUMMARY_FILE: ${{ runner.temp }}/update-release-summary.md SUMMARY_FILE: ${{ runner.temp }}/do-release-summary.md
steps: steps:
- name: Checkout - name: Checkout tagged revision
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
ref: ${{ github.ref }}
- name: Fetch and detect release tag - name: Checkout requested tag
id: detect-tag if: ${{ inputs.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 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
ref: refs/tags/${{ steps.resolve-version.outputs.tag }} ref: ${{ startsWith(inputs.tag, 'v') && format('refs/tags/{0}', inputs.tag) || format('refs/tags/v{0}', inputs.tag) }}
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:
go-version-file: go.mod go-version: '1.26.1'
check-latest: false check-latest: true
cache: true cache: true
cache-dependency-path: go.sum cache-dependency-path: go.sum
- name: Install UPX
uses: crazy-max/ghaction-upx@v3
with:
install-only: true
- name: Preflight release API access - name: Preflight release API access
env: env:
TAG_NAME: ${{ steps.resolve-version.outputs.tag }} REQUESTED_TAG: ${{ inputs.tag }}
run: | run: |
set -euo pipefail set -euo pipefail
if [[ -z "${RELEASE_TOKEN:-}" ]]; then if [[ -z "${RELEASE_TOKEN:-}" ]]; then
echo "No release token available. Set secrets.RELEASE_PAT." >&2 echo "No release token available. Set GITEA_TOKEN (or GITHUB_TOKEN on GitHub)." >&2
exit 1 exit 1
fi fi
@@ -151,17 +76,22 @@ jobs:
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"${repo_api}/releases?limit=1" >/dev/null "${repo_api}/releases?limit=1" >/dev/null
if ! git rev-parse --verify --quiet "refs/tags/${TAG_NAME}" >/dev/null; then requested_tag="$(printf '%s' "${REQUESTED_TAG:-}" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
echo "Tag ${TAG_NAME} was not found in the checked out repository." >&2 if [[ -n "$requested_tag" ]]; then
exit 1 normalized_tag="${requested_tag#v}"
tag_ref="refs/tags/v${normalized_tag}"
if ! git rev-parse --verify --quiet "$tag_ref" >/dev/null; then
echo "Requested tag ${tag_ref#refs/tags/} was not found in the checked out repository." >&2
exit 1
fi
fi fi
- name: Create or update release - name: Create or update release
id: publish id: publish
uses: ./publish uses: ./publish
with: with:
token: ${{ secrets.RELEASE_PAT }} token: ${{ secrets.GITHUB_TOKEN || secrets.GITEA_TOKEN }}
version: ${{ steps.resolve-version.outputs.version }} version: ${{ inputs.tag }}
- name: Build release binaries - name: Build release binaries
env: env:
@@ -169,15 +99,6 @@ jobs:
run: | run: |
set -euo pipefail 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 mkdir -p dist
for target in linux/amd64 linux/arm64; do for target in linux/amd64 linux/arm64; do
@@ -186,9 +107,6 @@ jobs:
bin="vociferate_${RELEASE_VERSION}_${os}_${arch}" bin="vociferate_${RELEASE_VERSION}_${os}_${arch}"
GOOS="$os" GOARCH="$arch" go build -trimpath -ldflags="-s -w" -o "dist/${bin}" ./cmd/vociferate 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 done
( (
@@ -203,27 +121,7 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
raw_release_id="$(printf '%s' "${RELEASE_ID:-}" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')" release_api="${GITHUB_API_URL:-${GITHUB_SERVER_URL%/}/api/v1}/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets"
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 for asset in dist/*; do
name="$(basename "$asset")" name="$(basename "$asset")"
@@ -252,32 +150,25 @@ jobs:
--data-binary "@${asset}" --data-binary "@${asset}"
done done
- name: Summary - name: Summarize published release
if: ${{ always() }}
env: env:
TAG_NAME: ${{ steps.publish.outputs.tag }} TAG_NAME: ${{ steps.publish.outputs.tag }}
RELEASE_VERSION: ${{ steps.publish.outputs.version }} RELEASE_VERSION: ${{ steps.publish.outputs.version }}
PUBLISH_OUTCOME: ${{ steps.publish.outcome }}
run: | run: |
set -euo pipefail set -euo pipefail
if [[ "${PUBLISH_OUTCOME}" == "success" ]]; then {
{ echo "## Release Published"
echo "## Release Published" echo
echo echo "- Tag: ${TAG_NAME}"
echo "- Tag: ${TAG_NAME}" echo "- Release notes sourced from changelog entry ${RELEASE_VERSION}."
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 "- Published assets: vociferate_${RELEASE_VERSION}_linux_amd64, vociferate_${RELEASE_VERSION}_linux_arm64, checksums.txt" } >> "$SUMMARY_FILE"
echo "- Release binaries are compressed with UPX from crazy-max/ghaction-upx@v3 when available, otherwise uploaded uncompressed."
} >> "$SUMMARY_FILE" - name: Summary
else if: ${{ always() }}
{ run: |
echo "## Release Failed" set -euo pipefail
echo
echo "- Tag: ${TAG_NAME:-unknown}"
echo "- Create or update release step did not complete successfully."
} >> "$SUMMARY_FILE"
fi
echo 'Summary' echo 'Summary'
echo echo
@@ -304,7 +195,7 @@ jobs:
run: run:
shell: bash shell: bash
env: env:
SUMMARY_FILE: ${{ runner.temp }}/update-release-validate-summary.md SUMMARY_FILE: ${{ runner.temp }}/do-release-validate-summary.md
steps: steps:
- name: Checkout tagged revision - name: Checkout tagged revision
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -320,7 +211,7 @@ jobs:
- name: Download released binary - name: Download released binary
env: env:
TOKEN: ${{ secrets.RELEASE_PAT }} TOKEN: ${{ secrets.GITHUB_TOKEN || secrets.GITEA_TOKEN }}
TAG_NAME: ${{ needs.release.outputs.tag }} TAG_NAME: ${{ needs.release.outputs.tag }}
RELEASE_VERSION: ${{ needs.release.outputs.version }} RELEASE_VERSION: ${{ needs.release.outputs.version }}
ASSET_ARCH: ${{ matrix.asset_arch }} ASSET_ARCH: ${{ matrix.asset_arch }}

View File

@@ -0,0 +1,150 @@
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: Validate formatting
run: test -z "$(gofmt -l .)"
- name: Module hygiene
run: |
set -euo pipefail
go mod tidy
go mod verify
- 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-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 }}
git-add-files: CHANGELOG.md release-version README.md AGENTS.md
- 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

View File

@@ -30,8 +30,8 @@ jobs:
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:
go-version-file: go.mod go-version: '1.26.1'
check-latest: false check-latest: true
cache: true cache: true
cache-dependency-path: go.sum cache-dependency-path: go.sum
@@ -41,15 +41,8 @@ jobs:
- name: Module hygiene - name: Module hygiene
run: | run: |
set -euo pipefail set -euo pipefail
go mod tidy go mod tidy
go mod verify
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 - name: Restore cached gosec binary
id: cache-gosec id: cache-gosec
@@ -73,8 +66,6 @@ jobs:
- name: Run govulncheck - name: Run govulncheck
uses: golang/govulncheck-action@v1.0.4 uses: golang/govulncheck-action@v1.0.4
with: with:
go-version-file: go.mod
check-latest: false
go-package: ./... go-package: ./...
cache: true cache: true
cache-dependency-path: go.sum cache-dependency-path: go.sum
@@ -124,8 +115,8 @@ jobs:
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:
go-version-file: go.mod go-version: '1.26.1'
check-latest: false check-latest: true
cache: true cache: true
cache-dependency-path: go.sum cache-dependency-path: go.sum

View File

@@ -1,466 +0,0 @@
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

View File

@@ -4,15 +4,15 @@ This guide is for agentic coding partners that need to integrate the composite a
## Source Of Truth ## Source Of Truth
Pin all action references to a released tag (for example `@v1.2.0`) and keep all vociferate references on the same tag in a workflow. 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: Published composite actions:
- `https://git.hrafn.xyz/aether/vociferate@v1.2.0` (root action) - `https://git.hrafn.xyz/aether/vociferate@v1.1.0` (root action)
- `https://git.hrafn.xyz/aether/vociferate/prepare@v1.2.0` - `https://git.hrafn.xyz/aether/vociferate/prepare@v1.1.0`
- `https://git.hrafn.xyz/aether/vociferate/publish@v1.2.0` - `https://git.hrafn.xyz/aether/vociferate/publish@v1.1.0`
- `https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.2.0` - `https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.1.0`
- `https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.2.0` - `https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.1.0`
## Action Selection Matrix ## Action Selection Matrix
@@ -30,8 +30,8 @@ Apply these checks before invoking actions:
- Checkout repository first. - Checkout repository first.
- For prepare/publish flows that depend on tags/history, use full history checkout (`fetch-depth: 0`). - 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. - Use valid credentials for release/comment API calls. On GitHub, `secrets.GITHUB_TOKEN` is used; on self-hosted Gitea, set `secrets.GITEA_TOKEN`.
- `release`, `update-release`, and `decorate-pr` run preflight API checks and fail fast when token credentials are missing or insufficient. - `do-release` and `decorate-pr` now run preflight API checks and fail fast when token credentials are missing or insufficient.
- Set required vars/secrets for coverage uploads: - Set required vars/secrets for coverage uploads:
- `vars.ARTEFACT_BUCKET_NAME` - `vars.ARTEFACT_BUCKET_NAME`
- `vars.ARTEFACT_BUCKET_ENDPONT` - `vars.ARTEFACT_BUCKET_ENDPONT`
@@ -83,26 +83,41 @@ Minimal template:
## Minimal Integration Patterns ## Minimal Integration Patterns
### 1. Full Release Workflow ### 1. Prepare Then Publish
```yaml ```yaml
jobs: jobs:
release: prepare:
uses: https://git.hrafn.xyz/aether/vociferate/.gitea/workflows/release.yml@v1.2.0 runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- id: prepare
uses: https://git.hrafn.xyz/aether/vociferate/prepare@v1.1.0
publish:
needs: prepare
uses: https://git.hrafn.xyz/aether/vociferate/.gitea/workflows/do-release.yml@v1.1.0
with: with:
version: ${{ inputs.version }} tag: ${{ needs.prepare.outputs.version }}
secrets: inherit secrets: inherit
``` ```
### 2. Update Existing Release Tag ### 2. Publish Existing Tag
```yaml ```yaml
jobs: jobs:
release: release:
uses: https://git.hrafn.xyz/aether/vociferate/.gitea/workflows/update-release.yml@v1.2.0 runs-on: ubuntu-latest
with: steps:
tag: v1.2.3 - uses: actions/checkout@v4
secrets: inherit with:
fetch-depth: 0
- id: publish
uses: https://git.hrafn.xyz/aether/vociferate/publish@v1.1.0
with:
version: v1.2.3
``` ```
### 3. Coverage Badge Publication ### 3. Coverage Badge Publication
@@ -121,7 +136,7 @@ jobs:
- name: Run tests with coverage - name: Run tests with coverage
run: go test -covermode=atomic -coverprofile=coverage.out ./... run: go test -covermode=atomic -coverprofile=coverage.out ./...
- id: badge - id: badge
uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.2.0 uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.1.0
with: with:
artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }} artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }}
artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }} artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }}
@@ -144,12 +159,12 @@ jobs:
- name: Run tests with coverage - name: Run tests with coverage
run: go test -covermode=atomic -coverprofile=coverage.out ./... run: go test -covermode=atomic -coverprofile=coverage.out ./...
- id: badge - id: badge
uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.2.0 uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.1.0
with: with:
artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }} artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }}
artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }} artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }}
- name: Decorate PR - name: Decorate PR
uses: https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.2.0 uses: https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.1.0
with: with:
coverage-percentage: ${{ steps.badge.outputs.total }} coverage-percentage: ${{ steps.badge.outputs.total }}
badge-url: ${{ steps.badge.outputs.badge-url }} badge-url: ${{ steps.badge.outputs.badge-url }}

View File

@@ -13,37 +13,6 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect
### Added ### Added
### Changed
### Removed
### Fixed
## [1.2.0] - 2026-03-21
### 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. - 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). - 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. - Docs-only PR exemption with customizable glob patterns for documentation files.
@@ -60,30 +29,18 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect
- `decorate-pr` now reads Unreleased changelog content through the `vociferate` Go CLI instead of maintaining separate shell parsing logic in the composite action. - `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. - `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`. - 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. - `run-vociferate/binary` and `run-vociferate/code` are now nested under `run-vociferate/` so callers reference them as `./run-vociferate/binary` and `./run-vociferate/code`.
- 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 ### Removed
### Fixed ### 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. - Fixed `decorate-pr/action.yml` YAML validation by extracting PR comment rendering into `decorate-pr/build-comment.sh`, removing the duplicated changelog extraction step, and correcting the gate failure output reference.
- 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 docs-only detection in `decorate-pr` changelog gate: file list was iterated in a piped subshell so `docs_only` never propagated to the parent scope; replaced pipe with process substitution.
- 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. - 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`. - 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`. - 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. - Fixed nested local composite-action references to use `./../run-vociferate` (instead of `../run-vociferate`) so strict runners that enforce `{org}/{repo}[/path]@ref` for non-local paths correctly classify them as local actions.
- 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 ## [1.0.2] - 2026-03-21
@@ -190,11 +147,9 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect
- Project/automation rename from `releaseprep` to `vociferate` (entrypoint, package paths, outputs). - Project/automation rename from `releaseprep` to `vociferate` (entrypoint, package paths, outputs).
- README guidance focused on primary cross-repository reuse workflows. - README guidance focused on primary cross-repository reuse workflows.
[Unreleased]: https://git.hrafn.xyz/aether/vociferate/compare/v1.2.0...main [Unreleased]: https://git.hrafn.xyz/aether/vociferate/compare/v1.0.2...main
[1.2.0]: https://git.hrafn.xyz/aether/vociferate/compare/v1.1.0...v1.2.0
[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.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.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 [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.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/995e397...v0.1.0 [0.1.0]: https://git.hrafn.xyz/aether/vociferate/compare/2060af6...v0.1.0

View File

@@ -154,8 +154,8 @@ if err != nil {
**Workflows analyzed:** **Workflows analyzed:**
- [push-validation.yml](.gitea/workflows/push-validation.yml) - [push-validation.yml](.gitea/workflows/push-validation.yml)
- [release.yml](.gitea/workflows/release.yml) - [prepare-release.yml](.gitea/workflows/prepare-release.yml)
- [update-release.yml](.gitea/workflows/update-release.yml) - [do-release.yml](.gitea/workflows/do-release.yml)
#### What's Implemented #### What's Implemented
@@ -171,7 +171,7 @@ if err != nil {
- ✅ Coverage badge publication - ✅ Coverage badge publication
- ✅ Release tag recommendation on `main` branch - ✅ Release tag recommendation on `main` branch
**release.yml:** **prepare-release.yml:**
- ✅ Go setup and caching - ✅ Go setup and caching
- ✅ Tests run before release preparation - ✅ Tests run before release preparation
@@ -361,7 +361,7 @@ validate
**Effort Invested:** **Effort Invested:**
- CI/CD improvements: workflow hardening in `push-validation.yml` and `release.yml` - CI/CD improvements: workflow hardening in `push-validation.yml` and `prepare-release.yml`
- Code organization: injected service boundaries for filesystem, environment, and git access - Code organization: injected service boundaries for filesystem, environment, and git access
- Local automation: `justfile` validation parity for format, modules, tests, and security - Local automation: `justfile` validation parity for format, modules, tests, and security
- **Primary commits:** 7cb7b05, 383aad4, 5c903c9 - **Primary commits:** 7cb7b05, 383aad4, 5c903c9

View File

@@ -1,8 +1,8 @@
# vociferate # vociferate
[![Main Validation](https://git.hrafn.xyz/aether/vociferate/actions/workflows/push-validation.yml/badge.svg?branch=main&event=push)](https://git.hrafn.xyz/aether/vociferate/actions/runs/latest?workflow=push-validation.yml&branch=main&event=push) [![Main Validation](https://git.hrafn.xyz/aether/vociferate/actions/workflows/push-validation.yml/badge.svg?branch=main&event=push)](https://git.hrafn.xyz/aether/vociferate/actions/runs/latest?workflow=push-validation.yml&branch=main&event=push)
[![Release](https://git.hrafn.xyz/aether/vociferate/actions/workflows/release.yml/badge.svg?event=workflow_dispatch)](https://git.hrafn.xyz/aether/vociferate/actions/runs/latest?workflow=release.yml) [![Prepare Release](https://git.hrafn.xyz/aether/vociferate/actions/workflows/prepare-release.yml/badge.svg?event=workflow_dispatch)](https://git.hrafn.xyz/aether/vociferate/actions/runs/latest?workflow=prepare-release.yml)
[![Update Release](https://git.hrafn.xyz/aether/vociferate/actions/workflows/update-release.yml/badge.svg?event=workflow_dispatch)](https://git.hrafn.xyz/aether/vociferate/actions/runs/latest?workflow=update-release.yml) [![Do Release](https://git.hrafn.xyz/aether/vociferate/actions/workflows/do-release.yml/badge.svg?event=push)](https://git.hrafn.xyz/aether/vociferate/actions/runs/latest?workflow=do-release.yml)
[![Coverage](https://s3.hrafn.xyz/aether-workflow-report-artefacts/vociferate/branch/main/coverage-badge.svg)](https://s3.hrafn.xyz/aether-workflow-report-artefacts/vociferate/branch/main/coverage.html) [![Coverage](https://s3.hrafn.xyz/aether-workflow-report-artefacts/vociferate/branch/main/coverage-badge.svg)](https://s3.hrafn.xyz/aether-workflow-report-artefacts/vociferate/branch/main/coverage.html)
`vociferate` is an `Æther` release orchestration tool written in Go for repositories that `vociferate` is an `Æther` release orchestration tool written in Go for repositories that
@@ -17,14 +17,14 @@ revision.
## Use In Other Repositories ## Use In Other Repositories
Vociferate ships composite actions covering release preparation, release publication, coverage badge publishing, and pull request decoration. Vociferate ships composite actions covering release preparation, release publication, coverage badge publishing, and pull request decoration.
Release tags now exist; pin all action and reusable-workflow references to the same released tag (for example, `@v1.2.0`) instead of `@main`. 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. 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: Release name: Prepare Release
on: on:
workflow_dispatch: workflow_dispatch:
@@ -41,13 +41,13 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: https://git.hrafn.xyz/aether/vociferate/prepare@v1.2.0 - 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: https://git.hrafn.xyz/aether/vociferate/.gitea/workflows/update-release.yml@v1.2.0 uses: https://git.hrafn.xyz/aether/vociferate/.gitea/workflows/do-release.yml@v1.1.0
with: with:
tag: ${{ needs.prepare.outputs.version }} tag: ${{ needs.prepare.outputs.version }}
secrets: inherit secrets: inherit
@@ -61,21 +61,20 @@ For repositories that embed the version inside source code, pass `version-file`
and `version-pattern`: and `version-pattern`:
```yaml ```yaml
- uses: https://git.hrafn.xyz/aether/vociferate/prepare@v1.2.0 - 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` requires a PAT input for authenticated commit/push/tag operations. `prepare` uses `github.token` internally for authenticated fetch/push operations,
Pass `token: ${{ secrets.RELEASE_PAT }}` when invoking the action. so no token input is required.
### `publish` — create release with changelog notes ### `publish` — create release with changelog notes
```yaml ```yaml
name: Update Release name: Do Release
on: on:
workflow_dispatch: workflow_dispatch:
@@ -86,7 +85,7 @@ on:
jobs: jobs:
release: release:
uses: https://git.hrafn.xyz/aether/vociferate/.gitea/workflows/update-release.yml@v1.2.0 uses: https://git.hrafn.xyz/aether/vociferate/.gitea/workflows/do-release.yml@v1.1.0
with: with:
tag: ${{ inputs.tag }} tag: ${{ inputs.tag }}
secrets: inherit secrets: inherit
@@ -96,21 +95,22 @@ 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 The reusable `Do Release` workflow now runs preflight checks before publish to
fail fast when the release token is missing or lacks API access. Set fail fast when the release token is missing or lacks API access. On
`secrets.RELEASE_PAT` and use it for prepare/publish release operations. self-hosted Gitea, set `secrets.GITEA_TOKEN`; on GitHub, `secrets.GITHUB_TOKEN`
is used automatically.
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: https://git.hrafn.xyz/aether/vociferate/publish@v1.2.0 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.RELEASE_PAT }}" \ -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-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"
@@ -125,7 +125,7 @@ Run your coverage tests first, then call the action to generate `coverage.html`,
run: go test -covermode=atomic -coverprofile=coverage.out ./... run: go test -covermode=atomic -coverprofile=coverage.out ./...
- id: coverage - id: coverage
uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.2.0 uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.1.0
with: with:
artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }} artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }}
artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }} artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }}
@@ -150,14 +150,14 @@ with a clear message when token permissions are insufficient.
run: go test -covermode=atomic -coverprofile=coverage.out ./... run: go test -covermode=atomic -coverprofile=coverage.out ./...
- id: coverage - id: coverage
uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.2.0 uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.1.0
with: with:
artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }} artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }}
artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }} artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }}
- name: Decorate pull request - name: Decorate pull request
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
uses: https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.2.0 uses: https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.1.0
with: with:
coverage-percentage: ${{ steps.coverage.outputs.total }} coverage-percentage: ${{ steps.coverage.outputs.total }}
badge-url: ${{ steps.coverage.outputs.badge-url }} badge-url: ${{ steps.coverage.outputs.badge-url }}
@@ -170,7 +170,7 @@ Enable changelog validation to enforce that code changes include `Unreleased` ch
```yaml ```yaml
- name: Decorate pull request with changelog gate - name: Decorate pull request with changelog gate
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
uses: https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.2.0 uses: https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.1.0
with: with:
coverage-percentage: ${{ steps.coverage.outputs.total }} coverage-percentage: ${{ steps.coverage.outputs.total }}
badge-url: ${{ steps.coverage.outputs.badge-url }} badge-url: ${{ steps.coverage.outputs.badge-url }}
@@ -196,7 +196,7 @@ Decision outputs enable downstream workflow logic:
- name: Decorate PR and check gate - name: Decorate PR and check gate
id: decorate id: decorate
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
uses: https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.2.0 uses: https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.1.0
with: with:
coverage-percentage: ${{ steps.coverage.outputs.total }} coverage-percentage: ${{ steps.coverage.outputs.total }}
badge-url: ${{ steps.coverage.outputs.badge-url }} badge-url: ${{ steps.coverage.outputs.badge-url }}

View File

@@ -1,85 +0,0 @@
# 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.

View File

@@ -1,91 +0,0 @@
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

View File

@@ -1,3 +0,0 @@
module git.hrafn.xyz/aether/vociferate/coverage-gate
go 1.26.1

View File

@@ -1,26 +0,0 @@
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)
}
}

View File

@@ -1,118 +0,0 @@
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
}

View File

@@ -1,85 +0,0 @@
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())
}
}

View File

@@ -1,235 +0,0 @@
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
}

View File

@@ -1,119 +0,0 @@
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)
}
}

View File

@@ -164,7 +164,7 @@ runs:
- name: Extract changelog unreleased entries - name: Extract changelog unreleased entries
id: extract-changelog id: extract-changelog
if: steps.changelog-file.outputs.exists == 'true' if: steps.changelog-file.outputs.exists == 'true'
uses: ./run-vociferate uses: ./../run-vociferate
with: with:
root: ${{ github.workspace }} root: ${{ github.workspace }}
changelog: ${{ inputs.changelog }} changelog: ${{ inputs.changelog }}

View File

@@ -42,11 +42,6 @@ inputs:
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:
@@ -71,7 +66,7 @@ runs:
- name: Recommend version - name: Recommend version
id: recommend-version id: recommend-version
if: steps.normalize-version.outputs.value == '' if: steps.normalize-version.outputs.value == ''
uses: ./run-vociferate uses: ./../run-vociferate
with: with:
root: ${{ github.workspace }} root: ${{ github.workspace }}
version-file: ${{ inputs.version-file }} version-file: ${{ inputs.version-file }}
@@ -107,7 +102,7 @@ runs:
printf 'value=%s\n' "$(date -u +%F)" >> "$GITHUB_OUTPUT" printf 'value=%s\n' "$(date -u +%F)" >> "$GITHUB_OUTPUT"
- name: Prepare release files - name: Prepare release files
uses: ./run-vociferate uses: ./../run-vociferate
with: with:
root: ${{ github.workspace }} root: ${{ github.workspace }}
version-file: ${{ inputs.version-file }} version-file: ${{ inputs.version-file }}
@@ -119,7 +114,7 @@ runs:
- name: Commit and push release - name: Commit and push release
shell: bash shell: bash
env: env:
TOKEN: ${{ inputs.token }} TOKEN: ${{ github.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 }}
@@ -129,11 +124,6 @@ runs:
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"

View File

@@ -7,9 +7,10 @@ description: >
inputs: inputs:
token: token:
description: > description: >
Personal access token used to authenticate release API calls. Token used to authenticate release API calls. Defaults to the
Required to support release updates across workflow boundaries. workflow token.
required: true required: false
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,
@@ -56,7 +57,6 @@ 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
@@ -65,7 +65,7 @@ runs:
- name: Extract release notes - name: Extract release notes
id: extract-notes id: extract-notes
uses: ./run-vociferate uses: ./../run-vociferate
with: with:
root: ${{ github.workspace }} root: ${{ github.workspace }}
changelog: ${{ inputs.changelog }} changelog: ${{ inputs.changelog }}
@@ -90,66 +90,41 @@ runs:
id: create-release id: create-release
shell: bash shell: bash
env: env:
TOKEN: ${{ inputs.token }} TOKEN: ${{ inputs.token != '' && inputs.token || github.token }}
TAG_NAME: ${{ steps.resolve-version.outputs.tag }} TAG_NAME: ${{ steps.resolve-version.outputs.tag }}
RELEASE_NOTES_FILE: ${{ steps.write-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 ${api_token}" \ -H "Authorization: token ${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="$(parse_release_id release-existing.json)" existing_release_id="$(sed -n 's/.*"id"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' release-existing.json | head -n 1)"
if [[ -z "$existing_release_id" ]]; then 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
if ! curl --fail-with-body \ curl --fail-with-body \
-X PATCH \ -X PATCH \
-H "Authorization: token ${api_token}" \ -H "Authorization: token ${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}\",\"name\":\"${TAG_NAME}\",\"body\":\"${escaped_release_notes}\",\"draft\":false,\"prerelease\":false}" \ --data "{\"tag_name\":\"${TAG_NAME}\",\"target\":\"${GITHUB_SHA}\",\"name\":\"${TAG_NAME}\",\"body\":\"${escaped_release_notes}\",\"draft\":false,\"prerelease\":false}" \
--output release.json; then --output release.json
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
@@ -157,18 +132,15 @@ runs:
cat release-existing.json >&2 cat release-existing.json >&2
exit 1 exit 1
else else
if ! curl --fail-with-body \ curl --fail-with-body \
-X POST \ -X POST \
-H "Authorization: token ${api_token}" \ -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"${release_api}" \ "${release_api}" \
--data "{\"tag_name\":\"${TAG_NAME}\",\"name\":\"${TAG_NAME}\",\"body\":\"${escaped_release_notes}\",\"draft\":false,\"prerelease\":false}" \ --data "{\"tag_name\":\"${TAG_NAME}\",\"target\":\"${GITHUB_SHA}\",\"name\":\"${TAG_NAME}\",\"body\":\"${escaped_release_notes}\",\"draft\":false,\"prerelease\":false}" \
--output release.json; then --output release.json
cat release.json >&2 || true
exit 1
fi
release_id="$(parse_release_id release.json)" release_id="$(sed -n 's/.*"id"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' release.json | head -n 1)"
if [[ -z "$release_id" ]]; then 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

View File

@@ -1 +1 @@
1.2.0 1.0.2

View File

@@ -63,198 +63,35 @@ runs:
printf 'use_binary=false\n' >> "$GITHUB_OUTPUT" printf 'use_binary=false\n' >> "$GITHUB_OUTPUT"
fi 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 - name: Run binary
id: run-binary id: run-binary
if: steps.resolve-runtime.outputs.use_binary == 'true' if: steps.resolve-runtime.outputs.use_binary == 'true'
shell: bash uses: ./binary
env: with:
VOCIFERATE_BIN: ${{ steps.resolve-binary.outputs.binary_path }} root: ${{ inputs.root }}
ROOT: ${{ inputs.root }} version-file: ${{ inputs.version-file }}
VERSION_FILE: ${{ inputs.version-file }} version-pattern: ${{ inputs.version-pattern }}
VERSION_PATTERN: ${{ inputs.version-pattern }} changelog: ${{ inputs.changelog }}
CHANGELOG: ${{ inputs.changelog }} version: ${{ inputs.version }}
VERSION: ${{ inputs.version }} date: ${{ inputs.date }}
DATE: ${{ inputs.date }} recommend: ${{ inputs.recommend }}
RECOMMEND: ${{ inputs.recommend }} print-unreleased: ${{ inputs.print-unreleased }}
PRINT_UNRELEASED: ${{ inputs.print-unreleased }} print-release-notes: ${{ inputs.print-release-notes }}
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 - name: Run source
id: run-code id: run-code
if: steps.resolve-runtime.outputs.use_binary != 'true' if: steps.resolve-runtime.outputs.use_binary != 'true'
shell: bash uses: ./code
env: with:
ROOT: ${{ inputs.root }} root: ${{ inputs.root }}
VERSION_FILE: ${{ inputs.version-file }} version-file: ${{ inputs.version-file }}
VERSION_PATTERN: ${{ inputs.version-pattern }} version-pattern: ${{ inputs.version-pattern }}
CHANGELOG: ${{ inputs.changelog }} changelog: ${{ inputs.changelog }}
VERSION: ${{ inputs.version }} version: ${{ inputs.version }}
DATE: ${{ inputs.date }} date: ${{ inputs.date }}
RECOMMEND: ${{ inputs.recommend }} recommend: ${{ inputs.recommend }}
PRINT_UNRELEASED: ${{ inputs.print-unreleased }} print-unreleased: ${{ inputs.print-unreleased }}
PRINT_RELEASE_NOTES: ${{ inputs.print-release-notes }} 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 - name: Finalize stdout
id: finalize id: finalize

View File

@@ -0,0 +1,176 @@
name: vociferate/run-vociferate-binary
description: Execute vociferate through a released binary.
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 vociferate invocation.
value: ${{ steps.run.outputs.stdout }}
runs:
using: composite
steps:
- name: Resolve binary metadata
id: resolve-binary
shell: bash
env:
ACTION_REF: ${{ github.action_ref }}
ACTION_REPOSITORY: ${{ github.action_repository }}
CACHE_TOKEN: ${{ env.VOCIFERATE_CACHE_TOKEN }}
SERVER_URL: ${{ github.server_url }}
RUNNER_ARCH: ${{ runner.arch }}
RUNNER_TEMP: ${{ runner.temp }}
run: |
set -euo pipefail
if [[ "$ACTION_REF" != v* ]]; then
echo "run-vociferate.binary 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
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.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
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"

View File

@@ -0,0 +1,125 @@
name: vociferate/run-vociferate-code
description: Execute vociferate from the checked-out Go source.
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 vociferate invocation.
value: ${{ steps.run.outputs.stdout }}
runs:
using: composite
steps:
- name: Resolve source root
id: resolve-source
shell: bash
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
printf 'source_root=%s\n' "$source_root" >> "$GITHUB_OUTPUT"
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.26.1'
cache: true
cache-dependency-path: ${{ steps.resolve-source.outputs.source_root }}/go.sum
- name: Run source
id: run
shell: bash
working-directory: ${{ steps.resolve-source.outputs.source_root }}
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
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
"${command[@]}" > "$stdout_file"
delimiter="EOF_STDOUT"
printf 'stdout<<%s\n' "$delimiter" >> "$GITHUB_OUTPUT"
cat "$stdout_file" >> "$GITHUB_OUTPUT"
printf '%s\n' "$delimiter" >> "$GITHUB_OUTPUT"