19 Commits

Author SHA1 Message Date
gitea-actions[bot]
45bb09af27 release: prepare v1.2.0 2026-03-21 23:15:26 +00:00
Micheal Wilkinson
995e397bff docs: update upx fallback note
All checks were successful
Push Validation / coverage-badge (push) Successful in 1m24s
Push Validation / recommend-release (push) Successful in 28s
2026-03-21 23:12:30 +00:00
Micheal Wilkinson
8bf7184479 chore(workflows): install upx via ghaction-upx 2026-03-21 23:12:30 +00:00
Micheal Wilkinson
41918cd5de docs: note containerized upx fallback
All checks were successful
Push Validation / coverage-badge (push) Successful in 1m23s
Push Validation / recommend-release (push) Successful in 25s
2026-03-21 23:02:35 +00:00
Micheal Wilkinson
0cec30c9bb chore(workflows): add container upx fallback 2026-03-21 23:02:29 +00:00
Micheal Wilkinson
24dd65da67 docs: log upx fallback in release workflows
All checks were successful
Push Validation / coverage-badge (push) Successful in 1m18s
Push Validation / recommend-release (push) Successful in 25s
2026-03-21 23:00:08 +00:00
Micheal Wilkinson
1ab56b0536 chore(workflows): allow release builds without upx 2026-03-21 23:00:05 +00:00
Micheal Wilkinson
6919061240 docs: log coverage-gate security hardening
Some checks failed
Push Validation / recommend-release (push) Has been cancelled
Push Validation / coverage-badge (push) Has been cancelled
2026-03-21 22:47:04 +00:00
Micheal Wilkinson
7b739e04c8 chore(go): harden coverage-gate file input handling 2026-03-21 22:47:02 +00:00
Micheal Wilkinson
98ea91f2df docs: log coverage-gate extraction to Unreleased
Some checks failed
Push Validation / coverage-badge (push) Failing after 32s
Push Validation / recommend-release (push) Has been skipped
2026-03-21 22:39:21 +00:00
Micheal Wilkinson
532f6a98d8 feat: extract coverage-gate action from Cue for reuse across projects
Some checks failed
Push Validation / coverage-badge (push) Failing after 44s
Push Validation / recommend-release (push) Has been skipped
- Move coveragegate tool from cue/tools to vociferate/coverage-gate
- Create composite action with JSON metrics output for CI
- Update tool to export passes/total_coverage/packages_checked/packages_failed
- Support per-package threshold policy via JSON configuration
- Change module path to git.hrafn.xyz/aether/vociferate/coverage-gate
2026-03-21 22:34:01 +00:00
Micheal Wilkinson
a3e2b4e44e refactor(release): rename workflows and align update-release path
All checks were successful
Push Validation / coverage-badge (push) Successful in 1m30s
Push Validation / recommend-release (push) Successful in 23s
Rename the reusable workflows to release.yml and update-release.yml,
add UPX compression for release binaries, and sync the standalone
update-release workflow with the active release pipeline fixes.
Update README, AGENTS, compliance notes, and changelog references to
match the new workflow names and usage patterns.
2026-03-21 20:26:13 +00:00
gitea-actions[bot]
f82dace4b2 release: prepare v1.1.0 2026-03-21 20:16:35 +00:00
Micheal Wilkinson
81dced6ada fix(publish): avoid heredoc parsing in composite json helper
All checks were successful
Push Validation / coverage-badge (push) Successful in 1m53s
Push Validation / recommend-release (push) Successful in 28s
Replace the embedded python heredoc with python3 -c so the composite
action remains valid YAML and shell across teacup parsing.
2026-03-21 20:14:49 +00:00
Micheal Wilkinson
62693935d0 fix(release): parse release id robustly and validate upload endpoint
All checks were successful
Push Validation / coverage-badge (push) Successful in 1m36s
Push Validation / recommend-release (push) Successful in 47s
Use JSON parsing for release id extraction in publish action instead of
regex matching, preventing wrong id selection from nested fields.
Add a pre-upload release endpoint check to fail early with explicit
release URL diagnostics when the resolved id/path is invalid.
2026-03-21 20:07:45 +00:00
Micheal Wilkinson
c0b5ec385c fix(release): normalize wrapped release-id before asset upload
Some checks failed
Push Validation / recommend-release (push) Has been cancelled
Push Validation / coverage-badge (push) Has been cancelled
Teacup can wrap workflow outputs as %touch docker-compose.yml(string=...), which produced an
invalid releases/{id}/assets URL and a 404 in Upload release binaries.
Unwrap and validate release-id before building the API path.
2026-03-21 20:03:00 +00:00
Micheal Wilkinson
84f6fbcfc8 fix(release): unwrap teacup token inputs and correct failure summary
Some checks failed
Push Validation / recommend-release (push) Has been cancelled
Push Validation / coverage-badge (push) Has been cancelled
Normalize %touch docker-compose.yml(string=...) wrapped token values in publish composite before
API calls. This prevents malformed Authorization headers under teacup.
Also only print 'Release Published' summary when the publish step succeeds,
and print a failure summary otherwise.
2026-03-21 19:51:26 +00:00
Micheal Wilkinson
4a2d234ba3 fix(publish): stop sending target field in release payload
Some checks failed
Push Validation / recommend-release (push) Has been cancelled
Push Validation / coverage-badge (push) Has been cancelled
Prepare already creates and pushes the release tag, so publish should not
retarget it. Sending target can trigger 403 on Gitea when tag retargeting
is restricted. Build PATCH/POST payloads from tag_name + notes only.
2026-03-21 19:46:53 +00:00
Micheal Wilkinson
4841b04076 fix(publish): use tag's actual commit SHA for release target
Some checks failed
Push Validation / recommend-release (push) Has been cancelled
Push Validation / coverage-badge (push) Has been cancelled
GITHUB_SHA (github.sha) reflects the workflow trigger commit, which is
the main HEAD before the prepare job ran. The tag itself points to the
release commit created by prepare — a different SHA. Gitea rejects PATCH
and POST with 403 when target_commitish doesn't match the tag's commit.

Use git rev-list -n 1 TAG to resolve the exact SHA the tag points to,
ensuring the target field is always correct regardless of when or how
the release workflow is called.
2026-03-21 19:39:17 +00:00
16 changed files with 1003 additions and 113 deletions

View File

@@ -1,4 +1,4 @@
name: Prepare Release name: Release
on: on:
workflow_dispatch: workflow_dispatch:
@@ -24,7 +24,7 @@ jobs:
run: run:
shell: bash shell: bash
env: env:
SUMMARY_FILE: ${{ runner.temp }}/prepare-release-summary.md SUMMARY_FILE: ${{ runner.temp }}/release-prepare-summary.md
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -39,6 +39,11 @@ jobs:
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: Validate formatting - name: Validate formatting
run: test -z "$(gofmt -l .)" run: test -z "$(gofmt -l .)"
@@ -242,6 +247,15 @@ 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
@@ -250,6 +264,9 @@ 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
( (
@@ -264,7 +281,27 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
release_api="${GITHUB_API_URL:-${GITHUB_SERVER_URL%/}/api/v1}/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets" 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 for asset in dist/*; do
name="$(basename "$asset")" name="$(basename "$asset")"
@@ -298,16 +335,27 @@ jobs:
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 echo "## Release Published"
echo "- Tag: ${TAG_NAME}" echo
echo "- Release notes sourced from changelog entry ${RELEASE_VERSION}." echo "- Tag: ${TAG_NAME}"
echo "- Published assets: vociferate_${RELEASE_VERSION}_linux_amd64, vociferate_${RELEASE_VERSION}_linux_arm64, checksums.txt" echo "- Release notes sourced from changelog entry ${RELEASE_VERSION}."
} >> "$SUMMARY_FILE" 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 'Summary'
echo echo

View File

@@ -1,4 +1,4 @@
name: Do Release name: Update Release
on: on:
push: push:
@@ -29,7 +29,7 @@ jobs:
shell: bash shell: bash
env: env:
RELEASE_TOKEN: ${{ secrets.RELEASE_PAT }} RELEASE_TOKEN: ${{ secrets.RELEASE_PAT }}
SUMMARY_FILE: ${{ runner.temp }}/do-release-summary.md SUMMARY_FILE: ${{ runner.temp }}/update-release-summary.md
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -59,7 +59,6 @@ jobs:
id: resolve-version id: resolve-version
env: env:
INPUT_TAG: ${{ inputs.tag }} INPUT_TAG: ${{ inputs.tag }}
CALLER_TAG: ${{ needs.prepare.outputs.tag }}
DETECTED_TAG: ${{ steps.detect-tag.outputs.detected_tag }} DETECTED_TAG: ${{ steps.detect-tag.outputs.detected_tag }}
run: | run: |
set -euo pipefail set -euo pipefail
@@ -79,16 +78,10 @@ jobs:
} }
input_tag="$(normalize_candidate "${INPUT_TAG}")" input_tag="$(normalize_candidate "${INPUT_TAG}")"
caller_tag="$(normalize_candidate "${CALLER_TAG}")"
detected_tag="$(normalize_candidate "${DETECTED_TAG}")" detected_tag="$(normalize_candidate "${DETECTED_TAG}")"
# Try explicit input first. # Try explicit input first.
requested_tag="$input_tag" requested_tag="$input_tag"
# Fall back to caller workflow output when workflow_call input forwarding is unreliable.
if [[ -z "$requested_tag" && -n "$caller_tag" ]]; then
requested_tag="$caller_tag"
fi
# Fall back to detected tag if neither input nor caller tag is available. # Fall back to detected tag if neither input nor caller tag is available.
if [[ -z "$requested_tag" && -n "$detected_tag" ]]; then if [[ -z "$requested_tag" && -n "$detected_tag" ]]; then
@@ -107,7 +100,6 @@ jobs:
else else
echo "Error: Could not resolve release version" >&2 echo "Error: Could not resolve release version" >&2
echo " - inputs.tag(raw): '$INPUT_TAG'" >&2 echo " - inputs.tag(raw): '$INPUT_TAG'" >&2
echo " - needs.prepare.outputs.tag(raw): '$CALLER_TAG'" >&2
echo " - detected_tag(raw): '${DETECTED_TAG}'" >&2 echo " - detected_tag(raw): '${DETECTED_TAG}'" >&2
echo " - GITHUB_REF: '$GITHUB_REF'" >&2 echo " - GITHUB_REF: '$GITHUB_REF'" >&2
exit 1 exit 1
@@ -130,6 +122,11 @@ jobs:
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 }} TAG_NAME: ${{ steps.resolve-version.outputs.tag }}
@@ -172,6 +169,15 @@ 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
@@ -180,6 +186,9 @@ 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
( (
@@ -194,7 +203,27 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
release_api="${GITHUB_API_URL:-${GITHUB_SERVER_URL%/}/api/v1}/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets" 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 for asset in dist/*; do
name="$(basename "$asset")" name="$(basename "$asset")"
@@ -223,25 +252,32 @@ jobs:
--data-binary "@${asset}" --data-binary "@${asset}"
done done
- name: Summarize published release - name: Summary
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 echo "## Release Published"
echo "- Tag: ${TAG_NAME}" echo
echo "- Release notes sourced from changelog entry ${RELEASE_VERSION}." echo "- Tag: ${TAG_NAME}"
echo "- Published assets: vociferate_${RELEASE_VERSION}_linux_amd64, vociferate_${RELEASE_VERSION}_linux_arm64, checksums.txt" echo "- Release notes sourced from changelog entry ${RELEASE_VERSION}."
} >> "$SUMMARY_FILE" 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."
- name: Summary } >> "$SUMMARY_FILE"
if: ${{ always() }} else
run: | {
set -euo pipefail 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 'Summary'
echo echo
@@ -268,7 +304,7 @@ jobs:
run: run:
shell: bash shell: bash
env: env:
SUMMARY_FILE: ${{ runner.temp }}/do-release-validate-summary.md SUMMARY_FILE: ${{ runner.temp }}/update-release-validate-summary.md
steps: steps:
- name: Checkout tagged revision - name: Checkout tagged revision
uses: actions/checkout@v4 uses: actions/checkout@v4

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.0.2`) and keep all vociferate references on the same tag in a workflow. 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.
Published composite actions: Published composite actions:
- `https://git.hrafn.xyz/aether/vociferate@v1.0.2` (root action) - `https://git.hrafn.xyz/aether/vociferate@v1.2.0` (root action)
- `https://git.hrafn.xyz/aether/vociferate/prepare@v1.0.2` - `https://git.hrafn.xyz/aether/vociferate/prepare@v1.2.0`
- `https://git.hrafn.xyz/aether/vociferate/publish@v1.0.2` - `https://git.hrafn.xyz/aether/vociferate/publish@v1.2.0`
- `https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.0.2` - `https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.2.0`
- `https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.0.2` - `https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.2.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/do-release) so tag pushes trigger downstream workflows reliably. - Use `secrets.RELEASE_PAT` for release/tag/update operations (`prepare`, `publish`, `release`, `update-release`) so authenticated release changes can be pushed and published reliably.
- `do-release` and `decorate-pr` now run preflight API checks and fail fast when token credentials are missing or insufficient. - `release`, `update-release`, and `decorate-pr` run preflight API checks and fail fast when token credentials are missing or insufficient.
- Set required vars/secrets for coverage uploads: - Set required vars/secrets for coverage uploads:
- `vars.ARTEFACT_BUCKET_NAME` - `vars.ARTEFACT_BUCKET_NAME`
- `vars.ARTEFACT_BUCKET_ENDPONT` - `vars.ARTEFACT_BUCKET_ENDPONT`
@@ -83,41 +83,26 @@ Minimal template:
## Minimal Integration Patterns ## Minimal Integration Patterns
### 1. Prepare Then Publish ### 1. Full Release Workflow
```yaml
jobs:
prepare:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- id: prepare
uses: https://git.hrafn.xyz/aether/vociferate/prepare@v1.0.2
publish:
needs: prepare
uses: https://git.hrafn.xyz/aether/vociferate/.gitea/workflows/do-release.yml@v1.0.2
with:
tag: ${{ needs.prepare.outputs.version }}
secrets: inherit
```
### 2. Publish Existing Tag
```yaml ```yaml
jobs: jobs:
release: release:
runs-on: ubuntu-latest uses: https://git.hrafn.xyz/aether/vociferate/.gitea/workflows/release.yml@v1.2.0
steps: with:
- uses: actions/checkout@v4 version: ${{ inputs.version }}
with: secrets: inherit
fetch-depth: 0 ```
- id: publish
uses: https://git.hrafn.xyz/aether/vociferate/publish@v1.0.2 ### 2. Update Existing Release Tag
with:
version: v1.2.3 ```yaml
jobs:
release:
uses: https://git.hrafn.xyz/aether/vociferate/.gitea/workflows/update-release.yml@v1.2.0
with:
tag: v1.2.3
secrets: inherit
``` ```
### 3. Coverage Badge Publication ### 3. Coverage Badge Publication
@@ -136,7 +121,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.0.2 uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.2.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 }}
@@ -159,12 +144,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.0.2 uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.2.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.0.2 uses: https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.2.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,6 +13,37 @@ 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.
@@ -31,6 +62,8 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect
- 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` now contains both binary and source execution flows directly in a single action implementation, removing nested local action wrappers for better runner compatibility.
- Release automation now requires `secrets.RELEASE_PAT` for prepare/publish/do-release operations instead of defaulting to `GITHUB_TOKEN`/`GITEA_TOKEN`. - 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
@@ -50,6 +83,7 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect
- 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 repository-local `./run-vociferate` paths so strict runners do not misparse parent-directory (`../`) action references as malformed remote coordinates.
- Consolidated `run-vociferate` binary and source execution flows directly into the main `run-vociferate` action to avoid nested local-action path resolution issues on strict runners. - 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. - 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
@@ -156,9 +190,11 @@ 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.0.2...main [Unreleased]: https://git.hrafn.xyz/aether/vociferate/compare/v1.2.0...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/2060af6...v0.1.0 [0.1.0]: https://git.hrafn.xyz/aether/vociferate/compare/995e397...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)
- [prepare-release.yml](.gitea/workflows/prepare-release.yml) - [release.yml](.gitea/workflows/release.yml)
- [do-release.yml](.gitea/workflows/do-release.yml) - [update-release.yml](.gitea/workflows/update-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
**prepare-release.yml:** **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 `prepare-release.yml` - CI/CD improvements: workflow hardening in `push-validation.yml` and `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)
[![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) [![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)
[![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) [![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)
[![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.0.2`) instead of `@main`. Release tags now exist; pin all action and reusable-workflow references to the same released tag (for example, `@v1.2.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: Prepare Release name: 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.0.2 - uses: https://git.hrafn.xyz/aether/vociferate/prepare@v1.2.0
with: with:
version: ${{ inputs.version }} version: ${{ inputs.version }}
publish: publish:
needs: prepare needs: prepare
uses: https://git.hrafn.xyz/aether/vociferate/.gitea/workflows/do-release.yml@v1.0.2 uses: https://git.hrafn.xyz/aether/vociferate/.gitea/workflows/update-release.yml@v1.2.0
with: with:
tag: ${{ needs.prepare.outputs.version }} tag: ${{ needs.prepare.outputs.version }}
secrets: inherit secrets: inherit
@@ -61,7 +61,7 @@ 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.0.2 - uses: https://git.hrafn.xyz/aether/vociferate/prepare@v1.2.0
with: with:
token: ${{ secrets.RELEASE_PAT }} token: ${{ secrets.RELEASE_PAT }}
version-file: internal/myapp/version/version.go version-file: internal/myapp/version/version.go
@@ -75,7 +75,7 @@ Pass `token: ${{ secrets.RELEASE_PAT }}` when invoking the action.
### `publish` — create release with changelog notes ### `publish` — create release with changelog notes
```yaml ```yaml
name: Do Release name: Update Release
on: on:
workflow_dispatch: workflow_dispatch:
@@ -86,7 +86,7 @@ on:
jobs: jobs:
release: release:
uses: https://git.hrafn.xyz/aether/vociferate/.gitea/workflows/do-release.yml@v1.0.2 uses: https://git.hrafn.xyz/aether/vociferate/.gitea/workflows/update-release.yml@v1.2.0
with: with:
tag: ${{ inputs.tag }} tag: ${{ inputs.tag }}
secrets: inherit secrets: inherit
@@ -96,7 +96,7 @@ 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 `Do Release` workflow now runs preflight checks before publish to The reusable `Update Release` workflow now runs preflight checks before publish to
fail fast when the release token is missing or lacks API access. Set fail fast when the release token is missing or lacks API access. Set
`secrets.RELEASE_PAT` and use it for prepare/publish release operations. `secrets.RELEASE_PAT` and use it for prepare/publish release operations.
@@ -105,7 +105,7 @@ assets after it runs:
```yaml ```yaml
- id: publish - id: publish
uses: https://git.hrafn.xyz/aether/vociferate/publish@v1.0.2 uses: https://git.hrafn.xyz/aether/vociferate/publish@v1.2.0
- name: Upload my binary - name: Upload my binary
run: | run: |
@@ -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.0.2 uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.2.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.0.2 uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.2.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.0.2 uses: https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.2.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.0.2 uses: https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.2.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.0.2 uses: https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.2.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 }}

85
coverage-gate/README.md Normal file
View File

@@ -0,0 +1,85 @@
# coveragegate
Standalone coverage-threshold enforcement tool for this repository.
This tool is a quality gate. It is not part of Cue runtime orchestration.
## What it does
- Reads a Go coverage profile (for example `_build/coverage.out`).
- Loads package coverage policy from JSON.
- Discovers packages under a source root using `go list ./...`.
- Evaluates per-package statement coverage against policy thresholds.
- Prints a package table and returns a non-zero exit code when any package fails.
## Repository integration
Primary repository flow:
1. `just test-coverage` runs tests in `src/` and writes `_build/coverage.out`.
2. `scripts/check-core-coverage.sh` runs this tool from `tools/coveragegate/`.
3. The script currently passes:
- `--profile $ROOT_DIR/_build/coverage.out`
- `--policy $ROOT_DIR/docs/coverage-thresholds.json`
- `--src-root $ROOT_DIR/src`
## Usage
From repository root:
```bash
cd tools/coveragegate
go run . \
--profile ../../_build/coverage.out \
--policy ../../docs/coverage-thresholds.json \
--src-root ../../src
```
Or use the repository wrapper:
```bash
bash scripts/check-core-coverage.sh
```
## Flags
- `--profile`: Path to Go coverage profile.
- `--policy`: Path to JSON policy file.
- `--src-root`: Directory where packages are discovered with `go list ./...`.
## Exit codes
- `0`: All in-scope packages meet threshold.
- `1`: Policy/profile/load failure or one or more packages below threshold.
- `2`: Invalid CLI arguments.
## Policy model (current)
The tool expects a JSON object with at least:
- `minimum_statement_coverage` (number)
- `critical_packages` (array)
Each critical package may include:
- `package` (string)
- `minimum_statement_coverage` (number)
- `include` (boolean)
- `exclusions` (array of strings)
Behavior notes:
- If a package has no policy override, the global minimum is used.
- Generated/composition files are excluded by built-in rules.
- Packages with no statements are treated as passing.
## Development
Run tests:
```bash
cd tools/coveragegate
go test ./...
```
Keep code `gofmt` and `go vet` clean.

91
coverage-gate/action.yml Normal file
View File

@@ -0,0 +1,91 @@
name: vociferate/coverage-gate
description: >
Enforce per-package code coverage thresholds against Go coverage profiles.
Supports JSON policy files with per-package overrides and global minimums.
inputs:
profile:
description: Path to Go coverage profile file (output from `go test -coverprofile=...`).
required: false
default: coverage.out
policy:
description: Path to JSON file defining coverage thresholds and per-package overrides.
required: false
default: docs/coverage-thresholds.json
src-root:
description: Source root directory for package discovery (passed to `go list ./...`).
required: false
default: .
summary-file:
description: Optional file path to append markdown summary of coverage results.
required: false
default: ''
outputs:
passed:
description: 'Boolean: true if all packages meet threshold, false if any failed.'
value: ${{ steps.gate.outputs.passed }}
total-coverage:
description: Repository-wide statement coverage percentage.
value: ${{ steps.gate.outputs.total_coverage }}
packages-checked:
description: Number of packages evaluated against policy.
value: ${{ steps.gate.outputs.packages_checked }}
packages-failed:
description: Number of packages below threshold.
value: ${{ steps.gate.outputs.packages_failed }}
runs:
using: composite
steps:
- id: gate
shell: bash
working-directory: ${{ github.action_path }}
env:
PROFILE: ${{ inputs.profile }}
POLICY: ${{ inputs.policy }}
SRC_ROOT: ${{ inputs.src-root }}
SUMMARY_FILE: ${{ inputs.summary-file }}
run: |
set -euo pipefail
# Run coverage gate and capture output
EXIT_CODE=0
OUTPUT=$(go run . \
--profile "$PROFILE" \
--policy "$POLICY" \
--src-root "$SRC_ROOT" \
) || EXIT_CODE=$?
echo "$OUTPUT"
# Parse summary from output (tool prints JSON stats on last line)
SUMMARY_LINE=$(echo "$OUTPUT" | tail -1)
# Determine pass/fail
if [[ $EXIT_CODE -eq 0 ]]; then
echo "passed=true" >> "$GITHUB_OUTPUT"
else
echo "passed=false" >> "$GITHUB_OUTPUT"
fi
# Extract metrics (tool outputs: packages_checked, packages_failed, total_coverage on summary line)
if echo "$SUMMARY_LINE" | jq . &>/dev/null; then
echo "total_coverage=$(echo "$SUMMARY_LINE" | jq -r '.total_coverage')" >> "$GITHUB_OUTPUT"
echo "packages_checked=$(echo "$SUMMARY_LINE" | jq -r '.packages_checked')" >> "$GITHUB_OUTPUT"
echo "packages_failed=$(echo "$SUMMARY_LINE" | jq -r '.packages_failed')" >> "$GITHUB_OUTPUT"
# Append to summary file if provided
if [[ -n "$SUMMARY_FILE" ]]; then
{
echo "## Coverage Gate Results"
echo
echo "- **Passed:** $([ "$EXIT_CODE" -eq 0 ] && echo '✓ Yes' || echo '✗ No')"
echo "- **Total Coverage:** $(echo "$SUMMARY_LINE" | jq -r '.total_coverage')%"
echo "- **Packages Checked:** $(echo "$SUMMARY_LINE" | jq -r '.packages_checked')"
echo "- **Packages Failed:** $(echo "$SUMMARY_LINE" | jq -r '.packages_failed')"
} >> "$SUMMARY_FILE"
fi
fi
exit $EXIT_CODE

3
coverage-gate/go.mod Normal file
View File

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

View File

@@ -0,0 +1,26 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestRun_ExitCodeOnInvalidProfile(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
policyPath := filepath.Join(tmp, "policy.json")
if err := os.WriteFile(policyPath, []byte(`{"minimum_statement_coverage":80,"critical_packages":[]}`), 0600); err != nil {
t.Fatalf("write policy: %v", err)
}
exit := run(
[]string{"--profile", filepath.Join(tmp, "missing.out"), "--policy", policyPath, "--src-root", "."},
os.Stdout,
os.Stderr,
func(_ string) ([]string, error) { return []string{"git.hrafn.xyz/aether/cue/service/llm"}, nil },
)
if exit != 1 {
t.Fatalf("expected exit 1 for missing profile, got %d", exit)
}
}

118
coverage-gate/main.go Normal file
View File

@@ -0,0 +1,118 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"io"
"os"
"os/exec"
"sort"
"strings"
)
type discoverPackagesFunc func(srcRoot string) ([]string, error)
func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr, discoverPackages))
}
func run(args []string, stdout io.Writer, stderr io.Writer, discover discoverPackagesFunc) int {
fs := flag.NewFlagSet("coveragegate", flag.ContinueOnError)
fs.SetOutput(stderr)
profilePath := fs.String("profile", "../_build/coverage.out", "path to go coverprofile")
policyPath := fs.String("policy", "../specs/003-testing-time/contracts/coverage-thresholds.json", "path to coverage policy json")
srcRoot := fs.String("src-root", ".", "path to src workspace root")
if err := fs.Parse(args); err != nil {
return 2
}
policy, err := LoadPolicy(*policyPath)
if err != nil {
fmt.Fprintf(stderr, "coverage gate: %v\n", err)
return 1
}
aggByPkg, err := ParseCoverProfile(*profilePath, policy)
if err != nil {
fmt.Fprintf(stderr, "coverage gate: %v\n", err)
return 1
}
pkgs, err := discover(*srcRoot)
if err != nil {
fmt.Fprintf(stderr, "coverage gate: %v\n", err)
return 1
}
results := EvaluateCoverage(pkgs, aggByPkg, policy)
if len(results) == 0 {
fmt.Fprintln(stderr, "coverage gate: no in-scope packages found")
return 1
}
fmt.Fprintln(stdout, "Package coverage results:")
fmt.Fprintln(stdout, "PACKAGE\tCOVERAGE\tTHRESHOLD\tSTATUS")
failed := false
totalCoverage := 0.0
for _, r := range results {
status := "PASS"
if !r.Pass {
status = "FAIL"
failed = true
}
fmt.Fprintf(stdout, "%s\t%.2f%%\t%.2f%%\t%s\n", r.Package, r.Percent, r.Threshold, status)
totalCoverage += r.Percent
}
if len(results) > 0 {
totalCoverage /= float64(len(results))
}
packagesFailed := 0
for _, r := range results {
if !r.Pass {
packagesFailed++
}
}
// Output JSON metrics for CI consumption
metrics := map[string]interface{}{
"passed": !failed,
"total_coverage": fmt.Sprintf("%.2f", totalCoverage),
"packages_checked": len(results),
"packages_failed": packagesFailed,
}
metricsJSON, _ := json.Marshal(metrics)
fmt.Fprintln(stdout, string(metricsJSON))
if failed {
fmt.Fprintln(stderr, "coverage gate: one or more packages are below threshold")
return 1
}
fmt.Fprintln(stdout, "coverage gate: all in-scope packages meet threshold")
return 0
}
func discoverPackages(srcRoot string) ([]string, error) {
cmd := exec.Command("go", "list", "./...")
cmd.Dir = srcRoot
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("discover packages with go list: %w", err)
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
pkgs := make([]string, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
pkgs = append(pkgs, line)
}
sort.Strings(pkgs)
return pkgs, nil
}

View File

@@ -0,0 +1,85 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
func TestRun_FailsWhenBelowThreshold(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
policyPath := filepath.Join(tmp, "policy.json")
profilePath := filepath.Join(tmp, "coverage.out")
policy := `{
"minimum_statement_coverage": 80,
"critical_packages": []
}`
if err := os.WriteFile(policyPath, []byte(policy), 0600); err != nil {
t.Fatalf("write policy: %v", err)
}
profile := "mode: set\n" +
"git.hrafn.xyz/aether/cue/service/llm/validator.go:1.1,2.1 10 0\n"
if err := os.WriteFile(profilePath, []byte(profile), 0600); err != nil {
t.Fatalf("write profile: %v", err)
}
var out bytes.Buffer
var errOut bytes.Buffer
exit := run(
[]string{"--profile", profilePath, "--policy", policyPath, "--src-root", "."},
&out,
&errOut,
func(_ string) ([]string, error) {
return []string{"git.hrafn.xyz/aether/cue/service/llm"}, nil
},
)
if exit != 1 {
t.Fatalf("expected exit 1, got %d\nstdout: %s\nstderr: %s", exit, out.String(), errOut.String())
}
if !strings.Contains(errOut.String(), "below threshold") {
t.Fatalf("expected threshold error, got: %s", errOut.String())
}
}
func TestRun_PassesWhenAllMeetThreshold(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
policyPath := filepath.Join(tmp, "policy.json")
profilePath := filepath.Join(tmp, "coverage.out")
policy := `{
"minimum_statement_coverage": 80,
"critical_packages": []
}`
if err := os.WriteFile(policyPath, []byte(policy), 0600); err != nil {
t.Fatalf("write policy: %v", err)
}
profile := "mode: set\n" +
"git.hrafn.xyz/aether/cue/service/llm/validator.go:1.1,2.1 10 1\n"
if err := os.WriteFile(profilePath, []byte(profile), 0600); err != nil {
t.Fatalf("write profile: %v", err)
}
var out bytes.Buffer
var errOut bytes.Buffer
exit := run(
[]string{"--profile", profilePath, "--policy", policyPath, "--src-root", "."},
&out,
&errOut,
func(_ string) ([]string, error) {
return []string{"git.hrafn.xyz/aether/cue/service/llm"}, nil
},
)
if exit != 0 {
t.Fatalf("expected exit 0, got %d\nstdout: %s\nstderr: %s", exit, out.String(), errOut.String())
}
if !strings.Contains(out.String(), "all in-scope packages meet threshold") {
t.Fatalf("expected success summary, got: %s", out.String())
}
}

235
coverage-gate/parse.go Normal file
View File

@@ -0,0 +1,235 @@
package main
import (
"bufio"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
)
// Policy describes coverage threshold configuration.
type Policy struct {
MinimumStatementCoverage float64 `json:"minimum_statement_coverage"`
CriticalPackages []PackagePolicy `json:"critical_packages"`
}
// PackagePolicy overrides defaults for a specific package.
type PackagePolicy struct {
Package string `json:"package"`
MinimumStatementCoverage float64 `json:"minimum_statement_coverage"`
Include bool `json:"include"`
Exclusions []string `json:"exclusions"`
}
// Coverage aggregates covered and total statements for a package.
type Coverage struct {
Covered int64
Total int64
}
// PackageResult is the policy-evaluated coverage result for one package.
type PackageResult struct {
Package string
Covered int64
Total int64
Percent float64
Threshold float64
Pass bool
}
// LoadPolicy reads policy JSON from disk.
func LoadPolicy(path string) (Policy, error) {
f, err := openValidatedReadOnlyFile(path, ".json", "policy")
if err != nil {
return Policy{}, err
}
defer f.Close()
var p Policy
if err := json.NewDecoder(f).Decode(&p); err != nil {
return Policy{}, fmt.Errorf("decode policy: %w", err)
}
if p.MinimumStatementCoverage <= 0 {
p.MinimumStatementCoverage = 80.0
}
return p, nil
}
// ParseCoverProfile parses a Go coverprofile and aggregates package coverage.
func ParseCoverProfile(profilePath string, policy Policy) (map[string]Coverage, error) {
f, err := openValidatedReadOnlyFile(profilePath, "", "coverage profile")
if err != nil {
return nil, err
}
defer f.Close()
coverage := make(map[string]Coverage)
s := bufio.NewScanner(f)
lineNo := 0
for s.Scan() {
lineNo++
line := strings.TrimSpace(s.Text())
if lineNo == 1 {
if !strings.HasPrefix(line, "mode:") {
return nil, fmt.Errorf("invalid coverage profile header")
}
continue
}
if line == "" {
continue
}
parts := strings.Fields(line)
if len(parts) != 3 {
return nil, fmt.Errorf("invalid coverage line %d", lineNo)
}
fileAndRange := parts[0]
numStmts, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid statements count at line %d: %w", lineNo, err)
}
execCount, err := strconv.ParseInt(parts[2], 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid execution count at line %d: %w", lineNo, err)
}
idx := strings.Index(fileAndRange, ":")
if idx < 0 {
return nil, fmt.Errorf("invalid file segment at line %d", lineNo)
}
filePath := fileAndRange[:idx]
pkg := filepath.ToSlash(filepath.Dir(filePath))
if isExcludedFile(pkg, filePath, policy) {
continue
}
agg := coverage[pkg]
agg.Total += numStmts
if execCount > 0 {
agg.Covered += numStmts
}
coverage[pkg] = agg
}
if err := s.Err(); err != nil {
return nil, fmt.Errorf("scan coverage profile: %w", err)
}
return coverage, nil
}
func openValidatedReadOnlyFile(path string, requiredExt string, label string) (*os.File, error) {
cleaned := filepath.Clean(strings.TrimSpace(path))
if cleaned == "" || cleaned == "." {
return nil, fmt.Errorf("invalid %s path", label)
}
if requiredExt != "" {
ext := strings.ToLower(filepath.Ext(cleaned))
if ext != strings.ToLower(requiredExt) {
return nil, fmt.Errorf("invalid %s file extension: got %q, want %q", label, ext, requiredExt)
}
}
absPath, err := filepath.Abs(cleaned)
if err != nil {
return nil, fmt.Errorf("resolve %s path: %w", label, err)
}
info, err := os.Stat(absPath)
if err != nil {
return nil, fmt.Errorf("stat %s: %w", label, err)
}
if info.IsDir() {
return nil, fmt.Errorf("%s path must be a file, got directory", label)
}
// #nosec G304 -- path is explicitly cleaned, normalized, and pre-validated as an existing file.
f, err := os.Open(absPath)
if err != nil {
return nil, fmt.Errorf("open %s: %w", label, err)
}
return f, nil
}
// EvaluateCoverage evaluates package coverage against policy thresholds.
func EvaluateCoverage(packages []string, byPackage map[string]Coverage, policy Policy) []PackageResult {
results := make([]PackageResult, 0, len(packages))
for _, pkg := range packages {
if !isPackageIncluded(pkg, policy) {
continue
}
agg := byPackage[pkg]
percent := 100.0
if agg.Total > 0 {
percent = float64(agg.Covered) * 100.0 / float64(agg.Total)
}
threshold := thresholdForPackage(pkg, policy)
pass := agg.Total == 0 || percent >= threshold
results = append(results, PackageResult{
Package: pkg,
Covered: agg.Covered,
Total: agg.Total,
Percent: percent,
Threshold: threshold,
Pass: pass,
})
}
sort.Slice(results, func(i, j int) bool {
return results[i].Package < results[j].Package
})
return results
}
func thresholdForPackage(pkg string, policy Policy) float64 {
for _, entry := range policy.CriticalPackages {
if entry.Package == pkg && entry.MinimumStatementCoverage > 0 {
return entry.MinimumStatementCoverage
}
}
if policy.MinimumStatementCoverage > 0 {
return policy.MinimumStatementCoverage
}
return 80.0
}
func isPackageIncluded(pkg string, policy Policy) bool {
for _, entry := range policy.CriticalPackages {
if entry.Package == pkg {
return entry.Include
}
}
return true
}
func isExcludedFile(pkg string, filePath string, policy Policy) bool {
base := filepath.Base(filePath)
// Exclude known generated artifacts and thin composition wiring.
if strings.HasSuffix(base, "_gen.go") ||
base == "generated.go" ||
base == "models_gen.go" ||
base == "schema.resolvers.go" ||
base == "main.go" {
return true
}
for _, entry := range policy.CriticalPackages {
if entry.Package != pkg {
continue
}
for _, ex := range entry.Exclusions {
if ex == "" {
continue
}
if strings.HasSuffix(filePath, ex) || strings.Contains(filePath, "/"+ex) {
return true
}
}
}
return false
}

119
coverage-gate/parse_test.go Normal file
View File

@@ -0,0 +1,119 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestParseCoverProfile_AppliesExclusions(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
profile := filepath.Join(tmp, "coverage.out")
content := "mode: set\n" +
"git.hrafn.xyz/aether/cue/api/graph/generated.go:1.1,2.1 2 1\n" +
"git.hrafn.xyz/aether/cue/api/graph/resolver.go:1.1,2.1 2 1\n" +
"git.hrafn.xyz/aether/cue/service/llm/validator.go:1.1,2.1 2 0\n"
if err := os.WriteFile(profile, []byte(content), 0600); err != nil {
t.Fatalf("write profile: %v", err)
}
policy := Policy{
MinimumStatementCoverage: 80,
CriticalPackages: []PackagePolicy{
{Package: "git.hrafn.xyz/aether/cue/api/graph", Include: true, Exclusions: []string{"generated.go"}},
},
}
got, err := ParseCoverProfile(profile, policy)
if err != nil {
t.Fatalf("ParseCoverProfile() error = %v", err)
}
api := got["git.hrafn.xyz/aether/cue/api/graph"]
if api.Total != 2 || api.Covered != 2 {
t.Fatalf("api coverage mismatch: got %+v", api)
}
llm := got["git.hrafn.xyz/aether/cue/service/llm"]
if llm.Total != 2 || llm.Covered != 0 {
t.Fatalf("llm coverage mismatch: got %+v", llm)
}
}
func TestEvaluateCoverage_UsesPolicyThresholds(t *testing.T) {
t.Parallel()
pkgs := []string{
"git.hrafn.xyz/aether/cue/service/llm",
"git.hrafn.xyz/aether/cue/service/orchestrator",
}
byPkg := map[string]Coverage{
"git.hrafn.xyz/aether/cue/service/llm": {Covered: 8, Total: 10},
"git.hrafn.xyz/aether/cue/service/orchestrator": {Covered: 3, Total: 10},
}
policy := Policy{
MinimumStatementCoverage: 80,
CriticalPackages: []PackagePolicy{
{Package: "git.hrafn.xyz/aether/cue/service/orchestrator", MinimumStatementCoverage: 30, Include: true},
},
}
results := EvaluateCoverage(pkgs, byPkg, policy)
if len(results) != 2 {
t.Fatalf("expected 2 results, got %d", len(results))
}
if !results[0].Pass {
t.Fatalf("expected llm to pass at default threshold: %+v", results[0])
}
if !results[1].Pass {
t.Fatalf("expected orchestrator to pass at overridden threshold: %+v", results[1])
}
}
func TestEvaluateCoverage_NoStatementsPasses(t *testing.T) {
t.Parallel()
pkg := "git.hrafn.xyz/aether/cue/repository/vector"
results := EvaluateCoverage(
[]string{pkg},
map[string]Coverage{pkg: {Covered: 0, Total: 0}},
Policy{MinimumStatementCoverage: 80},
)
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
if !results[0].Pass {
t.Fatalf("expected pass for no-statement package, got %+v", results[0])
}
}
func TestLoadPolicy_RejectsNonJSONPath(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
policyPath := filepath.Join(tmp, "policy.yaml")
if err := os.WriteFile(policyPath, []byte("minimum_statement_coverage: 80\n"), 0600); err != nil {
t.Fatalf("write policy file: %v", err)
}
_, err := LoadPolicy(policyPath)
if err == nil {
t.Fatal("expected LoadPolicy to fail for non-json extension")
}
if !strings.Contains(err.Error(), "invalid policy file extension") {
t.Fatalf("expected extension error, got: %v", err)
}
}
func TestParseCoverProfile_RejectsDirectoryPath(t *testing.T) {
t.Parallel()
_, err := ParseCoverProfile(t.TempDir(), Policy{MinimumStatementCoverage: 80})
if err == nil {
t.Fatal("expected ParseCoverProfile to fail for directory path")
}
if !strings.Contains(err.Error(), "coverage profile path must be a file") {
t.Fatalf("expected directory path error, got: %v", err)
}
}

View File

@@ -95,12 +95,29 @@ runs:
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
if [[ -z "${TOKEN:-}" ]]; then 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 echo "inputs.token is required (set to secrets.RELEASE_PAT)." >&2
exit 1 exit 1
fi fi
@@ -111,25 +128,28 @@ runs:
release_by_tag_api="${release_api}/tags/${TAG_NAME}" release_by_tag_api="${release_api}/tags/${TAG_NAME}"
status_code="$(curl -sS -o release-existing.json -w '%{http_code}' \ status_code="$(curl -sS -o release-existing.json -w '%{http_code}' \
-H "Authorization: token ${TOKEN}" \ -H "Authorization: token ${api_token}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"${release_by_tag_api}")" "${release_by_tag_api}")"
if [[ "$status_code" == "200" ]]; then if [[ "$status_code" == "200" ]]; then
existing_release_id="$(sed -n 's/.*"id"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' release-existing.json | head -n 1)" existing_release_id="$(parse_release_id release-existing.json)"
if [[ -z "$existing_release_id" ]]; then if [[ -z "$existing_release_id" ]]; then
echo "Failed to parse existing release id for ${TAG_NAME}" >&2 echo "Failed to parse existing release id for ${TAG_NAME}" >&2
cat release-existing.json >&2 cat release-existing.json >&2
exit 1 exit 1
fi fi
curl --fail-with-body \ if ! curl --fail-with-body \
-X PATCH \ -X PATCH \
-H "Authorization: token ${TOKEN}" \ -H "Authorization: token ${api_token}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"${release_api}/${existing_release_id}" \ "${release_api}/${existing_release_id}" \
--data "{\"tag_name\":\"${TAG_NAME}\",\"target\":\"${GITHUB_SHA}\",\"name\":\"${TAG_NAME}\",\"body\":\"${escaped_release_notes}\",\"draft\":false,\"prerelease\":false}" \ --data "{\"tag_name\":\"${TAG_NAME}\",\"name\":\"${TAG_NAME}\",\"body\":\"${escaped_release_notes}\",\"draft\":false,\"prerelease\":false}" \
--output release.json --output release.json; then
cat release.json >&2 || true
exit 1
fi
echo "id=$existing_release_id" >> "$GITHUB_OUTPUT" echo "id=$existing_release_id" >> "$GITHUB_OUTPUT"
elif [[ "$status_code" != "404" ]]; then elif [[ "$status_code" != "404" ]]; then
@@ -137,15 +157,18 @@ runs:
cat release-existing.json >&2 cat release-existing.json >&2
exit 1 exit 1
else else
curl --fail-with-body \ if ! curl --fail-with-body \
-X POST \ -X POST \
-H "Authorization: token ${TOKEN}" \ -H "Authorization: token ${api_token}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"${release_api}" \ "${release_api}" \
--data "{\"tag_name\":\"${TAG_NAME}\",\"target\":\"${GITHUB_SHA}\",\"name\":\"${TAG_NAME}\",\"body\":\"${escaped_release_notes}\",\"draft\":false,\"prerelease\":false}" \ --data "{\"tag_name\":\"${TAG_NAME}\",\"name\":\"${TAG_NAME}\",\"body\":\"${escaped_release_notes}\",\"draft\":false,\"prerelease\":false}" \
--output release.json --output release.json; then
cat release.json >&2 || true
exit 1
fi
release_id="$(sed -n 's/.*"id"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' release.json | head -n 1)" release_id="$(parse_release_id release.json)"
if [[ -z "$release_id" ]]; then if [[ -z "$release_id" ]]; then
echo "Failed to parse release id from API response" >&2 echo "Failed to parse release id from API response" >&2
cat release.json >&2 cat release.json >&2

View File

@@ -1 +1 @@
1.0.2 1.2.0