102 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
Micheal Wilkinson
993768ae9b refactor(release): inline release and validate jobs into prepare-release
All checks were successful
Push Validation / coverage-badge (push) Successful in 1m24s
Push Validation / recommend-release (push) Successful in 42s
Replaces the workflow_call to do-release with directly inlined release
and validate jobs. All steps now appear flat in the Actions UI with full
individual step visibility instead of being collapsed under a setup job.

Tag resolution in the release job is simplified: the tag always comes
from needs.prepare.outputs.tag, removing the detect-tag guessing needed
for standalone dispatch.

do-release.yml is unchanged and remains available for manual dispatch.
2026-03-21 19:30:53 +00:00
Micheal Wilkinson
624b9d154c fix(release): re-enable workflow_call publish path in prepare-release
All checks were successful
Push Validation / coverage-badge (push) Successful in 1m18s
Push Validation / recommend-release (push) Successful in 23s
Tag push events never fire on this Gitea 1.25.x instance (confirmed
across 159 workflow run history). The workflow_call path is reliable and
has worked consistently. Remove the temporary if-false guard.
2026-03-21 19:23:31 +00:00
Micheal Wilkinson
53a097784e chore(workflows): use go-version-file for setup-go
All checks were successful
Push Validation / coverage-badge (push) Successful in 1m14s
Push Validation / recommend-release (push) Successful in 23s
2026-03-21 16:26:16 +00:00
Micheal Wilkinson
511110f466 chore(release): temporarily disable inline do-release call
All checks were successful
Push Validation / coverage-badge (push) Successful in 1m10s
Push Validation / recommend-release (push) Successful in 26s
Disable the publish job in prepare-release to validate that tag push triggers do-release automatically via workflow trigger path.
2026-03-21 16:19:22 +00:00
Micheal Wilkinson
d5170b6874 fix(release): require RELEASE_PAT for tag and release updates
All checks were successful
Push Validation / coverage-badge (push) Successful in 1m11s
Push Validation / recommend-release (push) Successful in 24s
Stop using GITHUB_TOKEN/GITEA_TOKEN fallbacks in prepare/do-release/publish mutation paths. Require explicit PAT wiring via secrets.RELEASE_PAT for commit/push/tag and release update operations so downstream workflows trigger reliably.
2026-03-21 16:17:17 +00:00
Micheal Wilkinson
925c99bb9e docs: note govulncheck api limit workflow fix
All checks were successful
Push Validation / coverage-badge (push) Successful in 1m20s
Push Validation / recommend-release (push) Successful in 22s
2026-03-21 16:08:51 +00:00
Micheal Wilkinson
d65af508a3 chore(go): avoid setup-go stable resolution in ci 2026-03-21 16:08:49 +00:00
Micheal Wilkinson
eae70bb20f fix(workflows): prefer caller tag in do-release resolution
Some checks failed
Push Validation / coverage-badge (push) Failing after 38s
Push Validation / recommend-release (push) Has been skipped
Handle Teacup workflow_call input forwarding gaps by using needs.prepare.outputs.tag as a fallback and normalizing %touch docker-compose.yml(string=...) wrappers before selecting the release tag.
2026-03-21 16:04:26 +00:00
Micheal Wilkinson
c96cab58ff docs: document HEAD-prioritized tag detection fix
Some checks failed
Push Validation / recommend-release (push) Has been cancelled
Push Validation / coverage-badge (push) Has been cancelled
2026-03-21 15:58:57 +00:00
Micheal Wilkinson
a6d57e4048 fix(workflows): prioritize HEAD tag detection over global latest tag
When prepare-release tags HEAD with a new release version, do-release should
immediately detect that tag rather than finding the latest tag chronologically.

Changes:
- Modified detect-tag step to check if HEAD is exactly at a tag first
- Falls back to latest tag only if HEAD is not tagged
- Fixes issue where v1.0.2 was detected instead of v1.1.0 at HEAD

This ensures correct version detection in prepare-release → do-release workflow chain.
2026-03-21 15:58:25 +00:00
Micheal Wilkinson
cb52dd909d docs: record tag detection fallback approach for workflow_call resilience
All checks were successful
Push Validation / coverage-badge (push) Successful in 1m21s
Push Validation / recommend-release (push) Successful in 27s
2026-03-21 15:53:05 +00:00
Micheal Wilkinson
acca6adacc fix(release): add tag detection fallback for workflow_call input issues 2026-03-21 15:53:02 +00:00
Micheal Wilkinson
dc4aeb1e51 docs: record tag-fetching improvement for workflow_call version resolution
Some checks failed
Push Validation / coverage-badge (push) Successful in 1m16s
Push Validation / recommend-release (push) Has been cancelled
2026-03-21 15:46:51 +00:00
Micheal Wilkinson
ea1b333da3 fix(release): fetch tags before version resolution to support workflow_call from prepare-release 2026-03-21 15:46:47 +00:00
Micheal Wilkinson
eb8bd80d48 docs: record version resolution fix in do-release workflow
All checks were successful
Push Validation / coverage-badge (push) Successful in 1m18s
Push Validation / recommend-release (push) Successful in 23s
2026-03-21 15:40:18 +00:00
Micheal Wilkinson
cddcf99873 fix(release): resolve version before publish to support workflow_call context 2026-03-21 15:40:16 +00:00
Micheal Wilkinson
bef39120d3 docs: record module hygiene retry behavior
All checks were successful
Push Validation / coverage-badge (push) Successful in 1m32s
Push Validation / recommend-release (push) Successful in 26s
2026-03-21 15:33:27 +00:00
Micheal Wilkinson
ad3d657db9 fix(ci): self-heal module cache on verify failure 2026-03-21 15:33:27 +00:00
Micheal Wilkinson
27a058a3ce docs: record run-vociferate consolidation
Some checks failed
Push Validation / coverage-badge (push) Failing after 35s
Push Validation / recommend-release (push) Has been skipped
2026-03-21 15:31:49 +00:00
Micheal Wilkinson
0d4310184e refactor(actions): inline run-vociferate binary and source flows 2026-03-21 15:31:49 +00:00
Micheal Wilkinson
0fbd7641c0 docs: record run-vociferate nested path fix
Some checks failed
Push Validation / coverage-badge (push) Failing after 33s
Push Validation / recommend-release (push) Has been skipped
2026-03-21 15:29:37 +00:00
Micheal Wilkinson
60a0e82587 fix(actions): use repo-root nested paths in run-vociferate 2026-03-21 15:29:37 +00:00
Micheal Wilkinson
1a67d8b0e1 docs: record repo-local action path fix
All checks were successful
Push Validation / coverage-badge (push) Successful in 1m39s
Push Validation / recommend-release (push) Successful in 27s
2026-03-21 15:26:29 +00:00
Micheal Wilkinson
1a78209408 fix(actions): use repo-local run-vociferate paths 2026-03-21 15:26:29 +00:00
Micheal Wilkinson
c05a1c48cb docs: record local action path syntax fix
All checks were successful
Push Validation / coverage-badge (push) Successful in 1m15s
Push Validation / recommend-release (push) Successful in 30s
2026-03-21 15:22:37 +00:00
Micheal Wilkinson
32327c6d72 fix(actions): mark nested run-vociferate refs as local paths 2026-03-21 15:22:37 +00:00
Micheal Wilkinson
72abf37b2d docs: record gosec cache restoration
All checks were successful
Push Validation / coverage-badge (push) Successful in 2m5s
Push Validation / recommend-release (push) Successful in 38s
2026-03-21 15:15:06 +00:00
Micheal Wilkinson
5bea62b8cf fix(ci): restore cached gosec binary in workflows 2026-03-21 15:15:06 +00:00
Micheal Wilkinson
dd86944e64 docs: record gosec toolchain fix 2026-03-21 15:14:01 +00:00
Micheal Wilkinson
38afdeffa0 fix(ci): run gosec via go install to use setup-go toolchain 2026-03-21 15:14:00 +00:00
Micheal Wilkinson
f9c57f34d0 docs: record GOTOOLCHAIN fix
Some checks failed
Push Validation / coverage-badge (push) Failing after 27s
Push Validation / recommend-release (push) Has been skipped
2026-03-21 15:10:44 +00:00
Micheal Wilkinson
5793a58888 fix(ci): add GOTOOLCHAIN=auto to gosec and govulncheck steps 2026-03-21 15:10:44 +00:00
Micheal Wilkinson
2177dae15f docs: correct govulncheck-action version in changelog
Some checks failed
Push Validation / coverage-badge (push) Failing after 46s
Push Validation / recommend-release (push) Has been skipped
2026-03-21 15:05:23 +00:00
Micheal Wilkinson
76508355be fix(ci): correct govulncheck-action tag to v1.0.4 2026-03-21 15:05:23 +00:00
Micheal Wilkinson
f069c116a1 docs: record gosec and govulncheck-action version pin
Some checks failed
Push Validation / coverage-badge (push) Failing after 16s
Push Validation / recommend-release (push) Has been skipped
2026-03-21 15:00:39 +00:00
Micheal Wilkinson
32a6ded499 fix(ci): pin gosec and govulncheck-action to concrete version tags 2026-03-21 15:00:34 +00:00
Micheal Wilkinson
b7c62634f4 docs: record action nesting and docs-only fix
Some checks failed
Push Validation / coverage-badge (push) Failing after 15s
Push Validation / recommend-release (push) Has been skipped
2026-03-21 14:56:42 +00:00
Micheal Wilkinson
224ba03ca4 fix(decorate-pr): replace piped while-read with process substitution for docs-only detection 2026-03-21 14:56:38 +00:00
Micheal Wilkinson
3f555fb894 refactor(actions): nest binary and code runners under run-vociferate/ 2026-03-21 14:54:25 +00:00
Micheal Wilkinson
ee274602a8 docs: clarify runtime action refactor 2026-03-21 14:50:32 +00:00
Micheal Wilkinson
1306f07003 refactor(actions): simplify run-vociferate runtime flow 2026-03-21 14:50:29 +00:00
Micheal Wilkinson
58e29aca0c docs: record composite runtime orchestration 2026-03-21 14:45:54 +00:00
Micheal Wilkinson
f04df719e2 chore(go): compose vociferate runtime flow 2026-03-21 14:45:50 +00:00
Micheal Wilkinson
9a91c70e5d docs: record runtime centralization changes 2026-03-21 14:34:27 +00:00
Micheal Wilkinson
3eb814a3d5 chore(go): centralize action runtime selection 2026-03-21 14:34:00 +00:00
Micheal Wilkinson
92f76fd19f chore(go): route release notes through vociferate 2026-03-21 14:33:53 +00:00
Micheal Wilkinson
9dc28e8229 chore(go): add release note extraction tests 2026-03-21 14:33:48 +00:00
Micheal Wilkinson
e625d475a5 chore(go): use vociferate for unreleased parsing 2026-03-21 14:25:27 +00:00
Micheal Wilkinson
b7d1760beb chore(go): add unreleased changelog tests 2026-03-21 14:25:19 +00:00
Micheal Wilkinson
64a7b6d86b docs: record vociferate changelog extraction 2026-03-21 14:24:18 +00:00
Micheal Wilkinson
c8365e39da docs: record decorate-pr yaml validation fix 2026-03-21 14:17:13 +00:00
Micheal Wilkinson
4a47580ea8 fix: extract decorate-pr comment rendering from action yaml 2026-03-21 14:17:07 +00:00
Micheal Wilkinson
5a207e7d5d docs: refresh compliance analysis for di and local validation 2026-03-21 14:14:52 +00:00
Micheal Wilkinson
5c903c98be docs: record di and local validation updates 2026-03-21 14:12:45 +00:00
Micheal Wilkinson
383aad48be chore(go): inject release service dependencies and mirror local validation 2026-03-21 14:12:15 +00:00
Micheal Wilkinson
f31141702d docs: update compliance analysis with fix implementations
Update COMPLIANCE_ANALYSIS.md to reflect completed standards improvements:
- CI/CD Workflows section: Mark as COMPLIANT with all checks implemented
- Validation Sequence section: Now FOLLOWING DOCUMENTED STANDARD
- Recommendations: Mark critical items as COMPLETED (commit 7cb7b05)
- Conclusion: Codebase now meets all documented standards
- Add details about commit 7cb7b05 improvements
2026-03-21 14:06:20 +00:00
Micheal Wilkinson
7cb7b050db chore: add missing CI validation checks (fmt, mod, gosec, govulncheck)
- Add go fmt validation to enforce consistent code formatting
- Add go mod tidy and verify checks for module hygiene
- Add gosec security analysis for static security scanning
- Add govulncheck for dependency vulnerability detection
- Reorganize regex variables with clarifying comments
- Follows documented validation sequence from copilot-instructions.md
2026-03-21 14:04:35 +00:00
Micheal Wilkinson
3c60be8587 chore: require full https:// URLs for all vociferate action references 2026-03-21 13:53:17 +00:00
Micheal Wilkinson
830e623fa9 docs: refine changelog gate documentation formatting and descriptions 2026-03-21 13:51:07 +00:00
Micheal Wilkinson
d4d911e6c7 docs: enhance decorate-pr documentation with changelog gate examples 2026-03-21 13:48:43 +00:00
Micheal Wilkinson
4b9372079b feat(decorate-pr): add changelog gate validation with strict/soft modes
Adds comprehensive changelog gate that validates qualifying code/behavior/security/workflow/tooling changes include Unreleased entries.

Features:
- Built-in changelog requirement validation
- Configurable change types requiring entries
- Docs-only PR exception with customizable glob patterns
- PR label-based exemptions
- Precise diff parsing: only added lines in Unreleased count
- Decision outputs: gate_passed, docs_only, unreleased_additions_count, failure_reason
- Integrated PR comment showing gate status with remediation guidance
- Strict mode (fails job) and soft mode (warns only)

New inputs:
- enable-changelog-gate
- changelog-gate-mode (strict/soft)
- changelog-gate-required-for
- changelog-gate-allow-docs-only
- changelog-gate-docs-globs
- changelog-gate-skip-labels
2026-03-21 13:46:50 +00:00
gitea-actions[bot]
b5530d0c48 release: prepare v1.0.2 2026-03-21 13:35:24 +00:00
Micheal Wilkinson
b1aaff9f3b docs: document preflight token checks
All checks were successful
Push Validation / coverage-badge (push) Successful in 35s
Push Validation / recommend-release (push) Successful in 14s
2026-03-21 13:17:39 +00:00
Micheal Wilkinson
3e03382781 chore(ci): add preflight token and API checks
All checks were successful
Push Validation / coverage-badge (push) Successful in 50s
Push Validation / recommend-release (push) Successful in 17s
2026-03-21 13:06:15 +00:00
Micheal Wilkinson
43018ae9ac chore: support both GITHUB_TOKEN and GITEA_TOKEN in do-release workflow
All checks were successful
Push Validation / coverage-badge (push) Successful in 56s
Push Validation / recommend-release (push) Successful in 14s
2026-03-21 12:57:40 +00:00
Micheal Wilkinson
3e384dd8a3 chore: update decorate-pr action version to v1.0.1 in examples 2026-03-21 12:51:50 +00:00
Micheal Wilkinson
821802c0c4 feat: add decorate-pr composite action for pull request review decoration 2026-03-21 12:51:28 +00:00
Micheal Wilkinson
2810d93b89 docs: add copilot instructions for Æther Go workflow 2026-03-21 12:50:27 +00:00
gitea-actions[bot]
02db91114d release: prepare v1.0.1 2026-03-21 11:33:08 +00:00
Micheal Wilkinson
c27b042bb1 fix: restore protocol-relative changelog links
All checks were successful
Push Validation / coverage-badge (push) Successful in 1m44s
Push Validation / recommend-release (push) Successful in 38s
2026-03-21 11:26:31 +00:00
Micheal Wilkinson
59ce683813 docs: remove non-action guardrails from AGENTS.md
Some checks failed
Push Validation / coverage-badge (push) Failing after 42s
Push Validation / recommend-release (push) Has been skipped
2026-03-21 10:03:42 +00:00
Micheal Wilkinson
d653f632d1 fix: add unreleased changelog entry for https:// protocol change
Some checks failed
Push Validation / coverage-badge (push) Failing after 43s
Push Validation / recommend-release (push) Has been skipped
2026-03-21 10:00:44 +00:00
Micheal Wilkinson
8e5d05fce6 fix: replace protocol-relative // URLs with explicit https://
Some checks failed
Push Validation / coverage-badge (push) Failing after 1m59s
Push Validation / recommend-release (push) Has been skipped
2026-03-21 09:57:53 +00:00
gitea-actions[bot]
5dad65cc3b release: prepare v1.0.0 2026-03-21 01:13:41 +00:00
Micheal Wilkinson
e99527f68b docs: refresh README wording and stylization
All checks were successful
Push Validation / coverage-badge (push) Successful in 1m43s
Push Validation / recommend-release (push) Successful in 38s
2026-03-21 00:48:46 +00:00
Micheal Wilkinson
f314d7da1b feat: sync docs action tags during prepare 2026-03-21 00:29:14 +00:00
Micheal Wilkinson
21a68647f3 refactor: normalize changelog filename docs 2026-03-21 00:27:32 +00:00
Micheal Wilkinson
ba715d9965 feat: migrate changelog path to CHANGELOG.md 2026-03-21 00:27:18 +00:00
Micheal Wilkinson
62f637614d test: require CHANGELOG.md defaults 2026-03-21 00:26:43 +00:00
Micheal Wilkinson
7d6ae6f486 docs: pin README examples to release tags 2026-03-21 00:20:08 +00:00
Micheal Wilkinson
16274ea1e5 feat: add reusable coverage-badge action 2026-03-21 00:18:21 +00:00
gitea-actions[bot]
8d9cc33802 release: prepare v0.2.0 2026-03-21 00:14:00 +00:00
Micheal Wilkinson
33e1d7c9cc fix: replace workflow step summaries
All checks were successful
Push Validation / validate (push) Successful in 1m46s
2026-03-21 00:03:26 +00:00
33 changed files with 4701 additions and 791 deletions

View File

@@ -1,214 +0,0 @@
name: Do Release
on:
push:
tags:
- 'v*.*.*'
workflow_dispatch:
inputs:
tag:
description: Semantic version tag to publish, with or without leading v. Defaults to the current tag ref when dispatched from a tag.
required: false
workflow_call:
inputs:
tag:
description: Semantic version tag to publish, with or without leading v. When omitted, the current tag ref is used.
required: false
default: ''
type: string
jobs:
release:
runs-on: ubuntu-latest
container: docker.io/catthehacker/ubuntu:act-latest
outputs:
tag: ${{ steps.publish.outputs.tag }}
version: ${{ steps.publish.outputs.version }}
defaults:
run:
shell: bash
env:
RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout tagged revision
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.ref }}
- name: Checkout requested tag
if: ${{ inputs.tag != '' }}
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ startsWith(inputs.tag, 'v') && format('refs/tags/{0}', inputs.tag) || format('refs/tags/v{0}', inputs.tag) }}
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.26.1'
check-latest: true
cache: true
cache-dependency-path: go.sum
- name: Create or update release
id: publish
uses: ./publish
with:
token: ${{ secrets.GITHUB_TOKEN }}
version: ${{ inputs.tag }}
- name: Build release binaries
env:
RELEASE_VERSION: ${{ steps.publish.outputs.version }}
run: |
set -euo pipefail
mkdir -p dist
for target in linux/amd64 linux/arm64; do
os="${target%/*}"
arch="${target#*/}"
bin="vociferate_${RELEASE_VERSION}_${os}_${arch}"
GOOS="$os" GOARCH="$arch" go build -trimpath -ldflags="-s -w" -o "dist/${bin}" ./cmd/vociferate
done
(
cd dist
shasum -a 256 * > checksums.txt
)
- name: Upload release binaries
env:
RELEASE_ID: ${{ steps.publish.outputs.release-id }}
RELEASE_VERSION: ${{ steps.publish.outputs.version }}
run: |
set -euo pipefail
release_api="${GITHUB_API_URL:-${GITHUB_SERVER_URL%/}/api/v1}/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets"
for asset in dist/*; do
name="$(basename "$asset")"
assets_json="$(curl -sS --fail-with-body \
-H "Authorization: token ${RELEASE_TOKEN}" \
-H "Content-Type: application/json" \
"${release_api}")"
escaped_name="$(printf '%s' "$name" | sed 's/[][(){}.^$*+?|\\/]/\\&/g')"
existing_asset_id="$(printf '%s' "$assets_json" | tr -d '\n' | sed -n "s/.*{\"id\":\([0-9][0-9]*\)[^}]*\"name\":\"${escaped_name}\".*/\1/p")"
if [[ -n "$existing_asset_id" ]]; then
curl --fail-with-body \
-X DELETE \
-H "Authorization: token ${RELEASE_TOKEN}" \
-H "Content-Type: application/json" \
"${release_api}/${existing_asset_id}"
fi
curl --fail-with-body \
-X POST \
-H "Authorization: token ${RELEASE_TOKEN}" \
-H "Content-Type: application/octet-stream" \
"${release_api}?name=${name}" \
--data-binary "@${asset}"
done
- name: Summarize published release
env:
TAG_NAME: ${{ steps.publish.outputs.tag }}
RELEASE_VERSION: ${{ steps.publish.outputs.version }}
run: |
set -euo pipefail
{
echo "## Release Published"
echo
echo "- Tag: ${TAG_NAME}"
echo "- Release notes sourced from changelog entry ${RELEASE_VERSION}."
echo "- Published assets: vociferate_${RELEASE_VERSION}_linux_amd64, vociferate_${RELEASE_VERSION}_linux_arm64, checksums.txt"
} >> "$GITHUB_STEP_SUMMARY"
validate:
runs-on: ubuntu-latest
container: docker.io/catthehacker/ubuntu:act-latest
needs: release
strategy:
fail-fast: false
matrix:
include:
- asset_arch: amd64
run_command: ./vociferate
- asset_arch: arm64
run_command: qemu-aarch64-static ./vociferate
defaults:
run:
shell: bash
steps:
- name: Checkout tagged revision
uses: actions/checkout@v4
with:
ref: refs/tags/${{ needs.release.outputs.tag }}
- name: Install arm64 emulator
if: ${{ matrix.asset_arch == 'arm64' }}
run: |
set -euo pipefail
apt-get update
apt-get install -y qemu-user-static
- name: Download released binary
env:
TOKEN: ${{ github.token }}
TAG_NAME: ${{ needs.release.outputs.tag }}
RELEASE_VERSION: ${{ needs.release.outputs.version }}
ASSET_ARCH: ${{ matrix.asset_arch }}
SERVER_URL: ${{ github.server_url }}
run: |
set -euo pipefail
asset_name="vociferate_${RELEASE_VERSION}_linux_${ASSET_ARCH}"
asset_url="${SERVER_URL}/aether/vociferate/releases/download/${TAG_NAME}/${asset_name}"
curl --fail --location \
-H "Authorization: token ${TOKEN}" \
-o vociferate \
"$asset_url"
chmod +x vociferate
echo "asset_name=${asset_name}" >> "$GITHUB_ENV"
- name: Smoke test released binary
env:
RUN_COMMAND: ${{ matrix.run_command }}
TAG_NAME: ${{ needs.release.outputs.tag }}
run: |
set -euo pipefail
${RUN_COMMAND} --help >/dev/null
recommend_stderr="$(mktemp)"
if ${RUN_COMMAND} --recommend --root . >/dev/null 2>"${recommend_stderr}"; then
echo "Expected --recommend to fail on the tagged release checkout" >&2
exit 1
fi
recommend_error="$(cat "${recommend_stderr}")"
case "${recommend_error}" in
*"unreleased section is empty"*)
;;
*)
echo "Unexpected recommend failure output: ${recommend_error}" >&2
exit 1
;;
esac
{
echo "## Released Binary Validation (${{ matrix.asset_arch }})"
echo
echo "- Release tag: ${TAG_NAME}"
echo "- Asset: ${asset_name}"
echo "- Binary executed successfully via --help."
echo "- --recommend failed as expected on the tagged checkout because Unreleased is empty."
} >> "$GITHUB_STEP_SUMMARY"

View File

@@ -1,71 +0,0 @@
name: Prepare Release
on:
workflow_dispatch:
inputs:
version:
description: Optional semantic version override, with or without leading v. When omitted, the recommended version is used.
required: false
workflow_call:
inputs:
version:
description: Optional semantic version override, with or without leading v. When omitted, the recommended version is used.
required: false
default: ''
type: string
jobs:
prepare:
runs-on: ubuntu-latest
container: docker.io/catthehacker/ubuntu:act-latest
outputs:
tag: ${{ steps.prepare.outputs.version }}
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.26.1'
check-latest: true
cache: true
cache-dependency-path: go.sum
- name: Run tests
run: go test ./...
- name: Resolve cache token
id: cache-token
run: echo "value=${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
- name: Prepare and tag release
id: prepare
uses: ./prepare
env:
VOCIFERATE_CACHE_TOKEN: ${{ steps.cache-token.outputs.value }}
with:
version: ${{ inputs.version }}
- name: Summarize prepared release
run: |
set -euo pipefail
tag="${{ steps.prepare.outputs.version }}"
{
echo "## Release Prepared"
echo
echo "- Tag pushed: ${tag}"
echo "- Calling Do Release workflow for ${tag}."
} >> "$GITHUB_STEP_SUMMARY"
publish:
needs: prepare
uses: ./.gitea/workflows/do-release.yml
with:
tag: ${{ needs.prepare.outputs.tag }}
secrets: inherit

View File

@@ -8,7 +8,7 @@ on:
- "*" - "*"
jobs: jobs:
validate: coverage-badge:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: docker.io/catthehacker/ubuntu:act-latest container: docker.io/catthehacker/ubuntu:act-latest
defaults: defaults:
@@ -22,6 +22,7 @@ jobs:
AWS_SECRET_ACCESS_KEY: ${{ secrets.ARTEFACT_BUCKET_WRITE_ACCESS_SECRET }} AWS_SECRET_ACCESS_KEY: ${{ secrets.ARTEFACT_BUCKET_WRITE_ACCESS_SECRET }}
AWS_DEFAULT_REGION: ${{ vars.ARTEFACT_BUCKET_REGION }} AWS_DEFAULT_REGION: ${{ vars.ARTEFACT_BUCKET_REGION }}
AWS_EC2_METADATA_DISABLED: true AWS_EC2_METADATA_DISABLED: true
SUMMARY_FILE: ${{ runner.temp }}/push-validation-summary.md
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -29,99 +30,106 @@ jobs:
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:
go-version: '1.26.1' go-version-file: go.mod
check-latest: true check-latest: false
cache: true cache: true
cache-dependency-path: go.sum cache-dependency-path: go.sum
- name: Install AWS CLI v2 - name: Validate formatting
uses: ankurk91/install-aws-cli-action@v1 run: test -z "$(gofmt -l .)"
- name: Verify AWS CLI - name: Module hygiene
run: aws --version run: |
set -euo pipefail
go mod tidy
if ! go mod verify; then
echo "go mod verify failed; refreshing module cache and retrying" >&2
go clean -modcache
go mod download
go mod verify
fi
- name: Restore cached gosec binary
id: cache-gosec
uses: actions/cache@v4
with:
path: ${{ runner.temp }}/gosec-bin
key: gosec-v2.22.4-${{ runner.os }}-${{ runner.arch }}
- name: Install gosec binary
if: steps.cache-gosec.outputs.cache-hit != 'true'
run: |
set -euo pipefail
mkdir -p "${RUNNER_TEMP}/gosec-bin"
GOBIN="${RUNNER_TEMP}/gosec-bin" go install github.com/securego/gosec/v2/cmd/gosec@v2.22.4
- name: Run gosec security analysis
run: |
set -euo pipefail
"${RUNNER_TEMP}/gosec-bin/gosec" ./...
- name: Run govulncheck
uses: golang/govulncheck-action@v1.0.4
with:
go-version-file: go.mod
check-latest: false
go-package: ./...
cache: true
cache-dependency-path: go.sum
- name: Run full unit test suite with coverage - name: Run full unit test suite with coverage
id: coverage
run: | run: |
set -euo pipefail set -euo pipefail
go test -covermode=atomic -coverprofile=coverage.out ./... go test -covermode=atomic -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
total="$(go tool cover -func=coverage.out | awk '/^total:/ {sub(/%/, "", $3); print $3}')" - name: Publish coverage badge artefacts
printf '{\n "total": "%s"\n}\n' "$total" > coverage-summary.json id: coverage
printf 'total=%s\n' "$total" >> "$GITHUB_OUTPUT" uses: ./coverage-badge
with:
artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }}
artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }}
summary-file: ${{ env.SUMMARY_FILE }}
- name: Generate coverage badge - name: Summary
env: if: ${{ always() }}
COVERAGE_TOTAL: ${{ steps.coverage.outputs.total }}
run: | run: |
set -euo pipefail set -euo pipefail
color="$(awk -v total="$COVERAGE_TOTAL" 'BEGIN { echo 'Summary'
if (total >= 80) print "brightgreen";
else if (total >= 70) print "green";
else if (total >= 60) print "yellowgreen";
else if (total >= 50) print "yellow";
else print "red";
}')"
cat > coverage-badge.svg <<EOF
<svg xmlns="http://www.w3.org/2000/svg" width="126" height="20" role="img" aria-label="coverage: ${COVERAGE_TOTAL}%">
<linearGradient id="smooth" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="round">
<rect width="126" height="20" rx="3" fill="#fff"/>
</clipPath>
<g clip-path="url(#round)">
<rect width="63" height="20" fill="#555"/>
<rect x="63" width="63" height="20" fill="${color}"/>
<rect width="126" height="20" fill="url(#smooth)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text x="32.5" y="15" fill="#010101" fill-opacity=".3">coverage</text>
<text x="32.5" y="14">coverage</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">${COVERAGE_TOTAL}%</text>
<text x="93.5" y="14">${COVERAGE_TOTAL}%</text>
</g>
</svg>
EOF
- name: Upload branch coverage artefacts
id: upload
run: |
set -euo pipefail
aws configure set default.s3.addressing_style path
repo_name="${GITHUB_REPOSITORY##*/}"
prefix="${repo_name}/branch/${GITHUB_REF_NAME}"
display_endpoint="${ARTEFACT_BUCKET_ENDPONT#https://}"
display_endpoint="${display_endpoint#http://}"
report_url="//${display_endpoint%/}/${ARTEFACT_BUCKET_NAME}/${prefix}/coverage.html"
badge_url="//${display_endpoint%/}/${ARTEFACT_BUCKET_NAME}/${prefix}/coverage-badge.svg"
aws --endpoint-url "${ARTEFACT_BUCKET_ENDPONT}" s3 cp coverage.html "s3://${ARTEFACT_BUCKET_NAME}/${prefix}/coverage.html" --content-type text/html
aws --endpoint-url "${ARTEFACT_BUCKET_ENDPONT}" s3 cp coverage-badge.svg "s3://${ARTEFACT_BUCKET_NAME}/${prefix}/coverage-badge.svg" --content-type image/svg+xml
aws --endpoint-url "${ARTEFACT_BUCKET_ENDPONT}" s3 cp coverage-summary.json "s3://${ARTEFACT_BUCKET_NAME}/${prefix}/coverage-summary.json" --content-type application/json
printf 'report_url=%s\n' "$report_url" >> "$GITHUB_OUTPUT"
printf 'badge_url=%s\n' "$badge_url" >> "$GITHUB_OUTPUT"
- name: Add coverage summary
run: |
{
echo '## Coverage'
echo echo
echo '- Total: `${{ steps.coverage.outputs.total }}%`'
echo '- Report: ${{ steps.upload.outputs.report_url }}' if [[ -s "$SUMMARY_FILE" ]]; then
echo '- Badge: ${{ steps.upload.outputs.badge_url }}' cat "$SUMMARY_FILE"
} >> "$GITHUB_STEP_SUMMARY" else
echo 'No summary generated.'
fi
recommend-release:
runs-on: ubuntu-latest
container: docker.io/catthehacker/ubuntu:act-latest
needs: coverage-badge
if: ${{ github.ref == 'refs/heads/main' }}
defaults:
run:
shell: bash
env:
SUMMARY_FILE: ${{ runner.temp }}/push-validation-recommend-summary.md
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
check-latest: false
cache: true
cache-dependency-path: go.sum
- name: Recommend next release tag on main pushes - name: Recommend next release tag on main pushes
if: ${{ github.ref == 'refs/heads/main' }}
run: | run: |
set -euo pipefail set -euo pipefail
@@ -131,7 +139,7 @@ jobs:
echo '## Release Recommendation' echo '## Release Recommendation'
echo echo
echo "- Recommended next tag: \`${recommended_tag}\`" echo "- Recommended next tag: \`${recommended_tag}\`"
} >> "$GITHUB_STEP_SUMMARY" } >> "$SUMMARY_FILE"
else else
recommendation_error="$(tr '\n' ' ' < release-recommendation.err | sed 's/[[:space:]]\+/ /g' | sed 's/^ //; s/ $//')" recommendation_error="$(tr '\n' ' ' < release-recommendation.err | sed 's/[[:space:]]\+/ /g' | sed 's/^ //; s/ $//')"
echo "::warning::${recommendation_error}" echo "::warning::${recommendation_error}"
@@ -140,5 +148,19 @@ jobs:
echo '## Release Recommendation' echo '## Release Recommendation'
echo echo
echo "- No recommended tag emitted: ${recommendation_error}" echo "- No recommended tag emitted: ${recommendation_error}"
} >> "$GITHUB_STEP_SUMMARY" } >> "$SUMMARY_FILE"
fi
- name: Summary
if: ${{ always() }}
run: |
set -euo pipefail
echo 'Summary'
echo
if [[ -s "$SUMMARY_FILE" ]]; then
cat "$SUMMARY_FILE"
else
echo 'No summary generated.'
fi fi

View File

@@ -0,0 +1,466 @@
name: Release
on:
workflow_dispatch:
inputs:
version:
description: Optional semantic version override, with or without leading v. When omitted, the recommended version is used.
required: false
workflow_call:
inputs:
version:
description: Optional semantic version override, with or without leading v. When omitted, the recommended version is used.
required: false
default: ''
type: string
jobs:
prepare:
runs-on: ubuntu-latest
container: docker.io/catthehacker/ubuntu:act-latest
outputs:
tag: ${{ steps.prepare.outputs.version }}
defaults:
run:
shell: bash
env:
SUMMARY_FILE: ${{ runner.temp }}/release-prepare-summary.md
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
check-latest: false
cache: true
cache-dependency-path: go.sum
- name: Install UPX
uses: crazy-max/ghaction-upx@v3
with:
install-only: true
- name: Validate formatting
run: test -z "$(gofmt -l .)"
- name: Module hygiene
run: |
set -euo pipefail
go mod tidy
if ! go mod verify; then
echo "go mod verify failed; refreshing module cache and retrying" >&2
go clean -modcache
go mod download
go mod verify
fi
- name: Restore cached gosec binary
id: cache-gosec
uses: actions/cache@v4
with:
path: ${{ runner.temp }}/gosec-bin
key: gosec-v2.22.4-${{ runner.os }}-${{ runner.arch }}
- name: Install gosec binary
if: steps.cache-gosec.outputs.cache-hit != 'true'
run: |
set -euo pipefail
mkdir -p "${RUNNER_TEMP}/gosec-bin"
GOBIN="${RUNNER_TEMP}/gosec-bin" go install github.com/securego/gosec/v2/cmd/gosec@v2.22.4
- name: Run gosec security analysis
run: |
set -euo pipefail
"${RUNNER_TEMP}/gosec-bin/gosec" ./...
- name: Run govulncheck
uses: golang/govulncheck-action@v1.0.4
with:
go-version-file: go.mod
check-latest: false
go-package: ./...
cache: true
cache-dependency-path: go.sum
- name: Run tests
run: |
set -euo pipefail
go test ./...
- name: Resolve cache token
id: cache-token
run: echo "value=${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
- name: Resolve release tag
id: resolve-version
run: |
set -euo pipefail
provided_version="$(printf '%s' "${{ inputs.version }}" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
if [[ -z "$provided_version" ]]; then
release_tag="$(go run ./cmd/vociferate --recommend --root .)"
elif [[ "$provided_version" == v* ]]; then
release_tag="$provided_version"
else
release_tag="v${provided_version}"
fi
echo "tag=${release_tag}" >> "$GITHUB_OUTPUT"
- name: Update agent docs action tags
run: |
set -euo pipefail
release_tag="${{ steps.resolve-version.outputs.tag }}"
for file in README.md AGENTS.md; do
sed -E -i "s/@v[0-9]+\.[0-9]+\.[0-9]+/@${release_tag}/g" "$file"
done
- name: Prepare and tag release
id: prepare
uses: ./prepare
env:
VOCIFERATE_CACHE_TOKEN: ${{ steps.cache-token.outputs.value }}
with:
version: ${{ steps.resolve-version.outputs.tag }}
token: ${{ secrets.RELEASE_PAT }}
git-add-files: CHANGELOG.md release-version README.md AGENTS.md
- name: Summary
if: ${{ always() }}
run: |
set -euo pipefail
tag="${{ steps.prepare.outputs.version }}"
{
echo "## Release Prepared"
echo
echo "- Tag pushed: ${tag}"
} >> "$SUMMARY_FILE"
echo 'Summary'
echo
if [[ -s "$SUMMARY_FILE" ]]; then
cat "$SUMMARY_FILE"
else
echo 'No summary generated.'
fi
release:
runs-on: ubuntu-latest
container: docker.io/catthehacker/ubuntu:act-latest
needs: prepare
outputs:
tag: ${{ steps.publish.outputs.tag }}
version: ${{ steps.publish.outputs.version }}
defaults:
run:
shell: bash
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_PAT }}
SUMMARY_FILE: ${{ runner.temp }}/release-summary.md
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Resolve release version
id: resolve-version
env:
PREPARE_TAG: ${{ needs.prepare.outputs.tag }}
run: |
set -euo pipefail
tag="$(printf '%s' "${PREPARE_TAG}" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
# Unwrap Teacup expression strings if present.
if [[ "${tag}" =~ ^%\!t\(string=(.*)\)$ ]]; then
tag="${BASH_REMATCH[1]}"
fi
normalized="${tag#v}"
tag="v${normalized}"
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
echo "version=${normalized}" >> "$GITHUB_OUTPUT"
- name: Checkout release tag
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: refs/tags/${{ steps.resolve-version.outputs.tag }}
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
check-latest: false
cache: true
cache-dependency-path: go.sum
- name: Preflight release API access
env:
TAG_NAME: ${{ steps.resolve-version.outputs.tag }}
run: |
set -euo pipefail
if [[ -z "${RELEASE_TOKEN:-}" ]]; then
echo "No release token available. Set secrets.RELEASE_PAT." >&2
exit 1
fi
api_base="${GITHUB_API_URL:-${GITHUB_SERVER_URL%/}/api/v1}"
repo_api="${api_base}/repos/${GITHUB_REPOSITORY}"
curl --fail-with-body -sS \
-H "Authorization: token ${RELEASE_TOKEN}" \
-H "Content-Type: application/json" \
"${repo_api}" >/dev/null
curl --fail-with-body -sS \
-H "Authorization: token ${RELEASE_TOKEN}" \
-H "Content-Type: application/json" \
"${repo_api}/releases?limit=1" >/dev/null
if ! git rev-parse --verify --quiet "refs/tags/${TAG_NAME}" >/dev/null; then
echo "Tag ${TAG_NAME} was not found in the checked out repository." >&2
exit 1
fi
- name: Create or update release
id: publish
uses: ./publish
with:
token: ${{ secrets.RELEASE_PAT }}
version: ${{ steps.resolve-version.outputs.version }}
- name: Build release binaries
env:
RELEASE_VERSION: ${{ steps.publish.outputs.version }}
run: |
set -euo pipefail
upx_cmd=""
if command -v upx >/dev/null 2>&1; then
upx_cmd=upx
elif command -v upx-ucl >/dev/null 2>&1; then
upx_cmd=upx-ucl
else
echo "UPX is not available on PATH after install step; continuing without binary compression." >&2
fi
mkdir -p dist
for target in linux/amd64 linux/arm64; do
os="${target%/*}"
arch="${target#*/}"
bin="vociferate_${RELEASE_VERSION}_${os}_${arch}"
GOOS="$os" GOARCH="$arch" go build -trimpath -ldflags="-s -w" -o "dist/${bin}" ./cmd/vociferate
if [[ -n "${upx_cmd}" ]]; then
"${upx_cmd}" --best --lzma "dist/${bin}"
fi
done
(
cd dist
shasum -a 256 * > checksums.txt
)
- name: Upload release binaries
env:
RELEASE_ID: ${{ steps.publish.outputs.release-id }}
RELEASE_VERSION: ${{ steps.publish.outputs.version }}
run: |
set -euo pipefail
raw_release_id="$(printf '%s' "${RELEASE_ID:-}" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
if [[ "$raw_release_id" =~ ^%\!t\(string=(.*)\)$ ]]; then
raw_release_id="${BASH_REMATCH[1]}"
fi
release_id="$(printf '%s' "$raw_release_id" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
if ! [[ "$release_id" =~ ^[0-9]+$ ]]; then
echo "Invalid release id from publish step: '${RELEASE_ID}'" >&2
exit 1
fi
release_detail_api="${GITHUB_API_URL:-${GITHUB_SERVER_URL%/}/api/v1}/repos/${GITHUB_REPOSITORY}/releases/${release_id}"
if ! curl --fail-with-body -sS \
-H "Authorization: token ${RELEASE_TOKEN}" \
-H "Content-Type: application/json" \
"$release_detail_api" >/dev/null; then
echo "Resolved release endpoint is not accessible: ${release_detail_api}" >&2
exit 1
fi
release_api="${GITHUB_API_URL:-${GITHUB_SERVER_URL%/}/api/v1}/repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets"
for asset in dist/*; do
name="$(basename "$asset")"
assets_json="$(curl -sS --fail-with-body \
-H "Authorization: token ${RELEASE_TOKEN}" \
-H "Content-Type: application/json" \
"${release_api}")"
escaped_name="$(printf '%s' "$name" | sed 's/[][(){}.^$*+?|\\/]/\\&/g')"
existing_asset_id="$(printf '%s' "$assets_json" | tr -d '\n' | sed -n "s/.*{\"id\":\([0-9][0-9]*\)[^}]*\"name\":\"${escaped_name}\".*/\1/p")"
if [[ -n "$existing_asset_id" ]]; then
curl --fail-with-body \
-X DELETE \
-H "Authorization: token ${RELEASE_TOKEN}" \
-H "Content-Type: application/json" \
"${release_api}/${existing_asset_id}"
fi
curl --fail-with-body \
-X POST \
-H "Authorization: token ${RELEASE_TOKEN}" \
-H "Content-Type: application/octet-stream" \
"${release_api}?name=${name}" \
--data-binary "@${asset}"
done
- name: Summary
if: ${{ always() }}
env:
TAG_NAME: ${{ steps.publish.outputs.tag }}
RELEASE_VERSION: ${{ steps.publish.outputs.version }}
PUBLISH_OUTCOME: ${{ steps.publish.outcome }}
run: |
set -euo pipefail
if [[ "${PUBLISH_OUTCOME}" == "success" ]]; then
{
echo "## Release Published"
echo
echo "- Tag: ${TAG_NAME}"
echo "- Release notes sourced from changelog entry ${RELEASE_VERSION}."
echo "- Published assets: vociferate_${RELEASE_VERSION}_linux_amd64, vociferate_${RELEASE_VERSION}_linux_arm64, checksums.txt"
echo "- Release binaries are compressed with UPX from crazy-max/ghaction-upx@v3 when available, otherwise uploaded uncompressed."
} >> "$SUMMARY_FILE"
else
{
echo "## Release Failed"
echo
echo "- Tag: ${TAG_NAME:-unknown}"
echo "- Create or update release step did not complete successfully."
} >> "$SUMMARY_FILE"
fi
echo 'Summary'
echo
if [[ -s "$SUMMARY_FILE" ]]; then
cat "$SUMMARY_FILE"
else
echo 'No summary generated.'
fi
validate:
runs-on: ubuntu-latest
container: docker.io/catthehacker/ubuntu:act-latest
needs: release
strategy:
fail-fast: false
matrix:
include:
- asset_arch: amd64
run_command: ./vociferate
- asset_arch: arm64
run_command: qemu-aarch64-static ./vociferate
defaults:
run:
shell: bash
env:
SUMMARY_FILE: ${{ runner.temp }}/validate-summary.md
steps:
- name: Checkout tagged revision
uses: actions/checkout@v4
with:
ref: refs/tags/${{ needs.release.outputs.tag }}
- name: Install arm64 emulator
if: ${{ matrix.asset_arch == 'arm64' }}
run: |
set -euo pipefail
apt-get update
apt-get install -y qemu-user-static
- name: Download released binary
env:
TOKEN: ${{ secrets.RELEASE_PAT }}
TAG_NAME: ${{ needs.release.outputs.tag }}
RELEASE_VERSION: ${{ needs.release.outputs.version }}
ASSET_ARCH: ${{ matrix.asset_arch }}
SERVER_URL: ${{ github.server_url }}
run: |
set -euo pipefail
asset_name="vociferate_${RELEASE_VERSION}_linux_${ASSET_ARCH}"
asset_url="${SERVER_URL}/aether/vociferate/releases/download/${TAG_NAME}/${asset_name}"
curl --fail --location \
-H "Authorization: token ${TOKEN}" \
-o vociferate \
"$asset_url"
chmod +x vociferate
echo "asset_name=${asset_name}" >> "$GITHUB_ENV"
- name: Smoke test released binary
env:
RUN_COMMAND: ${{ matrix.run_command }}
TAG_NAME: ${{ needs.release.outputs.tag }}
run: |
set -euo pipefail
${RUN_COMMAND} --help >/dev/null
recommend_stderr="$(mktemp)"
if ${RUN_COMMAND} --recommend --root . >/dev/null 2>"${recommend_stderr}"; then
echo "Expected --recommend to fail on the tagged release checkout" >&2
exit 1
fi
recommend_error="$(cat "${recommend_stderr}")"
case "${recommend_error}" in
*"unreleased section is empty"*)
;;
*)
echo "Unexpected recommend failure output: ${recommend_error}" >&2
exit 1
;;
esac
{
echo "## Released Binary Validation (${{ matrix.asset_arch }})"
echo
echo "- Release tag: ${TAG_NAME}"
echo "- Asset: ${asset_name}"
echo "- Binary executed successfully via --help."
echo "- --recommend failed as expected on the tagged checkout because Unreleased is empty."
} >> "$SUMMARY_FILE"
- name: Summary
if: ${{ always() }}
run: |
set -euo pipefail
echo 'Summary'
echo
if [[ -s "$SUMMARY_FILE" ]]; then
cat "$SUMMARY_FILE"
else
echo 'No summary generated.'
fi

View File

@@ -0,0 +1,388 @@
name: Update Release
on:
push:
tags:
- 'v*.*.*'
workflow_dispatch:
inputs:
tag:
description: Semantic version tag to publish, with or without leading v. Defaults to the current tag ref when dispatched from a tag.
required: false
workflow_call:
inputs:
tag:
description: Semantic version tag to publish, with or without leading v. When omitted, the current tag ref is used.
required: false
default: ''
type: string
jobs:
release:
runs-on: ubuntu-latest
container: docker.io/catthehacker/ubuntu:act-latest
outputs:
tag: ${{ steps.publish.outputs.tag }}
version: ${{ steps.publish.outputs.version }}
defaults:
run:
shell: bash
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_PAT }}
SUMMARY_FILE: ${{ runner.temp }}/update-release-summary.md
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Fetch and detect release tag
id: detect-tag
run: |
set -euo pipefail
# Fetch all tags from origin first
git fetch origin --tags --force --quiet 2>/dev/null || true
# Check if HEAD is at a tag (prepare-release may have just tagged it)
if head_tag="$(git describe --exact-match --tags HEAD 2>/dev/null)" && [[ -n "$head_tag" ]]; then
echo "detected_tag=$head_tag" >> "$GITHUB_OUTPUT"
exit 0
fi
# Fall back to finding the most recent tag
if latest_tag="$(git describe --tags --abbrev=0 2>/dev/null)" && [[ -n "$latest_tag" ]]; then
echo "detected_tag=$latest_tag" >> "$GITHUB_OUTPUT"
fi
- name: Resolve release version
id: resolve-version
env:
INPUT_TAG: ${{ inputs.tag }}
DETECTED_TAG: ${{ steps.detect-tag.outputs.detected_tag }}
run: |
set -euo pipefail
normalize_candidate() {
local raw="$1"
raw="$(printf '%s' "$raw" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
# Teacup can surface expression strings as %!t(string=value); unwrap it.
if [[ "$raw" =~ ^%\!t\(string=(.*)\)$ ]]; then
raw="${BASH_REMATCH[1]}"
fi
raw="$(printf '%s' "$raw" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
printf '%s' "$raw"
}
input_tag="$(normalize_candidate "${INPUT_TAG}")"
detected_tag="$(normalize_candidate "${DETECTED_TAG}")"
# Try explicit input first.
requested_tag="$input_tag"
# Fall back to detected tag if neither input nor caller tag is available.
if [[ -z "$requested_tag" && -n "$detected_tag" ]]; then
requested_tag="$detected_tag"
fi
# Try GITHUB_REF if still empty
if [[ -z "$requested_tag" && "$GITHUB_REF" == refs/tags/* ]]; then
requested_tag="${GITHUB_REF#refs/tags/}"
fi
if [[ -n "$requested_tag" ]]; then
# Normalize to v-prefixed format
normalized="${requested_tag#v}"
tag="v${normalized}"
else
echo "Error: Could not resolve release version" >&2
echo " - inputs.tag(raw): '$INPUT_TAG'" >&2
echo " - detected_tag(raw): '${DETECTED_TAG}'" >&2
echo " - GITHUB_REF: '$GITHUB_REF'" >&2
exit 1
fi
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
echo "version=${normalized}" >> "$GITHUB_OUTPUT"
- name: Checkout release tag
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: refs/tags/${{ steps.resolve-version.outputs.tag }}
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
check-latest: false
cache: true
cache-dependency-path: go.sum
- name: Install UPX
uses: crazy-max/ghaction-upx@v3
with:
install-only: true
- name: Preflight release API access
env:
TAG_NAME: ${{ steps.resolve-version.outputs.tag }}
run: |
set -euo pipefail
if [[ -z "${RELEASE_TOKEN:-}" ]]; then
echo "No release token available. Set secrets.RELEASE_PAT." >&2
exit 1
fi
api_base="${GITHUB_API_URL:-${GITHUB_SERVER_URL%/}/api/v1}"
repo_api="${api_base}/repos/${GITHUB_REPOSITORY}"
curl --fail-with-body -sS \
-H "Authorization: token ${RELEASE_TOKEN}" \
-H "Content-Type: application/json" \
"${repo_api}" >/dev/null
curl --fail-with-body -sS \
-H "Authorization: token ${RELEASE_TOKEN}" \
-H "Content-Type: application/json" \
"${repo_api}/releases?limit=1" >/dev/null
if ! git rev-parse --verify --quiet "refs/tags/${TAG_NAME}" >/dev/null; then
echo "Tag ${TAG_NAME} was not found in the checked out repository." >&2
exit 1
fi
- name: Create or update release
id: publish
uses: ./publish
with:
token: ${{ secrets.RELEASE_PAT }}
version: ${{ steps.resolve-version.outputs.version }}
- name: Build release binaries
env:
RELEASE_VERSION: ${{ steps.publish.outputs.version }}
run: |
set -euo pipefail
upx_cmd=""
if command -v upx >/dev/null 2>&1; then
upx_cmd=upx
elif command -v upx-ucl >/dev/null 2>&1; then
upx_cmd=upx-ucl
else
echo "UPX is not available on PATH after install step; continuing without binary compression." >&2
fi
mkdir -p dist
for target in linux/amd64 linux/arm64; do
os="${target%/*}"
arch="${target#*/}"
bin="vociferate_${RELEASE_VERSION}_${os}_${arch}"
GOOS="$os" GOARCH="$arch" go build -trimpath -ldflags="-s -w" -o "dist/${bin}" ./cmd/vociferate
if [[ -n "${upx_cmd}" ]]; then
"${upx_cmd}" --best --lzma "dist/${bin}"
fi
done
(
cd dist
shasum -a 256 * > checksums.txt
)
- name: Upload release binaries
env:
RELEASE_ID: ${{ steps.publish.outputs.release-id }}
RELEASE_VERSION: ${{ steps.publish.outputs.version }}
run: |
set -euo pipefail
raw_release_id="$(printf '%s' "${RELEASE_ID:-}" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
if [[ "$raw_release_id" =~ ^%\!t\(string=(.*)\)$ ]]; then
raw_release_id="${BASH_REMATCH[1]}"
fi
release_id="$(printf '%s' "$raw_release_id" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
if ! [[ "$release_id" =~ ^[0-9]+$ ]]; then
echo "Invalid release id from publish step: '${RELEASE_ID}'" >&2
exit 1
fi
release_detail_api="${GITHUB_API_URL:-${GITHUB_SERVER_URL%/}/api/v1}/repos/${GITHUB_REPOSITORY}/releases/${release_id}"
if ! curl --fail-with-body -sS \
-H "Authorization: token ${RELEASE_TOKEN}" \
-H "Content-Type: application/json" \
"$release_detail_api" >/dev/null; then
echo "Resolved release endpoint is not accessible: ${release_detail_api}" >&2
exit 1
fi
release_api="${GITHUB_API_URL:-${GITHUB_SERVER_URL%/}/api/v1}/repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets"
for asset in dist/*; do
name="$(basename "$asset")"
assets_json="$(curl -sS --fail-with-body \
-H "Authorization: token ${RELEASE_TOKEN}" \
-H "Content-Type: application/json" \
"${release_api}")"
escaped_name="$(printf '%s' "$name" | sed 's/[][(){}.^$*+?|\\/]/\\&/g')"
existing_asset_id="$(printf '%s' "$assets_json" | tr -d '\n' | sed -n "s/.*{\"id\":\([0-9][0-9]*\)[^}]*\"name\":\"${escaped_name}\".*/\1/p")"
if [[ -n "$existing_asset_id" ]]; then
curl --fail-with-body \
-X DELETE \
-H "Authorization: token ${RELEASE_TOKEN}" \
-H "Content-Type: application/json" \
"${release_api}/${existing_asset_id}"
fi
curl --fail-with-body \
-X POST \
-H "Authorization: token ${RELEASE_TOKEN}" \
-H "Content-Type: application/octet-stream" \
"${release_api}?name=${name}" \
--data-binary "@${asset}"
done
- name: Summary
if: ${{ always() }}
env:
TAG_NAME: ${{ steps.publish.outputs.tag }}
RELEASE_VERSION: ${{ steps.publish.outputs.version }}
PUBLISH_OUTCOME: ${{ steps.publish.outcome }}
run: |
set -euo pipefail
if [[ "${PUBLISH_OUTCOME}" == "success" ]]; then
{
echo "## Release Published"
echo
echo "- Tag: ${TAG_NAME}"
echo "- Release notes sourced from changelog entry ${RELEASE_VERSION}."
echo "- Published assets: vociferate_${RELEASE_VERSION}_linux_amd64, vociferate_${RELEASE_VERSION}_linux_arm64, checksums.txt"
echo "- Release binaries are compressed with UPX from crazy-max/ghaction-upx@v3 when available, otherwise uploaded uncompressed."
} >> "$SUMMARY_FILE"
else
{
echo "## Release Failed"
echo
echo "- Tag: ${TAG_NAME:-unknown}"
echo "- Create or update release step did not complete successfully."
} >> "$SUMMARY_FILE"
fi
echo 'Summary'
echo
if [[ -s "$SUMMARY_FILE" ]]; then
cat "$SUMMARY_FILE"
else
echo 'No summary generated.'
fi
validate:
runs-on: ubuntu-latest
container: docker.io/catthehacker/ubuntu:act-latest
needs: release
strategy:
fail-fast: false
matrix:
include:
- asset_arch: amd64
run_command: ./vociferate
- asset_arch: arm64
run_command: qemu-aarch64-static ./vociferate
defaults:
run:
shell: bash
env:
SUMMARY_FILE: ${{ runner.temp }}/update-release-validate-summary.md
steps:
- name: Checkout tagged revision
uses: actions/checkout@v4
with:
ref: refs/tags/${{ needs.release.outputs.tag }}
- name: Install arm64 emulator
if: ${{ matrix.asset_arch == 'arm64' }}
run: |
set -euo pipefail
apt-get update
apt-get install -y qemu-user-static
- name: Download released binary
env:
TOKEN: ${{ secrets.RELEASE_PAT }}
TAG_NAME: ${{ needs.release.outputs.tag }}
RELEASE_VERSION: ${{ needs.release.outputs.version }}
ASSET_ARCH: ${{ matrix.asset_arch }}
SERVER_URL: ${{ github.server_url }}
run: |
set -euo pipefail
asset_name="vociferate_${RELEASE_VERSION}_linux_${ASSET_ARCH}"
asset_url="${SERVER_URL}/aether/vociferate/releases/download/${TAG_NAME}/${asset_name}"
curl --fail --location \
-H "Authorization: token ${TOKEN}" \
-o vociferate \
"$asset_url"
chmod +x vociferate
echo "asset_name=${asset_name}" >> "$GITHUB_ENV"
- name: Smoke test released binary
env:
RUN_COMMAND: ${{ matrix.run_command }}
TAG_NAME: ${{ needs.release.outputs.tag }}
run: |
set -euo pipefail
${RUN_COMMAND} --help >/dev/null
recommend_stderr="$(mktemp)"
if ${RUN_COMMAND} --recommend --root . >/dev/null 2>"${recommend_stderr}"; then
echo "Expected --recommend to fail on the tagged release checkout" >&2
exit 1
fi
recommend_error="$(cat "${recommend_stderr}")"
case "${recommend_error}" in
*"unreleased section is empty"*)
;;
*)
echo "Unexpected recommend failure output: ${recommend_error}" >&2
exit 1
;;
esac
{
echo "## Released Binary Validation (${{ matrix.asset_arch }})"
echo
echo "- Release tag: ${TAG_NAME}"
echo "- Asset: ${asset_name}"
echo "- Binary executed successfully via --help."
echo "- --recommend failed as expected on the tagged checkout because Unreleased is empty."
} >> "$SUMMARY_FILE"
- name: Summary
if: ${{ always() }}
run: |
set -euo pipefail
echo 'Summary'
echo
if [[ -s "$SUMMARY_FILE" ]]; then
cat "$SUMMARY_FILE"
else
echo 'No summary generated.'
fi

440
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,440 @@
# Æther Go Project Workflow Instructions
These instructions apply to all coding work in Æther Go repositories.
This file is self-contained. Do not assume access to this Guides repository or any other Æther repository when following these instructions.
## Engineering Process: Strict TDD
Follow TDD (Red, Green, Refactor) for all feature and bug-fix work:
- **Red**: Write or update a test first. Confirm the test fails for the expected reason.
- **Green**: Implement the minimum code needed to pass the test.
- **Refactor**: Improve code only after tests are green.
Do not implement behavior before a failing test exists, unless the user explicitly asks to skip TDD.
## Testing Requirements
- Always create and update tests in `*_test.go` files (Go language standard).
- Use the repository's established test framework (prefer testify suites for new coverage where appropriate).
- Run focused tests for touched packages first using `go test -run <TestName> ./path/to/package`.
- Run broader package or module suites as needed.
- Run full project validation when requested or when change risk warrants it.
- Preserve behavior validated by the repository's behavior/integration suites unless a behavioral change is explicitly requested.
## Coverage Standards
Apply coverage gates per package/module, not repository-wide aggregate:
- **Target**: 80%+ coverage per module.
- **Warning zone**: 65% to 79.99% (below target, improve during normal engineering work).
- **High risk**: 50% to 64.99% (requires explicit justification and follow-up).
- **Fail gate**: Below 50% (unacceptable).
Exclude generated code from coverage calculations. Run coverage analysis for changed modules:
```bash
go test -covermode=atomic -coverprofile=coverage.out ./...
```
## Commit Checkpoints
Create commits at these checkpoints unless the user explicitly asks not to commit:
- After writing failing tests (red commit).
- After implementation passes tests (green commit).
- After refactoring (if substantial refactoring occurred).
- After changelog updates (separate docs-only commit).
For each functional change block, create at least one code commit before moving to unrelated work. Keep commits small and scoped to one change unit. Use non-interactive git commands.
### Changelog Commits
After completing a change block:
1. Update the changelog (typically `CHANGELOG.md`).
2. Create a separate docs-only commit for changelog updates.
3. Keep changelog commits scoped to documentation changes only—do not mix code edits into that commit.
### Commit Message Guidance
Prefer conventional commits with clear scopes and concise summaries.
- Preferred format for Go maintenance and tooling changes: `chore(go): <summary>`
- Preferred format for documentation updates: `docs: <summary>`
- Keep summaries lowercase, imperative, and under 72 characters when possible.
- Use one commit per logical change.
Examples:
- `chore(go): update dependency injection guidance`
- `docs: clarify security scanning requirements`
## Go Conventions
- **Go version**: Target the repository's standard Go toolchain (typically `1.26.1`) and maintain compatibility with declared repository settings.
- **Test files**: Keep tests in `*_test.go` files in the same package as the code being tested.
- **Test suites**: Prefer testify suites for new Go test coverage where appropriate.
- **Focused testing**: Run package-specific tests first, then broader validation when requested.
- **Behavior parity**: Use the repository behavior suite for behavior parity validation when relevant.
## Dependency Injection (DI) Pattern
Dependency Injection is a required architectural pattern in Æther Go projects. Use the standards below directly:
- **Interfaces as contracts**: Define interfaces to represent dependencies; place interfaces in the same package as consumers.
- **Concrete structs**: Implement concrete types as structs that satisfy interfaces.
- **Constructor functions**: Use `New<TypeName>` constructor functions to wire dependencies and validate inputs.
- **No globals or singletons**: Accept dependencies as parameters; avoid hidden global state.
- **Testing with DI**: Create concrete mock implementations of interfaces for testing; avoid reflection-based mocking.
Example pattern:
```go
// Interface defines a contract.
type UserRepository interface {
GetUser(ctx context.Context, id string) (*User, error)
}
// Concrete implementation satisfies the interface.
type PostgresUserRepository struct {
db *sql.DB
}
func (r *PostgresUserRepository) GetUser(ctx context.Context, id string) (*User, error) {
// Implementation
}
// Constructor function injects dependencies.
func NewPostgresUserRepository(db *sql.DB) (*PostgresUserRepository, error) {
if db == nil {
return nil, errors.New("database connection cannot be nil")
}
return &PostgresUserRepository{db: db}, nil
}
// Consumer type accepts interface dependency.
type UserService struct {
repo UserRepository
}
func NewUserService(repo UserRepository) (*UserService, error) {
if repo == nil {
return nil, errors.New("UserRepository cannot be nil")
}
return &UserService{repo: repo}, nil
}
```
Key rules:
- Validate injected dependencies in constructors; return error if validation fails.
- Keep interfaces minimal and focused on the consumer's contract (Interface Segregation Principle).
- Organize packages by domain, not by layer (avoid `service`, `handler`, `repository` top-level packages).
- Break circular dependencies using interfaces; define the interface in the consuming package.
## Additional Go Conventions
Beyond dependency injection, follow these Go-specific conventions:
### Error Handling
- Return errors as the last return value.
- Use `errors.New()` or `fmt.Errorf()` for simple errors; use custom error types for complex cases.
- Wrap errors with context using `fmt.Errorf("%w", err)` in Go 1.13+.
- Do not log and return errors; let the caller decide how to handle.
Example:
```go
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
if id == "" {
return nil, fmt.Errorf("GetUser: invalid user id")
}
user, err := s.repo.GetUser(ctx, id)
if err != nil {
return nil, fmt.Errorf("GetUser: %w", err)
}
return user, nil
}
```
### Package Organization
- Use domain-driven design principles; packages represent domain entities or use cases.
- Keep package dependencies acyclic; use interfaces to break circular dependencies.
- Place interfaces in consumer packages, not separate `interfaces` packages.
- Consider a `cmd/` directory for application entry points and `internal/` for domain logic.
Example structure:
```
myapp/
cmd/
myapp/
main.go # Dependency wiring
internal/
user/
user.go # Domain model
service.go # UserService and UserRepository interface
repository.go # PostgresUserRepository
auth/
auth.go # Domain model
service.go # AuthService and AuthProvider interface
```
### Concurrency Patterns
- Use goroutines and channels for concurrent work.
- Prefer message passing (channels) over shared memory.
- Use `context.Context` for cancellation and timeouts.
- Protect shared state with mutexes only when channels are not practical.
Example:
```go
func (s *UserService) ProcessUsers(ctx context.Context, ids []string) error {
workers := 5
jobs := make(chan string, len(ids))
errors := make(chan error, len(ids))
for i := 0; i < workers; i++ {
go func() {
for id := range jobs {
if err := s.processUser(ctx, id); err != nil {
errors <- err
}
}
}()
}
for _, id := range ids {
jobs <- id
}
close(jobs)
for i := 0; i < len(ids); i++ {
select {
case err := <-errors:
if err != nil {
return err
}
case <-ctx.Done():
return ctx.Err()
}
}
return nil
}
```
### Code Organization and Naming
- Use clear, descriptive names for types and functions.
- Avoid `util`, `helper`, or `common` packages; prefer domain-specific package names.
- Keep files focused; one struct per file is reasonable if the struct is substantial.
- Use `context.Context` as the first parameter in functions that can block or make external calls.
## Workflow and Release Standards
When updating CI workflows or release logic:
- Use the repository's standard Go setup (typically `actions/setup-go@v5` with pinned version).
- Enforce Go dependency/build caching in every Go CI job to reduce repeated module and build downloads.
- Require `actions/setup-go@v5` caching with `cache: true` and `cache-dependency-path: go.sum`.
- For workflows that split jobs across multiple Go-related steps (test/lint/security), ensure caches are restored in each job.
- Enforce formatting in local and CI workflows:
- Require `go fmt ./...` before commit.
- Require formatting validation in CI (for example `test -z "$(gofmt -l .)"`), or use a standard formatter action that provides equivalent enforcement.
- Enforce module hygiene in local and CI workflows:
- Require `go mod tidy` and `go mod verify` as part of validation.
- CI may use standard actions/automation that perform equivalent module tidy and verification checks.
- Enforce changelog gate in PR validation workflows:
- Fail PR validation when no entry is added under `## [Unreleased]` in `CHANGELOG.md` for code, behavior, security, workflow, or tooling changes.
- Repository policy may allow explicit docs-only/metadata-only exceptions.
- Keep workflow summary output using the summary-file pattern:
- Define `SUMMARY_FILE` environment variable per job.
- Append markdown output from steps to the summary file.
- Print the summary in a final `Summary` step with `if: ${{ always() }}` condition.
- Badge URLs must use `https://{HOST}/{OWNER}/{REPO}/actions/workflows/{WORKFLOW_FILE}.yml/badge.svg{?CONTEXT_PARAMS}`.
- Badge-link targets must use `https://{HOST}/{OWNER}/{REPO}/actions/runs/latest?workflow={WORKFLOW_FILE}.yml{&CONTEXT_PARAMS}`.
- `CONTEXT_PARAMS` is optional; available params are `branch`, `event`, `style` for badge URLs and `branch`, `event` for badge-link targets. Prefer `branch` and `event` when filtering run context; if `style` is used, place it last.
- Prefer latest-run pages for badge links for fast status triage.
### Required Go Security Actions and Caching Pattern (GitHub Actions)
When using GitHub Actions for Go repositories, explicitly use these actions in CI:
- `securego/gosec@v2`
- `golang/govulncheck-action@v1`
Minimum recommended pattern:
```yaml
jobs:
security:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go with cache
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
cache-dependency-path: go.sum
- name: Validate formatting
run: |
set -euo pipefail
test -z "$(gofmt -l .)"
- name: Module hygiene
run: |
set -euo pipefail
go mod tidy
go mod verify
- name: Run gosec
uses: securego/gosec@v2
with:
args: ./...
- name: Run govulncheck
id: govulncheck
uses: golang/govulncheck-action@v1
with:
go-package: ./...
cache: true
cache-dependency-path: go.sum
```
### Composite Actions and Release Orchestration
Use `https://git.hrafn.xyz/aether/vociferate` as the default release-management tool when integrating Æther composite actions:
- **Always use full `https://` URLs** in `uses:` references for all vociferate actions (for example `uses: https://git.hrafn.xyz/aether/vociferate/prepare@v1.0.2`). This ensures correct action resolution on both GitHub and self-hosted Gitea instances. Never use shorthand coordinates like `aether/vociferate` without the full URL.
- Pin all action references to released tags (for example `@v1.0.2`).
- Keep all vociferate references on the same tag within a workflow.
- Use `prepare` action to update changelog/version and create release tags.
- Use `publish` action to create/update release notes and assets from existing tags.
- Do not mix alternate release actions unless a repository-local policy explicitly documents an override.
- Use `coverage-badge` action after tests produce `coverage.out` for coverage artifact uploads.
- For pre-conditions: checkout with full history (`fetch-depth: 0`), valid credentials, and required bucket variables/secrets.
Minimal standalone release workflow example:
```yaml
name: release
on:
push:
branches: [main]
permissions:
contents: write
jobs:
prepare:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Vociferate prepare
uses: https://git.hrafn.xyz/aether/vociferate/prepare@v1.0.0
publish:
needs: prepare
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Vociferate publish
uses: https://git.hrafn.xyz/aether/vociferate/publish@v1.0.0
```
## Project Conventions
- **Automation**: Prefer `justfile` for task automation; mirror core CI operations locally.
- **Dependency management**: Use `go.mod` and `go.sum` for version tracking.
- **Code formatting**: Run `go fmt ./...` before committing changes.
- **Module hygiene**: Run `go mod tidy` and `go mod verify` during local validation.
- **Structure**: Keep code organized in logical packages; avoid deep nesting.
## Security Standards
Security standards (self-contained):
- **gosec**: Run static security analysis for Go code.
- Command: `gosec ./...`
- Purpose: Detect common security issues (hard-coded secrets, SQL injection, weak crypto, etc.)
- Suppress: Use `#nosec` comments only with documented justification.
- **govulncheck**: Check Go code and dependencies for known vulnerabilities.
- Command: `govulncheck ./...`
- Purpose: Detect vulnerabilities in direct and transitive dependencies.
- Address: Update vulnerable dependencies to patched versions.
- **GitHub Actions enforcement** (for GitHub-hosted CI):
- Use `securego/gosec@v2` in CI workflows.
- Use `golang/govulncheck-action@v1` in CI workflows.
- Enable caching in these workflows (`actions/setup-go@v5` with `cache: true` and `cache-dependency-path`).
- **Dependency hygiene**: Keep `go.mod` and `go.sum` clean; run `go mod tidy` and `go mod verify` regularly.
Integrate both tools into CI workflows; fail builds on high/critical findings.
## Validation Sequence
Execute validation in this order (unless repository policy specifies otherwise):
1. Run `go fmt ./...` for code formatting.
2. Validate formatting (for example `test -z "$(gofmt -l .)"`) before or within CI.
3. Run `go mod tidy` and `go mod verify` (or equivalent standard automation).
4. Run focused package tests that directly cover the changed code.
5. Run broader package or module test suites as needed.
6. Run `gosec ./...` for security analysis.
7. Run `govulncheck ./...` for vulnerability scanning.
8. Run full project or behavior/integration suites when change scope or risk warrants it.
9. Verify coverage gates per changed module/class (target 80%, low bound 65%, fail below 50%).
## Safety and Scope
- Do not revert unrelated local changes.
- Avoid broad refactors outside the requested scope.
- Keep implementation minimal and aimed only at passing the failing test.
- Do not add code that the test does not exercise.
## Disambiguation and Decision-Making
If blocked by ambiguity:
- Ask one concise clarifying question rather than guessing.
- Proceed with the most reasonable interpretation based on context.
- Document any assumption in a commit message or code comment if relevant.
## Checklist: Feature or Bug-Fix Completion
Before considering a task done:
- ✓ A failing test existed before implementation (or skip-TDD was explicitly requested).
- ✓ Implementation was minimal and aimed at passing the test only.
- ✓ Refactoring happened only after tests were green.
- ✓ Focused tests passed for all changed packages.
- ✓ Broader validation was run when risk or scope justified it.
- ✓ Code was formatted with `go fmt ./...` and formatting validation passed.
- ✓ Module hygiene checks passed (`go mod tidy` and `go mod verify`, or equivalent standard automation).
- ✓ PR validation changelog gate passed (`CHANGELOG.md` has required addition under `## [Unreleased]` when policy applies).
- ✓ Coverage gates were evaluated per changed module/class (target 80%, low bound 65%, fail below 50%).
- ✓ Behavioral parity expectations were preserved unless change was explicitly requested.
- ✓ Security scanning passed: `gosec ./...` and `govulncheck ./...` without unacknowledged findings.
- ✓ Dependency injection properly applied: dependencies injected via constructors, not globals; interfaces define contracts; concrete implementations satisfy interfaces.
- ✓ Commits were created at checkpoints (red test, green implementation, changelog).
- ✓ Changelog was updated with a separate docs-only commit.

240
AGENTS.md Normal file
View File

@@ -0,0 +1,240 @@
# Agent Integration Guide
This guide is for agentic coding partners that need to integrate the composite actions published by this repository.
## Source Of Truth
Pin all action references to a released tag (for example `@v1.2.0`) and keep all vociferate references on the same tag in a workflow.
Published composite actions:
- `https://git.hrafn.xyz/aether/vociferate@v1.2.0` (root action)
- `https://git.hrafn.xyz/aether/vociferate/prepare@v1.2.0`
- `https://git.hrafn.xyz/aether/vociferate/publish@v1.2.0`
- `https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.2.0`
- `https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.2.0`
## Action Selection Matrix
Use this when deciding which action to call:
- Choose `prepare` when you need to update changelog/version files, commit, and push a release tag.
- Choose `publish` when a tag already exists and you need to create or update release notes/assets.
- Choose `coverage-badge` after tests have produced `coverage.out` and you need coverage artefacts uploaded.
- Choose `decorate-pr` to annotate pull requests with coverage information and unreleased changelog entries.
- Choose root `vociferate` for direct recommend/prepare logic without commit/tag/push behavior.
## Preconditions
Apply these checks before invoking actions:
- Checkout repository first.
- For prepare/publish flows that depend on tags/history, use full history checkout (`fetch-depth: 0`).
- Use `secrets.RELEASE_PAT` for release/tag/update operations (`prepare`, `publish`, `release`, `update-release`) so authenticated release changes can be pushed and published reliably.
- `release`, `update-release`, and `decorate-pr` run preflight API checks and fail fast when token credentials are missing or insufficient.
- Set required vars/secrets for coverage uploads:
- `vars.ARTEFACT_BUCKET_NAME`
- `vars.ARTEFACT_BUCKET_ENDPONT`
- `secrets.ARTEFACT_BUCKET_WRITE_ACCESS_KEY`
- `secrets.ARTEFACT_BUCKET_WRITE_ACCESS_SECRET`
- For externally visible changelog links, set `vars.VOCIFERATE_REPOSITORY_URL` to the server/base URL only.
## Changelog Format Guidance
Agents should keep `CHANGELOG.md` in a Keep a Changelog compatible structure because vociferate derives versions and release notes from headings.
Required conventions:
- Keep the top-level heading as `# Changelog`.
- Maintain an `## [Unreleased]` section.
- Keep the standard subsections under `Unreleased` in this order:
- `### Breaking`
- `### Added`
- `### Changed`
- `### Removed`
- `### Fixed`
- Record releases with headings like `## [1.2.3] - YYYY-MM-DD`.
- Use bullet entries under subsections (for example `- Added new publish output`).
- Preserve or regenerate bottom reference links (`[Unreleased]: ...`, `[1.2.3]: ...`) instead of mixing inline heading links.
Semver behavior used by recommendation logic:
- `Breaking` or `Removed` entries trigger a major bump.
- `Added` entries trigger a minor bump.
- Otherwise recommendation falls back to a patch bump.
Minimal template:
```markdown
# Changelog
## [Unreleased]
### Breaking
### Added
### Changed
### Removed
### Fixed
```
## Minimal Integration Patterns
### 1. Full Release Workflow
```yaml
jobs:
release:
uses: https://git.hrafn.xyz/aether/vociferate/.gitea/workflows/release.yml@v1.2.0
with:
version: ${{ inputs.version }}
secrets: inherit
```
### 2. Update Existing Release Tag
```yaml
jobs:
release:
uses: https://git.hrafn.xyz/aether/vociferate/.gitea/workflows/update-release.yml@v1.2.0
with:
tag: v1.2.3
secrets: inherit
```
### 3. Coverage Badge Publication
```yaml
jobs:
coverage:
runs-on: ubuntu-latest
env:
AWS_ACCESS_KEY_ID: ${{ secrets.ARTEFACT_BUCKET_WRITE_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ARTEFACT_BUCKET_WRITE_ACCESS_SECRET }}
AWS_DEFAULT_REGION: ${{ vars.ARTEFACT_BUCKET_REGION }}
AWS_EC2_METADATA_DISABLED: true
steps:
- uses: actions/checkout@v4
- name: Run tests with coverage
run: go test -covermode=atomic -coverprofile=coverage.out ./...
- id: badge
uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.2.0
with:
artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }}
artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }}
```
### 4. Decorate Pull Request With Coverage and Changes
```yaml
jobs:
coverage:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
env:
AWS_ACCESS_KEY_ID: ${{ secrets.ARTEFACT_BUCKET_WRITE_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ARTEFACT_BUCKET_WRITE_ACCESS_SECRET }}
AWS_DEFAULT_REGION: ${{ vars.ARTEFACT_BUCKET_REGION }}
AWS_EC2_METADATA_DISABLED: true
steps:
- uses: actions/checkout@v4
- name: Run tests with coverage
run: go test -covermode=atomic -coverprofile=coverage.out ./...
- id: badge
uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.2.0
with:
artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }}
artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }}
- name: Decorate PR
uses: https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.2.0
with:
coverage-percentage: ${{ steps.badge.outputs.total }}
badge-url: ${{ steps.badge.outputs.badge-url }}
```
## Inputs And Outputs Cheatsheet
### prepare
Common inputs:
- `version` (optional override)
- `version-file` (optional)
- `version-pattern` (optional)
- `changelog` (optional)
Primary output:
- `version` (resolved tag, for example `v1.2.3`)
### publish
Common inputs:
- `token` (optional, defaults to workflow token)
- `version` (optional if running from tag ref)
- `changelog` (optional)
Primary outputs:
- `release-id`
- `tag`
- `version`
### coverage-badge
Required inputs:
- `artefact-bucket-name`
- `artefact-bucket-endpoint`
Useful optional inputs:
- `coverage-profile` (default `coverage.out`)
- `summary-file` (append markdown summary)
Primary outputs:
- `total`
- `report-url`
- `badge-url`
### decorate-pr
Required inputs:
- `coverage-percentage` (0-100, typically from coverage-badge action)
- `badge-url` (SVG badge URL, typically from coverage-badge action)
Useful optional inputs:
- `changelog` (default `CHANGELOG.md`)
- `comment-title` (default `Vociferate Review`)
- `token` (defaults to workflow token)
- `enable-changelog-gate` (default `false`) - Enable changelog validation gate
- `changelog-gate-mode` (default `soft`) - `strict` or `soft` mode for gate
- `changelog-gate-required-for` (default `code,behavior,security,workflow,tooling`) - Change types requiring entries
- `changelog-gate-allow-docs-only` (default `true`) - Skip requirement for docs-only PRs
- `changelog-gate-docs-globs` (default `docs/**,**.md,**.txt,**.rst`) - Docs file patterns
- `changelog-gate-skip-labels` (default empty) - PR labels that bypass requirement
Primary outputs:
- `comment-id` - Comment ID
- `comment-url` - Comment URL
- `gate-passed` - Whether changelog gate validation passed
- `docs-only` - Whether PR is docs-only
- `unreleased-additions-count` - Number of Unreleased additions detected
- `gate-failure-reason` - Reason for gate failure, if applicable
## Guardrails For Agents
Use these rules to avoid common automation mistakes:
- Do not mix action tags in one workflow update.
- Do not assume a release workflow will run from a tag push in all environments; reusable workflow call paths are supported.
- Do not treat `VOCIFERATE_REPOSITORY_URL` as a full repository URL; it must be a base URL.
- Do not bypass preflight failures with broad retry loops; fix token scope/secret wiring first.

200
CHANGELOG.md Normal file
View File

@@ -0,0 +1,200 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
A `### Breaking` section is used in addition to Keep a Changelog's standard sections to explicitly document changes that are backwards-incompatible but would otherwise appear under `### Changed`. Entries under `### Breaking` trigger a major version bump in automated release recommendation logic.
## [Unreleased]
### Breaking
### Added
### 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.
- Changelog gate modes: `strict` (fails job on violation) and `soft` (warns via PR comment).
- Docs-only PR exemption with customizable glob patterns for documentation files.
- PR label-based exemptions for changelog gate (example: `skip-changelog`).
- Precise diff parsing: validates only added lines within the Unreleased section.
- Gate decision outputs: `gate-passed`, `docs-only`, `unreleased-additions-count`, `gate-failure-reason` for reuse downstream.
- Integrated remediation guidance in PR comments showing how to add changelog entries.
### Changed
- Refactored `internal/vociferate` to use a constructor-backed service with injected filesystem, environment, and git dependencies while preserving the existing package-level API.
- Hardened `prepare-release` validation to enforce formatting checks, module hygiene, `gosec`, and `govulncheck` before preparing a release.
- Added matching local validation targets in `justfile` for formatting, module hygiene, tests, and security checks.
- `decorate-pr` now reads Unreleased changelog content through the `vociferate` Go CLI instead of maintaining separate shell parsing logic in the composite action.
- `publish` now extracts tagged release notes through the `vociferate` Go CLI instead of duplicating changelog section parsing in shell.
- Composite actions now share a centralized `run-vociferate` orchestration flow, with binary-versus-source execution delegated through shared composite actions and single-use runtime/download logic folded back into `run-vociferate.binary`.
- `run-vociferate` now contains both binary and source execution flows directly in a single action implementation, removing nested local action wrappers for better runner compatibility.
- Release automation now requires `secrets.RELEASE_PAT` for prepare/publish/do-release operations instead of defaulting to `GITHUB_TOKEN`/`GITEA_TOKEN`.
- Renamed the reusable Gitea workflows to `release.yml` and `update-release.yml`, and inlined release publication into the main `release` workflow for clearer per-step job output.
- Release binary builds now compress published linux artifacts with UPX before checksum generation and upload.
### Removed
### Fixed
- Prevented `govulncheck-action` from defaulting to `setup-go` version `stable` by explicitly setting `go-version-file` and disabling `check-latest`, avoiding unauthenticated GitHub API rate-limit failures on self-hosted/act-style runners.
- Made `do-release` version resolution resilient to `workflow_call` input passing issues by adding a separate tag detection step that fetches and discovers the latest tag from origin as a fallback when `inputs.tag` is empty, enabling proper operation even when Gitea's workflow_call doesn't pass inputs through correctly.
- Fixed version resolution in `do-release` workflow by moving version calculation before checkout, resolving from inputs/git tags, and always passing explicit version to `publish` action.
- Fixed tag detection in `do-release` to prioritize the tag at current HEAD (created by `prepare-release`) over the globally latest tag, ensuring correct version is detected when called from `prepare-release` workflow.
- Fixed `do-release` workflow_call resolution on Teacup runners by explicitly falling back to `needs.prepare.outputs.tag` and normalizing `%!t(string=...)` wrapped values before choosing a release tag.
- Fixed release-chain triggering by using a PAT for release commit/tag pushes so downstream release workflows are triggered reliably.
- Made `publish` action version resolution more robust with clearer error messages when version input is missing and workflow is not running from a tag push.
- Fixed `do-release` workflow to always checkout the resolved release tag, eliminating conditional checkout logic that could skip the checkout when called from `prepare-release` workflow.
- Pinned `securego/gosec` and `golang/govulncheck-action` to concrete version tags (`v2.22.4` and `v1.0.4`) so self-hosted Gitea runners can resolve them via direct git clone without relying on the GitHub Actions floating-tag API.
- Restored explicit gosec caching by storing a pinned `v2.22.4` binary under `${{ runner.temp }}/gosec-bin` with `actions/cache@v4`, so CI keeps fast security scans while still using the Go 1.26 toolchain from `setup-go`.
- Replaced `securego/gosec` composite action with a direct `go install github.com/securego/gosec/v2/cmd/gosec@v2.22.4 && gosec ./...` run step so gosec uses the Go 1.26 toolchain installed by `setup-go` rather than the action's bundled Go 1.24 binary which ignores `GOTOOLCHAIN=auto`.
- Fixed nested local composite-action references to use repository-local `./run-vociferate` paths so strict runners do not misparse parent-directory (`../`) action references as malformed remote coordinates.
- Consolidated `run-vociferate` binary and source execution flows directly into the main `run-vociferate` action to avoid nested local-action path resolution issues on strict runners.
- Hardened workflow module hygiene by retrying `go mod verify` after a module-cache refresh (`go clean -modcache` + `go mod download`) when runners report modified cached dependency directories.
- Synced `update-release.yml` with the active release pipeline fixes for Teacup-wrapped outputs, release-id normalization, upload endpoint validation, and accurate success or failure summaries.
## [1.0.2] - 2026-03-21
### Breaking
### Added
### Changed
- Documented release/PR-decoration preflight token and API-access checks, including `GITHUB_TOKEN`/`GITEA_TOKEN` behavior for self-hosted Gitea.
### Removed
### Fixed
## [1.0.1] - 2026-03-21
### Breaking
### Added
### Changed
### Removed
### Fixed
- Enforced explicit `https://` changelog reference links in prepare output for browser-safe markdown links.
## [1.0.0] - 2026-03-21
### Breaking
### Added
### Changed
- Canonical changelog filename is now `CHANGELOG.md`, and action/code defaults were updated to match.
- README now uses `Æther` stylization in prose and corrects released-tag guidance wording.
### Removed
### Fixed
## [0.2.0] - 2026-03-21
### Breaking
### Added
- Added a project LICENSE file.
- Root and prepare actions now read `${{ vars.VOCIFERATE_REPOSITORY_URL }}` and forward it to `VOCIFERATE_REPOSITORY_URL` for repository URL override.
- Added a published `coverage-badge` composite action for generating and uploading coverage report/badge artefacts for reuse across repositories.
- Added `AGENTS.md`, an explicit integration guide for agentic coding partners using vociferate composite actions.
### Changed
- Push validation now handles coverage artefact and badge generation in a dedicated `coverage-badge` job, with release recommendation isolated in a separate dependent job.
- Push validation now calls the reusable `./coverage-badge` composite action for coverage badge generation and publication.
### Removed
### Fixed
- Browser-facing URLs emitted in generated changelog links, workflow summaries, and markdown now use explicit `https://` forms.
- Release workflows now collect summary markdown into portable temp files and print it in explicit `Summary` steps instead of relying on unsupported `GITHUB_STEP_SUMMARY` output.
- Prepare now recreates the standard `Unreleased` section headers after promoting notes into a tagged release entry.
- First-release recommendation remains `v1.0.0` when no prior releases exist in the changelog.
- Do Release smoke validation now expects `--recommend` to fail on tagged release checkouts where `Unreleased` is intentionally empty.
- Changelog reference links now use compare URLs (`previous...current` for releases and `latest...main` for Unreleased), with first release links comparing from the repository's first commit short hash.
- Repository URL derivation now supports `VOCIFERATE_REPOSITORY_URL` as the highest-priority base-URL override for changelog link generation.
## [0.1.0] - 2026-03-20
### Changed
- README workflow badges now link to the latest workflow run pages instead of the workflow definition pages.
### Fixed
- Publish action falls back to `git describe` when `inputs.version` is empty and `GITHUB_REF` is not a tag ref, resolving `workflow_call` input propagation failures in act runner v0.3.0.
### Added
- Coverage badge in README linked to S3-hosted main-branch report.
- S3 coverage artefact publishing (HTML report, badge, JSON summary) in push validation pipeline.
- CLI tests and internal helper tests raising total coverage to 84%.
- Test suite isolation against ambient CI environment variables for changelog link generation tests.
- Go CLI for changelog-driven release preparation and semantic version recommendation.
- Version recommendation from changelog release headings, including first-release support (`0.0.0` base -> `v1.0.0`).
- Automatic `release-version` creation/update during release preparation.
- Configurable version source/parsing via `--version-file` and `--version-pattern`.
- Configurable changelog path via `--changelog`.
- Recommended-version fallback when `version` is omitted in CLI and action flows.
- Major-version recommendation trigger from `Unreleased` `### Breaking`.
- Root composite action (`action.yml`) for recommend/prepare flows.
- Subdirectory composite actions: `prepare/action.yml` (prepare/commit/tag/push) and `publish/action.yml` (extract notes/create-or-update release).
- `publish` outputs for downstream automation: `release-id`, `tag`, and `version`.
- Dual execution mode for actions: `go run` from source on `@main`, prebuilt binaries on tagged refs.
- Repository-scoped binary cache keys with workflow-defined fixed token support via `VOCIFERATE_CACHE_TOKEN`.
- Tag-driven release publication with idempotent release updates and asset replacement on reruns.
- Release artifacts for `linux/amd64`, `linux/arm64`, and `checksums.txt`.
- Reusable Gitea workflows (`prepare-release.yml`, `do-release.yml`) with `workflow_call` support.
- Project/automation rename from `releaseprep` to `vociferate` (entrypoint, package paths, outputs).
- README guidance focused on primary cross-repository reuse workflows.
[Unreleased]: https://git.hrafn.xyz/aether/vociferate/compare/v1.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.1]: https://git.hrafn.xyz/aether/vociferate/compare/v1.0.0...v1.0.1
[1.0.0]: https://git.hrafn.xyz/aether/vociferate/compare/v0.2.0...v1.0.0
[0.2.0]: https://git.hrafn.xyz/aether/vociferate/compare/v0.1.0...v0.2.0
[0.1.0]: https://git.hrafn.xyz/aether/vociferate/compare/995e397...v0.1.0

371
COMPLIANCE_ANALYSIS.md Normal file
View File

@@ -0,0 +1,371 @@
# Vociferate Standards Compliance Analysis
**Date:** March 21, 2026
**Repository:** git.hrafn.xyz/aether/vociferate
**Analysis Scope:** Go codebase, CI workflows, and engineering practices
---
## Executive Summary
The vociferate codebase demonstrates **solid fundamentals** in testing, error handling, and package organization but **lacks critical CI/CD workflow validation** steps documented in the project standards. The main gaps are:
-**Strong:** Test structure (testify suites), coverage (80%+), error handling (proper wrapping)
- ⚠️ **Acceptable:** Dependency injection patterns (functional options pattern used appropriately)
-**Critical Gaps:** Missing `go fmt`, `go mod tidy/verify`, `gosec`, `govulncheck` in CI workflows
---
## 1. Testing Structure
### ✅ Status: COMPLIANT
**Findings:**
- **Test file format:** Properly organized in `*_test.go` files
- [cmd/vociferate/main_test.go](cmd/vociferate/main_test.go)
- [internal/vociferate/vociferate_test.go](internal/vociferate/vociferate_test.go)
- [internal/vociferate/vociferate_internal_test.go](internal/vociferate/vociferate_internal_test.go)
- **Testify suite usage:** ✅ Yes, properly implemented
- `PrepareSuite` in [vociferate_test.go](internal/vociferate/vociferate_test.go#L12) uses `suite.Suite`
- Tests use `require` assertions from testify
- Setup/teardown via `SetupTest()` method
- **Coverage analysis:**
- **cmd/vociferate:** 84.6% ✅ (exceeds 80% target)
- **internal/vociferate:** 80.9% ✅ (meets 80% target)
- **Total:** Both packages meet or exceed target
- Coverage methodology: `go test -covermode=atomic -coverprofile=coverage.out ./...`
**Compliance:** ✅ Full compliance with testing standards
---
## 2. Dependency Injection
### ⚠️ Status: PARTIAL COMPLIANCE
**Findings:**
**What's Good:**
- ✅ No global singletons or hidden state
- ✅ Package state is minimal and functions are stateless
- ✅ Functional options pattern used (`vociferate.Options` struct):
```go
type Options struct {
VersionFile string
VersionPattern string
Changelog string
}
```
- ✅ Functions accept options explicitly (not constructor-injected, but appropriate for this use case)
**What Needs Attention:**
- ⚠️ **No explicit `New*` constructor functions** — This is acceptable for a utility library, but pattern not followed
- ⚠️ **Global regex variables** (4 instances, should be const or lazy-initialized):
```go
var releasedSectionRe = regexp.MustCompile(`(?m)^## \[(\d+\.\d+\.\d+)\] - `)
var linkedReleasedSectionRe = regexp.MustCompile(...)
var unreleasedHeadingRe = regexp.MustCompile(...)
var releaseHeadingRe = regexp.MustCompile(...)
var refLinkLineRe = regexp.MustCompile(...)
```
- **Issue:** Mutable global state; should be const or initialized once
- **Low risk** for this codebase (single-use CLI), but violates best practices
**Compliance:** ⚠️ Acceptable for library code; regex vars could be improved
---
## 3. Error Handling
### ✅ Status: EXCELLENT
**Findings:**
- ✅ All errors wrapped with context using `fmt.Errorf("%w", err)`
- ✅ Consistent error wrapping throughout codebase:
- [vociferate.go lines 68, 73, 81, 87, 104](internal/vociferate/vociferate.go#L68-L87)
- `"version must not be empty"` → `fmt.Errorf("version must not be empty")`
- `"compile version pattern: %w"` → wraps underlying error
- `"read version file: %w"` → proper context wrapping
- `"write changelog: %w"` → proper context wrapping
- ✅ No log-and-return anti-pattern observed
- ✅ Error propagation allows callers to decide handling
**Examples of proper error handling:**
```go
// From updateVersionFile
if err := os.ReadFile(path); err != nil {
if os.IsNotExist(err) {
return os.WriteFile(...)
}
return fmt.Errorf("read version file: %w", err)
}
// From resolveOptions
versionExpr, err := regexp.Compile(pattern)
if err != nil {
return resolvedOptions{}, fmt.Errorf("compile version pattern: %w", err)
}
```
**Compliance:** ✅ Full compliance with error handling standards
---
## 4. Package Organization
### ✅ Status: COMPLIANT
**Findings:**
- ✅ **Domain-driven structure:**
- `internal/vociferate/` — Core domain logic
- `cmd/vociferate/` — CLI entry point
- No layer-based top-level packages (no `service/`, `handler/`, `repository/`)
- ✅ **Clear separation of concerns:**
- CLI parsing and execution in `cmd/vociferate/main.go`
- Domain logic in `internal/vociferate/vociferate.go`
- Tests colocated with implementations
- ✅ **Version placeholder package** (empty, future-ready):
- `internal/vociferate/version/` — Prepared for versioning but not yet populated
- ✅ **Minimal, focused code organization:**
- No unnecessary intermediate packages
- Clear domain boundaries
**Compliance:** ✅ Full compliance with package organization standards
---
## 5. CI/CD Workflows
### ✅ Status: COMPLIANT
**Workflows analyzed:**
- [push-validation.yml](.gitea/workflows/push-validation.yml)
- [release.yml](.gitea/workflows/release.yml)
- [update-release.yml](.gitea/workflows/update-release.yml)
#### What's Implemented
**push-validation.yml:**
- ✅ Go 1.26.1 setup with `actions/setup-go@v5`
- ✅ Caching enabled (`cache: true`, `cache-dependency-path: go.sum`)
- ✅ Code formatting validation (`go fmt` check)
- ✅ Module hygiene checks (`go mod tidy` and `go mod verify`)
- ✅ Security analysis with `gosec`
- ✅ Vulnerability scanning with `govulncheck`
- ✅ Full unit test suite with coverage (`go test -covermode=atomic -coverprofile=coverage.out`)
- ✅ Coverage badge publication
- ✅ Release tag recommendation on `main` branch
**release.yml:**
- ✅ Go setup and caching
- ✅ Tests run before release preparation
- ✅ Version and changelog updates
- ✅ Tag creation
#### What's Fixed
| Step | Documented Requirement | Push Validation | Status |
| --------------------- | ----------------------------------------- | --------------- | -------- |
| **go fmt validation** | Required | ✅ YES | Enforced |
| **go mod tidy** | Required | ✅ YES | Enforced |
| **go mod verify** | Required | ✅ YES | Enforced |
| **gosec** | Required (`securego/gosec@v2`) | ✅ YES | Enforced |
| **govulncheck** | Required (`golang/govulncheck-action@v1`) | ✅ YES | Enforced |
**Implemented Actions (commit 7cb7b05):**
```yaml
# Now in push-validation.yml:
- name: Validate formatting
run: test -z "$(gofmt -l .)"
- name: Module hygiene
run: |
set -euo pipefail
go mod tidy
go mod verify
- name: Run gosec security analysis
uses: securego/gosec@v2
with:
args: ./...
- name: Run govulncheck
uses: golang/govulncheck-action@v1
with:
go-package: ./...
cache: true
cache-dependency-path: go.sum
```
**Note:** Changelog gate is a PR-level feature implemented in the `decorate-pr` action, not a push validation check.
---
## 6. Validation Sequence
### ✅ Status: NOW FOLLOWING DOCUMENTED STANDARD
**Documented sequence (from copilot-instructions.md):**
1. ✅ Run `go fmt ./...` for code formatting
2. ✅ **Validate formatting** — **NOW IMPLEMENTED**
3. ✅ **Run `go mod tidy` and `go mod verify`** — **NOW IMPLEMENTED**
4. ✅ Run focused package tests
5. ✅ Run broader test suites
6. ✅ **Run `gosec ./...`** — **NOW IMPLEMENTED**
7. ✅ **Run `govulncheck ./...`** — **NOW IMPLEMENTED**
8. ✅ Run full project validation (coverage checks)
9. ✅ Verify coverage gates per module (target 80%)
**Current workflow sequence (after commit 7cb7b05):**
1. Setup Go environment with caching ✅
2. Validate code formatting ✅
3. Check module hygiene (tidy + verify) ✅
4. Run security analysis (gosec) ✅
5. Run vulnerability scanning (govulncheck) ✅
6. Run full unit test suite with coverage ✅
7. Publish coverage badge ✅
8. (On main) Recommend next release tag ✅
**Impact:** All security, formatting, and module checks now run in CI, preventing:
- Inconsistent code formatting from merging ✅
- Stale/incorrect `go.mod` from merging ✅
- Known vulnerabilities from going undetected ✅
---
## 7. Additional Observations
### Code Quality Improvements (commit 7cb7b05)
**Regex Variables in `internal/vociferate/vociferate.go`:**
- ✅ Grouped into `var (...)` block for clarity
- ✅ Added clarifying comment about read-only nature
- Maintains Go idioms while signaling immutability intent
- No functional changes; improves code organization
### Justfile (Local Automation)
**Current state:** Aligned with CI baseline for local validation
```bash
go-build
go-test
validate-fmt
validate-mod
security
validate
```
**Implemented locally (commit 383aad4):**
- ✅ `validate-fmt` runs `go fmt ./...` and verifies `gofmt -l .` is clean
- ✅ `validate-mod` runs `go mod tidy` and `go mod verify`
- ✅ `security` runs `gosec ./...` and `govulncheck ./...`
- ✅ `validate` composes formatting, module hygiene, tests, and security checks
### Go Module Configuration
✅ **go.mod** is properly configured:
- Go 1.26 with toolchain 1.26.1
- Dependencies: `github.com/stretchr/testify v1.10.0` (for test suites)
- No extraneous dependencies
### Code Formatting
✅ **Code appears to follow Go conventions:**
- Consistent naming (camelCase for exported names)
- Proper error returns
- Clear package documentation
---
## Recommendations (Priority Order)
### ✅ COMPLETED (commit 7cb7b05)
1. ✅ **`gosec` security scanning** — Now implemented in `push-validation.yml`
2. ✅ **`govulncheck` vulnerability scanning** — Now implemented in `push-validation.yml`
3. ✅ **`go fmt` validation** — Now implemented in `push-validation.yml`
4. ✅ **Module hygiene checks** (`go mod tidy` + `go mod verify`) — Now implemented in `push-validation.yml`
5. ✅ **Regex variable organization** — Grouped with clarifying comments in `vociferate.go`
6. ✅ **DI service boundary** — `internal/vociferate` now uses a constructor-backed service with injected filesystem, environment, and git dependencies (commit 383aad4)
7. ✅ **Local validation parity** — `justfile` now mirrors CI checks for format, modules, tests, and security (commit 383aad4)
### 🟡 FUTURE (Lower Priority)
8. **Implement changelog gate in PR workflows** — The `decorate-pr` action has changelog gate support; consider enabling `changelog-gate-mode: soft` in workflow if desired for future enhancement.
---
## Summary Table
| Category | Standard | Status | Details |
| ------------------------ | ------------------------------------ | ------- | ------------------------------------------------------ |
| **Testing** | `*_test.go` + testify suites | ✅ PASS | 80%+ coverage in all packages |
| **DI Pattern** | Constructor functions, no singletons | ✅ PASS | Constructor-backed service with injected collaborators |
| **Error Handling** | fmt.Errorf with `%w` wrapping | ✅ PASS | Consistent throughout codebase |
| **Package Organization** | Domain-driven, no layer-based | ✅ PASS | Clean structure, no over-engineering |
| **go fmt validation** | Fail if formatting inconsistent | ✅ PASS | Enforced in workflows and local automation |
| **go mod checks** | tidy + verify | ✅ PASS | Enforced in workflows and local automation |
| **gosec** | Static security analysis | ✅ PASS | Enforced in workflows and local automation |
| **govulncheck** | Vulnerability scanning | ✅ PASS | Enforced in workflows and local automation |
| **Coverage gates** | 80% target per module | ✅ PASS | Both packages exceed/meet target |
| **Changelog gate** | Enforce changelog entries | ❌ FAIL | Not implemented |
---
## Conclusion
**Current State (Updated):** The codebase now demonstrates strong engineering fundamentals in testing, error handling, structure, **and CI/CD validation**.
✅ **All critical standards gaps have been addressed** across commits 7cb7b05 and 383aad4:
- Security scanning (`gosec` + `govulncheck`) now enforced
- Code formatting validation now required
- Module hygiene checks (`go mod tidy`/`verify`) now enforced
- Regex variable organization clarified
- Dependency injection implemented through a constructor-backed service
- Local `justfile` validation now mirrors CI checks
**Validation Sequence:** The workflow now follows the documented 8-step validation sequence from copilot-instructions.md:
1. Format validation
2. Module hygiene
3. Security analysis
4. Vulnerability scanning
5. Full test suite
6. Coverage analysis
**Effort Invested:**
- CI/CD improvements: workflow hardening in `push-validation.yml` and `release.yml`
- Code organization: injected service boundaries for filesystem, environment, and git access
- Local automation: `justfile` validation parity for format, modules, tests, and security
- **Primary commits:** 7cb7b05, 383aad4, 5c903c9
**Next Steps (Optional):**
- Consider enabling changelog gate in PR workflows for future enhancement

150
README.md
View File

@@ -1,11 +1,11 @@
# vociferate # vociferate
[![Main Validation](//git.hrafn.xyz/aether/vociferate/actions/workflows/push-validation.yml/badge.svg?branch=main&event=push)](//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](//git.hrafn.xyz/aether/vociferate/actions/workflows/prepare-release.yml/badge.svg?event=workflow_dispatch)](//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](//git.hrafn.xyz/aether/vociferate/actions/workflows/do-release.yml/badge.svg?event=push)](//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](//s3.hrafn.xyz/aether-workflow-report-artefacts/vociferate/branch/main/coverage-badge.svg)](//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 `aether` release orchestration tool written in Go for repositories that `vociferate` is an `Æther` release orchestration tool written in Go for repositories that
want changelog-driven versioning, automated release preparation, and repeatable want changelog-driven versioning, automated release preparation, and repeatable
tag publication. tag publication.
@@ -16,13 +16,15 @@ revision.
## Use In Other Repositories ## Use In Other Repositories
Vociferate ships two composite actions that together cover the full release flow. Vociferate ships composite actions covering release preparation, release publication, coverage badge publishing, and pull request decoration.
Until release tags are created, reference `@main`. Once tags exist again, pin both actions to the same released tag. Release tags now exist; pin all action and reusable-workflow references to the same released tag (for example, `@v1.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.
### `prepare` — update files, commit, and push tag ### `prepare` — update files, commit, and push tag
```yaml ```yaml
name: Prepare Release name: Release
on: on:
workflow_dispatch: workflow_dispatch:
@@ -39,19 +41,19 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: git.hrafn.xyz/aether/vociferate/prepare@main - uses: https://git.hrafn.xyz/aether/vociferate/prepare@v1.2.0
with: with:
version: ${{ inputs.version }} version: ${{ inputs.version }}
publish: publish:
needs: prepare needs: prepare
uses: aether/vociferate/.gitea/workflows/do-release.yml@main uses: https://git.hrafn.xyz/aether/vociferate/.gitea/workflows/update-release.yml@v1.2.0
with: with:
tag: ${{ needs.prepare.outputs.version }} tag: ${{ needs.prepare.outputs.version }}
secrets: inherit secrets: inherit
``` ```
Downloads a prebuilt vociferate binary, runs it to update `changelog.md` and Downloads a prebuilt vociferate binary, runs it to update `CHANGELOG.md` and
`release-version`, then commits those changes to the default branch and pushes `release-version`, then commits those changes to the default branch and pushes
the release tag. Does not require Go on the runner. the release tag. Does not require Go on the runner.
@@ -59,20 +61,21 @@ For repositories that embed the version inside source code, pass `version-file`
and `version-pattern`: and `version-pattern`:
```yaml ```yaml
- uses: git.hrafn.xyz/aether/vociferate/prepare@main - uses: https://git.hrafn.xyz/aether/vociferate/prepare@v1.2.0
with: with:
token: ${{ secrets.RELEASE_PAT }}
version-file: internal/myapp/version/version.go version-file: internal/myapp/version/version.go
version-pattern: 'const Version = "([^"]+)"' version-pattern: 'const Version = "([^"]+)"'
git-add-files: changelog.md internal/myapp/version/version.go git-add-files: CHANGELOG.md internal/myapp/version/version.go
``` ```
`prepare` uses `github.token` internally for authenticated fetch/push operations, `prepare` requires a PAT input for authenticated commit/push/tag operations.
so no token input is required. Pass `token: ${{ secrets.RELEASE_PAT }}` when invoking the action.
### `publish` — create release with changelog notes ### `publish` — create release with changelog notes
```yaml ```yaml
name: Do Release name: Update Release
on: on:
workflow_dispatch: workflow_dispatch:
@@ -83,32 +86,135 @@ on:
jobs: jobs:
release: release:
uses: aether/vociferate/.gitea/workflows/do-release.yml@main uses: https://git.hrafn.xyz/aether/vociferate/.gitea/workflows/update-release.yml@v1.2.0
with: with:
tag: ${{ inputs.tag }} tag: ${{ inputs.tag }}
secrets: inherit secrets: inherit
``` ```
Reads the matching section from `changelog.md` and creates or updates the Reads the matching section from `CHANGELOG.md` and creates or updates the
Gitea/GitHub release with those notes. The `version` input is optional — when Gitea/GitHub release with those notes. The `version` input is optional — when
omitted it is derived from the current tag ref automatically. omitted it is derived from the current tag ref automatically.
The reusable `Update Release` workflow now runs preflight checks before publish to
fail fast when the release token is missing or lacks API access. Set
`secrets.RELEASE_PAT` and use it for prepare/publish release operations.
The `publish` action outputs `release-id` so you can upload additional release The `publish` action outputs `release-id` so you can upload additional release
assets after it runs: assets after it runs:
```yaml ```yaml
- id: publish - id: publish
uses: git.hrafn.xyz/aether/vociferate/publish@main uses: https://git.hrafn.xyz/aether/vociferate/publish@v1.2.0
- name: Upload my binary - name: Upload my binary
run: | run: |
curl --fail-with-body -X POST \ curl --fail-with-body -X POST \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ -H "Authorization: token ${{ secrets.RELEASE_PAT }}" \
-H "Content-Type: application/octet-stream" \ -H "Content-Type: application/octet-stream" \
"${{ github.api_url }}/repos/${{ github.repository }}/releases/${{ steps.publish.outputs.release-id }}/assets?name=myapp" \ "${{ github.api_url }}/repos/${{ github.repository }}/releases/${{ steps.publish.outputs.release-id }}/assets?name=myapp" \
--data-binary "@dist/myapp" --data-binary "@dist/myapp"
``` ```
### `coverage-badge` - publish coverage report and badge
Run your coverage tests first, then call the action to generate `coverage.html`, `coverage-badge.svg`, and `coverage-summary.json`, upload them to S3-compatible storage, and emit output URLs.
```yaml
- name: Run tests with coverage
run: go test -covermode=atomic -coverprofile=coverage.out ./...
- id: coverage
uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.2.0
with:
artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }}
artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }}
- name: Print coverage links
run: |
echo "Report: ${{ steps.coverage.outputs.report-url }}"
echo "Badge: ${{ steps.coverage.outputs.badge-url }}"
```
### `decorate-pr` - annotate pull requests with coverage and changes
Decorate pull requests with coverage badges, coverage percentages, and unreleased changelog entries. The action creates a new comment or updates an existing one on each run.
`decorate-pr` also runs a preflight comment API check so workflows fail early
with a clear message when token permissions are insufficient.
#### Basic Usage
```yaml
- name: Run tests with coverage
run: go test -covermode=atomic -coverprofile=coverage.out ./...
- id: coverage
uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.2.0
with:
artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }}
artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }}
- name: Decorate pull request
if: github.event_name == 'pull_request'
uses: https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.2.0
with:
coverage-percentage: ${{ steps.coverage.outputs.total }}
badge-url: ${{ steps.coverage.outputs.badge-url }}
```
#### Changelog Gate (Strict Mode)
Enable changelog validation to enforce that code changes include `Unreleased` changelog entries. The gate fails the workflow in strict mode, or warns in soft mode:
```yaml
- name: Decorate pull request with changelog gate
if: github.event_name == 'pull_request'
uses: https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.2.0
with:
coverage-percentage: ${{ steps.coverage.outputs.total }}
badge-url: ${{ steps.coverage.outputs.badge-url }}
enable-changelog-gate: true
changelog-gate-mode: strict
changelog-gate-required-for: "code,behavior,security,workflow,tooling"
changelog-gate-allow-docs-only: true
changelog-gate-docs-globs: "docs/**,**.md,**.txt,**.rst"
changelog-gate-skip-labels: "skip-changelog"
```
The gate automatically:
- Parses diffs to detect docs-only PRs (skips requirement for doc-only changes)
- Counts `Unreleased` additions using section-aware parsing (ignores edits outside the section)
- Checks PR labels for skip exemptions (for example, `skip-changelog`)
- Outputs decision status and remediation guidance in the PR comment
- Handles both strict (fail) and soft (warn) modes
Decision outputs enable downstream workflow logic:
```yaml
- name: Decorate PR and check gate
id: decorate
if: github.event_name == 'pull_request'
uses: https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.2.0
with:
coverage-percentage: ${{ steps.coverage.outputs.total }}
badge-url: ${{ steps.coverage.outputs.badge-url }}
enable-changelog-gate: true
changelog-gate-mode: soft
- name: Gate decision
run: |
echo "Gate passed: ${{ steps.decorate.outputs.gate-passed }}"
echo "Is docs-only PR: ${{ steps.decorate.outputs.docs-only }}"
echo "Unreleased additions: ${{ steps.decorate.outputs.unreleased-additions-count }}"
if [[ "${{ steps.decorate.outputs.gate-passed }}" == "false" ]]; then
echo "Gate failure reason: ${{ steps.decorate.outputs.gate-failure-reason }}"
fi
```
The action automatically finds existing vociferate comments by their marker and updates them instead of creating duplicates. This keeps PR timelines clean while keeping review information current.
## Why The Name ## Why The Name
> **vociferate** _(verb)_: to cry out loudly or forcefully. > **vociferate** _(verb)_: to cry out loudly or forcefully.
@@ -163,7 +269,7 @@ Defaults:
- `version-file`: `release-version` - `version-file`: `release-version`
- `version-pattern`: `^\s*([^\r\n]+)\s*$` - `version-pattern`: `^\s*([^\r\n]+)\s*$`
- `changelog`: `changelog.md` - `changelog`: `CHANGELOG.md`
When no `--version-file` flag is provided, `vociferate` derives the current version from the most recent released section heading in the changelog (`## [x.y.z] - ...`). If no prior releases exist, it defaults to `0.0.0` and recommends `v1.0.0` as the first tag. When no `--version-file` flag is provided, `vociferate` derives the current version from the most recent released section heading in the changelog (`## [x.y.z] - ...`). If no prior releases exist, it defaults to `0.0.0` and recommends `v1.0.0` as the first tag.
@@ -183,7 +289,7 @@ just go-test
Releases use two workflows: Releases use two workflows:
- `Prepare Release` runs on demand, updates `release-version` and `changelog.md`, commits those changes back to `main`, and pushes the release tag. - `Prepare Release` runs on demand, updates `release-version` and `CHANGELOG.md`, commits those changes back to `main`, and pushes the release tag.
- `Prepare Release` then calls `Do Release` directly via reusable `workflow_call` with the resolved tag. - `Prepare Release` then calls `Do Release` directly via reusable `workflow_call` with the resolved tag.
- `Do Release` reads the matching changelog section from that tagged revision, creates or updates the release, and uploads prebuilt binaries. - `Do Release` reads the matching changelog section from that tagged revision, creates or updates the release, and uploads prebuilt binaries.

View File

@@ -16,7 +16,7 @@ inputs:
changelog: changelog:
description: Path to changelog file relative to repository root. description: Path to changelog file relative to repository root.
required: false required: false
default: changelog.md default: CHANGELOG.md
recommend: recommend:
description: If true, print recommended next release tag. description: If true, print recommended next release tag.
required: false required: false
@@ -25,142 +25,71 @@ inputs:
outputs: outputs:
version: version:
description: Resolved version used for prepare mode, or the emitted recommended version for recommend mode. description: Resolved version used for prepare mode, or the emitted recommended version for recommend mode.
value: ${{ steps.run-vociferate.outputs.version }} value: ${{ steps.finalize-version.outputs.version }}
runs: runs:
using: composite using: composite
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Resolve vociferate binary metadata - name: Resolve release date
id: resolve-binary id: resolve-date
shell: bash shell: bash
env:
ACTION_REF: ${{ github.action_ref }}
ACTION_REPOSITORY: ${{ github.action_repository }}
CACHE_TOKEN: ${{ env.VOCIFERATE_CACHE_TOKEN }}
SERVER_URL: ${{ github.server_url }}
API_URL: ${{ github.api_url }}
TOKEN: ${{ github.token }}
RUNNER_ARCH: ${{ runner.arch }}
RUNNER_TEMP: ${{ runner.temp }}
run: | run: |
set -euo pipefail set -euo pipefail
case "$RUNNER_ARCH" in printf 'value=%s\n' "$(date -u +%F)" >> "$GITHUB_OUTPUT"
X64)
arch="amd64"
;;
ARM64)
arch="arm64"
;;
*)
echo "Unsupported runner architecture: $RUNNER_ARCH" >&2
exit 1
;;
esac
if [[ "$ACTION_REF" == v* ]]; then - name: Normalize version input
release_tag="$ACTION_REF" id: normalize-version
normalized_version="${release_tag#v}" shell: bash
asset_name="vociferate_${normalized_version}_linux_${arch}" env:
cache_dir="${RUNNER_TEMP}/vociferate/${release_tag}/linux-${arch}" INPUT_VERSION: ${{ inputs.version }}
binary_path="${cache_dir}/vociferate" run: |
asset_url="${SERVER_URL}/aether/vociferate/releases/download/${release_tag}/${asset_name}" set -euo pipefail
provided_cache_token="$(printf '%s' "${CACHE_TOKEN:-}" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')" resolved_version="$(printf '%s' "${INPUT_VERSION:-}" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
if [[ -n "$provided_cache_token" ]]; then printf 'value=%s\n' "$resolved_version" >> "$GITHUB_OUTPUT"
cache_token="$provided_cache_token"
else
cache_token="${ACTION_REPOSITORY:-aether/vociferate}-${release_tag}"
fi
mkdir -p "$cache_dir" - name: Recommend version
id: recommend-version
echo "use_binary=true" >> "$GITHUB_OUTPUT" if: inputs.recommend == 'true' || steps.normalize-version.outputs.value == ''
echo "release_tag=$release_tag" >> "$GITHUB_OUTPUT" uses: ./run-vociferate
echo "cache_token=$cache_token" >> "$GITHUB_OUTPUT"
echo "asset_name=$asset_name" >> "$GITHUB_OUTPUT"
echo "asset_url=$asset_url" >> "$GITHUB_OUTPUT"
echo "cache_dir=$cache_dir" >> "$GITHUB_OUTPUT"
echo "binary_path=$binary_path" >> "$GITHUB_OUTPUT"
else
echo "use_binary=false" >> "$GITHUB_OUTPUT"
fi
- name: Setup Go
if: steps.resolve-binary.outputs.use_binary != 'true'
uses: actions/setup-go@v5
with: with:
go-version: '1.26.1' root: ${{ github.workspace }}
cache: true version-file: ${{ inputs.version-file }}
cache-dependency-path: ${{ github.action_path }}/go.sum version-pattern: ${{ inputs.version-pattern }}
changelog: ${{ inputs.changelog }}
recommend: 'true'
- name: Restore cached vociferate binary - name: Finalize version
id: cache-vociferate id: finalize-version
if: steps.resolve-binary.outputs.use_binary == 'true'
uses: actions/cache@v4
with:
path: ${{ steps.resolve-binary.outputs.cache_dir }}
key: vociferate-${{ steps.resolve-binary.outputs.cache_token }}-linux-${{ runner.arch }}
- name: Download vociferate binary
if: steps.resolve-binary.outputs.use_binary == 'true' && steps.cache-vociferate.outputs.cache-hit != 'true'
shell: bash shell: bash
env: env:
TOKEN: ${{ github.token }} PROVIDED_VERSION: ${{ steps.normalize-version.outputs.value }}
ASSET_URL: ${{ steps.resolve-binary.outputs.asset_url }} RECOMMENDED_VERSION: ${{ steps.recommend-version.outputs.stdout }}
BINARY_PATH: ${{ steps.resolve-binary.outputs.binary_path }}
run: | run: |
set -euo pipefail set -euo pipefail
curl --fail --location \ if [[ -n "$PROVIDED_VERSION" ]] && [[ "${{ inputs.recommend }}" != 'true' ]]; then
-H "Authorization: token ${TOKEN}" \ resolved_version="$PROVIDED_VERSION"
-o "$BINARY_PATH" \
"$ASSET_URL"
chmod +x "$BINARY_PATH"
- name: Run vociferate
id: run-vociferate
shell: bash
env:
VOCIFERATE_BIN: ${{ steps.resolve-binary.outputs.binary_path }}
USE_BINARY: ${{ steps.resolve-binary.outputs.use_binary }}
VOCIFERATE_REPOSITORY_URL: ${{ vars.VOCIFERATE_REPOSITORY_URL }}
run: |
set -euo pipefail
if [[ "$USE_BINARY" == "true" ]]; then
run_vociferate() { "$VOCIFERATE_BIN" "$@"; }
else else
run_vociferate() { (cd "$GITHUB_ACTION_PATH" && go run ./cmd/vociferate "$@"); } resolved_version="$RECOMMENDED_VERSION"
fi fi
common_args=(--root "$GITHUB_WORKSPACE") if [[ "${{ inputs.recommend }}" == 'true' ]]; then
if [[ -n "${{ inputs.version-file }}" ]]; then
common_args+=(--version-file "${{ inputs.version-file }}")
fi
if [[ -n "${{ inputs.version-pattern }}" ]]; then
common_args+=(--version-pattern "${{ inputs.version-pattern }}")
fi
if [[ -n "${{ inputs.changelog }}" ]]; then
common_args+=(--changelog "${{ inputs.changelog }}")
fi
if [[ "${{ inputs.recommend }}" == "true" ]]; then
resolved_version="$(run_vociferate "${common_args[@]}" --recommend)"
echo "$resolved_version" echo "$resolved_version"
echo "version=$resolved_version" >> "$GITHUB_OUTPUT"
exit 0
else
resolved_version="$(printf '%s' "${{ inputs.version }}" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
if [[ -z "$resolved_version" ]]; then
resolved_version="$(run_vociferate "${common_args[@]}" --recommend)"
fi
fi fi
echo "version=$resolved_version" >> "$GITHUB_OUTPUT" printf 'version=%s\n' "$resolved_version" >> "$GITHUB_OUTPUT"
run_vociferate "${common_args[@]}" --version "$resolved_version" --date "$(date -u +%F)"
- name: Prepare release files
if: inputs.recommend != 'true'
uses: ./run-vociferate
with:
root: ${{ github.workspace }}
version-file: ${{ inputs.version-file }}
version-pattern: ${{ inputs.version-pattern }}
changelog: ${{ inputs.changelog }}
version: ${{ steps.finalize-version.outputs.version }}
date: ${{ steps.resolve-date.outputs.value }}

View File

@@ -1,67 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](//keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](//semver.org/spec/v2.0.0.html).
A `### Breaking` section is used in addition to Keep a Changelog's standard sections to explicitly document changes that are backwards-incompatible but would otherwise appear under `### Changed`. Entries under `### Breaking` trigger a major version bump in automated release recommendation logic.
## [Unreleased]
### Breaking
### Added
- Added a project LICENSE file.
- Root and prepare actions now read `${{ vars.VOCIFERATE_REPOSITORY_URL }}` and forward it to `VOCIFERATE_REPOSITORY_URL` for repository URL override.
### Changed
### Removed
### Fixed
- Browser-facing URLs emitted in generated changelog links, workflow summaries, and markdown now use protocol-relative `//` forms.
- Prepare now recreates the standard `Unreleased` section headers after promoting notes into a tagged release entry.
- First-release recommendation remains `v1.0.0` when no prior releases exist in the changelog.
- Do Release smoke validation now expects `--recommend` to fail on tagged release checkouts where `Unreleased` is intentionally empty.
- Changelog reference links now use compare URLs (`previous...current` for releases and `latest...main` for Unreleased), with first release links comparing from the repository's first commit short hash.
- Repository URL derivation now supports `VOCIFERATE_REPOSITORY_URL` as the highest-priority base-URL override for changelog link generation.
## [0.1.0] - 2026-03-20
### Changed
- README workflow badges now link to the latest workflow run pages instead of the workflow definition pages.
### Fixed
- Publish action falls back to `git describe` when `inputs.version` is empty and `GITHUB_REF` is not a tag ref, resolving `workflow_call` input propagation failures in act runner v0.3.0.
### Added
- Coverage badge in README linked to S3-hosted main-branch report.
- S3 coverage artefact publishing (HTML report, badge, JSON summary) in push validation pipeline.
- CLI tests and internal helper tests raising total coverage to 84%.
- Test suite isolation against ambient CI environment variables for changelog link generation tests.
- Go CLI for changelog-driven release preparation and semantic version recommendation.
- Version recommendation from changelog release headings, including first-release support (`0.0.0` base -> `v1.0.0`).
- Automatic `release-version` creation/update during release preparation.
- Configurable version source/parsing via `--version-file` and `--version-pattern`.
- Configurable changelog path via `--changelog`.
- Recommended-version fallback when `version` is omitted in CLI and action flows.
- Major-version recommendation trigger from `Unreleased` `### Breaking`.
- Root composite action (`action.yml`) for recommend/prepare flows.
- Subdirectory composite actions: `prepare/action.yml` (prepare/commit/tag/push) and `publish/action.yml` (extract notes/create-or-update release).
- `publish` outputs for downstream automation: `release-id`, `tag`, and `version`.
- Dual execution mode for actions: `go run` from source on `@main`, prebuilt binaries on tagged refs.
- Repository-scoped binary cache keys with workflow-defined fixed token support via `VOCIFERATE_CACHE_TOKEN`.
- Tag-driven release publication with idempotent release updates and asset replacement on reruns.
- Release artifacts for `linux/amd64`, `linux/arm64`, and `checksums.txt`.
- Reusable Gitea workflows (`prepare-release.yml`, `do-release.yml`) with `workflow_call` support.
- Project/automation rename from `releaseprep` to `vociferate` (entrypoint, package paths, outputs).
- README guidance focused on primary cross-repository reuse workflows.
[Unreleased]: //teapot:3000/aether/vociferate/src/branch/main
[0.1.0]: //teapot:3000/aether/vociferate/releases/tag/v0.1.0

View File

@@ -13,6 +13,8 @@ func main() {
version := flag.String("version", "", "semantic version to release, with or without leading v") version := flag.String("version", "", "semantic version to release, with or without leading v")
date := flag.String("date", "", "release date in YYYY-MM-DD format") date := flag.String("date", "", "release date in YYYY-MM-DD format")
recommend := flag.Bool("recommend", false, "print the recommended next release tag based on the changelog") recommend := flag.Bool("recommend", false, "print the recommended next release tag based on the changelog")
printUnreleased := flag.Bool("print-unreleased", false, "print the current Unreleased changelog body")
printReleaseNotes := flag.Bool("print-release-notes", false, "print the release notes section for --version")
root := flag.String("root", ".", "repository root to update") root := flag.String("root", ".", "repository root to update")
versionFile := flag.String("version-file", "", "path to the version file, relative to --root") versionFile := flag.String("version-file", "", "path to the version file, relative to --root")
versionPattern := flag.String("version-pattern", "", "regexp with one capture group for the version value") versionPattern := flag.String("version-pattern", "", "regexp with one capture group for the version value")
@@ -41,8 +43,28 @@ func main() {
return return
} }
if *printUnreleased {
body, err := vociferate.UnreleasedBody(absRoot, opts)
if err != nil {
fmt.Fprintf(os.Stderr, "print unreleased: %v\n", err)
os.Exit(1)
}
fmt.Print(body)
return
}
if *printReleaseNotes {
body, err := vociferate.ReleaseNotes(absRoot, *version, opts)
if err != nil {
fmt.Fprintf(os.Stderr, "print release notes: %v\n", err)
os.Exit(1)
}
fmt.Print(body)
return
}
if *version == "" || *date == "" { if *version == "" || *date == "" {
fmt.Fprintln(os.Stderr, "usage: vociferate --version <version> --date <YYYY-MM-DD> [--root <dir>] [--version-file <path>] [--version-pattern <regexp>] [--changelog <path>] | --recommend [--root <dir>] [--version-file <path>] [--version-pattern <regexp>] [--changelog <path>]") fmt.Fprintln(os.Stderr, "usage: vociferate --version <version> --date <YYYY-MM-DD> [--root <dir>] [--version-file <path>] [--version-pattern <regexp>] [--changelog <path>] | --recommend [--root <dir>] [--version-file <path>] [--version-pattern <regexp>] [--changelog <path>] | --print-unreleased [--root <dir>] [--changelog <path>] | --print-release-notes --version <version> [--root <dir>] [--changelog <path>]")
os.Exit(2) os.Exit(2)
} }

View File

@@ -12,7 +12,7 @@ import (
func TestMainRecommendPrintsTag(t *testing.T) { func TestMainRecommendPrintsTag(t *testing.T) {
root := t.TempDir() root := t.TempDir()
writeFile(t, filepath.Join(root, "release-version"), "1.1.6\n") writeFile(t, filepath.Join(root, "release-version"), "1.1.6\n")
writeFile(t, filepath.Join(root, "changelog.md"), "# Changelog\n\n## [Unreleased]\n\n### Added\n\n- Feature.\n\n## [1.1.6] - 2017-12-20\n") writeFile(t, filepath.Join(root, "CHANGELOG.md"), "# Changelog\n\n## [Unreleased]\n\n### Added\n\n- Feature.\n\n## [1.1.6] - 2017-12-20\n")
stdout, stderr, code := runMain(t, "--recommend", "--root", root) stdout, stderr, code := runMain(t, "--recommend", "--root", root)
if code != 0 { if code != 0 {
@@ -23,6 +23,32 @@ func TestMainRecommendPrintsTag(t *testing.T) {
} }
} }
func TestMainPrintUnreleasedWritesPendingNotes(t *testing.T) {
root := t.TempDir()
writeFile(t, filepath.Join(root, "CHANGELOG.md"), "# Changelog\n\n## [Unreleased]\n\n### Added\n\n- Feature.\n\n### Fixed\n\n- Bugfix.\n\n## [1.1.6] - 2017-12-20\n")
stdout, stderr, code := runMain(t, "--print-unreleased", "--root", root)
if code != 0 {
t.Fatalf("expected exit 0, got %d (stderr: %s)", code, stderr)
}
if stdout != "### Added\n\n- Feature.\n\n### Fixed\n\n- Bugfix.\n" {
t.Fatalf("unexpected unreleased output: %q", stdout)
}
}
func TestMainPrintReleaseNotesWritesTaggedSection(t *testing.T) {
root := t.TempDir()
writeFile(t, filepath.Join(root, "CHANGELOG.md"), "# Changelog\n\n## [Unreleased]\n\n### Added\n\n- Feature.\n\n## [1.1.6] - 2017-12-20\n\n### Fixed\n\n- Historical note.\n")
stdout, stderr, code := runMain(t, "--print-release-notes", "--version", "1.1.6", "--root", root)
if code != 0 {
t.Fatalf("expected exit 0, got %d (stderr: %s)", code, stderr)
}
if stdout != "## [1.1.6] - 2017-12-20\n\n### Fixed\n\n- Historical note.\n" {
t.Fatalf("unexpected release notes output: %q", stdout)
}
}
func TestMainUsageExitWhenRequiredFlagsMissing(t *testing.T) { func TestMainUsageExitWhenRequiredFlagsMissing(t *testing.T) {
_, stderr, code := runMain(t) _, stderr, code := runMain(t)
if code != 2 { if code != 2 {
@@ -37,7 +63,7 @@ func TestMainPrepareUpdatesFiles(t *testing.T) {
root := t.TempDir() root := t.TempDir()
writeFile(t, filepath.Join(root, ".git", "config"), "[remote \"origin\"]\n\turl = git@git.hrafn.xyz:aether/vociferate.git\n") writeFile(t, filepath.Join(root, ".git", "config"), "[remote \"origin\"]\n\turl = git@git.hrafn.xyz:aether/vociferate.git\n")
writeFile(t, filepath.Join(root, "release-version"), "1.1.6\n") writeFile(t, filepath.Join(root, "release-version"), "1.1.6\n")
writeFile(t, filepath.Join(root, "changelog.md"), "# Changelog\n\n## [Unreleased]\n\n### Fixed\n\n- Patch note.\n\n## [1.1.6] - 2017-12-20\n") writeFile(t, filepath.Join(root, "CHANGELOG.md"), "# Changelog\n\n## [Unreleased]\n\n### Fixed\n\n- Patch note.\n\n## [1.1.6] - 2017-12-20\n")
_, stderr, code := runMain(t, "--version", "v1.1.7", "--date", "2026-03-20", "--root", root) _, stderr, code := runMain(t, "--version", "v1.1.7", "--date", "2026-03-20", "--root", root)
if code != 0 { if code != 0 {

168
coverage-badge/action.yml Normal file
View File

@@ -0,0 +1,168 @@
name: vociferate/coverage-badge
description: >
Generate coverage report artefacts, publish them to object storage,
and expose report URLs for workflow summaries.
inputs:
coverage-profile:
description: Path to the Go coverage profile file.
required: false
default: coverage.out
coverage-html:
description: Output path for the rendered HTML coverage report.
required: false
default: coverage.html
coverage-badge:
description: Output path for the generated SVG badge.
required: false
default: coverage-badge.svg
coverage-summary:
description: Output path for the generated coverage summary JSON.
required: false
default: coverage-summary.json
artefact-bucket-name:
description: S3 bucket name for published coverage artefacts.
required: true
artefact-bucket-endpoint:
description: Endpoint URL used for S3-compatible uploads.
required: true
branch-name:
description: Branch name used in the publication path.
required: false
default: ''
repository-name:
description: Repository name used in the publication path.
required: false
default: ''
summary-file:
description: Optional file path to append markdown summary output.
required: false
default: ''
outputs:
total:
description: Computed coverage percentage.
value: ${{ steps.generate.outputs.total }}
report-url:
description: Browser-facing URL for the published coverage report.
value: ${{ steps.upload.outputs.report_url }}
badge-url:
description: Browser-facing URL for the published coverage badge.
value: ${{ steps.upload.outputs.badge_url }}
runs:
using: composite
steps:
- name: Install AWS CLI v2
uses: ankurk91/install-aws-cli-action@v1
- name: Verify AWS CLI
shell: bash
run: aws --version
- name: Generate coverage artefacts
id: generate
shell: bash
env:
COVERAGE_PROFILE: ${{ inputs.coverage-profile }}
COVERAGE_HTML: ${{ inputs.coverage-html }}
COVERAGE_BADGE: ${{ inputs.coverage-badge }}
COVERAGE_SUMMARY: ${{ inputs.coverage-summary }}
run: |
set -euo pipefail
go tool cover -html="$COVERAGE_PROFILE" -o "$COVERAGE_HTML"
total="$(go tool cover -func="$COVERAGE_PROFILE" | awk '/^total:/ {sub(/%/, "", $3); print $3}')"
printf '{\n "total": "%s"\n}\n' "$total" > "$COVERAGE_SUMMARY"
printf 'total=%s\n' "$total" >> "$GITHUB_OUTPUT"
color="$(awk -v total="$total" 'BEGIN {
if (total >= 80) print "brightgreen";
else if (total >= 70) print "green";
else if (total >= 60) print "yellowgreen";
else if (total >= 50) print "yellow";
else print "red";
}')"
cat > "$COVERAGE_BADGE" <<EOF
<svg xmlns="http://www.w3.org/2000/svg" width="126" height="20" role="img" aria-label="coverage: ${total}%">
<linearGradient id="smooth" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="round">
<rect width="126" height="20" rx="3" fill="#fff"/>
</clipPath>
<g clip-path="url(#round)">
<rect width="63" height="20" fill="#555"/>
<rect x="63" width="63" height="20" fill="${color}"/>
<rect width="126" height="20" fill="url(#smooth)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text x="32.5" y="15" fill="#010101" fill-opacity=".3">coverage</text>
<text x="32.5" y="14">coverage</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">${total}%</text>
<text x="93.5" y="14">${total}%</text>
</g>
</svg>
EOF
- name: Upload coverage artefacts
id: upload
shell: bash
env:
ARTEFACT_BUCKET_NAME: ${{ inputs.artefact-bucket-name }}
ARTEFACT_BUCKET_ENDPONT: ${{ inputs.artefact-bucket-endpoint }}
INPUT_BRANCH_NAME: ${{ inputs.branch-name }}
INPUT_REPOSITORY_NAME: ${{ inputs.repository-name }}
COVERAGE_HTML: ${{ inputs.coverage-html }}
COVERAGE_BADGE: ${{ inputs.coverage-badge }}
COVERAGE_SUMMARY: ${{ inputs.coverage-summary }}
run: |
set -euo pipefail
aws configure set default.s3.addressing_style path
branch_name="$(printf '%s' "$INPUT_BRANCH_NAME" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
if [[ -z "$branch_name" ]]; then
branch_name="${GITHUB_REF_NAME}"
fi
repo_name="$(printf '%s' "$INPUT_REPOSITORY_NAME" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
if [[ -z "$repo_name" ]]; then
repo_name="${GITHUB_REPOSITORY##*/}"
fi
prefix="${repo_name}/branch/${branch_name}"
display_endpoint="${ARTEFACT_BUCKET_ENDPONT#https://}"
display_endpoint="${display_endpoint#http://}"
report_url="https://${display_endpoint%/}/${ARTEFACT_BUCKET_NAME}/${prefix}/coverage.html"
badge_url="https://${display_endpoint%/}/${ARTEFACT_BUCKET_NAME}/${prefix}/coverage-badge.svg"
aws --endpoint-url "${ARTEFACT_BUCKET_ENDPONT}" s3 cp "$COVERAGE_HTML" "s3://${ARTEFACT_BUCKET_NAME}/${prefix}/coverage.html" --content-type text/html
aws --endpoint-url "${ARTEFACT_BUCKET_ENDPONT}" s3 cp "$COVERAGE_BADGE" "s3://${ARTEFACT_BUCKET_NAME}/${prefix}/coverage-badge.svg" --content-type image/svg+xml
aws --endpoint-url "${ARTEFACT_BUCKET_ENDPONT}" s3 cp "$COVERAGE_SUMMARY" "s3://${ARTEFACT_BUCKET_NAME}/${prefix}/coverage-summary.json" --content-type application/json
printf 'report_url=%s\n' "$report_url" >> "$GITHUB_OUTPUT"
printf 'badge_url=%s\n' "$badge_url" >> "$GITHUB_OUTPUT"
- name: Append coverage summary
if: ${{ inputs.summary-file != '' }}
shell: bash
env:
SUMMARY_FILE: ${{ inputs.summary-file }}
TOTAL: ${{ steps.generate.outputs.total }}
REPORT_URL: ${{ steps.upload.outputs.report_url }}
BADGE_URL: ${{ steps.upload.outputs.badge_url }}
run: |
set -euo pipefail
{
echo '## Coverage'
echo
echo "- Total: \`${TOTAL}%\`"
echo "- Report: ${REPORT_URL}"
echo "- Badge: ${BADGE_URL}"
} >> "$SUMMARY_FILE"

85
coverage-gate/README.md Normal file
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)
}
}

395
decorate-pr/action.yml Normal file
View File

@@ -0,0 +1,395 @@
name: vociferate/decorate-pr
description: >
Decorate pull requests with coverage badges, unreleased changelog entries,
and other useful review information. Updates existing comment or creates a
new one if it doesn't exist.
inputs:
coverage-percentage:
description: >
Computed coverage percentage (0-100). Typically from coverage-badge
action outputs.
required: true
badge-url:
description: >
Browser-facing URL for the coverage badge image (SVG). Typically from
coverage-badge action outputs.
required: true
changelog:
description: Path to changelog file relative to repository root.
required: false
default: CHANGELOG.md
comment-title:
description: >
Title/identifier for the PR comment. Used to locate existing comment
for updates. Defaults to 'Vociferate Review'.
required: false
default: 'Vociferate Review'
token:
description: >
GitHub or Gitea token for posting PR comments. Defaults to the
workflow token.
required: false
default: ''
enable-changelog-gate:
description: >
Enable changelog gate validation. Validates that qualifying changes
include entries in the Unreleased section of CHANGELOG.
required: false
default: 'false'
changelog-gate-mode:
description: >
Gate mode: 'strict' fails the job if validation fails; 'soft' warns
via PR comment only.
required: false
default: 'soft'
changelog-gate-required-for:
description: >
Comma-separated types of changes that require changelog entries.
Valid: code, behavior, security, workflow, tooling.
required: false
default: 'code,behavior,security,workflow,tooling'
changelog-gate-allow-docs-only:
description: >
Allow PRs that only modify documentation files to skip changelog
requirement.
required: false
default: 'true'
changelog-gate-docs-globs:
description: >
Comma-separated glob patterns for files that are considered
documentation (case-insensitive).
required: false
default: 'docs/**,**.md,**.txt,**.rst'
changelog-gate-skip-labels:
description: >
Comma-separated PR labels that exempt a PR from changelog requirement.
Example: skip-changelog.
required: false
default: ''
outputs:
comment-id:
description: Numeric ID of the posted or updated PR comment.
value: ${{ steps.post-comment.outputs.comment_id }}
comment-url:
description: URL to the posted or updated PR comment.
value: ${{ steps.post-comment.outputs.comment_url }}
gate-passed:
description: >
Whether changelog gate validation passed (true/false). Only set when
gate is enabled.
value: ${{ steps.changelog-gate.outputs.gate_passed }}
docs-only:
description: >
Whether PR is docs-only (true/false). Only set when gate is enabled.
value: ${{ steps.changelog-gate.outputs.docs_only }}
unreleased-additions-count:
description: >
Number of lines added to Unreleased section in this PR. Only set when
gate is enabled.
value: ${{ steps.changelog-gate.outputs.unreleased_additions_count }}
gate-failure-reason:
description: >
Human-readable reason for gate failure, if applicable. Only set when
gate is enabled and failed.
value: ${{ steps.changelog-gate.outputs.failure_reason }}
runs:
using: composite
steps:
- name: Validate PR context
id: validate
shell: bash
env:
GITHUB_EVENT_NAME: ${{ github.event_name }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
if [[ "$GITHUB_EVENT_NAME" != "pull_request" ]]; then
echo "This action only works on pull_request events" >&2
exit 1
fi
if [[ -z "$PR_NUMBER" ]]; then
echo "Could not determine PR number from context" >&2
exit 1
fi
printf 'pr_number=%s\n' "$PR_NUMBER" >> "$GITHUB_OUTPUT"
- name: Preflight comment API access
id: preflight
shell: bash
env:
AUTH_TOKEN: ${{ inputs.token != '' && inputs.token || github.token }}
PR_NUMBER: ${{ steps.validate.outputs.pr_number }}
SERVER_URL: ${{ github.server_url }}
REPOSITORY: ${{ github.repository }}
run: |
set -euo pipefail
if [[ -z "$AUTH_TOKEN" ]]; then
echo "No token available for PR comment API calls. Set input token or provide workflow token." >&2
exit 1
fi
api_url="${SERVER_URL}/api/v1"
if [[ "$SERVER_URL" == *"github.com"* ]]; then
api_url="https://api.github.com"
fi
comments_url="${api_url}/repos/${REPOSITORY}/issues/${PR_NUMBER}/comments"
curl --fail-with-body -sS \
-H "Authorization: Bearer ${AUTH_TOKEN}" \
-H "Content-Type: application/json" \
"$comments_url" >/dev/null
- name: Detect changelog file
id: changelog-file
shell: bash
env:
CHANGELOG: ${{ inputs.changelog }}
run: |
set -euo pipefail
if [[ ! -f "$CHANGELOG" ]]; then
printf 'exists=false\n' >> "$GITHUB_OUTPUT"
else
printf 'exists=true\n' >> "$GITHUB_OUTPUT"
fi
- name: Extract changelog unreleased entries
id: extract-changelog
if: steps.changelog-file.outputs.exists == 'true'
uses: ./run-vociferate
with:
root: ${{ github.workspace }}
changelog: ${{ inputs.changelog }}
print-unreleased: 'true'
- name: Validate changelog gate
id: changelog-gate
shell: bash
env:
ENABLE_GATE: ${{ inputs.enable-changelog-gate }}
GATE_MODE: ${{ inputs.changelog-gate-mode }}
REQUIRED_FOR: ${{ inputs.changelog-gate-required-for }}
ALLOW_DOCS_ONLY: ${{ inputs.changelog-gate-allow-docs-only }}
DOCS_GLOBS: ${{ inputs.changelog-gate-docs-globs }}
SKIP_LABELS: ${{ inputs.changelog-gate-skip-labels }}
CHANGELOG: ${{ inputs.changelog }}
PR_NUMBER: ${{ steps.validate.outputs.pr_number }}
SERVER_URL: ${{ github.server_url }}
REPOSITORY: ${{ github.repository }}
AUTH_TOKEN: ${{ inputs.token != '' && inputs.token || github.token }}
run: |
set -euo pipefail
# If gate is disabled, skip
if [[ "$ENABLE_GATE" != "true" ]]; then
echo "gate_enabled=false" >> "$GITHUB_OUTPUT"
exit 0
fi
api_url="${SERVER_URL}/api/v1"
if [[ "$SERVER_URL" == *"github.com"* ]]; then
api_url="https://api.github.com"
fi
# Get PR labels
pr_url="${api_url}/repos/${REPOSITORY}/pulls/${PR_NUMBER}"
pr_data=$(curl -sS -H "Authorization: Bearer ${AUTH_TOKEN}" -H "Content-Type: application/json" "$pr_url")
pr_labels=$(printf '%s' "$pr_data" | jq -r '.labels[].name' 2>/dev/null | tr '\n' ',' || echo "")
# Check skip labels
if [[ -n "$SKIP_LABELS" ]]; then
IFS=',' read -ra skip_array <<< "$SKIP_LABELS"
for skip_label in "${skip_array[@]}"; do
skip_label="${skip_label// /}"
if [[ ",$pr_labels," == *",$skip_label,"* ]]; then
printf 'gate_passed=true\n' >> "$GITHUB_OUTPUT"
printf 'docs_only=false\n' >> "$GITHUB_OUTPUT"
printf 'unreleased_additions_count=0\n' >> "$GITHUB_OUTPUT"
printf 'skip_reason=skip_label_detected\n' >> "$GITHUB_OUTPUT"
exit 0
fi
done
fi
# Get PR diff
diff_url="${api_url}/repos/${REPOSITORY}/pulls/${PR_NUMBER}/files"
diff_data=$(curl -sS -H "Authorization: Bearer ${AUTH_TOKEN}" -H "Content-Type: application/json" "$diff_url")
# Determine if PR only modifies docs
total_files=$(printf '%s' "$diff_data" | jq 'length' 2>/dev/null || echo 0)
docs_only_true=true
has_qualifying_changes=false
if [[ "$total_files" -gt 0 ]]; then
while IFS= read -r filename; do
# Check if file matches docs globs
is_doc=false
IFS=',' read -ra glob_array <<< "$DOCS_GLOBS"
for glob in "${glob_array[@]}"; do
glob="${glob// /}"
# Simple glob matching (case-insensitive)
if [[ "${filename,,}" == ${glob,,} ]] || [[ "${filename,,}" == *"/${glob,,}" ]]; then
is_doc=true
break
fi
done
# .md files are always docs
if [[ "$filename" == *.md ]]; then
is_doc=true
fi
if [[ "$is_doc" != "true" ]]; then
docs_only_true=false
fi
done < <(printf '%s' "$diff_data" | jq -r '.[].filename' 2>/dev/null)
fi
# Get changeset for changelog file
changelog_diff=""
if [[ -f "$CHANGELOG" ]]; then
changelog_diff=$(printf '%s' "$diff_data" | jq -r ".[] | select(.filename == \"$CHANGELOG\") | .patch" 2>/dev/null || echo "")
fi
# Count additions to Unreleased section in changelog diff
unreleased_additions=0
if [[ -n "$changelog_diff" ]]; then
# Find lines added within Unreleased section
in_unreleased=false
while IFS= read -r line; do
# Skip processing metadata lines
[[ "$line" =~ ^(\+\+\+|---|@@) ]] && continue
# Check if entering Unreleased section
if [[ "$line" == "+## [Unreleased]"* ]] || [[ "$line" == " ## [Unreleased]"* ]]; then
in_unreleased=true
continue
fi
# Check if exiting Unreleased section
if [[ "$line" == "+## ["* ]] || [[ "$line" == " ## ["* ]]; then
if [[ "$line" != "+## [Unreleased]"* ]] && [[ "$line" != " ## [Unreleased]"* ]]; then
in_unreleased=false
fi
continue
fi
# Count non-empty additions in Unreleased
if $in_unreleased && [[ "$line" == "+"* ]] && [[ ! "$line" =~ ^(\+\+\+|---|@@) ]]; then
content="${line:1}" # Remove the '+' prefix
if [[ -n "${content// /}" ]]; then # Check if not just whitespace
unreleased_additions=$((unreleased_additions + 1))
fi
fi
done <<< "$changelog_diff"
fi
# Evaluate gate
gate_passed=true
failure_reason=""
if [[ "$docs_only_true" == "true" ]] && [[ "$ALLOW_DOCS_ONLY" == "true" ]]; then
gate_passed=true
docs_only=true
else
docs_only=false
# Check if code requires changelog entry
if [[ "$total_files" -gt 0 ]] && [[ "$unreleased_additions" -eq 0 ]]; then
gate_passed=false
failure_reason="Code changes detected but no entries added to Unreleased section of CHANGELOG.md"
fi
fi
printf 'gate_enabled=true\n' >> "$GITHUB_OUTPUT"
printf 'gate_passed=%s\n' "$gate_passed" >> "$GITHUB_OUTPUT"
printf 'docs_only=%s\n' "$docs_only" >> "$GITHUB_OUTPUT"
printf 'unreleased_additions_count=%s\n' "$unreleased_additions" >> "$GITHUB_OUTPUT"
printf 'failure_reason=%s\n' "$failure_reason" >> "$GITHUB_OUTPUT"
# Fail job if strict mode and gate failed
if [[ "$GATE_MODE" == "strict" ]] && [[ "$gate_passed" != "true" ]]; then
echo "$failure_reason" >&2
exit 1
fi
- name: Build PR comment markdown
id: build-comment
shell: bash
env:
COMMENT_TITLE: ${{ inputs.comment-title }}
COVERAGE_PCT: ${{ inputs.coverage-percentage }}
BADGE_URL: ${{ inputs.badge-url }}
UNRELEASED: ${{ steps.extract-changelog.outputs.stdout }}
GATE_ENABLED: ${{ steps.changelog-gate.outputs.gate_enabled }}
GATE_PASSED: ${{ steps.changelog-gate.outputs.gate_passed }}
GATE_MODE: ${{ inputs.changelog-gate-mode }}
DOCS_ONLY: ${{ steps.changelog-gate.outputs.docs_only }}
ADDITIONS_COUNT: ${{ steps.changelog-gate.outputs.unreleased_additions_count }}
FAILURE_REASON: ${{ steps.changelog-gate.outputs.failure_reason }}
run: |
set -euo pipefail
bash "$GITHUB_ACTION_PATH/build-comment.sh"
- name: Find and update/post PR comment
id: post-comment
shell: bash
env:
GITHUB_TOKEN: ${{ inputs.token != '' && inputs.token || github.token }}
PR_NUMBER: ${{ steps.validate.outputs.pr_number }}
COMMENT_BODY: ${{ steps.build-comment.outputs.comment_body }}
SERVER_URL: ${{ github.server_url }}
REPOSITORY: ${{ github.repository }}
run: |
set -euo pipefail
API_URL="${SERVER_URL}/api/v1"
if [[ "$SERVER_URL" == *"github.com"* ]]; then
API_URL="https://api.github.com"
fi
# List existing comments to find vociferate comment
comments_url="${API_URL}/repos/${REPOSITORY}/issues/${PR_NUMBER}/comments"
response=$(curl -s -H "Authorization: Bearer ${GITHUB_TOKEN}" "$comments_url")
# Find existing vociferate comment by checking for the marker
existing_comment_id=$(printf '%s' "$response" | \
jq -r '.[] | select(.body | contains("vociferate-pr-review")) | .id' 2>/dev/null | \
head -1 || echo "")
if [[ -n "$existing_comment_id" ]]; then
# Update existing comment
update_url="${API_URL}/repos/${REPOSITORY}/issues/comments/${existing_comment_id}"
curl -s -X PATCH \
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(printf '{"body": %s}' "$(printf '%s' "$COMMENT_BODY" | jq -Rs .)")" \
"$update_url" > /dev/null
printf 'comment_id=%s\n' "$existing_comment_id" >> "$GITHUB_OUTPUT"
printf 'comment_url=%s/repos/%s/issues/%s#issuecomment-%s\n' \
"$SERVER_URL" "$REPOSITORY" "$PR_NUMBER" "$existing_comment_id" >> "$GITHUB_OUTPUT"
else
# Create new comment
create_url="${API_URL}/repos/${REPOSITORY}/issues/${PR_NUMBER}/comments"
response=$(curl -s -X POST \
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(printf '{"body": %s}' "$(printf '%s' "$COMMENT_BODY" | jq -Rs .)")" \
"$create_url")
comment_id=$(printf '%s' "$response" | jq -r '.id' 2>/dev/null)
printf 'comment_id=%s\n' "$comment_id" >> "$GITHUB_OUTPUT"
printf 'comment_url=%s/repos/%s/issues/%s#issuecomment-%s\n' \
"$SERVER_URL" "$REPOSITORY" "$PR_NUMBER" "$comment_id" >> "$GITHUB_OUTPUT"
fi

View File

@@ -0,0 +1,71 @@
#!/usr/bin/env bash
set -euo pipefail
tmp_file=$(mktemp)
trap 'rm -f "$tmp_file"' EXIT
{
printf '%s\n' '<!-- vociferate-pr-review -->'
printf '\n## %s\n\n' "$COMMENT_TITLE"
printf '### Coverage\n'
printf '![Coverage Badge](%s)\n\n' "$BADGE_URL"
printf '**Coverage:** %s%%\n' "$COVERAGE_PCT"
} > "$tmp_file"
if [[ "$GATE_ENABLED" == "true" ]]; then
gate_status="Pass"
if [[ "$GATE_PASSED" != "true" ]]; then
if [[ "$GATE_MODE" == "strict" ]]; then
gate_status="Fail"
else
gate_status="Warning"
fi
fi
{
printf '\n### Changelog Gate\n'
printf '**Status:** %s\n\n' "$gate_status"
} >> "$tmp_file"
if [[ "$DOCS_ONLY" == "true" ]]; then
printf '%s\n\n' 'This PR only modifies documentation; changelog entry not required.' >> "$tmp_file"
elif [[ "$GATE_PASSED" == "true" ]]; then
printf 'Found %s line(s) added to Unreleased section.\n\n' "$ADDITIONS_COUNT" >> "$tmp_file"
else
printf '**Issue:** %s\n\n' "$FAILURE_REASON" >> "$tmp_file"
cat >> "$tmp_file" <<'EOF'
**How to fix:** Add an entry under the appropriate subsection in the `## [Unreleased]` section of `CHANGELOG.md`. Use one of:
- `### Breaking` for backwards-incompatible changes
- `### Added` for new features
- `### Changed` for behavior changes
- `### Removed` for deprecated removals
- `### Fixed` for bug fixes
Example:
```markdown
## [Unreleased]
### Added
- New changelog gate validation for PR decoration.
```
EOF
fi
fi
if [[ -n "$UNRELEASED" ]]; then
{
printf '### Unreleased Changes\n\n'
printf '%s\n\n' "$UNRELEASED"
} >> "$tmp_file"
fi
cat >> "$tmp_file" <<'EOF'
---
*This comment was automatically generated by [vociferate/decorate-pr](https://git.hrafn.xyz/aether/vociferate).*
EOF
delimiter="EOF_COMMENT"
printf 'comment_body<<%s\n' "$delimiter" >> "$GITHUB_OUTPUT"
cat "$tmp_file" >> "$GITHUB_OUTPUT"
printf '%s\n' "$delimiter" >> "$GITHUB_OUTPUT"

View File

@@ -19,15 +19,115 @@ import (
const ( const (
defaultVersionFile = "release-version" defaultVersionFile = "release-version"
defaultVersionExpr = `^\s*([^\r\n]+)\s*$` defaultVersionExpr = `^\s*([^\r\n]+)\s*$`
defaultChangelog = "changelog.md" defaultChangelog = "CHANGELOG.md"
defaultUnreleasedTemplate = "### Breaking\n\n### Added\n\n### Changed\n\n### Removed\n\n### Fixed\n" defaultUnreleasedTemplate = "### Breaking\n\n### Added\n\n### Changed\n\n### Removed\n\n### Fixed\n"
) )
var releasedSectionRe = regexp.MustCompile(`(?m)^## \[(\d+\.\d+\.\d+)\] - `) // Pre-compiled regex patterns used for changelog parsing.
var linkedReleasedSectionRe = regexp.MustCompile(`(?m)^## \[(\d+\.\d+\.\d+)\](?:\([^\n)]*\))? - `) // These are read-only after initialization.
var unreleasedHeadingRe = regexp.MustCompile(`(?m)^## \[Unreleased\](?:\([^\n)]*\))?\n`) var (
var releaseHeadingRe = regexp.MustCompile(`(?m)^## \[(\d+\.\d+\.\d+)\](?:\([^\n)]*\))? - `) releasedSectionRe = regexp.MustCompile(`(?m)^## \[(\d+\.\d+\.\d+)\] - `)
var refLinkLineRe = regexp.MustCompile(`^\[[^\]]+\]: \S`) linkedReleasedSectionRe = regexp.MustCompile(`(?m)^## \[(\d+\.\d+\.\d+)\](?:\([^\n)]*\))? - `)
unreleasedHeadingRe = regexp.MustCompile(`(?m)^## \[Unreleased\](?:\([^\n)]*\))?\n`)
releaseHeadingRe = regexp.MustCompile(`(?m)^## \[(\d+\.\d+\.\d+)\](?:\([^\n)]*\))? - `)
refLinkLineRe = regexp.MustCompile(`^\[[^\]]+\]: \S`)
)
type fileSystem interface {
ReadFile(path string) ([]byte, error)
WriteFile(path string, data []byte, perm os.FileMode) error
}
type environment interface {
Getenv(key string) string
}
type gitRunner interface {
FirstCommitShortHash(rootDir string) (string, bool)
}
type osFileSystem struct{}
func (osFileSystem) ReadFile(path string) ([]byte, error) {
// #nosec G304 -- This adapter intentionally accepts caller-provided paths so the service can work against repository-relative files.
return os.ReadFile(path)
}
func (osFileSystem) WriteFile(path string, data []byte, perm os.FileMode) error {
return os.WriteFile(path, data, perm)
}
type osEnvironment struct{}
func (osEnvironment) Getenv(key string) string {
return os.Getenv(key)
}
type commandGitRunner struct{}
func (commandGitRunner) FirstCommitShortHash(rootDir string) (string, bool) {
command := exec.Command("git", "-C", rootDir, "rev-list", "--max-parents=0", "--abbrev-commit", "HEAD")
output, err := command.Output()
if err != nil {
return "", false
}
commit := strings.TrimSpace(string(output))
if commit == "" {
return "", false
}
if strings.Contains(commit, "\n") {
commit = strings.SplitN(commit, "\n", 2)[0]
}
return commit, true
}
// Dependencies defines the injected collaborators required by Service.
type Dependencies struct {
FileSystem fileSystem
Environment environment
Git gitRunner
}
// Service coordinates changelog and version file operations using injected dependencies.
type Service struct {
fileSystem fileSystem
environment environment
git gitRunner
}
// NewService validates and wires the dependencies required by the release service.
func NewService(deps Dependencies) (*Service, error) {
if deps.FileSystem == nil {
return nil, fmt.Errorf("file system dependency must not be nil")
}
if deps.Environment == nil {
return nil, fmt.Errorf("environment dependency must not be nil")
}
if deps.Git == nil {
return nil, fmt.Errorf("git runner dependency must not be nil")
}
return &Service{
fileSystem: deps.FileSystem,
environment: deps.Environment,
git: deps.Git,
}, nil
}
func defaultService() *Service {
service, err := NewService(Dependencies{
FileSystem: osFileSystem{},
Environment: osEnvironment{},
Git: commandGitRunner{},
})
if err != nil {
panic(err)
}
return service
}
type Options struct { type Options struct {
// VersionFile is the path to the file that stores the current version, // VersionFile is the path to the file that stores the current version,
@@ -38,7 +138,7 @@ type Options struct {
// When empty, a line-oriented default matcher is used. // When empty, a line-oriented default matcher is used.
VersionPattern string VersionPattern string
// Changelog is the path to the changelog file, relative to the repository // Changelog is the path to the changelog file, relative to the repository
// root. When empty, changelog.md is used. // root. When empty, CHANGELOG.md is used.
Changelog string Changelog string
} }
@@ -63,6 +163,12 @@ type resolvedOptions struct {
// when repository metadata can be derived from CI environment variables or the // when repository metadata can be derived from CI environment variables or the
// origin git remote. // origin git remote.
func Prepare(rootDir, version, releaseDate string, options Options) error { func Prepare(rootDir, version, releaseDate string, options Options) error {
return defaultService().Prepare(rootDir, version, releaseDate, options)
}
// Prepare updates version state and promotes the Unreleased changelog notes
// into a new release section.
func (s *Service) Prepare(rootDir, version, releaseDate string, options Options) error {
normalizedVersion := strings.TrimPrefix(strings.TrimSpace(version), "v") normalizedVersion := strings.TrimPrefix(strings.TrimSpace(version), "v")
if normalizedVersion == "" { if normalizedVersion == "" {
return fmt.Errorf("version must not be empty") return fmt.Errorf("version must not be empty")
@@ -78,11 +184,11 @@ func Prepare(rootDir, version, releaseDate string, options Options) error {
return err return err
} }
if err := updateVersionFile(rootDir, normalizedVersion, resolved); err != nil { if err := s.updateVersionFile(rootDir, normalizedVersion, resolved); err != nil {
return err return err
} }
if err := updateChangelog(rootDir, normalizedVersion, releaseDate, resolved.Changelog); err != nil { if err := s.updateChangelog(rootDir, normalizedVersion, releaseDate, resolved.Changelog); err != nil {
return err return err
} }
@@ -100,6 +206,22 @@ func Prepare(rootDir, version, releaseDate string, options Options) error {
// When no previous release is present in the changelog, the first // When no previous release is present in the changelog, the first
// recommendation is always v1.0.0. // recommendation is always v1.0.0.
func RecommendedTag(rootDir string, options Options) (string, error) { func RecommendedTag(rootDir string, options Options) (string, error) {
return defaultService().RecommendedTag(rootDir, options)
}
// UnreleasedBody returns the current Unreleased changelog body exactly as it
// should appear in downstream tooling.
func UnreleasedBody(rootDir string, options Options) (string, error) {
return defaultService().UnreleasedBody(rootDir, options)
}
// ReleaseNotes returns the release section for a specific semantic version.
func ReleaseNotes(rootDir, version string, options Options) (string, error) {
return defaultService().ReleaseNotes(rootDir, version, options)
}
// RecommendedTag returns the next semantic release tag based on current changelog state.
func (s *Service) RecommendedTag(rootDir string, options Options) (string, error) {
resolved, err := resolveOptions(options) resolved, err := resolveOptions(options)
if err != nil { if err != nil {
return "", err return "", err
@@ -108,12 +230,12 @@ func RecommendedTag(rootDir string, options Options) (string, error) {
var currentVersion string var currentVersion string
isFirstRelease := false isFirstRelease := false
if options.VersionFile != "" { if options.VersionFile != "" {
currentVersion, err = readCurrentVersion(rootDir, resolved) currentVersion, err = s.readCurrentVersion(rootDir, resolved)
if err != nil { if err != nil {
return "", err return "", err
} }
} else { } else {
version, found, err := readLatestChangelogVersion(rootDir, resolved.Changelog) version, found, err := s.readLatestChangelogVersion(rootDir, resolved.Changelog)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -125,7 +247,7 @@ func RecommendedTag(rootDir string, options Options) (string, error) {
} }
} }
unreleasedBody, err := readUnreleasedBody(rootDir, resolved.Changelog) unreleasedBody, err := s.readUnreleasedBody(rootDir, resolved.Changelog)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -154,6 +276,26 @@ func RecommendedTag(rootDir string, options Options) (string, error) {
return fmt.Sprintf("v%d.%d.%d", parsed.major, parsed.minor, parsed.patch), nil return fmt.Sprintf("v%d.%d.%d", parsed.major, parsed.minor, parsed.patch), nil
} }
// UnreleasedBody returns the current Unreleased changelog body.
func (s *Service) UnreleasedBody(rootDir string, options Options) (string, error) {
resolved, err := resolveOptions(options)
if err != nil {
return "", err
}
return s.readUnreleasedBody(rootDir, resolved.Changelog)
}
// ReleaseNotes returns the release section for a specific semantic version.
func (s *Service) ReleaseNotes(rootDir, version string, options Options) (string, error) {
resolved, err := resolveOptions(options)
if err != nil {
return "", err
}
return s.readReleaseNotes(rootDir, strings.TrimPrefix(strings.TrimSpace(version), "v"), resolved.Changelog)
}
func sectionHasEntries(unreleasedBody, sectionName string) bool { func sectionHasEntries(unreleasedBody, sectionName string) bool {
heading := "### " + sectionName heading := "### " + sectionName
sectionStart := strings.Index(unreleasedBody, heading) sectionStart := strings.Index(unreleasedBody, heading)
@@ -201,11 +343,15 @@ func resolveOptions(options Options) (resolvedOptions, error) {
} }
func updateVersionFile(rootDir, version string, options resolvedOptions) error { func updateVersionFile(rootDir, version string, options resolvedOptions) error {
return defaultService().updateVersionFile(rootDir, version, options)
}
func (s *Service) updateVersionFile(rootDir, version string, options resolvedOptions) error {
path := filepath.Join(rootDir, options.VersionFile) path := filepath.Join(rootDir, options.VersionFile)
contents, err := os.ReadFile(path) contents, err := s.fileSystem.ReadFile(path)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
return os.WriteFile(path, []byte(version+"\n"), 0o644) return s.fileSystem.WriteFile(path, []byte(version+"\n"), 0o644)
} }
return fmt.Errorf("read version file: %w", err) return fmt.Errorf("read version file: %w", err)
} }
@@ -221,7 +367,7 @@ func updateVersionFile(rootDir, version string, options resolvedOptions) error {
return nil return nil
} }
if err := os.WriteFile(path, []byte(updated), 0o644); err != nil { if err := s.fileSystem.WriteFile(path, []byte(updated), 0o644); err != nil {
return fmt.Errorf("write version file: %w", err) return fmt.Errorf("write version file: %w", err)
} }
@@ -229,7 +375,11 @@ func updateVersionFile(rootDir, version string, options resolvedOptions) error {
} }
func updateChangelog(rootDir, version, releaseDate, changelogPath string) error { func updateChangelog(rootDir, version, releaseDate, changelogPath string) error {
unreleasedBody, text, afterHeader, nextSectionStart, path, err := readChangelogState(rootDir, changelogPath) return defaultService().updateChangelog(rootDir, version, releaseDate, changelogPath)
}
func (s *Service) updateChangelog(rootDir, version, releaseDate, changelogPath string) error {
unreleasedBody, text, afterHeader, nextSectionStart, path, err := s.readChangelogState(rootDir, changelogPath)
if err != nil { if err != nil {
return err return err
} }
@@ -245,11 +395,11 @@ func updateChangelog(rootDir, version, releaseDate, changelogPath string) error
} }
updated := text[:afterHeader] + "\n" + defaultUnreleasedTemplate + "\n" + newSection + text[nextSectionStart:] updated := text[:afterHeader] + "\n" + defaultUnreleasedTemplate + "\n" + newSection + text[nextSectionStart:]
repoURL, ok := deriveRepositoryURL(rootDir) repoURL, ok := s.deriveRepositoryURL(rootDir)
if ok { if ok {
updated = addChangelogLinks(updated, repoURL, rootDir) updated = s.addChangelogLinks(updated, repoURL, rootDir)
} }
if err := os.WriteFile(path, []byte(updated), 0o644); err != nil { if err := s.fileSystem.WriteFile(path, []byte(updated), 0o644); err != nil {
return fmt.Errorf("write changelog: %w", err) return fmt.Errorf("write changelog: %w", err)
} }
@@ -257,8 +407,12 @@ func updateChangelog(rootDir, version, releaseDate, changelogPath string) error
} }
func readCurrentVersion(rootDir string, options resolvedOptions) (string, error) { func readCurrentVersion(rootDir string, options resolvedOptions) (string, error) {
return defaultService().readCurrentVersion(rootDir, options)
}
func (s *Service) readCurrentVersion(rootDir string, options resolvedOptions) (string, error) {
path := filepath.Join(rootDir, options.VersionFile) path := filepath.Join(rootDir, options.VersionFile)
contents, err := os.ReadFile(path) contents, err := s.fileSystem.ReadFile(path)
if err != nil { if err != nil {
return "", fmt.Errorf("read version file: %w", err) return "", fmt.Errorf("read version file: %w", err)
} }
@@ -272,7 +426,11 @@ func readCurrentVersion(rootDir string, options resolvedOptions) (string, error)
} }
func readUnreleasedBody(rootDir, changelogPath string) (string, error) { func readUnreleasedBody(rootDir, changelogPath string) (string, error) {
unreleasedBody, _, _, _, _, err := readChangelogState(rootDir, changelogPath) return defaultService().readUnreleasedBody(rootDir, changelogPath)
}
func (s *Service) readUnreleasedBody(rootDir, changelogPath string) (string, error) {
unreleasedBody, _, _, _, _, err := s.readChangelogState(rootDir, changelogPath)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -297,8 +455,12 @@ func unreleasedHasEntries(unreleasedBody string) bool {
} }
func readChangelogState(rootDir, changelogPath string) (string, string, int, int, string, error) { func readChangelogState(rootDir, changelogPath string) (string, string, int, int, string, error) {
return defaultService().readChangelogState(rootDir, changelogPath)
}
func (s *Service) readChangelogState(rootDir, changelogPath string) (string, string, int, int, string, error) {
path := filepath.Join(rootDir, changelogPath) path := filepath.Join(rootDir, changelogPath)
contents, err := os.ReadFile(path) contents, err := s.fileSystem.ReadFile(path)
if err != nil { if err != nil {
return "", "", 0, 0, "", fmt.Errorf("read changelog: %w", err) return "", "", 0, 0, "", fmt.Errorf("read changelog: %w", err)
} }
@@ -321,8 +483,16 @@ func readChangelogState(rootDir, changelogPath string) (string, string, int, int
} }
func readLatestChangelogVersion(rootDir, changelogPath string) (string, bool, error) { func readLatestChangelogVersion(rootDir, changelogPath string) (string, bool, error) {
return defaultService().readLatestChangelogVersion(rootDir, changelogPath)
}
func readReleaseNotes(rootDir, version, changelogPath string) (string, error) {
return defaultService().readReleaseNotes(rootDir, version, changelogPath)
}
func (s *Service) readLatestChangelogVersion(rootDir, changelogPath string) (string, bool, error) {
path := filepath.Join(rootDir, changelogPath) path := filepath.Join(rootDir, changelogPath)
contents, err := os.ReadFile(path) contents, err := s.fileSystem.ReadFile(path)
if err != nil { if err != nil {
return "", false, fmt.Errorf("read changelog: %w", err) return "", false, fmt.Errorf("read changelog: %w", err)
} }
@@ -334,10 +504,46 @@ func readLatestChangelogVersion(rootDir, changelogPath string) (string, bool, er
return match[1], true, nil return match[1], true, nil
} }
func (s *Service) readReleaseNotes(rootDir, version, changelogPath string) (string, error) {
if version == "" {
return "", fmt.Errorf("release version must not be empty")
}
path := filepath.Join(rootDir, changelogPath)
contents, err := s.fileSystem.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read changelog: %w", err)
}
text := string(contents)
headingExpr := regexp.MustCompile(`(?m)^## \[` + regexp.QuoteMeta(version) + `\](?:\([^\n)]*\))? - `)
headingLoc := headingExpr.FindStringIndex(text)
if headingLoc == nil {
return "", fmt.Errorf("release notes section for %s not found in changelog", version)
}
nextSectionRelative := strings.Index(text[headingLoc[0]+1:], "\n## [")
sectionEnd := len(text)
if nextSectionRelative != -1 {
sectionEnd = headingLoc[0] + 1 + nextSectionRelative
}
section := text[headingLoc[0]:sectionEnd]
if !strings.HasSuffix(section, "\n") {
section += "\n"
}
return section, nil
}
func deriveRepositoryURL(rootDir string) (string, bool) { func deriveRepositoryURL(rootDir string) (string, bool) {
override := strings.TrimSpace(os.Getenv("VOCIFERATE_REPOSITORY_URL")) return defaultService().deriveRepositoryURL(rootDir)
}
func (s *Service) deriveRepositoryURL(rootDir string) (string, bool) {
override := strings.TrimSpace(s.environment.Getenv("VOCIFERATE_REPOSITORY_URL"))
if override != "" { if override != "" {
repositoryPath, ok := deriveRepositoryPath(rootDir) repositoryPath, ok := s.deriveRepositoryPath(rootDir)
if !ok { if !ok {
return "", false return "", false
} }
@@ -346,14 +552,14 @@ func deriveRepositoryURL(rootDir string) (string, bool) {
return baseURL + "/" + repositoryPath, true return baseURL + "/" + repositoryPath, true
} }
serverURL := strings.TrimSpace(os.Getenv("GITHUB_SERVER_URL")) serverURL := strings.TrimSpace(s.environment.Getenv("GITHUB_SERVER_URL"))
repository := strings.TrimSpace(os.Getenv("GITHUB_REPOSITORY")) repository := strings.TrimSpace(s.environment.Getenv("GITHUB_REPOSITORY"))
if serverURL != "" && repository != "" { if serverURL != "" && repository != "" {
return strings.TrimSuffix(serverURL, "/") + "/" + strings.TrimPrefix(repository, "/"), true return strings.TrimSuffix(serverURL, "/") + "/" + strings.TrimPrefix(repository, "/"), true
} }
gitConfigPath := filepath.Join(rootDir, ".git", "config") gitConfigPath := filepath.Join(rootDir, ".git", "config")
contents, err := os.ReadFile(gitConfigPath) contents, err := s.fileSystem.ReadFile(gitConfigPath)
if err != nil { if err != nil {
return "", false return "", false
} }
@@ -372,13 +578,17 @@ func deriveRepositoryURL(rootDir string) (string, bool) {
} }
func deriveRepositoryPath(rootDir string) (string, bool) { func deriveRepositoryPath(rootDir string) (string, bool) {
repository := strings.TrimSpace(os.Getenv("GITHUB_REPOSITORY")) return defaultService().deriveRepositoryPath(rootDir)
}
func (s *Service) deriveRepositoryPath(rootDir string) (string, bool) {
repository := strings.TrimSpace(s.environment.Getenv("GITHUB_REPOSITORY"))
if repository != "" { if repository != "" {
return strings.TrimPrefix(repository, "/"), true return strings.TrimPrefix(repository, "/"), true
} }
gitConfigPath := filepath.Join(rootDir, ".git", "config") gitConfigPath := filepath.Join(rootDir, ".git", "config")
contents, err := os.ReadFile(gitConfigPath) contents, err := s.fileSystem.ReadFile(gitConfigPath)
if err != nil { if err != nil {
return "", false return "", false
} }
@@ -472,6 +682,10 @@ func normalizeRepoURL(remoteURL string) (string, bool) {
} }
func addChangelogLinks(text, repoURL, rootDir string) string { func addChangelogLinks(text, repoURL, rootDir string) string {
return defaultService().addChangelogLinks(text, repoURL, rootDir)
}
func (s *Service) addChangelogLinks(text, repoURL, rootDir string) string {
repoURL = strings.TrimSuffix(strings.TrimSpace(repoURL), "/") repoURL = strings.TrimSuffix(strings.TrimSpace(repoURL), "/")
if repoURL == "" { if repoURL == "" {
return text return text
@@ -519,7 +733,7 @@ func addChangelogLinks(text, repoURL, rootDir string) string {
linkDefs = append(linkDefs, fmt.Sprintf("[Unreleased]: %s/src/branch/main", displayRepoURL)) linkDefs = append(linkDefs, fmt.Sprintf("[Unreleased]: %s/src/branch/main", displayRepoURL))
} }
firstCommitShort, hasFirstCommit := firstCommitShortHash(rootDir) firstCommitShort, hasFirstCommit := s.firstCommitShortHash(rootDir)
for i, version := range releasedVersions { for i, version := range releasedVersions {
if i+1 < len(releasedVersions) { if i+1 < len(releasedVersions) {
previousVersion := releasedVersions[i+1] previousVersion := releasedVersions[i+1]
@@ -541,31 +755,20 @@ func addChangelogLinks(text, repoURL, rootDir string) string {
func displayURL(url string) string { func displayURL(url string) string {
trimmed := strings.TrimSpace(url) trimmed := strings.TrimSpace(url)
if strings.HasPrefix(trimmed, "https://") { if strings.HasPrefix(trimmed, "https://") {
return "//" + strings.TrimPrefix(trimmed, "https://") return trimmed
} }
if strings.HasPrefix(trimmed, "http://") { if strings.HasPrefix(trimmed, "http://") {
return "//" + strings.TrimPrefix(trimmed, "http://") return "https://" + strings.TrimPrefix(trimmed, "http://")
} }
return trimmed return trimmed
} }
func firstCommitShortHash(rootDir string) (string, bool) { func firstCommitShortHash(rootDir string) (string, bool) {
command := exec.Command("git", "-C", rootDir, "rev-list", "--max-parents=0", "--abbrev-commit", "HEAD") return defaultService().firstCommitShortHash(rootDir)
output, err := command.Output() }
if err != nil {
return "", false
}
commit := strings.TrimSpace(string(output)) func (s *Service) firstCommitShortHash(rootDir string) (string, bool) {
if commit == "" { return s.git.FirstCommitShortHash(rootDir)
return "", false
}
if strings.Contains(commit, "\n") {
commit = strings.SplitN(commit, "\n", 2)[0]
}
return commit, true
} }
func compareURL(repoURL, baseRef, headRef string) string { func compareURL(repoURL, baseRef, headRef string) string {

View File

@@ -3,9 +3,54 @@ package vociferate
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
) )
type stubFileSystem struct {
files map[string][]byte
}
func newStubFileSystem(files map[string]string) *stubFileSystem {
backing := make(map[string][]byte, len(files))
for path, contents := range files {
backing[path] = []byte(contents)
}
return &stubFileSystem{files: backing}
}
func (fs *stubFileSystem) ReadFile(path string) ([]byte, error) {
contents, ok := fs.files[path]
if !ok {
return nil, os.ErrNotExist
}
clone := make([]byte, len(contents))
copy(clone, contents)
return clone, nil
}
func (fs *stubFileSystem) WriteFile(path string, data []byte, _ os.FileMode) error {
clone := make([]byte, len(data))
copy(clone, data)
fs.files[path] = clone
return nil
}
type stubEnvironment map[string]string
func (env stubEnvironment) Getenv(key string) string {
return env[key]
}
type stubGitRunner struct {
commit string
ok bool
}
func (runner stubGitRunner) FirstCommitShortHash(_ string) (string, bool) {
return runner.commit, runner.ok
}
func TestNormalizeRepoURL(t *testing.T) { func TestNormalizeRepoURL(t *testing.T) {
t.Parallel() t.Parallel()
@@ -146,3 +191,123 @@ func TestDeriveRepositoryURL_UsesOverrideAsHighestPriority(t *testing.T) {
t.Fatalf("unexpected repository URL: %q", url) t.Fatalf("unexpected repository URL: %q", url)
} }
} }
func TestResolveOptions_UsesUppercaseChangelogDefault(t *testing.T) {
t.Parallel()
resolved, err := resolveOptions(Options{})
if err != nil {
t.Fatalf("resolveOptions returned unexpected error: %v", err)
}
if resolved.Changelog != "CHANGELOG.md" {
t.Fatalf("resolved changelog = %q, want %q", resolved.Changelog, "CHANGELOG.md")
}
}
func TestNewService_ValidatesDependencies(t *testing.T) {
t.Parallel()
validFS := newStubFileSystem(nil)
validEnv := stubEnvironment{}
validGit := stubGitRunner{}
tests := []struct {
name string
deps Dependencies
wantErr string
}{
{
name: "missing file system",
deps: Dependencies{Environment: validEnv, Git: validGit},
wantErr: "file system dependency must not be nil",
},
{
name: "missing environment",
deps: Dependencies{FileSystem: validFS, Git: validGit},
wantErr: "environment dependency must not be nil",
},
{
name: "missing git runner",
deps: Dependencies{FileSystem: validFS, Environment: validEnv},
wantErr: "git runner dependency must not be nil",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := NewService(tt.deps)
if err == nil || err.Error() != tt.wantErr {
t.Fatalf("NewService() error = %v, want %q", err, tt.wantErr)
}
})
}
}
func TestServicePrepare_UsesInjectedEnvironmentForRepositoryLinks(t *testing.T) {
t.Parallel()
rootDir := "/repo"
fs := newStubFileSystem(map[string]string{
filepath.Join(rootDir, "release-version"): "1.1.6\n",
filepath.Join(rootDir, "CHANGELOG.md"): "# Changelog\n\n## [Unreleased]\n\n### Added\n\n- New thing.\n\n## [1.1.6] - 2017-12-20\n\n### Fixed\n\n- Historical note.\n",
})
svc, err := NewService(Dependencies{
FileSystem: fs,
Environment: stubEnvironment{"GITHUB_SERVER_URL": "https://git.hrafn.xyz", "GITHUB_REPOSITORY": "aether/vociferate"},
Git: stubGitRunner{commit: "deadbee", ok: true},
})
if err != nil {
t.Fatalf("NewService() unexpected error: %v", err)
}
err = svc.Prepare(rootDir, "1.1.7", "2026-03-21", Options{})
if err != nil {
t.Fatalf("Prepare() unexpected error: %v", err)
}
updated := string(fs.files[filepath.Join(rootDir, "CHANGELOG.md")])
if !contains(updated, "[Unreleased]: https://git.hrafn.xyz/aether/vociferate/compare/v1.1.7...main") {
t.Fatalf("Prepare() changelog missing injected environment link:\n%s", updated)
}
if !contains(updated, "[1.1.7]: https://git.hrafn.xyz/aether/vociferate/compare/v1.1.6...v1.1.7") {
t.Fatalf("Prepare() changelog missing injected release link:\n%s", updated)
}
}
func TestServicePrepare_UsesInjectedGitRunnerForFirstCommitLink(t *testing.T) {
t.Parallel()
rootDir := "/repo"
fs := newStubFileSystem(map[string]string{
filepath.Join(rootDir, "release-version"): "1.1.6\n",
filepath.Join(rootDir, "CHANGELOG.md"): "# Changelog\n\n## [Unreleased]\n\n### Added\n\n- New thing.\n\n## [1.1.6] - 2017-12-20\n\n### Fixed\n\n- Historical note.\n",
filepath.Join(rootDir, ".git", "config"): "[remote \"origin\"]\n\turl = git@git.hrafn.xyz:aether/vociferate.git\n",
})
svc, err := NewService(Dependencies{
FileSystem: fs,
Environment: stubEnvironment{},
Git: stubGitRunner{commit: "abc1234", ok: true},
})
if err != nil {
t.Fatalf("NewService() unexpected error: %v", err)
}
err = svc.Prepare(rootDir, "1.1.7", "2026-03-21", Options{})
if err != nil {
t.Fatalf("Prepare() unexpected error: %v", err)
}
updated := string(fs.files[filepath.Join(rootDir, "CHANGELOG.md")])
if !contains(updated, "[1.1.6]: https://git.hrafn.xyz/aether/vociferate/compare/abc1234...v1.1.6") {
t.Fatalf("Prepare() changelog missing injected git link:\n%s", updated)
}
}
func contains(text, fragment string) bool {
return len(fragment) > 0 && strings.Contains(text, fragment)
}

View File

@@ -41,7 +41,7 @@ func (s *PrepareSuite) SetupTest() {
)) ))
require.NoError(s.T(), os.WriteFile( require.NoError(s.T(), os.WriteFile(
filepath.Join(s.rootDir, "changelog.md"), filepath.Join(s.rootDir, "CHANGELOG.md"),
[]byte("# Changelog\n\n## [Unreleased]\n\n### Breaking\n\n### Added\n\n- New thing.\n\n### Fixed\n\n- Old thing.\n\n## [1.1.6] - 2017-12-20\n\n### Fixed\n\n- Historical note.\n"), []byte("# Changelog\n\n## [Unreleased]\n\n### Breaking\n\n### Added\n\n- New thing.\n\n### Fixed\n\n- Old thing.\n\n## [1.1.6] - 2017-12-20\n\n### Fixed\n\n- Historical note.\n"),
0o644, 0o644,
)) ))
@@ -72,15 +72,15 @@ func (s *PrepareSuite) TestPrepare_UpdatesVersionAndPromotesUnreleasedNotes() {
require.NoError(s.T(), err) require.NoError(s.T(), err)
require.Equal(s.T(), "1.1.7\n", string(versionBytes)) require.Equal(s.T(), "1.1.7\n", string(versionBytes))
changelogBytes, err := os.ReadFile(filepath.Join(s.rootDir, "changelog.md")) changelogBytes, err := os.ReadFile(filepath.Join(s.rootDir, "CHANGELOG.md"))
require.NoError(s.T(), err) require.NoError(s.T(), err)
firstCommit := firstCommitShortHash(s.T(), s.rootDir) firstCommit := firstCommitShortHash(s.T(), s.rootDir)
require.Equal(s.T(), "# Changelog\n\n## [Unreleased]\n\n### Breaking\n\n### Added\n\n### Changed\n\n### Removed\n\n### Fixed\n\n## [1.1.7] - 2026-03-20\n\n### Breaking\n\n### Added\n\n- New thing.\n\n### Fixed\n\n- Old thing.\n\n## [1.1.6] - 2017-12-20\n\n### Fixed\n\n- Historical note.\n\n[Unreleased]: //git.hrafn.xyz/aether/vociferate/compare/v1.1.7...main\n[1.1.7]: //git.hrafn.xyz/aether/vociferate/compare/v1.1.6...v1.1.7\n[1.1.6]: //git.hrafn.xyz/aether/vociferate/compare/"+firstCommit+"...v1.1.6\n", string(changelogBytes)) require.Equal(s.T(), "# Changelog\n\n## [Unreleased]\n\n### Breaking\n\n### Added\n\n### Changed\n\n### Removed\n\n### Fixed\n\n## [1.1.7] - 2026-03-20\n\n### Breaking\n\n### Added\n\n- New thing.\n\n### Fixed\n\n- Old thing.\n\n## [1.1.6] - 2017-12-20\n\n### Fixed\n\n- Historical note.\n\n[Unreleased]: https://git.hrafn.xyz/aether/vociferate/compare/v1.1.7...main\n[1.1.7]: https://git.hrafn.xyz/aether/vociferate/compare/v1.1.6...v1.1.7\n[1.1.6]: https://git.hrafn.xyz/aether/vociferate/compare/"+firstCommit+"...v1.1.6\n", string(changelogBytes))
} }
func (s *PrepareSuite) TestPrepare_ReturnsErrorWhenUnreleasedSectionMissing() { func (s *PrepareSuite) TestPrepare_ReturnsErrorWhenUnreleasedSectionMissing() {
require.NoError(s.T(), os.WriteFile( require.NoError(s.T(), os.WriteFile(
filepath.Join(s.rootDir, "changelog.md"), filepath.Join(s.rootDir, "CHANGELOG.md"),
[]byte("# Changelog\n\n## [1.1.6] - 2017-12-20\n"), []byte("# Changelog\n\n## [1.1.6] - 2017-12-20\n"),
0o644, 0o644,
)) ))
@@ -92,7 +92,7 @@ func (s *PrepareSuite) TestPrepare_ReturnsErrorWhenUnreleasedSectionMissing() {
func (s *PrepareSuite) TestPrepare_ReturnsErrorWhenUnreleasedSectionIsEmpty() { func (s *PrepareSuite) TestPrepare_ReturnsErrorWhenUnreleasedSectionIsEmpty() {
require.NoError(s.T(), os.WriteFile( require.NoError(s.T(), os.WriteFile(
filepath.Join(s.rootDir, "changelog.md"), filepath.Join(s.rootDir, "CHANGELOG.md"),
[]byte("# Changelog\n\n## [Unreleased]\n\n## [1.1.6] - 2017-12-20\n"), []byte("# Changelog\n\n## [Unreleased]\n\n## [1.1.6] - 2017-12-20\n"),
0o644, 0o644,
)) ))
@@ -109,9 +109,41 @@ func (s *PrepareSuite) TestRecommendedTag_UsesMinorBumpWhenBreakingHeadingIsEmpt
require.Equal(s.T(), "v1.2.0", tag) require.Equal(s.T(), "v1.2.0", tag)
} }
func (s *PrepareSuite) TestUnreleasedBody_ReturnsStructuredPendingReleaseNotes() {
body, err := vociferate.UnreleasedBody(s.rootDir, vociferate.Options{})
require.NoError(s.T(), err)
require.Equal(s.T(), "### Breaking\n\n### Added\n\n- New thing.\n\n### Fixed\n\n- Old thing.\n", body)
}
func (s *PrepareSuite) TestUnreleasedBody_ReturnsErrorWhenUnreleasedSectionMissing() {
require.NoError(s.T(), os.WriteFile(
filepath.Join(s.rootDir, "CHANGELOG.md"),
[]byte("# Changelog\n\n## [1.1.6] - 2017-12-20\n"),
0o644,
))
_, err := vociferate.UnreleasedBody(s.rootDir, vociferate.Options{})
require.ErrorContains(s.T(), err, "unreleased section")
}
func (s *PrepareSuite) TestReleaseNotes_ReturnsReleaseSectionForVersion() {
notes, err := vociferate.ReleaseNotes(s.rootDir, "1.1.6", vociferate.Options{})
require.NoError(s.T(), err)
require.Equal(s.T(), "## [1.1.6] - 2017-12-20\n\n### Fixed\n\n- Historical note.\n", notes)
}
func (s *PrepareSuite) TestReleaseNotes_ReturnsErrorWhenVersionSectionMissing() {
_, err := vociferate.ReleaseNotes(s.rootDir, "9.9.9", vociferate.Options{})
require.ErrorContains(s.T(), err, "release notes section")
}
func (s *PrepareSuite) TestRecommendedTag_ReturnsErrorWhenUnreleasedHasOnlyTemplateHeadings() { func (s *PrepareSuite) TestRecommendedTag_ReturnsErrorWhenUnreleasedHasOnlyTemplateHeadings() {
require.NoError(s.T(), os.WriteFile( require.NoError(s.T(), os.WriteFile(
filepath.Join(s.rootDir, "changelog.md"), filepath.Join(s.rootDir, "CHANGELOG.md"),
[]byte("# Changelog\n\n## [Unreleased]\n\n### Breaking\n\n### Added\n\n### Changed\n\n### Removed\n\n### Fixed\n\n## [1.1.6] - 2017-12-20\n"), []byte("# Changelog\n\n## [Unreleased]\n\n### Breaking\n\n### Added\n\n### Changed\n\n### Removed\n\n### Fixed\n\n## [1.1.6] - 2017-12-20\n"),
0o644, 0o644,
)) ))
@@ -123,7 +155,7 @@ func (s *PrepareSuite) TestRecommendedTag_ReturnsErrorWhenUnreleasedHasOnlyTempl
func (s *PrepareSuite) TestRecommendedTag_UsesPatchBumpForFixOnlyChanges() { func (s *PrepareSuite) TestRecommendedTag_UsesPatchBumpForFixOnlyChanges() {
require.NoError(s.T(), os.WriteFile( require.NoError(s.T(), os.WriteFile(
filepath.Join(s.rootDir, "changelog.md"), filepath.Join(s.rootDir, "CHANGELOG.md"),
[]byte("# Changelog\n\n## [Unreleased]\n\n### Fixed\n\n- Patch note.\n\n## [1.1.6] - 2017-12-20\n"), []byte("# Changelog\n\n## [Unreleased]\n\n### Fixed\n\n- Patch note.\n\n## [1.1.6] - 2017-12-20\n"),
0o644, 0o644,
)) ))
@@ -136,7 +168,7 @@ func (s *PrepareSuite) TestRecommendedTag_UsesPatchBumpForFixOnlyChanges() {
func (s *PrepareSuite) TestRecommendedTag_UsesMajorBumpWhenRemovedEntriesExist() { func (s *PrepareSuite) TestRecommendedTag_UsesMajorBumpWhenRemovedEntriesExist() {
require.NoError(s.T(), os.WriteFile( require.NoError(s.T(), os.WriteFile(
filepath.Join(s.rootDir, "changelog.md"), filepath.Join(s.rootDir, "CHANGELOG.md"),
[]byte("# Changelog\n\n## [Unreleased]\n\n### Removed\n\n- Breaking removal.\n\n## [1.1.6] - 2017-12-20\n"), []byte("# Changelog\n\n## [Unreleased]\n\n### Removed\n\n- Breaking removal.\n\n## [1.1.6] - 2017-12-20\n"),
0o644, 0o644,
)) ))
@@ -149,7 +181,7 @@ func (s *PrepareSuite) TestRecommendedTag_UsesMajorBumpWhenRemovedEntriesExist()
func (s *PrepareSuite) TestRecommendedTag_UsesMajorBumpWhenBreakingEntriesExist() { func (s *PrepareSuite) TestRecommendedTag_UsesMajorBumpWhenBreakingEntriesExist() {
require.NoError(s.T(), os.WriteFile( require.NoError(s.T(), os.WriteFile(
filepath.Join(s.rootDir, "changelog.md"), filepath.Join(s.rootDir, "CHANGELOG.md"),
[]byte("# Changelog\n\n## [Unreleased]\n\n### Breaking\n\n- Changed API contract.\n\n### Changed\n\n- Updated defaults.\n\n## [1.1.6] - 2017-12-20\n"), []byte("# Changelog\n\n## [Unreleased]\n\n### Breaking\n\n- Changed API contract.\n\n### Changed\n\n- Updated defaults.\n\n## [1.1.6] - 2017-12-20\n"),
0o644, 0o644,
)) ))
@@ -206,7 +238,7 @@ func (s *PrepareSuite) TestRecommendedTag_UsesChangelogVersionWhenNoVersionFileC
0o644, 0o644,
)) ))
require.NoError(s.T(), os.WriteFile( require.NoError(s.T(), os.WriteFile(
filepath.Join(s.rootDir, "changelog.md"), filepath.Join(s.rootDir, "CHANGELOG.md"),
[]byte("# Changelog\n\n## [Unreleased]\n\n### Fixed\n\n- A fix.\n\n## [3.0.0] - 2026-01-01\n\n### Fixed\n\n- Historical.\n"), []byte("# Changelog\n\n## [Unreleased]\n\n### Fixed\n\n- A fix.\n\n## [3.0.0] - 2026-01-01\n\n### Fixed\n\n- Historical.\n"),
0o644, 0o644,
)) ))
@@ -220,7 +252,7 @@ func (s *PrepareSuite) TestRecommendedTag_UsesChangelogVersionWhenNoVersionFileC
func (s *PrepareSuite) TestRecommendedTag_DefaultsToV1WhenNoPriorReleasesInChangelog() { func (s *PrepareSuite) TestRecommendedTag_DefaultsToV1WhenNoPriorReleasesInChangelog() {
require.NoError(s.T(), os.Remove(filepath.Join(s.rootDir, "release-version"))) require.NoError(s.T(), os.Remove(filepath.Join(s.rootDir, "release-version")))
require.NoError(s.T(), os.WriteFile( require.NoError(s.T(), os.WriteFile(
filepath.Join(s.rootDir, "changelog.md"), filepath.Join(s.rootDir, "CHANGELOG.md"),
[]byte("# Changelog\n\n## [Unreleased]\n\n### Breaking\n\n### Added\n\n- First feature.\n"), []byte("# Changelog\n\n## [Unreleased]\n\n### Breaking\n\n### Added\n\n- First feature.\n"),
0o644, 0o644,
)) ))
@@ -252,7 +284,7 @@ func (s *PrepareSuite) TestRecommendedTag_UsesCustomVersionFileAndPattern() {
0o644, 0o644,
)) ))
require.NoError(s.T(), os.WriteFile( require.NoError(s.T(), os.WriteFile(
filepath.Join(s.rootDir, "changelog.md"), filepath.Join(s.rootDir, "CHANGELOG.md"),
[]byte("# Changelog\n\n## [Unreleased]\n\n### Added\n\n- Feature.\n\n## [2.3.4] - 2026-03-10\n"), []byte("# Changelog\n\n## [Unreleased]\n\n### Added\n\n- Feature.\n\n## [2.3.4] - 2026-03-10\n"),
0o644, 0o644,
)) ))
@@ -273,7 +305,7 @@ func (s *PrepareSuite) TestPrepare_UsesGitHrafnXYZEnvironmentForChangelogLinks()
err := vociferate.Prepare(s.rootDir, "1.1.7", "2026-03-20", vociferate.Options{}) err := vociferate.Prepare(s.rootDir, "1.1.7", "2026-03-20", vociferate.Options{})
require.NoError(s.T(), err) require.NoError(s.T(), err)
changelogBytes, readErr := os.ReadFile(filepath.Join(s.rootDir, "changelog.md")) changelogBytes, readErr := os.ReadFile(filepath.Join(s.rootDir, "CHANGELOG.md"))
require.NoError(s.T(), readErr) require.NoError(s.T(), readErr)
changelog := string(changelogBytes) changelog := string(changelogBytes)
firstCommit := firstCommitShortHash(s.T(), s.rootDir) firstCommit := firstCommitShortHash(s.T(), s.rootDir)
@@ -283,9 +315,9 @@ func (s *PrepareSuite) TestPrepare_UsesGitHrafnXYZEnvironmentForChangelogLinks()
require.Contains(s.T(), changelog, "### Removed\n") require.Contains(s.T(), changelog, "### Removed\n")
require.Contains(s.T(), changelog, "## [1.1.7] - 2026-03-20") require.Contains(s.T(), changelog, "## [1.1.7] - 2026-03-20")
require.Contains(s.T(), changelog, "## [1.1.6] - 2017-12-20") require.Contains(s.T(), changelog, "## [1.1.6] - 2017-12-20")
require.Contains(s.T(), changelog, "[Unreleased]: //git.hrafn.xyz/aether/vociferate/compare/v1.1.7...main") require.Contains(s.T(), changelog, "[Unreleased]: https://git.hrafn.xyz/aether/vociferate/compare/v1.1.7...main")
require.Contains(s.T(), changelog, "[1.1.7]: //git.hrafn.xyz/aether/vociferate/compare/v1.1.6...v1.1.7") require.Contains(s.T(), changelog, "[1.1.7]: https://git.hrafn.xyz/aether/vociferate/compare/v1.1.6...v1.1.7")
require.Contains(s.T(), changelog, "[1.1.6]: //git.hrafn.xyz/aether/vociferate/compare/"+firstCommit+"...v1.1.6") require.Contains(s.T(), changelog, "[1.1.6]: https://git.hrafn.xyz/aether/vociferate/compare/"+firstCommit+"...v1.1.6")
} }
func (s *PrepareSuite) TestPrepare_UsesGitHubEnvironmentForChangelogLinks() { func (s *PrepareSuite) TestPrepare_UsesGitHubEnvironmentForChangelogLinks() {
@@ -295,7 +327,7 @@ func (s *PrepareSuite) TestPrepare_UsesGitHubEnvironmentForChangelogLinks() {
err := vociferate.Prepare(s.rootDir, "1.1.7", "2026-03-20", vociferate.Options{}) err := vociferate.Prepare(s.rootDir, "1.1.7", "2026-03-20", vociferate.Options{})
require.NoError(s.T(), err) require.NoError(s.T(), err)
changelogBytes, readErr := os.ReadFile(filepath.Join(s.rootDir, "changelog.md")) changelogBytes, readErr := os.ReadFile(filepath.Join(s.rootDir, "CHANGELOG.md"))
require.NoError(s.T(), readErr) require.NoError(s.T(), readErr)
changelog := string(changelogBytes) changelog := string(changelogBytes)
firstCommit := firstCommitShortHash(s.T(), s.rootDir) firstCommit := firstCommitShortHash(s.T(), s.rootDir)
@@ -305,7 +337,7 @@ func (s *PrepareSuite) TestPrepare_UsesGitHubEnvironmentForChangelogLinks() {
require.Contains(s.T(), changelog, "### Removed\n") require.Contains(s.T(), changelog, "### Removed\n")
require.Contains(s.T(), changelog, "## [1.1.7] - 2026-03-20") require.Contains(s.T(), changelog, "## [1.1.7] - 2026-03-20")
require.Contains(s.T(), changelog, "## [1.1.6] - 2017-12-20") require.Contains(s.T(), changelog, "## [1.1.6] - 2017-12-20")
require.Contains(s.T(), changelog, "[Unreleased]: //github.com/aether/vociferate/compare/v1.1.7...main") require.Contains(s.T(), changelog, "[Unreleased]: https://github.com/aether/vociferate/compare/v1.1.7...main")
require.Contains(s.T(), changelog, "[1.1.7]: //github.com/aether/vociferate/compare/v1.1.6...v1.1.7") require.Contains(s.T(), changelog, "[1.1.7]: https://github.com/aether/vociferate/compare/v1.1.6...v1.1.7")
require.Contains(s.T(), changelog, "[1.1.6]: //github.com/aether/vociferate/compare/"+firstCommit+"...v1.1.6") require.Contains(s.T(), changelog, "[1.1.6]: https://github.com/aether/vociferate/compare/"+firstCommit+"...v1.1.6")
} }

View File

@@ -9,3 +9,17 @@ go-build:
go-test: go-test:
go test ./... go test ./...
validate-fmt:
go fmt ./...
test -z "$(gofmt -l .)"
validate-mod:
go mod tidy
go mod verify
security:
gosec ./...
govulncheck ./...
validate: validate-fmt validate-mod go-test security

View File

@@ -26,7 +26,7 @@ inputs:
changelog: changelog:
description: Path to changelog file relative to repository root. description: Path to changelog file relative to repository root.
required: false required: false
default: changelog.md default: CHANGELOG.md
git-user-name: git-user-name:
description: Name for the release commit author. description: Name for the release commit author.
required: false required: false
@@ -38,160 +38,102 @@ inputs:
git-add-files: git-add-files:
description: > description: >
Space-separated list of file paths to stage for the release commit. Space-separated list of file paths to stage for the release commit.
Defaults to changelog.md and release-version. Adjust when using a Defaults to CHANGELOG.md and release-version. Adjust when using a
custom version-file. custom version-file.
required: false required: false
default: 'changelog.md release-version' default: 'CHANGELOG.md release-version'
token:
description: >
Personal access token used to authenticate commit, push, and tag
operations. Required to ensure downstream workflows trigger on tag push.
required: true
outputs: outputs:
version: version:
description: > description: >
The resolved version tag (e.g. v1.2.3) that was committed and pushed. The resolved version tag (e.g. v1.2.3) that was committed and pushed.
value: ${{ steps.run-vociferate.outputs.version }} value: ${{ steps.finalize-version.outputs.version }}
runs: runs:
using: composite using: composite
steps: steps:
- name: Resolve vociferate binary metadata - name: Normalize version input
id: resolve-binary id: normalize-version
shell: bash shell: bash
env: env:
ACTION_REF: ${{ github.action_ref }}
ACTION_REPOSITORY: ${{ github.action_repository }}
CACHE_TOKEN: ${{ env.VOCIFERATE_CACHE_TOKEN }}
SERVER_URL: ${{ github.server_url }}
API_URL: ${{ github.api_url }}
TOKEN: ${{ github.token }}
RUNNER_ARCH: ${{ runner.arch }}
RUNNER_TEMP: ${{ runner.temp }}
run: |
set -euo pipefail
case "$RUNNER_ARCH" in
X64) arch="amd64" ;;
ARM64) arch="arm64" ;;
*)
echo "Unsupported runner architecture: $RUNNER_ARCH" >&2
exit 1
;;
esac
if [[ "$ACTION_REF" == v* ]]; then
release_tag="$ACTION_REF"
normalized_version="${release_tag#v}"
asset_name="vociferate_${normalized_version}_linux_${arch}"
cache_dir="${RUNNER_TEMP}/vociferate/${release_tag}/linux-${arch}"
binary_path="${cache_dir}/vociferate"
asset_url="${SERVER_URL}/aether/vociferate/releases/download/${release_tag}/${asset_name}"
provided_cache_token="$(printf '%s' "${CACHE_TOKEN:-}" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
if [[ -n "$provided_cache_token" ]]; then
cache_token="$provided_cache_token"
else
cache_token="${ACTION_REPOSITORY:-aether/vociferate}-${release_tag}"
fi
mkdir -p "$cache_dir"
echo "use_binary=true" >> "$GITHUB_OUTPUT"
echo "release_tag=$release_tag" >> "$GITHUB_OUTPUT"
echo "cache_token=$cache_token" >> "$GITHUB_OUTPUT"
echo "asset_name=$asset_name" >> "$GITHUB_OUTPUT"
echo "asset_url=$asset_url" >> "$GITHUB_OUTPUT"
echo "cache_dir=$cache_dir" >> "$GITHUB_OUTPUT"
echo "binary_path=$binary_path" >> "$GITHUB_OUTPUT"
else
echo "use_binary=false" >> "$GITHUB_OUTPUT"
fi
- name: Setup Go
if: steps.resolve-binary.outputs.use_binary != 'true'
uses: actions/setup-go@v5
with:
go-version: '1.26.1'
cache: false
- name: Restore cached vociferate binary
id: cache-vociferate
if: steps.resolve-binary.outputs.use_binary == 'true'
uses: actions/cache@v4
with:
path: ${{ steps.resolve-binary.outputs.cache_dir }}
key: vociferate-${{ steps.resolve-binary.outputs.cache_token }}-linux-${{ runner.arch }}
- name: Download vociferate binary
if: steps.resolve-binary.outputs.use_binary == 'true' && steps.cache-vociferate.outputs.cache-hit != 'true'
shell: bash
env:
TOKEN: ${{ github.token }}
ASSET_URL: ${{ steps.resolve-binary.outputs.asset_url }}
BINARY_PATH: ${{ steps.resolve-binary.outputs.binary_path }}
run: |
set -euo pipefail
curl --fail --location \
-H "Authorization: token ${TOKEN}" \
-o "$BINARY_PATH" \
"$ASSET_URL"
chmod +x "$BINARY_PATH"
- name: Run vociferate
id: run-vociferate
shell: bash
env:
VOCIFERATE_BIN: ${{ steps.resolve-binary.outputs.binary_path }}
USE_BINARY: ${{ steps.resolve-binary.outputs.use_binary }}
INPUT_VERSION: ${{ inputs.version }} INPUT_VERSION: ${{ inputs.version }}
VOCIFERATE_REPOSITORY_URL: ${{ vars.VOCIFERATE_REPOSITORY_URL }}
run: | run: |
set -euo pipefail set -euo pipefail
if [[ "$USE_BINARY" == "true" ]]; then
run_vociferate() { "$VOCIFERATE_BIN" "$@"; }
else
action_root="$(realpath "$GITHUB_ACTION_PATH/..")"
run_vociferate() { (cd "$action_root" && go run ./cmd/vociferate "$@"); }
fi
common_args=(--root "$GITHUB_WORKSPACE")
if [[ -n "${{ inputs.version-file }}" ]]; then
common_args+=(--version-file "${{ inputs.version-file }}")
fi
if [[ -n "${{ inputs.version-pattern }}" ]]; then
common_args+=(--version-pattern "${{ inputs.version-pattern }}")
fi
if [[ -n "${{ inputs.changelog }}" ]]; then
common_args+=(--changelog "${{ inputs.changelog }}")
fi
provided_version="$(printf '%s' "${INPUT_VERSION:-}" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')" provided_version="$(printf '%s' "${INPUT_VERSION:-}" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
printf 'value=%s\n' "$provided_version" >> "$GITHUB_OUTPUT"
- name: Recommend version
id: recommend-version
if: steps.normalize-version.outputs.value == ''
uses: ./run-vociferate
with:
root: ${{ github.workspace }}
version-file: ${{ inputs.version-file }}
version-pattern: ${{ inputs.version-pattern }}
changelog: ${{ inputs.changelog }}
recommend: 'true'
- name: Finalize version
id: finalize-version
shell: bash
env:
PROVIDED_VERSION: ${{ steps.normalize-version.outputs.value }}
RECOMMENDED_VERSION: ${{ steps.recommend-version.outputs.stdout }}
run: |
set -euo pipefail
provided_version="$PROVIDED_VERSION"
if [[ -z "$provided_version" ]]; then if [[ -z "$provided_version" ]]; then
provided_version="$(run_vociferate "${common_args[@]}" --recommend)" provided_version="$RECOMMENDED_VERSION"
fi fi
normalized_version="${provided_version#v}" normalized_version="${provided_version#v}"
tag="v${normalized_version}" tag="v${normalized_version}"
run_vociferate "${common_args[@]}" --version "$provided_version" --date "$(date -u +%F)" printf 'version=%s\n' "$tag" >> "$GITHUB_OUTPUT"
echo "version=$tag" >> "$GITHUB_OUTPUT" - name: Resolve release date
id: resolve-date
shell: bash
run: |
set -euo pipefail
printf 'value=%s\n' "$(date -u +%F)" >> "$GITHUB_OUTPUT"
- name: Prepare release files
uses: ./run-vociferate
with:
root: ${{ github.workspace }}
version-file: ${{ inputs.version-file }}
version-pattern: ${{ inputs.version-pattern }}
changelog: ${{ inputs.changelog }}
version: ${{ steps.finalize-version.outputs.version }}
date: ${{ steps.resolve-date.outputs.value }}
- name: Commit and push release - name: Commit and push release
shell: bash shell: bash
env: env:
TOKEN: ${{ github.token }} TOKEN: ${{ inputs.token }}
GIT_USER_NAME: ${{ inputs.git-user-name }} GIT_USER_NAME: ${{ inputs.git-user-name }}
GIT_USER_EMAIL: ${{ inputs.git-user-email }} GIT_USER_EMAIL: ${{ inputs.git-user-email }}
GIT_ADD_FILES: ${{ inputs.git-add-files }} GIT_ADD_FILES: ${{ inputs.git-add-files }}
RELEASE_TAG: ${{ steps.run-vociferate.outputs.version }} RELEASE_TAG: ${{ steps.finalize-version.outputs.version }}
GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_SERVER_URL: ${{ github.server_url }}
GITHUB_REPOSITORY: ${{ github.repository }} GITHUB_REPOSITORY: ${{ github.repository }}
run: | run: |
set -euo pipefail set -euo pipefail
if [[ -z "${TOKEN:-}" ]]; then
echo "A release PAT is required. Provide inputs.token (for example secrets.RELEASE_PAT)." >&2
exit 1
fi
case "$GITHUB_SERVER_URL" in case "$GITHUB_SERVER_URL" in
https://*) https://*)
authed_remote="https://oauth2:${TOKEN}@${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY}.git" authed_remote="https://oauth2:${TOKEN}@${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY}.git"

View File

@@ -7,10 +7,9 @@ description: >
inputs: inputs:
token: token:
description: > description: >
Token used to authenticate release API calls. Defaults to the Personal access token used to authenticate release API calls.
workflow token. Required to support release updates across workflow boundaries.
required: false required: true
default: ''
version: version:
description: > description: >
Semantic version to publish (with or without leading v). When omitted, Semantic version to publish (with or without leading v). When omitted,
@@ -20,7 +19,7 @@ inputs:
changelog: changelog:
description: Path to changelog file relative to repository root. description: Path to changelog file relative to repository root.
required: false required: false
default: changelog.md default: CHANGELOG.md
outputs: outputs:
release-id: release-id:
@@ -57,78 +56,100 @@ runs:
normalized="${tag#v}" normalized="${tag#v}"
else else
echo "A version input is required when the workflow is not running from a tag push" >&2 echo "A version input is required when the workflow is not running from a tag push" >&2
echo "Provide version via input or ensure HEAD is at a tagged commit." >&2
exit 1 exit 1
fi fi
echo "tag=$tag" >> "$GITHUB_OUTPUT" echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "version=$normalized" >> "$GITHUB_OUTPUT" echo "version=$normalized" >> "$GITHUB_OUTPUT"
- name: Extract release notes from changelog - name: Extract release notes
id: extract-notes id: extract-notes
uses: ./run-vociferate
with:
root: ${{ github.workspace }}
changelog: ${{ inputs.changelog }}
version: ${{ steps.resolve-version.outputs.version }}
print-release-notes: 'true'
- name: Write release notes file
id: write-notes
shell: bash shell: bash
env: env:
CHANGELOG: ${{ inputs.changelog != '' && inputs.changelog || 'changelog.md' }} RELEASE_NOTES: ${{ steps.extract-notes.outputs.stdout }}
RELEASE_VERSION: ${{ steps.resolve-version.outputs.version }}
RUNNER_TEMP: ${{ runner.temp }} RUNNER_TEMP: ${{ runner.temp }}
run: | run: |
set -euo pipefail set -euo pipefail
release_notes="$(awk -v version="$RELEASE_VERSION" '
$0 ~ "^## \\[" version "\\]" {capture=1}
capture {
if ($0 ~ "^## \\[" && $0 !~ "^## \\[" version "\\]") exit
print
}
' "$CHANGELOG")"
if [[ -z "${release_notes//[[:space:]]/}" ]]; then
echo "Release notes section for ${RELEASE_VERSION} was not found in ${CHANGELOG}" >&2
exit 1
fi
notes_file="${RUNNER_TEMP}/release-notes.md" notes_file="${RUNNER_TEMP}/release-notes.md"
printf '%s\n' "$release_notes" > "$notes_file" printf '%s\n' "$RELEASE_NOTES" > "$notes_file"
echo "notes_file=$notes_file" >> "$GITHUB_OUTPUT"
printf 'notes_file=%s\n' "$notes_file" >> "$GITHUB_OUTPUT"
- name: Create or update release - name: Create or update release
id: create-release id: create-release
shell: bash shell: bash
env: env:
TOKEN: ${{ inputs.token != '' && inputs.token || github.token }} TOKEN: ${{ inputs.token }}
TAG_NAME: ${{ steps.resolve-version.outputs.tag }} TAG_NAME: ${{ steps.resolve-version.outputs.tag }}
RELEASE_NOTES_FILE: ${{ steps.extract-notes.outputs.notes_file }} RELEASE_NOTES_FILE: ${{ steps.write-notes.outputs.notes_file }}
GITHUB_API_URL: ${{ github.api_url }} GITHUB_API_URL: ${{ github.api_url }}
GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_SERVER_URL: ${{ github.server_url }}
GITHUB_SHA: ${{ github.sha }}
GITHUB_REPOSITORY: ${{ github.repository }} GITHUB_REPOSITORY: ${{ github.repository }}
run: | run: |
set -euo pipefail set -euo pipefail
parse_release_id() {
local json_file="$1"
if command -v python3 >/dev/null 2>&1; then
python3 -c 'import json, sys; payload = json.load(open(sys.argv[1], encoding="utf-8")); value = payload.get("id"); print(value if isinstance(value, int) else "")' "$json_file"
return
fi
# Fallback for environments without python3.
sed -n 's/.*"id"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' "$json_file" | head -n 1
}
raw_token="$(printf '%s' "${TOKEN:-}" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
if [[ "$raw_token" =~ ^%\!t\(string=(.*)\)$ ]]; then
raw_token="${BASH_REMATCH[1]}"
fi
api_token="$(printf '%s' "$raw_token" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
if [[ -z "$api_token" ]]; then
echo "inputs.token is required (set to secrets.RELEASE_PAT)." >&2
exit 1
fi
release_notes="$(cat "$RELEASE_NOTES_FILE")" release_notes="$(cat "$RELEASE_NOTES_FILE")"
escaped_release_notes="$(printf '%s' "$release_notes" | sed 's/\\/\\\\/g; s/"/\\"/g; :a;N;$!ba;s/\n/\\n/g')" escaped_release_notes="$(printf '%s' "$release_notes" | sed 's/\\/\\\\/g; s/"/\\"/g; :a;N;$!ba;s/\n/\\n/g')"
release_api="${GITHUB_API_URL:-${GITHUB_SERVER_URL%/}/api/v1}/repos/${GITHUB_REPOSITORY}/releases" release_api="${GITHUB_API_URL:-${GITHUB_SERVER_URL%/}/api/v1}/repos/${GITHUB_REPOSITORY}/releases"
release_by_tag_api="${release_api}/tags/${TAG_NAME}" release_by_tag_api="${release_api}/tags/${TAG_NAME}"
status_code="$(curl -sS -o release-existing.json -w '%{http_code}' \ status_code="$(curl -sS -o release-existing.json -w '%{http_code}' \
-H "Authorization: token ${TOKEN}" \ -H "Authorization: token ${api_token}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"${release_by_tag_api}")" "${release_by_tag_api}")"
if [[ "$status_code" == "200" ]]; then if [[ "$status_code" == "200" ]]; then
existing_release_id="$(sed -n 's/.*"id"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' release-existing.json | head -n 1)" existing_release_id="$(parse_release_id release-existing.json)"
if [[ -z "$existing_release_id" ]]; then if [[ -z "$existing_release_id" ]]; then
echo "Failed to parse existing release id for ${TAG_NAME}" >&2 echo "Failed to parse existing release id for ${TAG_NAME}" >&2
cat release-existing.json >&2 cat release-existing.json >&2
exit 1 exit 1
fi fi
curl --fail-with-body \ if ! curl --fail-with-body \
-X PATCH \ -X PATCH \
-H "Authorization: token ${TOKEN}" \ -H "Authorization: token ${api_token}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"${release_api}/${existing_release_id}" \ "${release_api}/${existing_release_id}" \
--data "{\"tag_name\":\"${TAG_NAME}\",\"target\":\"${GITHUB_SHA}\",\"name\":\"${TAG_NAME}\",\"body\":\"${escaped_release_notes}\",\"draft\":false,\"prerelease\":false}" \ --data "{\"tag_name\":\"${TAG_NAME}\",\"name\":\"${TAG_NAME}\",\"body\":\"${escaped_release_notes}\",\"draft\":false,\"prerelease\":false}" \
--output release.json --output release.json; then
cat release.json >&2 || true
exit 1
fi
echo "id=$existing_release_id" >> "$GITHUB_OUTPUT" echo "id=$existing_release_id" >> "$GITHUB_OUTPUT"
elif [[ "$status_code" != "404" ]]; then elif [[ "$status_code" != "404" ]]; then
@@ -136,15 +157,18 @@ runs:
cat release-existing.json >&2 cat release-existing.json >&2
exit 1 exit 1
else else
curl --fail-with-body \ if ! curl --fail-with-body \
-X POST \ -X POST \
-H "Authorization: token ${TOKEN}" \ -H "Authorization: token ${api_token}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"${release_api}" \ "${release_api}" \
--data "{\"tag_name\":\"${TAG_NAME}\",\"target\":\"${GITHUB_SHA}\",\"name\":\"${TAG_NAME}\",\"body\":\"${escaped_release_notes}\",\"draft\":false,\"prerelease\":false}" \ --data "{\"tag_name\":\"${TAG_NAME}\",\"name\":\"${TAG_NAME}\",\"body\":\"${escaped_release_notes}\",\"draft\":false,\"prerelease\":false}" \
--output release.json --output release.json; then
cat release.json >&2 || true
exit 1
fi
release_id="$(sed -n 's/.*"id"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' release.json | head -n 1)" release_id="$(parse_release_id release.json)"
if [[ -z "$release_id" ]]; then if [[ -z "$release_id" ]]; then
echo "Failed to parse release id from API response" >&2 echo "Failed to parse release id from API response" >&2
cat release.json >&2 cat release.json >&2

View File

@@ -1 +1 @@
0.1.0 1.2.0

276
run-vociferate/action.yml Normal file
View File

@@ -0,0 +1,276 @@
name: vociferate/run-vociferate
description: Resolve the preferred runtime for vociferate and execute it with a consistent output contract.
inputs:
root:
description: Repository root to pass to vociferate.
required: true
version-file:
description: Optional version file path.
required: false
default: ''
version-pattern:
description: Optional version pattern.
required: false
default: ''
changelog:
description: Optional changelog path.
required: false
default: ''
version:
description: Optional version argument.
required: false
default: ''
date:
description: Optional date argument.
required: false
default: ''
recommend:
description: Whether to run vociferate with --recommend.
required: false
default: 'false'
print-unreleased:
description: Whether to print the Unreleased body.
required: false
default: 'false'
print-release-notes:
description: Whether to print the release notes section for version.
required: false
default: 'false'
outputs:
stdout:
description: Captured stdout from the selected runtime.
value: ${{ steps.finalize.outputs.stdout }}
use_binary:
description: Whether the selected runtime was the released binary.
value: ${{ steps.resolve-runtime.outputs.use_binary }}
runs:
using: composite
steps:
- name: Resolve runtime
id: resolve-runtime
shell: bash
env:
ACTION_REF: ${{ github.action_ref }}
run: |
set -euo pipefail
if [[ "$ACTION_REF" == v* ]]; then
printf 'use_binary=true\n' >> "$GITHUB_OUTPUT"
else
printf 'use_binary=false\n' >> "$GITHUB_OUTPUT"
fi
- name: Resolve binary metadata
id: resolve-binary
if: steps.resolve-runtime.outputs.use_binary == 'true'
shell: bash
env:
ACTION_REF: ${{ github.action_ref }}
ACTION_REPOSITORY: ${{ github.action_repository }}
CACHE_TOKEN: ${{ env.VOCIFERATE_CACHE_TOKEN }}
SERVER_URL: ${{ github.server_url }}
RUNNER_ARCH: ${{ runner.arch }}
RUNNER_TEMP: ${{ runner.temp }}
run: |
set -euo pipefail
if [[ "$ACTION_REF" != v* ]]; then
echo "run-vociferate binary path requires github.action_ref to be a release tag" >&2
exit 1
fi
case "$RUNNER_ARCH" in
X64)
arch="amd64"
;;
ARM64)
arch="arm64"
;;
*)
echo "Unsupported runner architecture: $RUNNER_ARCH" >&2
exit 1
;;
esac
release_tag="$ACTION_REF"
normalized_version="${release_tag#v}"
asset_name="vociferate_${normalized_version}_linux_${arch}"
cache_dir="${RUNNER_TEMP}/vociferate/${release_tag}/linux-${arch}"
binary_path="${cache_dir}/vociferate"
asset_url="${SERVER_URL}/aether/vociferate/releases/download/${release_tag}/${asset_name}"
provided_cache_token="$(printf '%s' "${CACHE_TOKEN:-}" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
if [[ -n "$provided_cache_token" ]]; then
cache_token="$provided_cache_token"
else
cache_token="${ACTION_REPOSITORY:-aether/vociferate}-${release_tag}"
fi
mkdir -p "$cache_dir"
printf 'cache_token=%s\n' "$cache_token" >> "$GITHUB_OUTPUT"
printf 'cache_dir=%s\n' "$cache_dir" >> "$GITHUB_OUTPUT"
printf 'binary_path=%s\n' "$binary_path" >> "$GITHUB_OUTPUT"
printf 'asset_url=%s\n' "$asset_url" >> "$GITHUB_OUTPUT"
- name: Restore cached binary
id: cache-vociferate
if: steps.resolve-runtime.outputs.use_binary == 'true'
uses: actions/cache@v4
with:
path: ${{ steps.resolve-binary.outputs.cache_dir }}
key: vociferate-${{ steps.resolve-binary.outputs.cache_token }}-linux-${{ runner.arch }}
- name: Download binary
if: steps.resolve-runtime.outputs.use_binary == 'true' && steps.cache-vociferate.outputs.cache-hit != 'true'
shell: bash
env:
TOKEN: ${{ github.token }}
ASSET_URL: ${{ steps.resolve-binary.outputs.asset_url }}
BINARY_PATH: ${{ steps.resolve-binary.outputs.binary_path }}
run: |
set -euo pipefail
curl --fail --location \
-H "Authorization: token ${TOKEN}" \
-o "$BINARY_PATH" \
"$ASSET_URL"
chmod +x "$BINARY_PATH"
- name: Run binary
id: run-binary
if: steps.resolve-runtime.outputs.use_binary == 'true'
shell: bash
env:
VOCIFERATE_BIN: ${{ steps.resolve-binary.outputs.binary_path }}
ROOT: ${{ inputs.root }}
VERSION_FILE: ${{ inputs.version-file }}
VERSION_PATTERN: ${{ inputs.version-pattern }}
CHANGELOG: ${{ inputs.changelog }}
VERSION: ${{ inputs.version }}
DATE: ${{ inputs.date }}
RECOMMEND: ${{ inputs.recommend }}
PRINT_UNRELEASED: ${{ inputs.print-unreleased }}
PRINT_RELEASE_NOTES: ${{ inputs.print-release-notes }}
VOCIFERATE_REPOSITORY_URL: ${{ vars.VOCIFERATE_REPOSITORY_URL }}
run: |
set -euo pipefail
command=("$VOCIFERATE_BIN" --root "$ROOT")
if [[ -n "$VERSION_FILE" ]]; then
command+=(--version-file "$VERSION_FILE")
fi
if [[ -n "$VERSION_PATTERN" ]]; then
command+=(--version-pattern "$VERSION_PATTERN")
fi
if [[ -n "$CHANGELOG" ]]; then
command+=(--changelog "$CHANGELOG")
fi
if [[ -n "$VERSION" ]]; then
command+=(--version "$VERSION")
fi
if [[ -n "$DATE" ]]; then
command+=(--date "$DATE")
fi
if [[ "$RECOMMEND" == 'true' ]]; then
command+=(--recommend)
fi
if [[ "$PRINT_UNRELEASED" == 'true' ]]; then
command+=(--print-unreleased)
fi
if [[ "$PRINT_RELEASE_NOTES" == 'true' ]]; then
command+=(--print-release-notes)
fi
stdout_file="$(mktemp)"
trap 'rm -f "$stdout_file"' EXIT
"${command[@]}" > "$stdout_file"
delimiter="EOF_STDOUT"
printf 'stdout<<%s\n' "$delimiter" >> "$GITHUB_OUTPUT"
cat "$stdout_file" >> "$GITHUB_OUTPUT"
printf '%s\n' "$delimiter" >> "$GITHUB_OUTPUT"
- name: Run source
id: run-code
if: steps.resolve-runtime.outputs.use_binary != 'true'
shell: bash
env:
ROOT: ${{ inputs.root }}
VERSION_FILE: ${{ inputs.version-file }}
VERSION_PATTERN: ${{ inputs.version-pattern }}
CHANGELOG: ${{ inputs.changelog }}
VERSION: ${{ inputs.version }}
DATE: ${{ inputs.date }}
RECOMMEND: ${{ inputs.recommend }}
PRINT_UNRELEASED: ${{ inputs.print-unreleased }}
PRINT_RELEASE_NOTES: ${{ inputs.print-release-notes }}
VOCIFERATE_REPOSITORY_URL: ${{ vars.VOCIFERATE_REPOSITORY_URL }}
run: |
set -euo pipefail
source_root="$GITHUB_ACTION_PATH"
while [[ ! -f "$source_root/go.mod" ]] && [[ "$source_root" != "/" ]]; do
source_root="$(realpath "$source_root/..")"
done
if [[ ! -f "$source_root/go.mod" ]]; then
echo "Could not locate Go module root from $GITHUB_ACTION_PATH" >&2
exit 1
fi
command=(go run ./cmd/vociferate --root "$ROOT")
if [[ -n "$VERSION_FILE" ]]; then
command+=(--version-file "$VERSION_FILE")
fi
if [[ -n "$VERSION_PATTERN" ]]; then
command+=(--version-pattern "$VERSION_PATTERN")
fi
if [[ -n "$CHANGELOG" ]]; then
command+=(--changelog "$CHANGELOG")
fi
if [[ -n "$VERSION" ]]; then
command+=(--version "$VERSION")
fi
if [[ -n "$DATE" ]]; then
command+=(--date "$DATE")
fi
if [[ "$RECOMMEND" == 'true' ]]; then
command+=(--recommend)
fi
if [[ "$PRINT_UNRELEASED" == 'true' ]]; then
command+=(--print-unreleased)
fi
if [[ "$PRINT_RELEASE_NOTES" == 'true' ]]; then
command+=(--print-release-notes)
fi
stdout_file="$(mktemp)"
trap 'rm -f "$stdout_file"' EXIT
(cd "$source_root" && "${command[@]}") > "$stdout_file"
delimiter="EOF_STDOUT"
printf 'stdout<<%s\n' "$delimiter" >> "$GITHUB_OUTPUT"
cat "$stdout_file" >> "$GITHUB_OUTPUT"
printf '%s\n' "$delimiter" >> "$GITHUB_OUTPUT"
- name: Finalize stdout
id: finalize
shell: bash
env:
STDOUT_BINARY: ${{ steps.run-binary.outputs.stdout }}
STDOUT_CODE: ${{ steps.run-code.outputs.stdout }}
run: |
set -euo pipefail
stdout="$STDOUT_BINARY"
if [[ -z "$stdout" ]]; then
stdout="$STDOUT_CODE"
fi
delimiter="EOF_STDOUT"
printf 'stdout<<%s\n' "$delimiter" >> "$GITHUB_OUTPUT"
printf '%s\n' "$stdout" >> "$GITHUB_OUTPUT"
printf '%s\n' "$delimiter" >> "$GITHUB_OUTPUT"