Compare commits
14 Commits
8ea9acdebc
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5ecfeebde | ||
|
|
6bcc027076 | ||
|
|
fd23a8b238 | ||
|
|
3f7edea46e | ||
|
|
0234df7aa1 | ||
|
|
501ac71147 | ||
|
|
60bbc7409b | ||
|
|
4c5a49d685 | ||
|
|
87059d21fd | ||
|
|
3ea4af158e | ||
|
|
7ea5f05297 | ||
|
|
71c1b81426 | ||
|
|
68e4211fbf | ||
|
|
50e5f25329 |
@@ -14,6 +14,14 @@ jobs:
|
|||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
shell: bash
|
shell: bash
|
||||||
|
env:
|
||||||
|
ARTEFACT_BUCKET_NAME: ${{ vars.ARTEFACT_BUCKET_NAME }}
|
||||||
|
ARTEFACT_BUCKET_ENDPONT: ${{ vars.ARTEFACT_BUCKET_ENDPONT }}
|
||||||
|
ARTEFACT_BUCKET_REGION: ${{ vars.ARTEFACT_BUCKET_REGION }}
|
||||||
|
AWS_ACCESS_KEY_ID: ${{ secrets.ARTEFACT_BUCKET_WRITE_ACCESS_KEY }}
|
||||||
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.ARTEFACT_BUCKET_WRITE_ACCESS_SECRET }}
|
||||||
|
AWS_DEFAULT_REGION: ${{ vars.ARTEFACT_BUCKET_REGION }}
|
||||||
|
AWS_EC2_METADATA_DISABLED: true
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -26,11 +34,89 @@ jobs:
|
|||||||
cache: true
|
cache: true
|
||||||
cache-dependency-path: go.sum
|
cache-dependency-path: go.sum
|
||||||
|
|
||||||
|
- name: Install AWS CLI v2
|
||||||
|
uses: ankurk91/install-aws-cli-action@v1
|
||||||
|
|
||||||
|
- name: Verify AWS CLI
|
||||||
|
run: aws --version
|
||||||
|
|
||||||
- name: Run full unit test suite with coverage
|
- name: Run full unit test suite with coverage
|
||||||
|
id: coverage
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
go test -covermode=atomic -coverprofile=coverage.out ./...
|
go test -covermode=atomic -coverprofile=coverage.out ./...
|
||||||
go tool cover -func=coverage.out
|
go tool cover -html=coverage.out -o coverage.html
|
||||||
|
|
||||||
|
total="$(go tool cover -func=coverage.out | awk '/^total:/ {sub(/%/, "", $3); print $3}')"
|
||||||
|
printf '{\n "total": "%s"\n}\n' "$total" > coverage-summary.json
|
||||||
|
printf 'total=%s\n' "$total" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Generate coverage badge
|
||||||
|
env:
|
||||||
|
COVERAGE_TOTAL: ${{ steps.coverage.outputs.total }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
color="$(awk -v total="$COVERAGE_TOTAL" 'BEGIN {
|
||||||
|
if (total >= 80) print "brightgreen";
|
||||||
|
else if (total >= 70) print "green";
|
||||||
|
else if (total >= 60) print "yellowgreen";
|
||||||
|
else if (total >= 50) print "yellow";
|
||||||
|
else print "red";
|
||||||
|
}')"
|
||||||
|
|
||||||
|
cat > coverage-badge.svg <<EOF
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="126" height="20" role="img" aria-label="coverage: ${COVERAGE_TOTAL}%">
|
||||||
|
<linearGradient id="smooth" x2="0" y2="100%">
|
||||||
|
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||||
|
<stop offset="1" stop-opacity=".1"/>
|
||||||
|
</linearGradient>
|
||||||
|
<clipPath id="round">
|
||||||
|
<rect width="126" height="20" rx="3" fill="#fff"/>
|
||||||
|
</clipPath>
|
||||||
|
<g clip-path="url(#round)">
|
||||||
|
<rect width="63" height="20" fill="#555"/>
|
||||||
|
<rect x="63" width="63" height="20" fill="${color}"/>
|
||||||
|
<rect width="126" height="20" fill="url(#smooth)"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||||
|
<text x="32.5" y="15" fill="#010101" fill-opacity=".3">coverage</text>
|
||||||
|
<text x="32.5" y="14">coverage</text>
|
||||||
|
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">${COVERAGE_TOTAL}%</text>
|
||||||
|
<text x="93.5" y="14">${COVERAGE_TOTAL}%</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Upload branch coverage artefacts
|
||||||
|
id: upload
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
aws configure set default.s3.addressing_style path
|
||||||
|
|
||||||
|
repo_name="${GITHUB_REPOSITORY##*/}"
|
||||||
|
prefix="${repo_name}/branch/${GITHUB_REF_NAME}"
|
||||||
|
report_url="${ARTEFACT_BUCKET_ENDPONT%/}/${ARTEFACT_BUCKET_NAME}/${prefix}/coverage.html"
|
||||||
|
badge_url="${ARTEFACT_BUCKET_ENDPONT%/}/${ARTEFACT_BUCKET_NAME}/${prefix}/coverage-badge.svg"
|
||||||
|
|
||||||
|
aws --endpoint-url "${ARTEFACT_BUCKET_ENDPONT}" s3 cp coverage.html "s3://${ARTEFACT_BUCKET_NAME}/${prefix}/coverage.html" --content-type text/html
|
||||||
|
aws --endpoint-url "${ARTEFACT_BUCKET_ENDPONT}" s3 cp coverage-badge.svg "s3://${ARTEFACT_BUCKET_NAME}/${prefix}/coverage-badge.svg" --content-type image/svg+xml
|
||||||
|
aws --endpoint-url "${ARTEFACT_BUCKET_ENDPONT}" s3 cp coverage-summary.json "s3://${ARTEFACT_BUCKET_NAME}/${prefix}/coverage-summary.json" --content-type application/json
|
||||||
|
|
||||||
|
printf 'report_url=%s\n' "$report_url" >> "$GITHUB_OUTPUT"
|
||||||
|
printf 'badge_url=%s\n' "$badge_url" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Add coverage summary
|
||||||
|
run: |
|
||||||
|
{
|
||||||
|
echo '## Coverage'
|
||||||
|
echo
|
||||||
|
echo '- Total: `${{ steps.coverage.outputs.total }}%`'
|
||||||
|
echo '- Report: ${{ steps.upload.outputs.report_url }}'
|
||||||
|
echo '- Badge: ${{ steps.upload.outputs.badge_url }}'
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
- name: Recommend next release tag on main pushes
|
- name: Recommend next release tag on main pushes
|
||||||
if: ${{ github.ref == 'refs/heads/main' }}
|
if: ${{ github.ref == 'refs/heads/main' }}
|
||||||
|
|||||||
20
README.md
20
README.md
@@ -1,5 +1,10 @@
|
|||||||
# vociferate
|
# vociferate
|
||||||
|
|
||||||
|
[](https://git.hrafn.xyz/aether/vociferate/actions/runs/latest?workflow=push-validation.yml&branch=main&event=push)
|
||||||
|
[](https://git.hrafn.xyz/aether/vociferate/actions/runs/latest?workflow=prepare-release.yml)
|
||||||
|
[](https://git.hrafn.xyz/aether/vociferate/actions/runs/latest?workflow=do-release.yml)
|
||||||
|
[](https://s3.hrafn.xyz/aether-workflow-report-artefacts/vociferate/branch/main/coverage.html)
|
||||||
|
|
||||||
`vociferate` is an `aether` release orchestration tool written in Go for repositories that
|
`vociferate` is an `aether` release orchestration tool written in Go for repositories that
|
||||||
want changelog-driven versioning, automated release preparation, and repeatable
|
want changelog-driven versioning, automated release preparation, and repeatable
|
||||||
tag publication.
|
tag publication.
|
||||||
@@ -12,7 +17,7 @@ revision.
|
|||||||
## Use In Other Repositories
|
## Use In Other Repositories
|
||||||
|
|
||||||
Vociferate ships two composite actions that together cover the full release flow.
|
Vociferate ships two composite actions that together cover the full release flow.
|
||||||
Pin both to the same released tag.
|
Until release tags are created, reference `@main`. Once tags exist again, pin both actions to the same released tag.
|
||||||
|
|
||||||
### `prepare` — update files, commit, and push tag
|
### `prepare` — update files, commit, and push tag
|
||||||
|
|
||||||
@@ -34,7 +39,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- uses: git.hrafn.xyz/aether/vociferate/prepare@v1.0.0
|
- uses: git.hrafn.xyz/aether/vociferate/prepare@main
|
||||||
with:
|
with:
|
||||||
version: ${{ inputs.version }}
|
version: ${{ inputs.version }}
|
||||||
|
|
||||||
@@ -54,7 +59,7 @@ For repositories that embed the version inside source code, pass `version-file`
|
|||||||
and `version-pattern`:
|
and `version-pattern`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- uses: git.hrafn.xyz/aether/vociferate/prepare@v1.0.0
|
- uses: git.hrafn.xyz/aether/vociferate/prepare@main
|
||||||
with:
|
with:
|
||||||
version-file: internal/myapp/version/version.go
|
version-file: internal/myapp/version/version.go
|
||||||
version-pattern: 'const Version = "([^"]+)"'
|
version-pattern: 'const Version = "([^"]+)"'
|
||||||
@@ -93,7 +98,7 @@ assets after it runs:
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- id: publish
|
- id: publish
|
||||||
uses: git.hrafn.xyz/aether/vociferate/publish@v1.0.0
|
uses: git.hrafn.xyz/aether/vociferate/publish@main
|
||||||
|
|
||||||
- name: Upload my binary
|
- name: Upload my binary
|
||||||
run: |
|
run: |
|
||||||
@@ -162,12 +167,7 @@ Defaults:
|
|||||||
|
|
||||||
When no `--version-file` flag is provided, `vociferate` derives the current version from the most recent released section heading in the changelog (`## [x.y.z] - ...`). If no prior releases exist, it defaults to `0.0.0` and recommends `v1.0.0` as the first tag.
|
When no `--version-file` flag is provided, `vociferate` derives the current version from the most recent released section heading in the changelog (`## [x.y.z] - ...`). If no prior releases exist, it defaults to `0.0.0` and recommends `v1.0.0` as the first tag.
|
||||||
|
|
||||||
During prepare, vociferate also normalizes changelog heading links when it can determine the repository URL (from CI environment variables or `origin` git remote):
|
During prepare, vociferate can normalize changelog heading links when it can determine the repository URL (from CI environment variables or `origin` git remote). If you prefer changelog headings to stay plain while tags are being rebuilt, leave the changelog as plain headings and avoid retaining historical release-tag links.
|
||||||
|
|
||||||
- `## [Unreleased]` becomes a link to the repository main branch.
|
|
||||||
- `## [x.y.z] - YYYY-MM-DD` becomes a link to the corresponding release page.
|
|
||||||
|
|
||||||
If the repository URL cannot be determined, headings remain in plain form.
|
|
||||||
|
|
||||||
When running `--version`, the `release-version` file is created automatically if it does not exist, so new repositories do not need to pre-seed it.
|
When running `--version`, the `release-version` file is created automatically if it does not exist, so new repositories do not need to pre-seed it.
|
||||||
|
|
||||||
|
|||||||
17
changelog.md
17
changelog.md
@@ -9,8 +9,22 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-03-20
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- README workflow badges now link to the latest workflow run pages instead of the workflow definition pages.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Publish action falls back to `git describe` when `inputs.version` is empty and `GITHUB_REF` is not a tag ref, resolving `workflow_call` input propagation failures in act runner v0.3.0.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- Coverage badge in README linked to S3-hosted main-branch report.
|
||||||
|
- S3 coverage artefact publishing (HTML report, badge, JSON summary) in push validation pipeline.
|
||||||
|
- CLI tests and internal helper tests raising total coverage to 84%.
|
||||||
|
- Test suite isolation against ambient CI environment variables for changelog link generation tests.
|
||||||
- Go CLI for changelog-driven release preparation and semantic version recommendation.
|
- Go CLI for changelog-driven release preparation and semantic version recommendation.
|
||||||
- Version recommendation from changelog release headings, including first-release support (`0.0.0` base -> `v1.0.0`).
|
- Version recommendation from changelog release headings, including first-release support (`0.0.0` base -> `v1.0.0`).
|
||||||
- Automatic `release-version` creation/update during release preparation.
|
- Automatic `release-version` creation/update during release preparation.
|
||||||
@@ -28,3 +42,6 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect
|
|||||||
- Reusable Gitea workflows (`prepare-release.yml`, `do-release.yml`) with `workflow_call` support.
|
- Reusable Gitea workflows (`prepare-release.yml`, `do-release.yml`) with `workflow_call` support.
|
||||||
- Project/automation rename from `releaseprep` to `vociferate` (entrypoint, package paths, outputs).
|
- Project/automation rename from `releaseprep` to `vociferate` (entrypoint, package paths, outputs).
|
||||||
- README guidance focused on primary cross-repository reuse workflows.
|
- README guidance focused on primary cross-repository reuse workflows.
|
||||||
|
|
||||||
|
[Unreleased]: http://teapot:3000/aether/vociferate/src/branch/main
|
||||||
|
[0.1.0]: http://teapot:3000/aether/vociferate/releases/tag/v0.1.0
|
||||||
|
|||||||
120
cmd/vociferate/main_test.go
Normal file
120
cmd/vociferate/main_test.go
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMainRecommendPrintsTag(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
writeFile(t, filepath.Join(root, "release-version"), "1.1.6\n")
|
||||||
|
writeFile(t, filepath.Join(root, "changelog.md"), "# Changelog\n\n## [Unreleased]\n\n### Added\n\n- Feature.\n\n## [1.1.6] - 2017-12-20\n")
|
||||||
|
|
||||||
|
stdout, stderr, code := runMain(t, "--recommend", "--root", root)
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("expected exit 0, got %d (stderr: %s)", code, stderr)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(stdout) != "v1.2.0" {
|
||||||
|
t.Fatalf("unexpected recommended tag: %q", strings.TrimSpace(stdout))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMainUsageExitWhenRequiredFlagsMissing(t *testing.T) {
|
||||||
|
_, stderr, code := runMain(t)
|
||||||
|
if code != 2 {
|
||||||
|
t.Fatalf("expected exit 2, got %d", code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr, "usage: vociferate") {
|
||||||
|
t.Fatalf("expected usage text in stderr, got: %s", stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMainPrepareUpdatesFiles(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
writeFile(t, filepath.Join(root, ".git", "config"), "[remote \"origin\"]\n\turl = git@git.hrafn.xyz:aether/vociferate.git\n")
|
||||||
|
writeFile(t, filepath.Join(root, "release-version"), "1.1.6\n")
|
||||||
|
writeFile(t, filepath.Join(root, "changelog.md"), "# Changelog\n\n## [Unreleased]\n\n### Fixed\n\n- Patch note.\n\n## [1.1.6] - 2017-12-20\n")
|
||||||
|
|
||||||
|
_, stderr, code := runMain(t, "--version", "v1.1.7", "--date", "2026-03-20", "--root", root)
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("expected exit 0, got %d (stderr: %s)", code, stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
versionBytes, err := os.ReadFile(filepath.Join(root, "release-version"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read release-version: %v", err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(string(versionBytes)) != "1.1.7" {
|
||||||
|
t.Fatalf("unexpected version file value: %q", string(versionBytes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMainPrepareReturnsExitOneOnFailure(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
_, stderr, code := runMain(t, "--version", "v1.1.7", "--date", "2026-03-20", "--root", root)
|
||||||
|
if code != 1 {
|
||||||
|
t.Fatalf("expected exit 1, got %d", code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr, "prepare release") {
|
||||||
|
t.Fatalf("expected prepare error in stderr, got: %s", stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHelperProcess(t *testing.T) {
|
||||||
|
if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
idx := -1
|
||||||
|
for i, arg := range os.Args {
|
||||||
|
if arg == "--" {
|
||||||
|
idx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if idx == -1 {
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
args := append([]string{"vociferate"}, os.Args[idx+1:]...)
|
||||||
|
os.Args = args
|
||||||
|
flag.CommandLine = flag.NewFlagSet(args[0], flag.ExitOnError)
|
||||||
|
|
||||||
|
main()
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMain(t *testing.T, args ...string) (string, string, int) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
cmdArgs := append([]string{"-test.run=TestHelperProcess", "--"}, args...)
|
||||||
|
cmd := exec.Command(os.Args[0], cmdArgs...)
|
||||||
|
cmd.Env = append(os.Environ(), "GO_WANT_HELPER_PROCESS=1")
|
||||||
|
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
output := string(out)
|
||||||
|
if err == nil {
|
||||||
|
return output, "", 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||||
|
return "", output, exitErr.ExitCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Fatalf("run helper process: %v", err)
|
||||||
|
return "", "", -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFile(t *testing.T, path, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir for %s: %v", path, err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatalf("write file %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
|
// Package vociferate provides changelog-driven release preparation utilities.
|
||||||
|
//
|
||||||
|
// It updates version metadata, promotes the Unreleased changelog section into a
|
||||||
|
// dated version section, recommends the next semantic version tag from pending
|
||||||
|
// changelog entries, and normalizes changelog links when repository metadata is
|
||||||
|
// available.
|
||||||
package vociferate
|
package vociferate
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -19,11 +25,19 @@ var releasedSectionRe = regexp.MustCompile(`(?m)^## \[(\d+\.\d+\.\d+)\] - `)
|
|||||||
var linkedReleasedSectionRe = regexp.MustCompile(`(?m)^## \[(\d+\.\d+\.\d+)\](?:\([^\n)]*\))? - `)
|
var linkedReleasedSectionRe = regexp.MustCompile(`(?m)^## \[(\d+\.\d+\.\d+)\](?:\([^\n)]*\))? - `)
|
||||||
var unreleasedHeadingRe = regexp.MustCompile(`(?m)^## \[Unreleased\](?:\([^\n)]*\))?\n`)
|
var unreleasedHeadingRe = regexp.MustCompile(`(?m)^## \[Unreleased\](?:\([^\n)]*\))?\n`)
|
||||||
var releaseHeadingRe = regexp.MustCompile(`(?m)^## \[(\d+\.\d+\.\d+)\](?:\([^\n)]*\))? - `)
|
var releaseHeadingRe = regexp.MustCompile(`(?m)^## \[(\d+\.\d+\.\d+)\](?:\([^\n)]*\))? - `)
|
||||||
|
var refLinkLineRe = regexp.MustCompile(`^\[[^\]]+\]: \S`)
|
||||||
|
|
||||||
type Options struct {
|
type Options struct {
|
||||||
VersionFile string
|
// VersionFile is the path to the file that stores the current version,
|
||||||
|
// relative to the repository root. When empty, release-version is used.
|
||||||
|
VersionFile string
|
||||||
|
// VersionPattern is a regular expression with exactly one capture group for
|
||||||
|
// extracting the current version from VersionFile.
|
||||||
|
// When empty, a line-oriented default matcher is used.
|
||||||
VersionPattern string
|
VersionPattern string
|
||||||
Changelog string
|
// Changelog is the path to the changelog file, relative to the repository
|
||||||
|
// root. When empty, changelog.md is used.
|
||||||
|
Changelog string
|
||||||
}
|
}
|
||||||
|
|
||||||
type semver struct {
|
type semver struct {
|
||||||
@@ -38,6 +52,14 @@ type resolvedOptions struct {
|
|||||||
Changelog string
|
Changelog string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prepare updates version state and promotes the Unreleased changelog notes
|
||||||
|
// into a new release section.
|
||||||
|
//
|
||||||
|
// The version may be provided with or without a leading "v" and releaseDate
|
||||||
|
// must use YYYY-MM-DD formatting. Prepare updates both the configured version
|
||||||
|
// file and changelog, and enriches changelog headings with repository links
|
||||||
|
// when repository metadata can be derived from CI environment variables or the
|
||||||
|
// origin git remote.
|
||||||
func Prepare(rootDir, version, releaseDate string, options Options) error {
|
func Prepare(rootDir, version, releaseDate string, options Options) error {
|
||||||
normalizedVersion := strings.TrimPrefix(strings.TrimSpace(version), "v")
|
normalizedVersion := strings.TrimPrefix(strings.TrimSpace(version), "v")
|
||||||
if normalizedVersion == "" {
|
if normalizedVersion == "" {
|
||||||
@@ -65,6 +87,16 @@ func Prepare(rootDir, version, releaseDate string, options Options) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RecommendedTag returns the next semantic release tag (for example, v1.2.3)
|
||||||
|
// based on the current version and Unreleased changelog content.
|
||||||
|
//
|
||||||
|
// Bump rules are:
|
||||||
|
// - major: Unreleased contains a Breaking section or Removed entries
|
||||||
|
// - minor: Unreleased contains Added entries
|
||||||
|
// - patch: all other cases
|
||||||
|
//
|
||||||
|
// When no previous release is present in the changelog, the base version is
|
||||||
|
// treated as 0.0.0.
|
||||||
func RecommendedTag(rootDir string, options Options) (string, error) {
|
func RecommendedTag(rootDir string, options Options) (string, error) {
|
||||||
resolved, err := resolveOptions(options)
|
resolved, err := resolveOptions(options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -381,19 +413,38 @@ func addChangelogLinks(text, repoURL string) string {
|
|||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
mainURL := repoURL + "/src/branch/main"
|
// Normalize headings to plain format, stripping any existing inline links.
|
||||||
text = unreleasedHeadingRe.ReplaceAllString(text, fmt.Sprintf("## [Unreleased](%s)\n", mainURL))
|
text = unreleasedHeadingRe.ReplaceAllString(text, "## [Unreleased]\n")
|
||||||
|
|
||||||
text = releaseHeadingRe.ReplaceAllStringFunc(text, func(match string) string {
|
text = releaseHeadingRe.ReplaceAllStringFunc(text, func(match string) string {
|
||||||
parts := releaseHeadingRe.FindStringSubmatch(match)
|
parts := releaseHeadingRe.FindStringSubmatch(match)
|
||||||
if len(parts) < 2 {
|
if len(parts) < 2 {
|
||||||
return match
|
return match
|
||||||
}
|
}
|
||||||
version := parts[1]
|
version := parts[1]
|
||||||
return fmt.Sprintf("## [%s](%s/releases/tag/v%s) - ", version, repoURL, version)
|
return fmt.Sprintf("## [%s] - ", version)
|
||||||
})
|
})
|
||||||
|
|
||||||
return text
|
// Strip any trailing reference link block (blank lines followed by ref link lines).
|
||||||
|
lines := strings.Split(strings.TrimRight(text, "\n"), "\n")
|
||||||
|
cutAt := len(lines)
|
||||||
|
for i := len(lines) - 1; i >= 0; i-- {
|
||||||
|
if strings.TrimSpace(lines[i]) == "" || refLinkLineRe.MatchString(lines[i]) {
|
||||||
|
cutAt = i
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
text = strings.Join(lines[:cutAt], "\n") + "\n"
|
||||||
|
|
||||||
|
// Build and append reference link definitions.
|
||||||
|
linkDefs := []string{fmt.Sprintf("[Unreleased]: %s/src/branch/main", repoURL)}
|
||||||
|
for _, match := range releasedSectionRe.FindAllStringSubmatch(text, -1) {
|
||||||
|
if len(match) >= 2 {
|
||||||
|
linkDefs = append(linkDefs, fmt.Sprintf("[%s]: %s/releases/tag/v%s", match[1], repoURL, match[1]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimRight(text, "\n") + "\n\n" + strings.Join(linkDefs, "\n") + "\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseSemver(version string) (semver, error) {
|
func parseSemver(version string) (semver, error) {
|
||||||
|
|||||||
123
internal/vociferate/vociferate_internal_test.go
Normal file
123
internal/vociferate/vociferate_internal_test.go
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
package vociferate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeRepoURL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
remoteURL string
|
||||||
|
wantURL string
|
||||||
|
wantOK bool
|
||||||
|
}{
|
||||||
|
{name: "https", remoteURL: "https://git.hrafn.xyz/aether/vociferate.git", wantURL: "https://git.hrafn.xyz/aether/vociferate", wantOK: true},
|
||||||
|
{name: "http", remoteURL: "http://teapot:3000/aether/vociferate.git", wantURL: "http://teapot:3000/aether/vociferate", wantOK: true},
|
||||||
|
{name: "ssh with scheme", remoteURL: "ssh://git@git.hrafn.xyz/aether/vociferate.git", wantURL: "https://git.hrafn.xyz/aether/vociferate", wantOK: true},
|
||||||
|
{name: "scp style", remoteURL: "git@git.hrafn.xyz:aether/vociferate.git", wantURL: "https://git.hrafn.xyz/aether/vociferate", wantOK: true},
|
||||||
|
{name: "empty", remoteURL: "", wantURL: "", wantOK: false},
|
||||||
|
{name: "unsupported scheme", remoteURL: "ftp://example.com/repo.git", wantURL: "", wantOK: false},
|
||||||
|
{name: "invalid ssh missing user", remoteURL: "ssh://git.hrafn.xyz/aether/vociferate.git", wantURL: "", wantOK: false},
|
||||||
|
{name: "invalid scp style", remoteURL: "git.hrafn.xyz:aether/vociferate.git", wantURL: "", wantOK: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
tt := tt
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
gotURL, gotOK := normalizeRepoURL(tt.remoteURL)
|
||||||
|
if gotOK != tt.wantOK {
|
||||||
|
t.Fatalf("normalizeRepoURL(%q) ok = %v, want %v", tt.remoteURL, gotOK, tt.wantOK)
|
||||||
|
}
|
||||||
|
if gotURL != tt.wantURL {
|
||||||
|
t.Fatalf("normalizeRepoURL(%q) url = %q, want %q", tt.remoteURL, gotURL, tt.wantURL)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSemver(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want semver
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "valid", input: "1.2.3", want: semver{major: 1, minor: 2, patch: 3}, wantErr: false},
|
||||||
|
{name: "missing part", input: "1.2", wantErr: true},
|
||||||
|
{name: "non numeric", input: "1.two.3", wantErr: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
tt := tt
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
got, err := parseSemver(tt.input)
|
||||||
|
if tt.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("parseSemver(%q) expected error", tt.input)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseSemver(%q) unexpected error: %v", tt.input, err)
|
||||||
|
}
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("parseSemver(%q) = %+v, want %+v", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOriginRemoteURLFromGitConfig(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("origin exists", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
config := "[core]\n\trepositoryformatversion = 0\n[remote \"origin\"]\n\turl = git@git.hrafn.xyz:aether/vociferate.git\n"
|
||||||
|
url, ok := originRemoteURLFromGitConfig(config)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected origin url to be found")
|
||||||
|
}
|
||||||
|
if url != "git@git.hrafn.xyz:aether/vociferate.git" {
|
||||||
|
t.Fatalf("unexpected url: %q", url)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("origin missing", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
config := "[core]\n\trepositoryformatversion = 0\n[remote \"upstream\"]\n\turl = git@git.hrafn.xyz:aether/vociferate.git\n"
|
||||||
|
_, ok := originRemoteURLFromGitConfig(config)
|
||||||
|
if ok {
|
||||||
|
t.Fatal("expected origin url to be absent")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeriveRepositoryURLFromGitConfigFallback(t *testing.T) {
|
||||||
|
t.Setenv("GITHUB_SERVER_URL", "")
|
||||||
|
t.Setenv("GITHUB_REPOSITORY", "")
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
configPath := filepath.Join(root, ".git", "config")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir .git: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(configPath, []byte("[remote \"origin\"]\n\turl = git@git.hrafn.xyz:aether/vociferate.git\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write git config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
url, ok := deriveRepositoryURL(root)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected repository URL from git config")
|
||||||
|
}
|
||||||
|
if url != "https://git.hrafn.xyz/aether/vociferate" {
|
||||||
|
t.Fatalf("unexpected repository URL: %q", url)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ func TestPrepareSuite(t *testing.T) {
|
|||||||
|
|
||||||
func (s *PrepareSuite) SetupTest() {
|
func (s *PrepareSuite) SetupTest() {
|
||||||
s.rootDir = s.T().TempDir()
|
s.rootDir = s.T().TempDir()
|
||||||
|
s.T().Setenv("GITHUB_SERVER_URL", "")
|
||||||
|
s.T().Setenv("GITHUB_REPOSITORY", "")
|
||||||
require.NoError(s.T(), os.MkdirAll(filepath.Join(s.rootDir, ".git"), 0o755))
|
require.NoError(s.T(), os.MkdirAll(filepath.Join(s.rootDir, ".git"), 0o755))
|
||||||
require.NoError(s.T(), os.WriteFile(
|
require.NoError(s.T(), os.WriteFile(
|
||||||
filepath.Join(s.rootDir, ".git", "config"),
|
filepath.Join(s.rootDir, ".git", "config"),
|
||||||
@@ -52,7 +54,7 @@ func (s *PrepareSuite) TestPrepare_UpdatesVersionAndPromotesUnreleasedNotes() {
|
|||||||
|
|
||||||
changelogBytes, err := os.ReadFile(filepath.Join(s.rootDir, "changelog.md"))
|
changelogBytes, err := os.ReadFile(filepath.Join(s.rootDir, "changelog.md"))
|
||||||
require.NoError(s.T(), err)
|
require.NoError(s.T(), err)
|
||||||
require.Equal(s.T(), "# Changelog\n\n## [Unreleased](https://git.hrafn.xyz/aether/vociferate/src/branch/main)\n\n## [1.1.7](https://git.hrafn.xyz/aether/vociferate/releases/tag/v1.1.7) - 2026-03-20\n\n### Breaking\n\n### Added\n\n- New thing.\n\n### Fixed\n\n- Old thing.\n\n## [1.1.6](https://git.hrafn.xyz/aether/vociferate/releases/tag/v1.1.6) - 2017-12-20\n\n### Fixed\n\n- Historical note.\n", string(changelogBytes))
|
require.Equal(s.T(), "# Changelog\n\n## [Unreleased]\n\n## [1.1.7] - 2026-03-20\n\n### Breaking\n\n### Added\n\n- New thing.\n\n### Fixed\n\n- Old thing.\n\n## [1.1.6] - 2017-12-20\n\n### Fixed\n\n- Historical note.\n\n[Unreleased]: https://git.hrafn.xyz/aether/vociferate/src/branch/main\n[1.1.7]: https://git.hrafn.xyz/aether/vociferate/releases/tag/v1.1.7\n[1.1.6]: https://git.hrafn.xyz/aether/vociferate/releases/tag/v1.1.6\n", string(changelogBytes))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PrepareSuite) TestPrepare_ReturnsErrorWhenUnreleasedSectionMissing() {
|
func (s *PrepareSuite) TestPrepare_ReturnsErrorWhenUnreleasedSectionMissing() {
|
||||||
@@ -230,3 +232,41 @@ func (s *PrepareSuite) TestRecommendedTag_UsesCustomVersionFileAndPattern() {
|
|||||||
require.NoError(s.T(), err)
|
require.NoError(s.T(), err)
|
||||||
require.Equal(s.T(), "v2.4.0", tag)
|
require.Equal(s.T(), "v2.4.0", tag)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *PrepareSuite) TestPrepare_UsesGitHrafnXYZEnvironmentForChangelogLinks() {
|
||||||
|
s.T().Setenv("GITHUB_SERVER_URL", "https://git.hrafn.xyz")
|
||||||
|
s.T().Setenv("GITHUB_REPOSITORY", "aether/vociferate")
|
||||||
|
|
||||||
|
err := vociferate.Prepare(s.rootDir, "1.1.7", "2026-03-20", vociferate.Options{})
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
changelogBytes, readErr := os.ReadFile(filepath.Join(s.rootDir, "changelog.md"))
|
||||||
|
require.NoError(s.T(), readErr)
|
||||||
|
changelog := string(changelogBytes)
|
||||||
|
|
||||||
|
require.Contains(s.T(), changelog, "## [Unreleased]\n")
|
||||||
|
require.Contains(s.T(), changelog, "## [1.1.7] - 2026-03-20")
|
||||||
|
require.Contains(s.T(), changelog, "## [1.1.6] - 2017-12-20")
|
||||||
|
require.Contains(s.T(), changelog, "[Unreleased]: https://git.hrafn.xyz/aether/vociferate/src/branch/main")
|
||||||
|
require.Contains(s.T(), changelog, "[1.1.7]: https://git.hrafn.xyz/aether/vociferate/releases/tag/v1.1.7")
|
||||||
|
require.Contains(s.T(), changelog, "[1.1.6]: https://git.hrafn.xyz/aether/vociferate/releases/tag/v1.1.6")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PrepareSuite) TestPrepare_UsesGitHubEnvironmentForChangelogLinks() {
|
||||||
|
s.T().Setenv("GITHUB_SERVER_URL", "https://github.com")
|
||||||
|
s.T().Setenv("GITHUB_REPOSITORY", "aether/vociferate")
|
||||||
|
|
||||||
|
err := vociferate.Prepare(s.rootDir, "1.1.7", "2026-03-20", vociferate.Options{})
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
|
changelogBytes, readErr := os.ReadFile(filepath.Join(s.rootDir, "changelog.md"))
|
||||||
|
require.NoError(s.T(), readErr)
|
||||||
|
changelog := string(changelogBytes)
|
||||||
|
|
||||||
|
require.Contains(s.T(), changelog, "## [Unreleased]\n")
|
||||||
|
require.Contains(s.T(), changelog, "## [1.1.7] - 2026-03-20")
|
||||||
|
require.Contains(s.T(), changelog, "## [1.1.6] - 2017-12-20")
|
||||||
|
require.Contains(s.T(), changelog, "[Unreleased]: https://github.com/aether/vociferate/src/branch/main")
|
||||||
|
require.Contains(s.T(), changelog, "[1.1.7]: https://github.com/aether/vociferate/releases/tag/v1.1.7")
|
||||||
|
require.Contains(s.T(), changelog, "[1.1.6]: https://github.com/aether/vociferate/releases/tag/v1.1.6")
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ runs:
|
|||||||
elif [[ "${GITHUB_REF_VALUE}" == refs/tags/* ]]; then
|
elif [[ "${GITHUB_REF_VALUE}" == refs/tags/* ]]; then
|
||||||
tag="${GITHUB_REF_VALUE#refs/tags/}"
|
tag="${GITHUB_REF_VALUE#refs/tags/}"
|
||||||
normalized="${tag#v}"
|
normalized="${tag#v}"
|
||||||
|
elif head_tag="$(git describe --exact-match --tags HEAD 2>/dev/null)" && [[ -n "$head_tag" ]]; then
|
||||||
|
tag="$head_tag"
|
||||||
|
normalized="${tag#v}"
|
||||||
else
|
else
|
||||||
echo "A version input is required when the workflow is not running from a tag push" >&2
|
echo "A version input is required when the workflow is not running from a tag push" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -71,9 +74,9 @@ runs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
release_notes="$(awk -v version="$RELEASE_VERSION" '
|
release_notes="$(awk -v version="$RELEASE_VERSION" '
|
||||||
$0 ~ "^## \\[" version "\\] - " {capture=1}
|
$0 ~ "^## \\[" version "\\]" {capture=1}
|
||||||
capture {
|
capture {
|
||||||
if ($0 ~ "^## \\[" && $0 !~ "^## \\[" version "\\] - ") exit
|
if ($0 ~ "^## \\[" && $0 !~ "^## \\[" version "\\]") exit
|
||||||
print
|
print
|
||||||
}
|
}
|
||||||
' "$CHANGELOG")"
|
' "$CHANGELOG")"
|
||||||
|
|||||||
1
release-version
Normal file
1
release-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
0.1.0
|
||||||
Reference in New Issue
Block a user