Compare commits
43 Commits
v1.0.1
...
4714bfe272
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4714bfe272 | ||
|
|
72abf37b2d | ||
|
|
5bea62b8cf | ||
|
|
dd86944e64 | ||
|
|
38afdeffa0 | ||
|
|
f9c57f34d0 | ||
|
|
5793a58888 | ||
|
|
2177dae15f | ||
|
|
76508355be | ||
|
|
f069c116a1 | ||
|
|
32a6ded499 | ||
|
|
b7c62634f4 | ||
|
|
224ba03ca4 | ||
|
|
3f555fb894 | ||
|
|
ee274602a8 | ||
|
|
1306f07003 | ||
|
|
58e29aca0c | ||
|
|
f04df719e2 | ||
|
|
9a91c70e5d | ||
|
|
3eb814a3d5 | ||
|
|
92f76fd19f | ||
|
|
9dc28e8229 | ||
|
|
e625d475a5 | ||
|
|
b7d1760beb | ||
|
|
64a7b6d86b | ||
|
|
c8365e39da | ||
|
|
4a47580ea8 | ||
|
|
5a207e7d5d | ||
|
|
5c903c98be | ||
|
|
383aad48be | ||
|
|
f31141702d | ||
|
|
7cb7b050db | ||
|
|
3c60be8587 | ||
|
|
830e623fa9 | ||
|
|
d4d911e6c7 | ||
|
|
4b9372079b | ||
|
|
b5530d0c48 | ||
|
|
b1aaff9f3b | ||
|
|
3e03382781 | ||
|
|
43018ae9ac | ||
|
|
3e384dd8a3 | ||
|
|
821802c0c4 | ||
|
|
2810d93b89 |
@@ -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 }}
|
||||||
|
|||||||
@@ -39,8 +39,45 @@ 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: Restore cached gosec binary
|
||||||
|
id: cache-gosec
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ${{ runner.temp }}/gosec-bin
|
||||||
|
key: gosec-v2.22.4-${{ runner.os }}-${{ runner.arch }}
|
||||||
|
|
||||||
|
- name: Install gosec binary
|
||||||
|
if: steps.cache-gosec.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p "${RUNNER_TEMP}/gosec-bin"
|
||||||
|
GOBIN="${RUNNER_TEMP}/gosec-bin" go install github.com/securego/gosec/v2/cmd/gosec@v2.22.4
|
||||||
|
|
||||||
|
- name: Run gosec security analysis
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
"${RUNNER_TEMP}/gosec-bin/gosec" ./...
|
||||||
|
|
||||||
|
- name: Run govulncheck
|
||||||
|
uses: golang/govulncheck-action@v1.0.4
|
||||||
|
with:
|
||||||
|
go-package: ./...
|
||||||
|
cache: true
|
||||||
|
cache-dependency-path: go.sum
|
||||||
|
|
||||||
- name: Run tests
|
- 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
|
||||||
|
|||||||
@@ -35,6 +35,41 @@ 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: Restore cached gosec binary
|
||||||
|
id: cache-gosec
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ${{ runner.temp }}/gosec-bin
|
||||||
|
key: gosec-v2.22.4-${{ runner.os }}-${{ runner.arch }}
|
||||||
|
|
||||||
|
- name: Install gosec binary
|
||||||
|
if: steps.cache-gosec.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p "${RUNNER_TEMP}/gosec-bin"
|
||||||
|
GOBIN="${RUNNER_TEMP}/gosec-bin" go install github.com/securego/gosec/v2/cmd/gosec@v2.22.4
|
||||||
|
|
||||||
|
- name: Run gosec security analysis
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
"${RUNNER_TEMP}/gosec-bin/gosec" ./...
|
||||||
|
|
||||||
|
- name: Run govulncheck
|
||||||
|
uses: golang/govulncheck-action@v1.0.4
|
||||||
|
with:
|
||||||
|
go-package: ./...
|
||||||
|
cache: true
|
||||||
|
cache-dependency-path: go.sum
|
||||||
|
|
||||||
- name: Run 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
440
.github/copilot-instructions.md
vendored
Normal 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.
|
||||||
80
AGENTS.md
80
AGENTS.md
@@ -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.1`) and keep all vociferate references on the same tag in a workflow.
|
Pin all action references to a released tag (for example `@v1.1.0`) and keep all vociferate references on the same tag in a workflow.
|
||||||
|
|
||||||
Published composite actions:
|
Published composite actions:
|
||||||
|
|
||||||
- `git.hrafn.xyz/aether/vociferate@v1.0.1` (root action)
|
- `https://git.hrafn.xyz/aether/vociferate@v1.1.0` (root action)
|
||||||
- `git.hrafn.xyz/aether/vociferate/prepare@v1.0.1`
|
- `https://git.hrafn.xyz/aether/vociferate/prepare@v1.1.0`
|
||||||
- `git.hrafn.xyz/aether/vociferate/publish@v1.0.1`
|
- `https://git.hrafn.xyz/aether/vociferate/publish@v1.1.0`
|
||||||
- `git.hrafn.xyz/aether/vociferate/coverage-badge@v1.0.1`
|
- `https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.1.0`
|
||||||
|
- `https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.1.0`
|
||||||
|
|
||||||
## 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.1
|
uses: https://git.hrafn.xyz/aether/vociferate/prepare@v1.1.0
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
needs: prepare
|
needs: prepare
|
||||||
uses: aether/vociferate/.gitea/workflows/do-release.yml@v1.0.1
|
uses: https://git.hrafn.xyz/aether/vociferate/.gitea/workflows/do-release.yml@v1.1.0
|
||||||
with:
|
with:
|
||||||
tag: ${{ needs.prepare.outputs.version }}
|
tag: ${{ needs.prepare.outputs.version }}
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
@@ -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.1
|
uses: https://git.hrafn.xyz/aether/vociferate/publish@v1.1.0
|
||||||
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.1
|
uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.1.0
|
||||||
with:
|
with:
|
||||||
artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }}
|
artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }}
|
||||||
artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }}
|
artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 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.1.0
|
||||||
|
with:
|
||||||
|
artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }}
|
||||||
|
artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }}
|
||||||
|
- name: Decorate PR
|
||||||
|
uses: https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.1.0
|
||||||
|
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,3 +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.
|
||||||
|
- Do not bypass preflight failures with broad retry loops; fix token scope/secret wiring first.
|
||||||
|
|||||||
39
CHANGELOG.md
39
CHANGELOG.md
@@ -13,8 +13,44 @@ 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
|
### 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.
|
||||||
|
- Pinned `securego/gosec` and `golang/govulncheck-action` to concrete version tags (`v2.22.4` and `v1.0.4`) so self-hosted Gitea runners can resolve them via direct git clone without relying on the GitHub Actions floating-tag API.
|
||||||
|
- Restored explicit gosec caching by storing a pinned `v2.22.4` binary under `${{ runner.temp }}/gosec-bin` with `actions/cache@v4`, so CI keeps fast security scans while still using the Go 1.26 toolchain from `setup-go`.
|
||||||
|
- Replaced `securego/gosec` composite action with a direct `go install github.com/securego/gosec/v2/cmd/gosec@v2.22.4 && gosec ./...` run step so gosec uses the Go 1.26 toolchain installed by `setup-go` rather than the action's bundled Go 1.24 binary which ignores `GOTOOLCHAIN=auto`.
|
||||||
|
|
||||||
|
## [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
|
### Removed
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -110,7 +146,8 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect
|
|||||||
- Project/automation rename from `releaseprep` to `vociferate` (entrypoint, package paths, outputs).
|
- Project/automation rename from `releaseprep` to `vociferate` (entrypoint, package paths, outputs).
|
||||||
- README guidance focused on primary cross-repository reuse workflows.
|
- README guidance focused on primary cross-repository reuse workflows.
|
||||||
|
|
||||||
[Unreleased]: https://git.hrafn.xyz/aether/vociferate/compare/v1.0.1...main
|
[Unreleased]: https://git.hrafn.xyz/aether/vociferate/compare/v1.0.2...main
|
||||||
|
[1.0.2]: https://git.hrafn.xyz/aether/vociferate/compare/v1.0.1...v1.0.2
|
||||||
[1.0.1]: https://git.hrafn.xyz/aether/vociferate/compare/v1.0.0...v1.0.1
|
[1.0.1]: https://git.hrafn.xyz/aether/vociferate/compare/v1.0.0...v1.0.1
|
||||||
[1.0.0]: https://git.hrafn.xyz/aether/vociferate/compare/v0.2.0...v1.0.0
|
[1.0.0]: https://git.hrafn.xyz/aether/vociferate/compare/v0.2.0...v1.0.0
|
||||||
[0.2.0]: https://git.hrafn.xyz/aether/vociferate/compare/v0.1.0...v0.2.0
|
[0.2.0]: https://git.hrafn.xyz/aether/vociferate/compare/v0.1.0...v0.2.0
|
||||||
|
|||||||
371
COMPLIANCE_ANALYSIS.md
Normal file
371
COMPLIANCE_ANALYSIS.md
Normal 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
|
||||||
100
README.md
100
README.md
@@ -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.1`) instead of `@main`.
|
Release tags now exist; pin all action and reusable-workflow references to the same released tag (for example, `@v1.1.0`) instead of `@main`.
|
||||||
|
|
||||||
For agentic coding partners, see [`AGENTS.md`](AGENTS.md) for a direct integration playbook, selection matrix, and copy-paste workflow patterns.
|
For agentic coding partners, see [`AGENTS.md`](AGENTS.md) for a direct integration playbook, selection matrix, and copy-paste workflow patterns.
|
||||||
|
|
||||||
@@ -41,13 +41,13 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- uses: git.hrafn.xyz/aether/vociferate/prepare@v1.0.1
|
- uses: https://git.hrafn.xyz/aether/vociferate/prepare@v1.1.0
|
||||||
with:
|
with:
|
||||||
version: ${{ inputs.version }}
|
version: ${{ inputs.version }}
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
needs: prepare
|
needs: prepare
|
||||||
uses: aether/vociferate/.gitea/workflows/do-release.yml@v1.0.1
|
uses: https://git.hrafn.xyz/aether/vociferate/.gitea/workflows/do-release.yml@v1.1.0
|
||||||
with:
|
with:
|
||||||
tag: ${{ needs.prepare.outputs.version }}
|
tag: ${{ needs.prepare.outputs.version }}
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
@@ -61,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.1
|
- uses: https://git.hrafn.xyz/aether/vociferate/prepare@v1.1.0
|
||||||
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.1
|
uses: https://git.hrafn.xyz/aether/vociferate/.gitea/workflows/do-release.yml@v1.1.0
|
||||||
with:
|
with:
|
||||||
tag: ${{ inputs.tag }}
|
tag: ${{ inputs.tag }}
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
@@ -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.1
|
uses: https://git.hrafn.xyz/aether/vociferate/publish@v1.1.0
|
||||||
|
|
||||||
- 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.1
|
uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.1.0
|
||||||
with:
|
with:
|
||||||
artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }}
|
artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }}
|
||||||
artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }}
|
artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }}
|
||||||
@@ -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.1.0
|
||||||
|
with:
|
||||||
|
artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }}
|
||||||
|
artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }}
|
||||||
|
|
||||||
|
- name: Decorate pull request
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
uses: https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.1.0
|
||||||
|
with:
|
||||||
|
coverage-percentage: ${{ steps.coverage.outputs.total }}
|
||||||
|
badge-url: ${{ steps.coverage.outputs.badge-url }}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Changelog Gate (Strict Mode)
|
||||||
|
|
||||||
|
Enable changelog validation to enforce that code changes include `Unreleased` changelog entries. The gate fails the workflow in strict mode, or warns in soft mode:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Decorate pull request with changelog gate
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
uses: https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.1.0
|
||||||
|
with:
|
||||||
|
coverage-percentage: ${{ steps.coverage.outputs.total }}
|
||||||
|
badge-url: ${{ steps.coverage.outputs.badge-url }}
|
||||||
|
enable-changelog-gate: true
|
||||||
|
changelog-gate-mode: strict
|
||||||
|
changelog-gate-required-for: "code,behavior,security,workflow,tooling"
|
||||||
|
changelog-gate-allow-docs-only: true
|
||||||
|
changelog-gate-docs-globs: "docs/**,**.md,**.txt,**.rst"
|
||||||
|
changelog-gate-skip-labels: "skip-changelog"
|
||||||
|
```
|
||||||
|
|
||||||
|
The gate automatically:
|
||||||
|
|
||||||
|
- Parses diffs to detect docs-only PRs (skips requirement for doc-only changes)
|
||||||
|
- Counts `Unreleased` additions using section-aware parsing (ignores edits outside the section)
|
||||||
|
- Checks PR labels for skip exemptions (for example, `skip-changelog`)
|
||||||
|
- Outputs decision status and remediation guidance in the PR comment
|
||||||
|
- Handles both strict (fail) and soft (warn) modes
|
||||||
|
|
||||||
|
Decision outputs enable downstream workflow logic:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Decorate PR and check gate
|
||||||
|
id: decorate
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
uses: https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.1.0
|
||||||
|
with:
|
||||||
|
coverage-percentage: ${{ steps.coverage.outputs.total }}
|
||||||
|
badge-url: ${{ steps.coverage.outputs.badge-url }}
|
||||||
|
enable-changelog-gate: true
|
||||||
|
changelog-gate-mode: soft
|
||||||
|
|
||||||
|
- name: Gate decision
|
||||||
|
run: |
|
||||||
|
echo "Gate passed: ${{ steps.decorate.outputs.gate-passed }}"
|
||||||
|
echo "Is docs-only PR: ${{ steps.decorate.outputs.docs-only }}"
|
||||||
|
echo "Unreleased additions: ${{ steps.decorate.outputs.unreleased-additions-count }}"
|
||||||
|
if [[ "${{ steps.decorate.outputs.gate-passed }}" == "false" ]]; then
|
||||||
|
echo "Gate failure reason: ${{ steps.decorate.outputs.gate-failure-reason }}"
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
The action automatically finds existing vociferate comments by their marker and updates them instead of creating duplicates. This keeps PR timelines clean while keeping review information current.
|
||||||
|
|
||||||
## Why The Name
|
## Why The Name
|
||||||
|
|
||||||
> **vociferate** _(verb)_: to cry out loudly or forcefully.
|
> **vociferate** _(verb)_: to cry out loudly or forcefully.
|
||||||
|
|||||||
155
action.yml
155
action.yml
@@ -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 }}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
395
decorate-pr/action.yml
Normal file
395
decorate-pr/action.yml
Normal 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
|
||||||
71
decorate-pr/build-comment.sh
Normal file
71
decorate-pr/build-comment.sh
Normal 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 '\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"
|
||||||
@@ -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]
|
||||||
@@ -550,22 +764,11 @@ func displayURL(url string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
14
justfile
14
justfile
@@ -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
|
||||||
|
|||||||
@@ -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: |
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
1.0.1
|
1.0.2
|
||||||
|
|||||||
113
run-vociferate/action.yml
Normal file
113
run-vociferate/action.yml
Normal 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"
|
||||||
176
run-vociferate/binary/action.yml
Normal file
176
run-vociferate/binary/action.yml
Normal 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"
|
||||||
125
run-vociferate/code/action.yml
Normal file
125
run-vociferate/code/action.yml
Normal 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"
|
||||||
Reference in New Issue
Block a user