Compare commits
49 Commits
v1.0.2
...
dc4aeb1e51
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc4aeb1e51 | ||
|
|
ea1b333da3 | ||
|
|
eb8bd80d48 | ||
|
|
cddcf99873 | ||
|
|
bef39120d3 | ||
|
|
ad3d657db9 | ||
|
|
27a058a3ce | ||
|
|
0d4310184e | ||
|
|
0fbd7641c0 | ||
|
|
60a0e82587 | ||
|
|
1a67d8b0e1 | ||
|
|
1a78209408 | ||
|
|
c05a1c48cb | ||
|
|
32327c6d72 | ||
|
|
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 |
@@ -31,18 +31,51 @@ jobs:
|
||||
RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN || secrets.GITEA_TOKEN }}
|
||||
SUMMARY_FILE: ${{ runner.temp }}/do-release-summary.md
|
||||
steps:
|
||||
- name: Checkout tagged revision
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- name: Checkout requested tag
|
||||
if: ${{ inputs.tag != '' }}
|
||||
- name: Resolve release version
|
||||
id: resolve-version
|
||||
env:
|
||||
REQUESTED_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
requested_tag="$(printf '%s' "${REQUESTED_TAG:-}" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
|
||||
|
||||
if [[ -n "$requested_tag" ]]; then
|
||||
# Explicit tag was provided via input or workflow_call
|
||||
normalized="${requested_tag#v}"
|
||||
tag="v${normalized}"
|
||||
elif [[ "$GITHUB_REF" == refs/tags/* ]]; then
|
||||
# Running from a tag push
|
||||
tag="${GITHUB_REF#refs/tags/}"
|
||||
normalized="${tag#v}"
|
||||
else
|
||||
# Try to find tags at HEAD (fetch latest first in case called from prepare-release)
|
||||
git fetch origin --tags --quiet 2>/dev/null || true
|
||||
|
||||
if tag_at_head="$(git describe --exact-match --tags HEAD 2>/dev/null)" && [[ -n "$tag_at_head" ]]; then
|
||||
# Current HEAD is at a tag
|
||||
tag="$tag_at_head"
|
||||
normalized="${tag#v}"
|
||||
else
|
||||
echo "A release tag is required when the workflow is not running from a tag push" >&2
|
||||
echo "Provide a tag via the 'tag' input or ensure HEAD is at a tagged commit." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${normalized}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout release tag
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ startsWith(inputs.tag, 'v') && format('refs/tags/{0}', inputs.tag) || format('refs/tags/v{0}', inputs.tag) }}
|
||||
ref: refs/tags/${{ steps.resolve-version.outputs.tag }}
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
@@ -54,7 +87,7 @@ jobs:
|
||||
|
||||
- name: Preflight release API access
|
||||
env:
|
||||
REQUESTED_TAG: ${{ inputs.tag }}
|
||||
TAG_NAME: ${{ steps.resolve-version.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -76,14 +109,9 @@ jobs:
|
||||
-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
|
||||
if ! git rev-parse --verify --quiet "refs/tags/${TAG_NAME}" >/dev/null; then
|
||||
echo "Tag ${TAG_NAME} was not found in the checked out repository." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Create or update release
|
||||
@@ -91,7 +119,7 @@ jobs:
|
||||
uses: ./publish
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN || secrets.GITEA_TOKEN }}
|
||||
version: ${{ inputs.tag }}
|
||||
version: ${{ steps.resolve-version.outputs.version }}
|
||||
|
||||
- name: Build release binaries
|
||||
env:
|
||||
|
||||
@@ -39,8 +39,52 @@ jobs:
|
||||
cache: true
|
||||
cache-dependency-path: go.sum
|
||||
|
||||
- name: Validate formatting
|
||||
run: test -z "$(gofmt -l .)"
|
||||
|
||||
- name: Module hygiene
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
go mod tidy
|
||||
|
||||
if ! go mod verify; then
|
||||
echo "go mod verify failed; refreshing module cache and retrying" >&2
|
||||
go clean -modcache
|
||||
go mod download
|
||||
go mod verify
|
||||
fi
|
||||
|
||||
- name: Restore cached gosec binary
|
||||
id: cache-gosec
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/gosec-bin
|
||||
key: gosec-v2.22.4-${{ runner.os }}-${{ runner.arch }}
|
||||
|
||||
- name: Install gosec binary
|
||||
if: steps.cache-gosec.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "${RUNNER_TEMP}/gosec-bin"
|
||||
GOBIN="${RUNNER_TEMP}/gosec-bin" go install github.com/securego/gosec/v2/cmd/gosec@v2.22.4
|
||||
|
||||
- name: Run gosec security analysis
|
||||
run: |
|
||||
set -euo pipefail
|
||||
"${RUNNER_TEMP}/gosec-bin/gosec" ./...
|
||||
|
||||
- name: Run govulncheck
|
||||
uses: golang/govulncheck-action@v1.0.4
|
||||
with:
|
||||
go-package: ./...
|
||||
cache: true
|
||||
cache-dependency-path: go.sum
|
||||
|
||||
- name: Run tests
|
||||
run: go test ./...
|
||||
run: |
|
||||
set -euo pipefail
|
||||
go test ./...
|
||||
|
||||
- name: Resolve cache token
|
||||
id: cache-token
|
||||
|
||||
@@ -35,6 +35,48 @@ jobs:
|
||||
cache: true
|
||||
cache-dependency-path: go.sum
|
||||
|
||||
- name: Validate formatting
|
||||
run: test -z "$(gofmt -l .)"
|
||||
|
||||
- name: Module hygiene
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
go mod tidy
|
||||
|
||||
if ! go mod verify; then
|
||||
echo "go mod verify failed; refreshing module cache and retrying" >&2
|
||||
go clean -modcache
|
||||
go mod download
|
||||
go mod verify
|
||||
fi
|
||||
|
||||
- name: Restore cached gosec binary
|
||||
id: cache-gosec
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/gosec-bin
|
||||
key: gosec-v2.22.4-${{ runner.os }}-${{ runner.arch }}
|
||||
|
||||
- name: Install gosec binary
|
||||
if: steps.cache-gosec.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "${RUNNER_TEMP}/gosec-bin"
|
||||
GOBIN="${RUNNER_TEMP}/gosec-bin" go install github.com/securego/gosec/v2/cmd/gosec@v2.22.4
|
||||
|
||||
- name: Run gosec security analysis
|
||||
run: |
|
||||
set -euo pipefail
|
||||
"${RUNNER_TEMP}/gosec-bin/gosec" ./...
|
||||
|
||||
- name: Run govulncheck
|
||||
uses: golang/govulncheck-action@v1.0.4
|
||||
with:
|
||||
go-package: ./...
|
||||
cache: true
|
||||
cache-dependency-path: go.sum
|
||||
|
||||
- name: Run full unit test suite with coverage
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
92
.github/copilot-instructions.md
vendored
92
.github/copilot-instructions.md
vendored
@@ -239,7 +239,19 @@ func (s *UserService) ProcessUsers(ctx context.Context, ids []string) error {
|
||||
|
||||
When updating CI workflows or release logic:
|
||||
|
||||
- Use the repository's standard Go setup (typically `actions/setup-go@v5` with pinned version and go.sum caching).
|
||||
- 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.
|
||||
@@ -249,13 +261,62 @@ When updating CI workflows or release logic:
|
||||
- `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:
|
||||
|
||||
- Pin all action references to released tags (for example `@v1.0.0`).
|
||||
- **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.
|
||||
- In self-hosted runner environments (git.hrafn.xyz), use explicit `https://` action paths in `uses:` references and avoid shorthand owner/repo coordinates.
|
||||
- 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.
|
||||
@@ -303,6 +364,8 @@ jobs:
|
||||
|
||||
- **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
|
||||
@@ -319,6 +382,11 @@ Security standards (self-contained):
|
||||
- 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.
|
||||
@@ -327,12 +395,15 @@ Integrate both tools into CI workflows; fail builds on high/critical findings.
|
||||
|
||||
Execute validation in this order (unless repository policy specifies otherwise):
|
||||
|
||||
1. Run focused package tests that directly cover the changed code.
|
||||
2. Run broader package or module test suites as needed.
|
||||
3. Run `gosec ./...` for security analysis.
|
||||
4. Run `govulncheck ./...` for vulnerability scanning.
|
||||
5. Run full project or behavior/integration suites when change scope or risk warrants it.
|
||||
6. Verify coverage gates per changed module/class (target 80%, low bound 65%, fail below 50%).
|
||||
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
|
||||
|
||||
@@ -358,6 +429,9 @@ Before considering a task done:
|
||||
- ✓ 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.
|
||||
|
||||
36
AGENTS.md
36
AGENTS.md
@@ -8,11 +8,11 @@ Pin all action references to a released tag (for example `@v1.0.2`) and keep all
|
||||
|
||||
Published composite actions:
|
||||
|
||||
- `git.hrafn.xyz/aether/vociferate@v1.0.2` (root action)
|
||||
- `git.hrafn.xyz/aether/vociferate/prepare@v1.0.2`
|
||||
- `git.hrafn.xyz/aether/vociferate/publish@v1.0.2`
|
||||
- `git.hrafn.xyz/aether/vociferate/coverage-badge@v1.0.2`
|
||||
- `git.hrafn.xyz/aether/vociferate/decorate-pr@v1.0.2`
|
||||
- `https://git.hrafn.xyz/aether/vociferate@v1.0.2` (root action)
|
||||
- `https://git.hrafn.xyz/aether/vociferate/prepare@v1.0.2`
|
||||
- `https://git.hrafn.xyz/aether/vociferate/publish@v1.0.2`
|
||||
- `https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.0.2`
|
||||
- `https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.0.2`
|
||||
|
||||
## Action Selection Matrix
|
||||
|
||||
@@ -94,11 +94,11 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: prepare
|
||||
uses: git.hrafn.xyz/aether/vociferate/prepare@v1.0.2
|
||||
uses: https://git.hrafn.xyz/aether/vociferate/prepare@v1.0.2
|
||||
|
||||
publish:
|
||||
needs: prepare
|
||||
uses: aether/vociferate/.gitea/workflows/do-release.yml@v1.0.2
|
||||
uses: https://git.hrafn.xyz/aether/vociferate/.gitea/workflows/do-release.yml@v1.0.2
|
||||
with:
|
||||
tag: ${{ needs.prepare.outputs.version }}
|
||||
secrets: inherit
|
||||
@@ -115,7 +115,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: publish
|
||||
uses: git.hrafn.xyz/aether/vociferate/publish@v1.0.2
|
||||
uses: https://git.hrafn.xyz/aether/vociferate/publish@v1.0.2
|
||||
with:
|
||||
version: v1.2.3
|
||||
```
|
||||
@@ -136,7 +136,7 @@ jobs:
|
||||
- name: Run tests with coverage
|
||||
run: go test -covermode=atomic -coverprofile=coverage.out ./...
|
||||
- id: badge
|
||||
uses: git.hrafn.xyz/aether/vociferate/coverage-badge@v1.0.2
|
||||
uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.0.2
|
||||
with:
|
||||
artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }}
|
||||
artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }}
|
||||
@@ -159,12 +159,12 @@ jobs:
|
||||
- name: Run tests with coverage
|
||||
run: go test -covermode=atomic -coverprofile=coverage.out ./...
|
||||
- id: badge
|
||||
uses: git.hrafn.xyz/aether/vociferate/coverage-badge@v1.0.2
|
||||
uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.0.2
|
||||
with:
|
||||
artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }}
|
||||
artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }}
|
||||
- name: Decorate PR
|
||||
uses: git.hrafn.xyz/aether/vociferate/decorate-pr@v1.0.2
|
||||
uses: https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.0.2
|
||||
with:
|
||||
coverage-percentage: ${{ steps.badge.outputs.total }}
|
||||
badge-url: ${{ steps.badge.outputs.badge-url }}
|
||||
@@ -229,11 +229,21 @@ 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-url`
|
||||
- `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
|
||||
|
||||
|
||||
27
CHANGELOG.md
27
CHANGELOG.md
@@ -13,12 +13,39 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect
|
||||
|
||||
### Added
|
||||
|
||||
- Added changelog gate validation to `decorate-pr` action for enforcing changelog updates on qualifying code changes.
|
||||
- Changelog gate modes: `strict` (fails job on violation) and `soft` (warns via PR comment).
|
||||
- Docs-only PR exemption with customizable glob patterns for documentation files.
|
||||
- PR label-based exemptions for changelog gate (example: `skip-changelog`).
|
||||
- Precise diff parsing: validates only added lines within the Unreleased section.
|
||||
- Gate decision outputs: `gate-passed`, `docs-only`, `unreleased-additions-count`, `gate-failure-reason` for reuse downstream.
|
||||
- Integrated remediation guidance in PR comments showing how to add changelog entries.
|
||||
|
||||
### Changed
|
||||
|
||||
- Refactored `internal/vociferate` to use a constructor-backed service with injected filesystem, environment, and git dependencies while preserving the existing package-level API.
|
||||
- Hardened `prepare-release` validation to enforce formatting checks, module hygiene, `gosec`, and `govulncheck` before preparing a release.
|
||||
- Added matching local validation targets in `justfile` for formatting, module hygiene, tests, and security checks.
|
||||
- `decorate-pr` now reads Unreleased changelog content through the `vociferate` Go CLI instead of maintaining separate shell parsing logic in the composite action.
|
||||
- `publish` now extracts tagged release notes through the `vociferate` Go CLI instead of duplicating changelog section parsing in shell.
|
||||
- Composite actions now share a centralized `run-vociferate` orchestration flow, with binary-versus-source execution delegated through shared composite actions and single-use runtime/download logic folded back into `run-vociferate.binary`.
|
||||
- `run-vociferate` now contains both binary and source execution flows directly in a single action implementation, removing nested local action wrappers for better runner compatibility.
|
||||
|
||||
### Removed
|
||||
|
||||
### Fixed
|
||||
|
||||
- Made `do-release` version resolution support empty version input when called from `prepare-release` via `workflow_call` by fetching latest tags from origin before attempting to resolve from HEAD tags, allowing version discovery even if tags were just pushed and not yet local.
|
||||
- Fixed version resolution in `do-release` workflow by moving version calculation before checkout, resolving from inputs/git tags, and always passing explicit version to `publish` action.
|
||||
- Made `publish` action version resolution more robust with clearer error messages when version input is missing and workflow is not running from a tag push.
|
||||
- Fixed `do-release` workflow to always checkout the resolved release tag, eliminating conditional checkout logic that could skip the checkout when called from `prepare-release` workflow.
|
||||
- Pinned `securego/gosec` and `golang/govulncheck-action` to concrete version tags (`v2.22.4` and `v1.0.4`) so self-hosted Gitea runners can resolve them via direct git clone without relying on the GitHub Actions floating-tag API.
|
||||
- Restored explicit gosec caching by storing a pinned `v2.22.4` binary under `${{ runner.temp }}/gosec-bin` with `actions/cache@v4`, so CI keeps fast security scans while still using the Go 1.26 toolchain from `setup-go`.
|
||||
- Replaced `securego/gosec` composite action with a direct `go install github.com/securego/gosec/v2/cmd/gosec@v2.22.4 && gosec ./...` run step so gosec uses the Go 1.26 toolchain installed by `setup-go` rather than the action's bundled Go 1.24 binary which ignores `GOTOOLCHAIN=auto`.
|
||||
- Fixed nested local composite-action references to use repository-local `./run-vociferate` paths so strict runners do not misparse parent-directory (`../`) action references as malformed remote coordinates.
|
||||
- Consolidated `run-vociferate` binary and source execution flows directly into the main `run-vociferate` action to avoid nested local-action path resolution issues on strict runners.
|
||||
- Hardened workflow module hygiene by retrying `go mod verify` after a module-cache refresh (`go clean -modcache` + `go mod download`) when runners report modified cached dependency directories.
|
||||
|
||||
## [1.0.2] - 2026-03-21
|
||||
|
||||
### Breaking
|
||||
|
||||
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
|
||||
68
README.md
68
README.md
@@ -41,13 +41,13 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: git.hrafn.xyz/aether/vociferate/prepare@v1.0.2
|
||||
- uses: https://git.hrafn.xyz/aether/vociferate/prepare@v1.0.2
|
||||
with:
|
||||
version: ${{ inputs.version }}
|
||||
|
||||
publish:
|
||||
needs: prepare
|
||||
uses: aether/vociferate/.gitea/workflows/do-release.yml@v1.0.2
|
||||
uses: https://git.hrafn.xyz/aether/vociferate/.gitea/workflows/do-release.yml@v1.0.2
|
||||
with:
|
||||
tag: ${{ needs.prepare.outputs.version }}
|
||||
secrets: inherit
|
||||
@@ -61,7 +61,7 @@ For repositories that embed the version inside source code, pass `version-file`
|
||||
and `version-pattern`:
|
||||
|
||||
```yaml
|
||||
- uses: git.hrafn.xyz/aether/vociferate/prepare@v1.0.2
|
||||
- uses: https://git.hrafn.xyz/aether/vociferate/prepare@v1.0.2
|
||||
with:
|
||||
version-file: internal/myapp/version/version.go
|
||||
version-pattern: 'const Version = "([^"]+)"'
|
||||
@@ -85,7 +85,7 @@ on:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
uses: aether/vociferate/.gitea/workflows/do-release.yml@v1.0.2
|
||||
uses: https://git.hrafn.xyz/aether/vociferate/.gitea/workflows/do-release.yml@v1.0.2
|
||||
with:
|
||||
tag: ${{ inputs.tag }}
|
||||
secrets: inherit
|
||||
@@ -105,7 +105,7 @@ assets after it runs:
|
||||
|
||||
```yaml
|
||||
- id: publish
|
||||
uses: git.hrafn.xyz/aether/vociferate/publish@v1.0.2
|
||||
uses: https://git.hrafn.xyz/aether/vociferate/publish@v1.0.2
|
||||
|
||||
- name: Upload my binary
|
||||
run: |
|
||||
@@ -125,7 +125,7 @@ Run your coverage tests first, then call the action to generate `coverage.html`,
|
||||
run: go test -covermode=atomic -coverprofile=coverage.out ./...
|
||||
|
||||
- id: coverage
|
||||
uses: git.hrafn.xyz/aether/vociferate/coverage-badge@v1.0.2
|
||||
uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.0.2
|
||||
with:
|
||||
artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }}
|
||||
artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }}
|
||||
@@ -143,24 +143,76 @@ Decorate pull requests with coverage badges, coverage percentages, and unrelease
|
||||
`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: git.hrafn.xyz/aether/vociferate/coverage-badge@v1.0.2
|
||||
uses: https://git.hrafn.xyz/aether/vociferate/coverage-badge@v1.0.2
|
||||
with:
|
||||
artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }}
|
||||
artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }}
|
||||
|
||||
- name: Decorate pull request
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: git.hrafn.xyz/aether/vociferate/decorate-pr@v1.0.2
|
||||
uses: https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.0.2
|
||||
with:
|
||||
coverage-percentage: ${{ steps.coverage.outputs.total }}
|
||||
badge-url: ${{ steps.coverage.outputs.badge-url }}
|
||||
```
|
||||
|
||||
#### Changelog Gate (Strict Mode)
|
||||
|
||||
Enable changelog validation to enforce that code changes include `Unreleased` changelog entries. The gate fails the workflow in strict mode, or warns in soft mode:
|
||||
|
||||
```yaml
|
||||
- name: Decorate pull request with changelog gate
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.0.2
|
||||
with:
|
||||
coverage-percentage: ${{ steps.coverage.outputs.total }}
|
||||
badge-url: ${{ steps.coverage.outputs.badge-url }}
|
||||
enable-changelog-gate: true
|
||||
changelog-gate-mode: strict
|
||||
changelog-gate-required-for: "code,behavior,security,workflow,tooling"
|
||||
changelog-gate-allow-docs-only: true
|
||||
changelog-gate-docs-globs: "docs/**,**.md,**.txt,**.rst"
|
||||
changelog-gate-skip-labels: "skip-changelog"
|
||||
```
|
||||
|
||||
The gate automatically:
|
||||
|
||||
- Parses diffs to detect docs-only PRs (skips requirement for doc-only changes)
|
||||
- Counts `Unreleased` additions using section-aware parsing (ignores edits outside the section)
|
||||
- Checks PR labels for skip exemptions (for example, `skip-changelog`)
|
||||
- Outputs decision status and remediation guidance in the PR comment
|
||||
- Handles both strict (fail) and soft (warn) modes
|
||||
|
||||
Decision outputs enable downstream workflow logic:
|
||||
|
||||
```yaml
|
||||
- name: Decorate PR and check gate
|
||||
id: decorate
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: https://git.hrafn.xyz/aether/vociferate/decorate-pr@v1.0.2
|
||||
with:
|
||||
coverage-percentage: ${{ steps.coverage.outputs.total }}
|
||||
badge-url: ${{ steps.coverage.outputs.badge-url }}
|
||||
enable-changelog-gate: true
|
||||
changelog-gate-mode: soft
|
||||
|
||||
- name: Gate decision
|
||||
run: |
|
||||
echo "Gate passed: ${{ steps.decorate.outputs.gate-passed }}"
|
||||
echo "Is docs-only PR: ${{ steps.decorate.outputs.docs-only }}"
|
||||
echo "Unreleased additions: ${{ steps.decorate.outputs.unreleased-additions-count }}"
|
||||
if [[ "${{ steps.decorate.outputs.gate-passed }}" == "false" ]]; then
|
||||
echo "Gate failure reason: ${{ steps.decorate.outputs.gate-failure-reason }}"
|
||||
fi
|
||||
```
|
||||
|
||||
The action automatically finds existing vociferate comments by their marker and updates them instead of creating duplicates. This keeps PR timelines clean while keeping review information current.
|
||||
|
||||
## Why The Name
|
||||
|
||||
155
action.yml
155
action.yml
@@ -25,142 +25,71 @@ inputs:
|
||||
outputs:
|
||||
version:
|
||||
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:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve vociferate binary metadata
|
||||
id: resolve-binary
|
||||
- name: Resolve release date
|
||||
id: resolve-date
|
||||
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: |
|
||||
set -euo pipefail
|
||||
|
||||
case "$RUNNER_ARCH" in
|
||||
X64)
|
||||
arch="amd64"
|
||||
;;
|
||||
ARM64)
|
||||
arch="arm64"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported runner architecture: $RUNNER_ARCH" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
printf 'value=%s\n' "$(date -u +%F)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
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}"
|
||||
- name: Normalize version input
|
||||
id: normalize-version
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
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
|
||||
resolved_version="$(printf '%s' "${INPUT_VERSION:-}" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
|
||||
printf 'value=%s\n' "$resolved_version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
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
|
||||
- name: Recommend version
|
||||
id: recommend-version
|
||||
if: inputs.recommend == 'true' || steps.normalize-version.outputs.value == ''
|
||||
uses: ./run-vociferate
|
||||
with:
|
||||
go-version: '1.26.1'
|
||||
cache: true
|
||||
cache-dependency-path: ${{ github.action_path }}/go.sum
|
||||
root: ${{ github.workspace }}
|
||||
version-file: ${{ inputs.version-file }}
|
||||
version-pattern: ${{ inputs.version-pattern }}
|
||||
changelog: ${{ inputs.changelog }}
|
||||
recommend: 'true'
|
||||
|
||||
- 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'
|
||||
- name: Finalize version
|
||||
id: finalize-version
|
||||
shell: bash
|
||||
env:
|
||||
TOKEN: ${{ github.token }}
|
||||
ASSET_URL: ${{ steps.resolve-binary.outputs.asset_url }}
|
||||
BINARY_PATH: ${{ steps.resolve-binary.outputs.binary_path }}
|
||||
PROVIDED_VERSION: ${{ steps.normalize-version.outputs.value }}
|
||||
RECOMMENDED_VERSION: ${{ steps.recommend-version.outputs.stdout }}
|
||||
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 }}
|
||||
VOCIFERATE_REPOSITORY_URL: ${{ vars.VOCIFERATE_REPOSITORY_URL }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "$USE_BINARY" == "true" ]]; then
|
||||
run_vociferate() { "$VOCIFERATE_BIN" "$@"; }
|
||||
if [[ -n "$PROVIDED_VERSION" ]] && [[ "${{ inputs.recommend }}" != 'true' ]]; then
|
||||
resolved_version="$PROVIDED_VERSION"
|
||||
else
|
||||
run_vociferate() { (cd "$GITHUB_ACTION_PATH" && go run ./cmd/vociferate "$@"); }
|
||||
resolved_version="$RECOMMENDED_VERSION"
|
||||
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
|
||||
|
||||
if [[ "${{ inputs.recommend }}" == "true" ]]; then
|
||||
resolved_version="$(run_vociferate "${common_args[@]}" --recommend)"
|
||||
if [[ "${{ inputs.recommend }}" == 'true' ]]; then
|
||||
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
|
||||
|
||||
echo "version=$resolved_version" >> "$GITHUB_OUTPUT"
|
||||
run_vociferate "${common_args[@]}" --version "$resolved_version" --date "$(date -u +%F)"
|
||||
printf 'version=%s\n' "$resolved_version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- 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")
|
||||
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")
|
||||
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")
|
||||
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")
|
||||
@@ -41,8 +43,28 @@ func main() {
|
||||
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 == "" {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
_, stderr, code := runMain(t)
|
||||
if code != 2 {
|
||||
|
||||
@@ -31,6 +31,42 @@ inputs:
|
||||
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:
|
||||
@@ -39,6 +75,25 @@ outputs:
|
||||
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
|
||||
@@ -92,8 +147,8 @@ runs:
|
||||
-H "Content-Type: application/json" \
|
||||
"$comments_url" >/dev/null
|
||||
|
||||
- name: Extract changelog unreleased entries
|
||||
id: extract-changelog
|
||||
- name: Detect changelog file
|
||||
id: changelog-file
|
||||
shell: bash
|
||||
env:
|
||||
CHANGELOG: ${{ inputs.changelog }}
|
||||
@@ -101,28 +156,169 @@ runs:
|
||||
set -euo pipefail
|
||||
|
||||
if [[ ! -f "$CHANGELOG" ]]; then
|
||||
printf 'unreleased_entries=%s\n' "" >> "$GITHUB_OUTPUT"
|
||||
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
|
||||
|
||||
# Extract everything between [Unreleased] header and the next [X.Y.Z] header
|
||||
unreleased="$(awk '
|
||||
/^## \[Unreleased\]/ { in_unreleased=1; next }
|
||||
/^## \[[0-9]+\.[0-9]+\.[0-9]+\]/ { if (in_unreleased) exit }
|
||||
in_unreleased && NF { print }
|
||||
' "$CHANGELOG")"
|
||||
api_url="${SERVER_URL}/api/v1"
|
||||
if [[ "$SERVER_URL" == *"github.com"* ]]; then
|
||||
api_url="https://api.github.com"
|
||||
fi
|
||||
|
||||
# Use a temporary file to handle multiline content
|
||||
tmp_file=$(mktemp)
|
||||
printf '%s' "$unreleased" > "$tmp_file"
|
||||
|
||||
# Read it back and set as output
|
||||
delimiter="EOF_CHANGELOG"
|
||||
printf '%s<<%s\n' "unreleased_entries<<$delimiter" "$delimiter" >> "$GITHUB_OUTPUT"
|
||||
cat "$tmp_file" >> "$GITHUB_OUTPUT"
|
||||
printf '%s\n' "$delimiter" >> "$GITHUB_OUTPUT"
|
||||
|
||||
rm -f "$tmp_file"
|
||||
# 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
|
||||
@@ -131,52 +327,17 @@ runs:
|
||||
COMMENT_TITLE: ${{ inputs.comment-title }}
|
||||
COVERAGE_PCT: ${{ inputs.coverage-percentage }}
|
||||
BADGE_URL: ${{ inputs.badge-url }}
|
||||
UNRELEASED: ${{ steps.extract-changelog.outputs.unreleased_entries }}
|
||||
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
|
||||
|
||||
# Start building the comment
|
||||
tmp_file=$(mktemp)
|
||||
|
||||
# Add title and coverage section
|
||||
cat > "$tmp_file" << 'EOF'
|
||||
<!-- vociferate-pr-review -->
|
||||
EOF
|
||||
|
||||
printf '## %s\n\n' "$COMMENT_TITLE" >> "$tmp_file"
|
||||
|
||||
# Coverage badge section
|
||||
cat >> "$tmp_file" << EOF
|
||||
### Coverage
|
||||

|
||||
|
||||
**Coverage:** $COVERAGE_PCT%
|
||||
|
||||
EOF
|
||||
|
||||
# Changelog section
|
||||
if [[ -n "$UNRELEASED" ]]; then
|
||||
cat >> "$tmp_file" << 'EOF'
|
||||
### Unreleased Changes
|
||||
|
||||
EOF
|
||||
printf '%s\n' "$UNRELEASED" >> "$tmp_file"
|
||||
printf '\n' >> "$tmp_file"
|
||||
fi
|
||||
|
||||
# Add footer
|
||||
cat >> "$tmp_file" << 'EOF'
|
||||
---
|
||||
*This comment was automatically generated by [vociferate/decorate-pr](https://git.hrafn.xyz/aether/vociferate).*
|
||||
EOF
|
||||
|
||||
# Store as output using delimiter
|
||||
delimiter="EOF_COMMENT"
|
||||
printf '%s<<%s\n' "comment_body<<$delimiter" "$delimiter" >> "$GITHUB_OUTPUT"
|
||||
cat "$tmp_file" >> "$GITHUB_OUTPUT"
|
||||
printf '%s\n' "$delimiter" >> "$GITHUB_OUTPUT"
|
||||
|
||||
rm -f "$tmp_file"
|
||||
bash "$GITHUB_ACTION_PATH/build-comment.sh"
|
||||
|
||||
- name: Find and update/post PR comment
|
||||
id: post-comment
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
var releasedSectionRe = regexp.MustCompile(`(?m)^## \[(\d+\.\d+\.\d+)\] - `)
|
||||
var linkedReleasedSectionRe = regexp.MustCompile(`(?m)^## \[(\d+\.\d+\.\d+)\](?:\([^\n)]*\))? - `)
|
||||
var unreleasedHeadingRe = regexp.MustCompile(`(?m)^## \[Unreleased\](?:\([^\n)]*\))?\n`)
|
||||
var releaseHeadingRe = regexp.MustCompile(`(?m)^## \[(\d+\.\d+\.\d+)\](?:\([^\n)]*\))? - `)
|
||||
var refLinkLineRe = regexp.MustCompile(`^\[[^\]]+\]: \S`)
|
||||
// Pre-compiled regex patterns used for changelog parsing.
|
||||
// These are read-only after initialization.
|
||||
var (
|
||||
releasedSectionRe = regexp.MustCompile(`(?m)^## \[(\d+\.\d+\.\d+)\] - `)
|
||||
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 {
|
||||
// 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
|
||||
// origin git remote.
|
||||
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")
|
||||
if normalizedVersion == "" {
|
||||
return fmt.Errorf("version must not be empty")
|
||||
@@ -78,11 +184,11 @@ func Prepare(rootDir, version, releaseDate string, options Options) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := updateVersionFile(rootDir, normalizedVersion, resolved); err != nil {
|
||||
if err := s.updateVersionFile(rootDir, normalizedVersion, resolved); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -100,6 +206,22 @@ func Prepare(rootDir, version, releaseDate string, options Options) error {
|
||||
// When no previous release is present in the changelog, the first
|
||||
// recommendation is always v1.0.0.
|
||||
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)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -108,12 +230,12 @@ func RecommendedTag(rootDir string, options Options) (string, error) {
|
||||
var currentVersion string
|
||||
isFirstRelease := false
|
||||
if options.VersionFile != "" {
|
||||
currentVersion, err = readCurrentVersion(rootDir, resolved)
|
||||
currentVersion, err = s.readCurrentVersion(rootDir, resolved)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
version, found, err := readLatestChangelogVersion(rootDir, resolved.Changelog)
|
||||
version, found, err := s.readLatestChangelogVersion(rootDir, resolved.Changelog)
|
||||
if err != nil {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
heading := "### " + sectionName
|
||||
sectionStart := strings.Index(unreleasedBody, heading)
|
||||
@@ -201,11 +343,15 @@ func resolveOptions(options 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)
|
||||
contents, err := os.ReadFile(path)
|
||||
contents, err := s.fileSystem.ReadFile(path)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
@@ -221,7 +367,7 @@ func updateVersionFile(rootDir, version string, options resolvedOptions) error {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -229,7 +375,11 @@ func updateVersionFile(rootDir, version string, options resolvedOptions) 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 {
|
||||
return err
|
||||
}
|
||||
@@ -245,11 +395,11 @@ func updateChangelog(rootDir, version, releaseDate, changelogPath string) error
|
||||
}
|
||||
|
||||
updated := text[:afterHeader] + "\n" + defaultUnreleasedTemplate + "\n" + newSection + text[nextSectionStart:]
|
||||
repoURL, ok := deriveRepositoryURL(rootDir)
|
||||
repoURL, ok := s.deriveRepositoryURL(rootDir)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -257,8 +407,12 @@ func updateChangelog(rootDir, version, releaseDate, changelogPath 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)
|
||||
contents, err := os.ReadFile(path)
|
||||
contents, err := s.fileSystem.ReadFile(path)
|
||||
if err != nil {
|
||||
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) {
|
||||
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 {
|
||||
return "", err
|
||||
}
|
||||
@@ -297,8 +455,12 @@ func unreleasedHasEntries(unreleasedBody string) bool {
|
||||
}
|
||||
|
||||
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)
|
||||
contents, err := os.ReadFile(path)
|
||||
contents, err := s.fileSystem.ReadFile(path)
|
||||
if err != nil {
|
||||
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) {
|
||||
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)
|
||||
contents, err := os.ReadFile(path)
|
||||
contents, err := s.fileSystem.ReadFile(path)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
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 != "" {
|
||||
repositoryPath, ok := deriveRepositoryPath(rootDir)
|
||||
repositoryPath, ok := s.deriveRepositoryPath(rootDir)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
@@ -346,14 +552,14 @@ func deriveRepositoryURL(rootDir string) (string, bool) {
|
||||
return baseURL + "/" + repositoryPath, true
|
||||
}
|
||||
|
||||
serverURL := strings.TrimSpace(os.Getenv("GITHUB_SERVER_URL"))
|
||||
repository := strings.TrimSpace(os.Getenv("GITHUB_REPOSITORY"))
|
||||
serverURL := strings.TrimSpace(s.environment.Getenv("GITHUB_SERVER_URL"))
|
||||
repository := strings.TrimSpace(s.environment.Getenv("GITHUB_REPOSITORY"))
|
||||
if serverURL != "" && repository != "" {
|
||||
return strings.TrimSuffix(serverURL, "/") + "/" + strings.TrimPrefix(repository, "/"), true
|
||||
}
|
||||
|
||||
gitConfigPath := filepath.Join(rootDir, ".git", "config")
|
||||
contents, err := os.ReadFile(gitConfigPath)
|
||||
contents, err := s.fileSystem.ReadFile(gitConfigPath)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
@@ -372,13 +578,17 @@ func deriveRepositoryURL(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 != "" {
|
||||
return strings.TrimPrefix(repository, "/"), true
|
||||
}
|
||||
|
||||
gitConfigPath := filepath.Join(rootDir, ".git", "config")
|
||||
contents, err := os.ReadFile(gitConfigPath)
|
||||
contents, err := s.fileSystem.ReadFile(gitConfigPath)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
@@ -472,6 +682,10 @@ func normalizeRepoURL(remoteURL string) (string, bool) {
|
||||
}
|
||||
|
||||
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), "/")
|
||||
if repoURL == "" {
|
||||
return text
|
||||
@@ -519,7 +733,7 @@ func addChangelogLinks(text, repoURL, rootDir string) string {
|
||||
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 {
|
||||
if i+1 < len(releasedVersions) {
|
||||
previousVersion := releasedVersions[i+1]
|
||||
@@ -550,22 +764,11 @@ func displayURL(url string) string {
|
||||
}
|
||||
|
||||
func 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
|
||||
}
|
||||
return defaultService().firstCommitShortHash(rootDir)
|
||||
}
|
||||
|
||||
commit := strings.TrimSpace(string(output))
|
||||
if commit == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if strings.Contains(commit, "\n") {
|
||||
commit = strings.SplitN(commit, "\n", 2)[0]
|
||||
}
|
||||
|
||||
return commit, true
|
||||
func (s *Service) firstCommitShortHash(rootDir string) (string, bool) {
|
||||
return s.git.FirstCommitShortHash(rootDir)
|
||||
}
|
||||
|
||||
func compareURL(repoURL, baseRef, headRef string) string {
|
||||
|
||||
@@ -3,9 +3,54 @@ package vociferate
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"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) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -159,3 +204,110 @@ func TestResolveOptions_UsesUppercaseChangelogDefault(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
|
||||
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() {
|
||||
require.NoError(s.T(), os.WriteFile(
|
||||
filepath.Join(s.rootDir, "CHANGELOG.md"),
|
||||
|
||||
14
justfile
14
justfile
@@ -9,3 +9,17 @@ go-build:
|
||||
|
||||
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:
|
||||
description: >
|
||||
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:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Resolve vociferate binary metadata
|
||||
id: resolve-binary
|
||||
- name: Normalize version input
|
||||
id: normalize-version
|
||||
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: |
|
||||
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 }}
|
||||
VOCIFERATE_REPOSITORY_URL: ${{ vars.VOCIFERATE_REPOSITORY_URL }}
|
||||
run: |
|
||||
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:]]\+$//')"
|
||||
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
|
||||
provided_version="$(run_vociferate "${common_args[@]}" --recommend)"
|
||||
provided_version="$RECOMMENDED_VERSION"
|
||||
fi
|
||||
|
||||
normalized_version="${provided_version#v}"
|
||||
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
|
||||
shell: bash
|
||||
@@ -186,7 +118,7 @@ runs:
|
||||
GIT_USER_NAME: ${{ inputs.git-user-name }}
|
||||
GIT_USER_EMAIL: ${{ inputs.git-user-email }}
|
||||
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_REPOSITORY: ${{ github.repository }}
|
||||
run: |
|
||||
|
||||
@@ -57,38 +57,35 @@ runs:
|
||||
normalized="${tag#v}"
|
||||
else
|
||||
echo "A version input is required when the workflow is not running from a tag push" >&2
|
||||
echo "Provide version via input or ensure HEAD is at a tagged commit." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$normalized" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Extract release notes from changelog
|
||||
- name: Extract release 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
|
||||
env:
|
||||
CHANGELOG: ${{ inputs.changelog != '' && inputs.changelog || 'CHANGELOG.md' }}
|
||||
RELEASE_VERSION: ${{ steps.resolve-version.outputs.version }}
|
||||
RELEASE_NOTES: ${{ steps.extract-notes.outputs.stdout }}
|
||||
RUNNER_TEMP: ${{ runner.temp }}
|
||||
run: |
|
||||
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"
|
||||
printf '%s\n' "$release_notes" > "$notes_file"
|
||||
echo "notes_file=$notes_file" >> "$GITHUB_OUTPUT"
|
||||
printf '%s\n' "$RELEASE_NOTES" > "$notes_file"
|
||||
|
||||
printf 'notes_file=%s\n' "$notes_file" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create or update release
|
||||
id: create-release
|
||||
@@ -96,7 +93,7 @@ runs:
|
||||
env:
|
||||
TOKEN: ${{ inputs.token != '' && inputs.token || github.token }}
|
||||
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_SERVER_URL: ${{ github.server_url }}
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
|
||||
276
run-vociferate/action.yml
Normal file
276
run-vociferate/action.yml
Normal file
@@ -0,0 +1,276 @@
|
||||
name: vociferate/run-vociferate
|
||||
description: Resolve the preferred runtime for vociferate and execute it with a consistent output contract.
|
||||
|
||||
inputs:
|
||||
root:
|
||||
description: Repository root to pass to vociferate.
|
||||
required: true
|
||||
version-file:
|
||||
description: Optional version file path.
|
||||
required: false
|
||||
default: ''
|
||||
version-pattern:
|
||||
description: Optional version pattern.
|
||||
required: false
|
||||
default: ''
|
||||
changelog:
|
||||
description: Optional changelog path.
|
||||
required: false
|
||||
default: ''
|
||||
version:
|
||||
description: Optional version argument.
|
||||
required: false
|
||||
default: ''
|
||||
date:
|
||||
description: Optional date argument.
|
||||
required: false
|
||||
default: ''
|
||||
recommend:
|
||||
description: Whether to run vociferate with --recommend.
|
||||
required: false
|
||||
default: 'false'
|
||||
print-unreleased:
|
||||
description: Whether to print the Unreleased body.
|
||||
required: false
|
||||
default: 'false'
|
||||
print-release-notes:
|
||||
description: Whether to print the release notes section for version.
|
||||
required: false
|
||||
default: 'false'
|
||||
|
||||
outputs:
|
||||
stdout:
|
||||
description: Captured stdout from the selected runtime.
|
||||
value: ${{ steps.finalize.outputs.stdout }}
|
||||
use_binary:
|
||||
description: Whether the selected runtime was the released binary.
|
||||
value: ${{ steps.resolve-runtime.outputs.use_binary }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Resolve runtime
|
||||
id: resolve-runtime
|
||||
shell: bash
|
||||
env:
|
||||
ACTION_REF: ${{ github.action_ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "$ACTION_REF" == v* ]]; then
|
||||
printf 'use_binary=true\n' >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
printf 'use_binary=false\n' >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Resolve binary metadata
|
||||
id: resolve-binary
|
||||
if: steps.resolve-runtime.outputs.use_binary == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
ACTION_REF: ${{ github.action_ref }}
|
||||
ACTION_REPOSITORY: ${{ github.action_repository }}
|
||||
CACHE_TOKEN: ${{ env.VOCIFERATE_CACHE_TOKEN }}
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
RUNNER_ARCH: ${{ runner.arch }}
|
||||
RUNNER_TEMP: ${{ runner.temp }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "$ACTION_REF" != v* ]]; then
|
||||
echo "run-vociferate binary path requires github.action_ref to be a release tag" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$RUNNER_ARCH" in
|
||||
X64)
|
||||
arch="amd64"
|
||||
;;
|
||||
ARM64)
|
||||
arch="arm64"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported runner architecture: $RUNNER_ARCH" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
release_tag="$ACTION_REF"
|
||||
normalized_version="${release_tag#v}"
|
||||
asset_name="vociferate_${normalized_version}_linux_${arch}"
|
||||
cache_dir="${RUNNER_TEMP}/vociferate/${release_tag}/linux-${arch}"
|
||||
binary_path="${cache_dir}/vociferate"
|
||||
asset_url="${SERVER_URL}/aether/vociferate/releases/download/${release_tag}/${asset_name}"
|
||||
|
||||
provided_cache_token="$(printf '%s' "${CACHE_TOKEN:-}" | sed 's/^[[:space:]]\+//; s/[[:space:]]\+$//')"
|
||||
if [[ -n "$provided_cache_token" ]]; then
|
||||
cache_token="$provided_cache_token"
|
||||
else
|
||||
cache_token="${ACTION_REPOSITORY:-aether/vociferate}-${release_tag}"
|
||||
fi
|
||||
|
||||
mkdir -p "$cache_dir"
|
||||
|
||||
printf 'cache_token=%s\n' "$cache_token" >> "$GITHUB_OUTPUT"
|
||||
printf 'cache_dir=%s\n' "$cache_dir" >> "$GITHUB_OUTPUT"
|
||||
printf 'binary_path=%s\n' "$binary_path" >> "$GITHUB_OUTPUT"
|
||||
printf 'asset_url=%s\n' "$asset_url" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore cached binary
|
||||
id: cache-vociferate
|
||||
if: steps.resolve-runtime.outputs.use_binary == 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.resolve-binary.outputs.cache_dir }}
|
||||
key: vociferate-${{ steps.resolve-binary.outputs.cache_token }}-linux-${{ runner.arch }}
|
||||
|
||||
- name: Download binary
|
||||
if: steps.resolve-runtime.outputs.use_binary == 'true' && steps.cache-vociferate.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
env:
|
||||
TOKEN: ${{ github.token }}
|
||||
ASSET_URL: ${{ steps.resolve-binary.outputs.asset_url }}
|
||||
BINARY_PATH: ${{ steps.resolve-binary.outputs.binary_path }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
curl --fail --location \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-o "$BINARY_PATH" \
|
||||
"$ASSET_URL"
|
||||
chmod +x "$BINARY_PATH"
|
||||
|
||||
- name: Run binary
|
||||
id: run-binary
|
||||
if: steps.resolve-runtime.outputs.use_binary == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
VOCIFERATE_BIN: ${{ steps.resolve-binary.outputs.binary_path }}
|
||||
ROOT: ${{ inputs.root }}
|
||||
VERSION_FILE: ${{ inputs.version-file }}
|
||||
VERSION_PATTERN: ${{ inputs.version-pattern }}
|
||||
CHANGELOG: ${{ inputs.changelog }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
DATE: ${{ inputs.date }}
|
||||
RECOMMEND: ${{ inputs.recommend }}
|
||||
PRINT_UNRELEASED: ${{ inputs.print-unreleased }}
|
||||
PRINT_RELEASE_NOTES: ${{ inputs.print-release-notes }}
|
||||
VOCIFERATE_REPOSITORY_URL: ${{ vars.VOCIFERATE_REPOSITORY_URL }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
command=("$VOCIFERATE_BIN" --root "$ROOT")
|
||||
if [[ -n "$VERSION_FILE" ]]; then
|
||||
command+=(--version-file "$VERSION_FILE")
|
||||
fi
|
||||
if [[ -n "$VERSION_PATTERN" ]]; then
|
||||
command+=(--version-pattern "$VERSION_PATTERN")
|
||||
fi
|
||||
if [[ -n "$CHANGELOG" ]]; then
|
||||
command+=(--changelog "$CHANGELOG")
|
||||
fi
|
||||
if [[ -n "$VERSION" ]]; then
|
||||
command+=(--version "$VERSION")
|
||||
fi
|
||||
if [[ -n "$DATE" ]]; then
|
||||
command+=(--date "$DATE")
|
||||
fi
|
||||
if [[ "$RECOMMEND" == 'true' ]]; then
|
||||
command+=(--recommend)
|
||||
fi
|
||||
if [[ "$PRINT_UNRELEASED" == 'true' ]]; then
|
||||
command+=(--print-unreleased)
|
||||
fi
|
||||
if [[ "$PRINT_RELEASE_NOTES" == 'true' ]]; then
|
||||
command+=(--print-release-notes)
|
||||
fi
|
||||
|
||||
stdout_file="$(mktemp)"
|
||||
trap 'rm -f "$stdout_file"' EXIT
|
||||
"${command[@]}" > "$stdout_file"
|
||||
|
||||
delimiter="EOF_STDOUT"
|
||||
printf 'stdout<<%s\n' "$delimiter" >> "$GITHUB_OUTPUT"
|
||||
cat "$stdout_file" >> "$GITHUB_OUTPUT"
|
||||
printf '%s\n' "$delimiter" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Run source
|
||||
id: run-code
|
||||
if: steps.resolve-runtime.outputs.use_binary != 'true'
|
||||
shell: bash
|
||||
env:
|
||||
ROOT: ${{ inputs.root }}
|
||||
VERSION_FILE: ${{ inputs.version-file }}
|
||||
VERSION_PATTERN: ${{ inputs.version-pattern }}
|
||||
CHANGELOG: ${{ inputs.changelog }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
DATE: ${{ inputs.date }}
|
||||
RECOMMEND: ${{ inputs.recommend }}
|
||||
PRINT_UNRELEASED: ${{ inputs.print-unreleased }}
|
||||
PRINT_RELEASE_NOTES: ${{ inputs.print-release-notes }}
|
||||
VOCIFERATE_REPOSITORY_URL: ${{ vars.VOCIFERATE_REPOSITORY_URL }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
source_root="$GITHUB_ACTION_PATH"
|
||||
while [[ ! -f "$source_root/go.mod" ]] && [[ "$source_root" != "/" ]]; do
|
||||
source_root="$(realpath "$source_root/..")"
|
||||
done
|
||||
if [[ ! -f "$source_root/go.mod" ]]; then
|
||||
echo "Could not locate Go module root from $GITHUB_ACTION_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
command=(go run ./cmd/vociferate --root "$ROOT")
|
||||
if [[ -n "$VERSION_FILE" ]]; then
|
||||
command+=(--version-file "$VERSION_FILE")
|
||||
fi
|
||||
if [[ -n "$VERSION_PATTERN" ]]; then
|
||||
command+=(--version-pattern "$VERSION_PATTERN")
|
||||
fi
|
||||
if [[ -n "$CHANGELOG" ]]; then
|
||||
command+=(--changelog "$CHANGELOG")
|
||||
fi
|
||||
if [[ -n "$VERSION" ]]; then
|
||||
command+=(--version "$VERSION")
|
||||
fi
|
||||
if [[ -n "$DATE" ]]; then
|
||||
command+=(--date "$DATE")
|
||||
fi
|
||||
if [[ "$RECOMMEND" == 'true' ]]; then
|
||||
command+=(--recommend)
|
||||
fi
|
||||
if [[ "$PRINT_UNRELEASED" == 'true' ]]; then
|
||||
command+=(--print-unreleased)
|
||||
fi
|
||||
if [[ "$PRINT_RELEASE_NOTES" == 'true' ]]; then
|
||||
command+=(--print-release-notes)
|
||||
fi
|
||||
|
||||
stdout_file="$(mktemp)"
|
||||
trap 'rm -f "$stdout_file"' EXIT
|
||||
(cd "$source_root" && "${command[@]}") > "$stdout_file"
|
||||
|
||||
delimiter="EOF_STDOUT"
|
||||
printf 'stdout<<%s\n' "$delimiter" >> "$GITHUB_OUTPUT"
|
||||
cat "$stdout_file" >> "$GITHUB_OUTPUT"
|
||||
printf '%s\n' "$delimiter" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Finalize stdout
|
||||
id: finalize
|
||||
shell: bash
|
||||
env:
|
||||
STDOUT_BINARY: ${{ steps.run-binary.outputs.stdout }}
|
||||
STDOUT_CODE: ${{ steps.run-code.outputs.stdout }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
stdout="$STDOUT_BINARY"
|
||||
if [[ -z "$stdout" ]]; then
|
||||
stdout="$STDOUT_CODE"
|
||||
fi
|
||||
|
||||
delimiter="EOF_STDOUT"
|
||||
printf 'stdout<<%s\n' "$delimiter" >> "$GITHUB_OUTPUT"
|
||||
printf '%s\n' "$stdout" >> "$GITHUB_OUTPUT"
|
||||
printf '%s\n' "$delimiter" >> "$GITHUB_OUTPUT"
|
||||
Reference in New Issue
Block a user