12 Commits
v1.1.0 ... main

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
15 changed files with 916 additions and 92 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
( (
@@ -329,6 +346,7 @@ jobs:
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"
echo "- Release binaries are compressed with UPX from crazy-max/ghaction-upx@v3 when available, otherwise uploaded uncompressed."
} >> "$SUMMARY_FILE" } >> "$SUMMARY_FILE"
else else
{ {

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.1.0`) 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.1.0` (root action) - `https://git.hrafn.xyz/aether/vociferate@v1.2.0` (root action)
- `https://git.hrafn.xyz/aether/vociferate/prepare@v1.1.0` - `https://git.hrafn.xyz/aether/vociferate/prepare@v1.2.0`
- `https://git.hrafn.xyz/aether/vociferate/publish@v1.1.0` - `https://git.hrafn.xyz/aether/vociferate/publish@v1.2.0`
- `https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.1.0` - `https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.2.0`
- `https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.1.0` - `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.1.0
publish:
needs: prepare
uses: https://git.hrafn.xyz/aether/vociferate/.gitea/workflows/do-release.yml@v1.1.0
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.1.0 ### 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.1.0 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.1.0 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.1.0 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

@@ -19,6 +19,25 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect
### Fixed ### 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 ## [1.1.0] - 2026-03-21
### Breaking ### Breaking
@@ -43,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
@@ -62,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
@@ -168,10 +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.1.0...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.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/81dced6...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.1.0`) 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.1.0 - 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.1.0 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.1.0 - 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.1.0 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.1.0 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.1.0 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.1.0 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.1.0 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.1.0 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.1.0 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

@@ -1 +1 @@
1.1.0 1.2.0