37 Commits

Author SHA1 Message Date
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
24 changed files with 2604 additions and 338 deletions

View File

@@ -28,7 +28,7 @@ jobs:
run: run:
shell: bash shell: bash
env: env:
RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN || secrets.GITEA_TOKEN }}
SUMMARY_FILE: ${{ runner.temp }}/do-release-summary.md SUMMARY_FILE: ${{ runner.temp }}/do-release-summary.md
steps: steps:
- name: Checkout tagged revision - name: Checkout tagged revision
@@ -52,11 +52,45 @@ jobs:
cache: true cache: true
cache-dependency-path: go.sum cache-dependency-path: go.sum
- name: Preflight release API access
env:
REQUESTED_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
if [[ -z "${RELEASE_TOKEN:-}" ]]; then
echo "No release token available. Set GITEA_TOKEN (or GITHUB_TOKEN on GitHub)." >&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
requested_tag="$(printf '%s' "${REQUESTED_TAG:-}" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
if [[ -n "$requested_tag" ]]; then
normalized_tag="${requested_tag#v}"
tag_ref="refs/tags/v${normalized_tag}"
if ! git rev-parse --verify --quiet "$tag_ref" >/dev/null; then
echo "Requested tag ${tag_ref#refs/tags/} was not found in the checked out repository." >&2
exit 1
fi
fi
- name: Create or update release - name: Create or update release
id: publish id: publish
uses: ./publish uses: ./publish
with: with:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN || secrets.GITEA_TOKEN }}
version: ${{ inputs.tag }} version: ${{ inputs.tag }}
- name: Build release binaries - name: Build release binaries
@@ -177,7 +211,7 @@ jobs:
- name: Download released binary - name: Download released binary
env: env:
TOKEN: ${{ github.token }} TOKEN: ${{ secrets.GITHUB_TOKEN || secrets.GITEA_TOKEN }}
TAG_NAME: ${{ needs.release.outputs.tag }} TAG_NAME: ${{ needs.release.outputs.tag }}
RELEASE_VERSION: ${{ needs.release.outputs.version }} RELEASE_VERSION: ${{ needs.release.outputs.version }}
ASSET_ARCH: ${{ matrix.asset_arch }} ASSET_ARCH: ${{ matrix.asset_arch }}

View File

@@ -39,8 +39,31 @@ jobs:
cache: true cache: true
cache-dependency-path: go.sum cache-dependency-path: go.sum
- name: Validate formatting
run: test -z "$(gofmt -l .)"
- name: Module hygiene
run: |
set -euo pipefail
go mod tidy
go mod verify
- name: 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
- name: Run tests - name: Run tests
run: go test ./... run: |
set -euo pipefail
go test ./...
- name: Resolve cache token - name: Resolve cache token
id: cache-token id: cache-token

View File

@@ -35,6 +35,27 @@ jobs:
cache: true cache: true
cache-dependency-path: go.sum cache-dependency-path: go.sum
- name: Validate formatting
run: test -z "$(gofmt -l .)"
- name: Module hygiene
run: |
set -euo pipefail
go mod tidy
go mod verify
- name: 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
- name: Run full unit test suite with coverage - name: Run full unit test suite with coverage
run: | run: |
set -euo pipefail set -euo pipefail

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.

View File

@@ -4,14 +4,15 @@ This guide is for agentic coding partners that need to integrate the composite a
## Source Of Truth ## Source Of Truth
Pin all action references to a released tag (for example `@v1.0.0`) and keep all vociferate references on the same tag in a workflow. Pin all action references to a released tag (for example `@v1.0.2`) and keep all vociferate references on the same tag in a workflow.
Published composite actions: Published composite actions:
- `git.hrafn.xyz/aether/vociferate@v1.0.0` (root action) - `https://git.hrafn.xyz/aether/vociferate@v1.0.2` (root action)
- `git.hrafn.xyz/aether/vociferate/prepare@v1.0.0` - `https://git.hrafn.xyz/aether/vociferate/prepare@v1.0.2`
- `git.hrafn.xyz/aether/vociferate/publish@v1.0.0` - `https://git.hrafn.xyz/aether/vociferate/publish@v1.0.2`
- `git.hrafn.xyz/aether/vociferate/coverage-badge@v1.0.0` - `https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.0.2`
- `https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.0.2`
## Action Selection Matrix ## Action Selection Matrix
@@ -20,6 +21,7 @@ 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 `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 `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 `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. - Choose root `vociferate` for direct recommend/prepare logic without commit/tag/push behavior.
## Preconditions ## Preconditions
@@ -28,7 +30,8 @@ Apply these checks before invoking actions:
- Checkout repository first. - Checkout repository first.
- For prepare/publish flows that depend on tags/history, use full history checkout (`fetch-depth: 0`). - For prepare/publish flows that depend on tags/history, use full history checkout (`fetch-depth: 0`).
- Use valid credentials in `github.token` (or explicit token input for `publish` when needed). - Use valid credentials for release/comment API calls. On GitHub, `secrets.GITHUB_TOKEN` is used; on self-hosted Gitea, set `secrets.GITEA_TOKEN`.
- `do-release` and `decorate-pr` now run preflight API checks and fail fast when token credentials are missing or insufficient.
- Set required vars/secrets for coverage uploads: - Set required vars/secrets for coverage uploads:
- `vars.ARTEFACT_BUCKET_NAME` - `vars.ARTEFACT_BUCKET_NAME`
- `vars.ARTEFACT_BUCKET_ENDPONT` - `vars.ARTEFACT_BUCKET_ENDPONT`
@@ -91,11 +94,11 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- id: prepare - id: prepare
uses: git.hrafn.xyz/aether/vociferate/prepare@v1.0.0 uses: https://git.hrafn.xyz/aether/vociferate/prepare@v1.0.2
publish: publish:
needs: prepare needs: prepare
uses: aether/vociferate/.gitea/workflows/do-release.yml@v1.0.0 uses: https://git.hrafn.xyz/aether/vociferate/.gitea/workflows/do-release.yml@v1.0.2
with: with:
tag: ${{ needs.prepare.outputs.version }} tag: ${{ needs.prepare.outputs.version }}
secrets: inherit secrets: inherit
@@ -112,7 +115,7 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- id: publish - id: publish
uses: git.hrafn.xyz/aether/vociferate/publish@v1.0.0 uses: https://git.hrafn.xyz/aether/vociferate/publish@v1.0.2
with: with:
version: v1.2.3 version: v1.2.3
``` ```
@@ -133,12 +136,40 @@ jobs:
- name: Run tests with coverage - name: Run tests with coverage
run: go test -covermode=atomic -coverprofile=coverage.out ./... run: go test -covermode=atomic -coverprofile=coverage.out ./...
- id: badge - id: badge
uses: git.hrafn.xyz/aether/vociferate/coverage-badge@v1.0.0 uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.0.2
with: with:
artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }} artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }}
artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }} artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }}
``` ```
### 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.0.2
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.0.2
with:
coverage-percentage: ${{ steps.badge.outputs.total }}
badge-url: ${{ steps.badge.outputs.badge-url }}
```
## Inputs And Outputs Cheatsheet ## Inputs And Outputs Cheatsheet
### prepare ### prepare
@@ -186,6 +217,34 @@ Primary outputs:
- `report-url` - `report-url`
- `badge-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 ## Guardrails For Agents
Use these rules to avoid common automation mistakes: Use these rules to avoid common automation mistakes:
@@ -193,5 +252,4 @@ Use these rules to avoid common automation mistakes:
- Do not mix action tags in one workflow update. - 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 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 treat `VOCIFERATE_REPOSITORY_URL` as a full repository URL; it must be a base URL.
- Keep displayed URLs protocol-relative (`//`) when writing markdown/browser-facing outputs. - Do not bypass preflight failures with broad retry loops; fix token scope/secret wiring first.
- If a workflow environment does not support `GITHUB_STEP_SUMMARY`, append markdown to a file and print it in a final `Summary` step.

View File

@@ -2,8 +2,8 @@
All notable changes to this project will be documented in this file. 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/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](//semver.org/spec/v2.0.0.html). 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. 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.
@@ -13,12 +13,59 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect
### Added ### 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/binary` and `run-vociferate/code` are now nested under `run-vociferate/` so callers reference them as `./run-vociferate/binary` and `./run-vociferate/code`.
### Removed
### Fixed
- Fixed `decorate-pr/action.yml` YAML validation by extracting PR comment rendering into `decorate-pr/build-comment.sh`, removing the duplicated changelog extraction step, and correcting the gate failure output reference.
- Fixed docs-only detection in `decorate-pr` changelog gate: file list was iterated in a piped subshell so `docs_only` never propagated to the parent scope; replaced pipe with process substitution.
## [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 ### Changed
### Removed ### Removed
### Fixed ### Fixed
- Enforced explicit `https://` changelog reference links in prepare output for browser-safe markdown links.
## [1.0.0] - 2026-03-21 ## [1.0.0] - 2026-03-21
### Breaking ### Breaking
@@ -54,7 +101,7 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect
### Fixed ### Fixed
- Browser-facing URLs emitted in generated changelog links, workflow summaries, and markdown now use protocol-relative `//` forms. - 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. - 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. - 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. - First-release recommendation remains `v1.0.0` when no prior releases exist in the changelog.
@@ -96,7 +143,9 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect
- Project/automation rename from `releaseprep` to `vociferate` (entrypoint, package paths, outputs). - Project/automation rename from `releaseprep` to `vociferate` (entrypoint, package paths, outputs).
- README guidance focused on primary cross-repository reuse workflows. - README guidance focused on primary cross-repository reuse workflows.
[Unreleased]: //git.hrafn.xyz/aether/vociferate/compare/v1.0.0...main [Unreleased]: https://git.hrafn.xyz/aether/vociferate/compare/v1.0.2...main
[1.0.0]: //git.hrafn.xyz/aether/vociferate/compare/v0.2.0...v1.0.0 [1.0.2]: https://git.hrafn.xyz/aether/vociferate/compare/v1.0.1...v1.0.2
[0.2.0]: //git.hrafn.xyz/aether/vociferate/compare/v0.1.0...v0.2.0 [1.0.1]: https://git.hrafn.xyz/aether/vociferate/compare/v1.0.0...v1.0.1
[0.1.0]: //git.hrafn.xyz/aether/vociferate/compare/2060af6...v0.1.0 [1.0.0]: https://git.hrafn.xyz/aether/vociferate/compare/v0.2.0...v1.0.0
[0.2.0]: https://git.hrafn.xyz/aether/vociferate/compare/v0.1.0...v0.2.0
[0.1.0]: https://git.hrafn.xyz/aether/vociferate/compare/2060af6...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)
- [prepare-release.yml](.gitea/workflows/prepare-release.yml)
- [do-release.yml](.gitea/workflows/do-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
**prepare-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 `prepare-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

108
README.md
View File

@@ -1,9 +1,9 @@
# 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) [![Prepare Release](https://git.hrafn.xyz/aether/vociferate/actions/workflows/prepare-release.yml/badge.svg?event=workflow_dispatch)](https://git.hrafn.xyz/aether/vociferate/actions/runs/latest?workflow=prepare-release.yml)
[![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) [![Do Release](https://git.hrafn.xyz/aether/vociferate/actions/workflows/do-release.yml/badge.svg?event=push)](https://git.hrafn.xyz/aether/vociferate/actions/runs/latest?workflow=do-release.yml)
[![Coverage](//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 `Æther` 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
@@ -16,8 +16,8 @@ revision.
## Use In Other Repositories ## Use In Other Repositories
Vociferate ships three composite actions covering release preparation, release publication, and coverage badge publishing. Vociferate ships composite actions covering release preparation, release publication, coverage badge publishing, and pull request decoration.
Release tags now exist; pin all action and reusable-workflow references to the same released tag (for example, `@v1.0.0`) instead of `@main`. Release tags now exist; pin all action and reusable-workflow references to the same released tag (for example, `@v1.0.2`) instead of `@main`.
For agentic coding partners, see [`AGENTS.md`](AGENTS.md) for a direct integration playbook, selection matrix, and copy-paste workflow patterns. For agentic coding partners, see [`AGENTS.md`](AGENTS.md) for a direct integration playbook, selection matrix, and copy-paste workflow patterns.
@@ -41,13 +41,13 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: git.hrafn.xyz/aether/vociferate/prepare@v1.0.0 - uses: https://git.hrafn.xyz/aether/vociferate/prepare@v1.0.2
with: with:
version: ${{ inputs.version }} version: ${{ inputs.version }}
publish: publish:
needs: prepare needs: prepare
uses: aether/vociferate/.gitea/workflows/do-release.yml@v1.0.0 uses: https://git.hrafn.xyz/aether/vociferate/.gitea/workflows/do-release.yml@v1.0.2
with: with:
tag: ${{ needs.prepare.outputs.version }} tag: ${{ needs.prepare.outputs.version }}
secrets: inherit secrets: inherit
@@ -61,7 +61,7 @@ For repositories that embed the version inside source code, pass `version-file`
and `version-pattern`: and `version-pattern`:
```yaml ```yaml
- uses: git.hrafn.xyz/aether/vociferate/prepare@v1.0.0 - uses: https://git.hrafn.xyz/aether/vociferate/prepare@v1.0.2
with: with:
version-file: internal/myapp/version/version.go version-file: internal/myapp/version/version.go
version-pattern: 'const Version = "([^"]+)"' version-pattern: 'const Version = "([^"]+)"'
@@ -85,7 +85,7 @@ on:
jobs: jobs:
release: release:
uses: aether/vociferate/.gitea/workflows/do-release.yml@v1.0.0 uses: https://git.hrafn.xyz/aether/vociferate/.gitea/workflows/do-release.yml@v1.0.2
with: with:
tag: ${{ inputs.tag }} tag: ${{ inputs.tag }}
secrets: inherit secrets: inherit
@@ -95,12 +95,17 @@ Reads the matching section from `CHANGELOG.md` and creates or updates the
Gitea/GitHub release with those notes. The `version` input is optional — when Gitea/GitHub release with those notes. The `version` input is optional — when
omitted it is derived from the current tag ref automatically. omitted it is derived from the current tag ref automatically.
The reusable `Do Release` workflow now runs preflight checks before publish to
fail fast when the release token is missing or lacks API access. On
self-hosted Gitea, set `secrets.GITEA_TOKEN`; on GitHub, `secrets.GITHUB_TOKEN`
is used automatically.
The `publish` action outputs `release-id` so you can upload additional release The `publish` action outputs `release-id` so you can upload additional release
assets after it runs: assets after it runs:
```yaml ```yaml
- id: publish - id: publish
uses: git.hrafn.xyz/aether/vociferate/publish@v1.0.0 uses: https://git.hrafn.xyz/aether/vociferate/publish@v1.0.2
- name: Upload my binary - name: Upload my binary
run: | run: |
@@ -120,7 +125,7 @@ Run your coverage tests first, then call the action to generate `coverage.html`,
run: go test -covermode=atomic -coverprofile=coverage.out ./... run: go test -covermode=atomic -coverprofile=coverage.out ./...
- id: coverage - id: coverage
uses: git.hrafn.xyz/aether/vociferate/coverage-badge@v1.0.0 uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.0.2
with: with:
artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }} artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }}
artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }} artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }}
@@ -131,6 +136,85 @@ Run your coverage tests first, then call the action to generate `coverage.html`,
echo "Badge: ${{ steps.coverage.outputs.badge-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.0.2
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.0.2
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.0.2
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.0.2
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.

View File

@@ -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

@@ -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

@@ -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 {

View File

@@ -138,8 +138,8 @@ runs:
display_endpoint="${ARTEFACT_BUCKET_ENDPONT#https://}" display_endpoint="${ARTEFACT_BUCKET_ENDPONT#https://}"
display_endpoint="${display_endpoint#http://}" display_endpoint="${display_endpoint#http://}"
report_url="//${display_endpoint%/}/${ARTEFACT_BUCKET_NAME}/${prefix}/coverage.html" report_url="https://${display_endpoint%/}/${ARTEFACT_BUCKET_NAME}/${prefix}/coverage.html"
badge_url="//${display_endpoint%/}/${ARTEFACT_BUCKET_NAME}/${prefix}/coverage-badge.svg" 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_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_BADGE" "s3://${ARTEFACT_BUCKET_NAME}/${prefix}/coverage-badge.svg" --content-type image/svg+xml

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

@@ -23,11 +23,111 @@ const (
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,
@@ -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()
@@ -159,3 +204,110 @@ func TestResolveOptions_UsesUppercaseChangelogDefault(t *testing.T) {
t.Fatalf("resolved changelog = %q, want %q", 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

@@ -75,7 +75,7 @@ func (s *PrepareSuite) TestPrepare_UpdatesVersionAndPromotesUnreleasedNotes() {
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() {
@@ -109,6 +109,38 @@ 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"),
@@ -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() {
@@ -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

@@ -47,137 +47,69 @@ 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
@@ -186,7 +118,7 @@ runs:
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: |

View File

@@ -63,32 +63,28 @@ runs:
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
@@ -96,7 +92,7 @@ runs:
env: env:
TOKEN: ${{ inputs.token != '' && inputs.token || github.token }} TOKEN: ${{ inputs.token != '' && inputs.token || github.token }}
TAG_NAME: ${{ steps.resolve-version.outputs.tag }} TAG_NAME: ${{ steps.resolve-version.outputs.tag }}
RELEASE_NOTES_FILE: ${{ steps.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_SHA: ${{ github.sha }}

View File

@@ -1 +1 @@
1.0.0 1.0.2

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

@@ -0,0 +1,113 @@
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: Run binary
id: run-binary
if: steps.resolve-runtime.outputs.use_binary == 'true'
uses: ./binary
with:
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 }}
- name: Run source
id: run-code
if: steps.resolve-runtime.outputs.use_binary != 'true'
uses: ./code
with:
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 }}
- 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"

View File

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

View File

@@ -0,0 +1,125 @@
name: vociferate/run-vociferate-code
description: Execute vociferate from the checked-out Go source.
inputs:
root:
description: Repository root to pass to vociferate.
required: true
version-file:
description: Optional version file path.
required: false
default: ''
version-pattern:
description: Optional version pattern.
required: false
default: ''
changelog:
description: Optional changelog path.
required: false
default: ''
version:
description: Optional version argument.
required: false
default: ''
date:
description: Optional date argument.
required: false
default: ''
recommend:
description: Whether to run vociferate with --recommend.
required: false
default: 'false'
print-unreleased:
description: Whether to print the Unreleased body.
required: false
default: 'false'
print-release-notes:
description: Whether to print the release notes section for version.
required: false
default: 'false'
outputs:
stdout:
description: Captured stdout from the vociferate invocation.
value: ${{ steps.run.outputs.stdout }}
runs:
using: composite
steps:
- name: Resolve source root
id: resolve-source
shell: bash
run: |
set -euo pipefail
source_root="$GITHUB_ACTION_PATH"
while [[ ! -f "$source_root/go.mod" ]] && [[ "$source_root" != "/" ]]; do
source_root="$(realpath "$source_root/..")"
done
if [[ ! -f "$source_root/go.mod" ]]; then
echo "Could not locate Go module root from $GITHUB_ACTION_PATH" >&2
exit 1
fi
printf 'source_root=%s\n' "$source_root" >> "$GITHUB_OUTPUT"
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.26.1'
cache: true
cache-dependency-path: ${{ steps.resolve-source.outputs.source_root }}/go.sum
- name: Run source
id: run
shell: bash
working-directory: ${{ steps.resolve-source.outputs.source_root }}
env:
ROOT: ${{ inputs.root }}
VERSION_FILE: ${{ inputs.version-file }}
VERSION_PATTERN: ${{ inputs.version-pattern }}
CHANGELOG: ${{ inputs.changelog }}
VERSION: ${{ inputs.version }}
DATE: ${{ inputs.date }}
RECOMMEND: ${{ inputs.recommend }}
PRINT_UNRELEASED: ${{ inputs.print-unreleased }}
PRINT_RELEASE_NOTES: ${{ inputs.print-release-notes }}
VOCIFERATE_REPOSITORY_URL: ${{ vars.VOCIFERATE_REPOSITORY_URL }}
run: |
set -euo pipefail
command=(go run ./cmd/vociferate --root "$ROOT")
if [[ -n "$VERSION_FILE" ]]; then
command+=(--version-file "$VERSION_FILE")
fi
if [[ -n "$VERSION_PATTERN" ]]; then
command+=(--version-pattern "$VERSION_PATTERN")
fi
if [[ -n "$CHANGELOG" ]]; then
command+=(--changelog "$CHANGELOG")
fi
if [[ -n "$VERSION" ]]; then
command+=(--version "$VERSION")
fi
if [[ -n "$DATE" ]]; then
command+=(--date "$DATE")
fi
if [[ "$RECOMMEND" == 'true' ]]; then
command+=(--recommend)
fi
if [[ "$PRINT_UNRELEASED" == 'true' ]]; then
command+=(--print-unreleased)
fi
if [[ "$PRINT_RELEASE_NOTES" == 'true' ]]; then
command+=(--print-release-notes)
fi
stdout_file="$(mktemp)"
trap 'rm -f "$stdout_file"' EXIT
"${command[@]}" > "$stdout_file"
delimiter="EOF_STDOUT"
printf 'stdout<<%s\n' "$delimiter" >> "$GITHUB_OUTPUT"
cat "$stdout_file" >> "$GITHUB_OUTPUT"
printf '%s\n' "$delimiter" >> "$GITHUB_OUTPUT"