1 Commits

Author SHA1 Message Date
gitea-actions[bot]
58372d4564 release: prepare v1.0.0 2026-03-20 21:31:50 +00:00
10 changed files with 28 additions and 618 deletions

View File

@@ -18,8 +18,6 @@ jobs:
prepare:
runs-on: ubuntu-latest
container: docker.io/catthehacker/ubuntu:act-latest
outputs:
tag: ${{ steps.prepare.outputs.version }}
defaults:
run:
shell: bash
@@ -60,12 +58,5 @@ jobs:
echo "## Release Prepared"
echo
echo "- Tag pushed: ${tag}"
echo "- Calling Do Release workflow for ${tag}."
echo "- The tag-triggered Do Release workflow will create or update the release and publish binaries."
} >> "$GITHUB_STEP_SUMMARY"
publish:
needs: prepare
uses: ./.gitea/workflows/do-release.yml
with:
tag: ${{ needs.prepare.outputs.tag }}
secrets: inherit

View File

@@ -14,14 +14,6 @@ jobs:
defaults:
run:
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:
- name: Checkout
uses: actions/checkout@v4
@@ -34,89 +26,11 @@ jobs:
cache: true
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
id: coverage
run: |
set -euo pipefail
go test -covermode=atomic -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
total="$(go tool cover -func=coverage.out | awk '/^total:/ {sub(/%/, "", $3); print $3}')"
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"
go tool cover -func=coverage.out
- name: Recommend next release tag on main pushes
if: ${{ github.ref == 'refs/heads/main' }}

View File

@@ -1,10 +1,5 @@
# vociferate
[![Main Validation](https://git.hrafn.xyz/aether/vociferate/actions/workflows/push-validation.yml/badge.svg?branch=main&event=push)](https://git.hrafn.xyz/aether/vociferate/actions/runs/latest?workflow=push-validation.yml&branch=main&event=push)
[![Prepare Release](https://git.hrafn.xyz/aether/vociferate/actions/workflows/prepare-release.yml/badge.svg?event=workflow_dispatch)](https://git.hrafn.xyz/aether/vociferate/actions/runs/latest?workflow=prepare-release.yml)
[![Do Release](https://git.hrafn.xyz/aether/vociferate/actions/workflows/do-release.yml/badge.svg?event=push)](https://git.hrafn.xyz/aether/vociferate/actions/runs/latest?workflow=do-release.yml)
[![Coverage](https://s3.hrafn.xyz/aether-workflow-report-artefacts/vociferate/branch/main/coverage-badge.svg)](https://s3.hrafn.xyz/aether-workflow-report-artefacts/vociferate/branch/main/coverage.html)
`vociferate` is an `aether` release orchestration tool written in Go for repositories that
want changelog-driven versioning, automated release preparation, and repeatable
tag publication.
@@ -17,7 +12,7 @@ revision.
## Use In Other Repositories
Vociferate ships two composite actions that together cover the full release flow.
Until release tags are created, reference `@main`. Once tags exist again, pin both actions to the same released tag.
Pin both to the same released tag.
### `prepare` — update files, commit, and push tag
@@ -39,16 +34,9 @@ jobs:
with:
fetch-depth: 0
- uses: git.hrafn.xyz/aether/vociferate/prepare@main
- uses: git.hrafn.xyz/aether/vociferate/prepare@v1.0.0
with:
version: ${{ inputs.version }}
publish:
needs: prepare
uses: aether/vociferate/.gitea/workflows/do-release.yml@main
with:
tag: ${{ needs.prepare.outputs.version }}
secrets: inherit
```
Downloads a prebuilt vociferate binary, runs it to update `changelog.md` and
@@ -59,7 +47,7 @@ For repositories that embed the version inside source code, pass `version-file`
and `version-pattern`:
```yaml
- uses: git.hrafn.xyz/aether/vociferate/prepare@main
- uses: git.hrafn.xyz/aether/vociferate/prepare@v1.0.0
with:
version-file: internal/myapp/version/version.go
version-pattern: 'const Version = "([^"]+)"'
@@ -75,18 +63,17 @@ so no token input is required.
name: Do Release
on:
workflow_dispatch:
inputs:
tag:
description: Semantic version to publish (for example v1.2.3)
required: true
push:
tags:
- "v*.*.*"
jobs:
release:
uses: aether/vociferate/.gitea/workflows/do-release.yml@main
with:
tag: ${{ inputs.tag }}
secrets: inherit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: git.hrafn.xyz/aether/vociferate/publish@v1.0.0
```
Reads the matching section from `changelog.md` and creates or updates the
@@ -98,7 +85,7 @@ assets after it runs:
```yaml
- id: publish
uses: git.hrafn.xyz/aether/vociferate/publish@main
uses: git.hrafn.xyz/aether/vociferate/publish@v1.0.0
- name: Upload my binary
run: |
@@ -167,8 +154,6 @@ 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.
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.
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.
Repositories that keep the version inside source code should pass explicit `--version-file` and `--version-pattern` values; in that case the version file is used directly instead of the changelog.
@@ -184,10 +169,9 @@ just go-test
Releases use two workflows:
- `Prepare Release` runs on demand, updates `release-version` and `changelog.md`, commits those changes back to `main`, and pushes the release tag.
- `Prepare Release` then calls `Do Release` directly via reusable `workflow_call` with the resolved tag.
- `Do Release` reads the matching changelog section from that tagged revision, creates or updates the release, and uploads prebuilt binaries.
- `Do Release` runs from the pushed tag, reads the matching changelog section from that tagged revision, creates or updates the release, and uploads prebuilt binaries.
Calling `Do Release` directly avoids environments where tag pushes from workflow tokens do not emit a follow-up workflow trigger event.
This split matters because release notes must be generated from the tagged commit that already contains the promoted changelog section.
## Release Artifacts

View File

@@ -9,22 +9,10 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect
## [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.
## [1.0.0] - 2026-03-20
### Added
- Coverage badge in README linked to S3-hosted main-branch report.
- S3 coverage artefact publishing (HTML report, badge, JSON summary) in push validation pipeline.
- CLI tests and internal helper tests raising total coverage to 84%.
- Test suite isolation against ambient CI environment variables for changelog link generation tests.
- Go CLI for changelog-driven release preparation and semantic version recommendation.
- Version recommendation from changelog release headings, including first-release support (`0.0.0` base -> `v1.0.0`).
- Automatic `release-version` creation/update during release preparation.
@@ -42,6 +30,3 @@ 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.
- Project/automation rename from `releaseprep` to `vociferate` (entrypoint, package paths, outputs).
- 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

View File

@@ -1,120 +0,0 @@
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)
}
}

View File

@@ -1,9 +1,3 @@
// 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
import (
@@ -22,21 +16,10 @@ const (
)
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`)
type Options struct {
// 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
// Changelog is the path to the changelog file, relative to the repository
// root. When empty, changelog.md is used.
Changelog string
}
@@ -52,14 +35,6 @@ type resolvedOptions struct {
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 {
normalizedVersion := strings.TrimPrefix(strings.TrimSpace(version), "v")
if normalizedVersion == "" {
@@ -87,16 +62,6 @@ func Prepare(rootDir, version, releaseDate string, options Options) error {
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) {
resolved, err := resolveOptions(options)
if err != nil {
@@ -237,10 +202,6 @@ func updateChangelog(rootDir, version, releaseDate, changelogPath string) error
}
updated := text[:afterHeader] + "\n" + newSection + text[nextSectionStart:]
repoURL, ok := deriveRepositoryURL(rootDir)
if ok {
updated = addChangelogLinks(updated, repoURL)
}
if err := os.WriteFile(path, []byte(updated), 0o644); err != nil {
return fmt.Errorf("write changelog: %w", err)
}
@@ -284,12 +245,13 @@ func readChangelogState(rootDir, changelogPath string) (string, string, int, int
}
text := string(contents)
headerLoc := unreleasedHeadingRe.FindStringIndex(text)
if headerLoc == nil {
unreleasedHeader := "## [Unreleased]\n"
start := strings.Index(text, unreleasedHeader)
if start == -1 {
return "", "", 0, 0, "", fmt.Errorf("unreleased section not found in changelog")
}
afterHeader := headerLoc[1]
afterHeader := start + len(unreleasedHeader)
nextSectionRelative := strings.Index(text[afterHeader:], "\n## [")
if nextSectionRelative == -1 {
nextSectionRelative = len(text[afterHeader:])
@@ -307,146 +269,13 @@ func readLatestChangelogVersion(rootDir, changelogPath string) (string, bool, er
return "", false, fmt.Errorf("read changelog: %w", err)
}
match := linkedReleasedSectionRe.FindStringSubmatch(string(contents))
match := releasedSectionRe.FindStringSubmatch(string(contents))
if match == nil {
return "", false, nil
}
return match[1], true, nil
}
func deriveRepositoryURL(rootDir string) (string, bool) {
serverURL := strings.TrimSpace(os.Getenv("GITHUB_SERVER_URL"))
repository := strings.TrimSpace(os.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)
if err != nil {
return "", false
}
remoteURL, ok := originRemoteURLFromGitConfig(string(contents))
if !ok {
return "", false
}
repoURL, ok := normalizeRepoURL(remoteURL)
if !ok {
return "", false
}
return repoURL, true
}
func originRemoteURLFromGitConfig(config string) (string, bool) {
inOrigin := false
for _, line := range strings.Split(config, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
inOrigin = trimmed == `[remote "origin"]`
continue
}
if !inOrigin {
continue
}
if strings.HasPrefix(trimmed, "url") {
parts := strings.SplitN(trimmed, "=", 2)
if len(parts) != 2 {
continue
}
url := strings.TrimSpace(parts[1])
if url != "" {
return url, true
}
}
}
return "", false
}
func normalizeRepoURL(remoteURL string) (string, bool) {
remoteURL = strings.TrimSpace(remoteURL)
if remoteURL == "" {
return "", false
}
if strings.HasPrefix(remoteURL, "http://") || strings.HasPrefix(remoteURL, "https://") {
return strings.TrimSuffix(remoteURL, ".git"), true
}
if strings.HasPrefix(remoteURL, "ssh://") {
withoutScheme := strings.TrimPrefix(remoteURL, "ssh://")
at := strings.Index(withoutScheme, "@")
if at == -1 {
return "", false
}
hostAndPath := withoutScheme[at+1:]
host, path, ok := strings.Cut(hostAndPath, "/")
if !ok || host == "" || path == "" {
return "", false
}
return "https://" + host + "/" + strings.TrimSuffix(path, ".git"), true
}
if strings.Contains(remoteURL, "@") && strings.Contains(remoteURL, ":") {
afterAt, ok := strings.CutPrefix(remoteURL, strings.Split(remoteURL, "@")[0]+"@")
if !ok {
return "", false
}
host, path, ok := strings.Cut(afterAt, ":")
if !ok || host == "" || path == "" {
return "", false
}
return "https://" + host + "/" + strings.TrimSuffix(path, ".git"), true
}
return "", false
}
func addChangelogLinks(text, repoURL string) string {
repoURL = strings.TrimSuffix(strings.TrimSpace(repoURL), "/")
if repoURL == "" {
return text
}
// Normalize headings to plain format, stripping any existing inline links.
text = unreleasedHeadingRe.ReplaceAllString(text, "## [Unreleased]\n")
text = releaseHeadingRe.ReplaceAllStringFunc(text, func(match string) string {
parts := releaseHeadingRe.FindStringSubmatch(match)
if len(parts) < 2 {
return match
}
version := parts[1]
return fmt.Sprintf("## [%s] - ", version)
})
// 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) {
parts := strings.Split(strings.TrimSpace(version), ".")
if len(parts) != 3 {

View File

@@ -1,123 +0,0 @@
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)
}
}

View File

@@ -21,15 +21,6 @@ func TestPrepareSuite(t *testing.T) {
func (s *PrepareSuite) SetupTest() {
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.WriteFile(
filepath.Join(s.rootDir, ".git", "config"),
[]byte("[remote \"origin\"]\n\turl = git@git.hrafn.xyz:aether/vociferate.git\n"),
0o644,
))
require.NoError(s.T(), os.WriteFile(
filepath.Join(s.rootDir, "release-version"),
[]byte("1.1.6\n"),
@@ -54,7 +45,7 @@ func (s *PrepareSuite) TestPrepare_UpdatesVersionAndPromotesUnreleasedNotes() {
changelogBytes, err := os.ReadFile(filepath.Join(s.rootDir, "changelog.md"))
require.NoError(s.T(), err)
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))
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", string(changelogBytes))
}
func (s *PrepareSuite) TestPrepare_ReturnsErrorWhenUnreleasedSectionMissing() {
@@ -232,41 +223,3 @@ func (s *PrepareSuite) TestRecommendedTag_UsesCustomVersionFileAndPattern() {
require.NoError(s.T(), err)
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")
}

View File

@@ -52,9 +52,6 @@ runs:
elif [[ "${GITHUB_REF_VALUE}" == refs/tags/* ]]; then
tag="${GITHUB_REF_VALUE#refs/tags/}"
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
echo "A version input is required when the workflow is not running from a tag push" >&2
exit 1
@@ -74,9 +71,9 @@ runs:
set -euo pipefail
release_notes="$(awk -v version="$RELEASE_VERSION" '
$0 ~ "^## \\[" version "\\]" {capture=1}
$0 ~ "^## \\[" version "\\] - " {capture=1}
capture {
if ($0 ~ "^## \\[" && $0 !~ "^## \\[" version "\\]") exit
if ($0 ~ "^## \\[" && $0 !~ "^## \\[" version "\\] - ") exit
print
}
' "$CHANGELOG")"

View File

@@ -1 +1 @@
0.1.0
1.0.0