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: prepare:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: docker.io/catthehacker/ubuntu:act-latest container: docker.io/catthehacker/ubuntu:act-latest
outputs:
tag: ${{ steps.prepare.outputs.version }}
defaults: defaults:
run: run:
shell: bash shell: bash
@@ -60,12 +58,5 @@ jobs:
echo "## Release Prepared" echo "## Release Prepared"
echo echo
echo "- Tag pushed: ${tag}" 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" } >> "$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: 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
@@ -34,89 +26,11 @@ 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 -html=coverage.out -o coverage.html go tool cover -func=coverage.out
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' }}

View File

@@ -1,10 +1,5 @@
# vociferate # 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 `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.
@@ -17,7 +12,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.
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 ### `prepare` — update files, commit, and push tag
@@ -39,16 +34,9 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: git.hrafn.xyz/aether/vociferate/prepare@main - uses: git.hrafn.xyz/aether/vociferate/prepare@v1.0.0
with: with:
version: ${{ inputs.version }} 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 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`: and `version-pattern`:
```yaml ```yaml
- uses: git.hrafn.xyz/aether/vociferate/prepare@main - uses: git.hrafn.xyz/aether/vociferate/prepare@v1.0.0
with: with:
version-file: internal/myapp/version/version.go version-file: internal/myapp/version/version.go
version-pattern: 'const Version = "([^"]+)"' version-pattern: 'const Version = "([^"]+)"'
@@ -75,18 +63,17 @@ so no token input is required.
name: Do Release name: Do Release
on: on:
workflow_dispatch: push:
inputs: tags:
tag: - "v*.*.*"
description: Semantic version to publish (for example v1.2.3)
required: true
jobs: jobs:
release: release:
uses: aether/vociferate/.gitea/workflows/do-release.yml@main runs-on: ubuntu-latest
with: steps:
tag: ${{ inputs.tag }} - uses: actions/checkout@v4
secrets: inherit
- uses: git.hrafn.xyz/aether/vociferate/publish@v1.0.0
``` ```
Reads the matching section from `changelog.md` and creates or updates the Reads the matching section from `changelog.md` and creates or updates the
@@ -98,7 +85,7 @@ assets after it runs:
```yaml ```yaml
- id: publish - id: publish
uses: git.hrafn.xyz/aether/vociferate/publish@main uses: git.hrafn.xyz/aether/vociferate/publish@v1.0.0
- name: Upload my binary - name: Upload my binary
run: | 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. 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. 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. 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: 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` 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` runs from the pushed tag, reads the matching changelog section from that tagged revision, creates or updates the release, and uploads prebuilt binaries.
- `Do Release` 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 ## Release Artifacts

View File

@@ -9,22 +9,10 @@ A `### Breaking` section is used in addition to Keep a Changelog's standard sect
## [Unreleased] ## [Unreleased]
## [0.1.0] - 2026-03-20 ## [1.0.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.
@@ -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. - 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

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 package vociferate
import ( import (
@@ -22,22 +16,11 @@ const (
) )
var releasedSectionRe = regexp.MustCompile(`(?m)^## \[(\d+\.\d+\.\d+)\] - `) 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 { type Options struct {
// VersionFile is the path to the file that stores the current version, VersionFile string
// 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 is the path to the changelog file, relative to the repository Changelog string
// root. When empty, changelog.md is used.
Changelog string
} }
type semver struct { type semver struct {
@@ -52,14 +35,6 @@ 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 == "" {
@@ -87,16 +62,6 @@ 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 {
@@ -237,10 +202,6 @@ func updateChangelog(rootDir, version, releaseDate, changelogPath string) error
} }
updated := text[:afterHeader] + "\n" + newSection + text[nextSectionStart:] 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 { if err := os.WriteFile(path, []byte(updated), 0o644); err != nil {
return fmt.Errorf("write changelog: %w", err) return fmt.Errorf("write changelog: %w", err)
} }
@@ -284,12 +245,13 @@ func readChangelogState(rootDir, changelogPath string) (string, string, int, int
} }
text := string(contents) text := string(contents)
headerLoc := unreleasedHeadingRe.FindStringIndex(text) unreleasedHeader := "## [Unreleased]\n"
if headerLoc == nil { start := strings.Index(text, unreleasedHeader)
if start == -1 {
return "", "", 0, 0, "", fmt.Errorf("unreleased section not found in changelog") return "", "", 0, 0, "", fmt.Errorf("unreleased section not found in changelog")
} }
afterHeader := headerLoc[1] afterHeader := start + len(unreleasedHeader)
nextSectionRelative := strings.Index(text[afterHeader:], "\n## [") nextSectionRelative := strings.Index(text[afterHeader:], "\n## [")
if nextSectionRelative == -1 { if nextSectionRelative == -1 {
nextSectionRelative = len(text[afterHeader:]) nextSectionRelative = len(text[afterHeader:])
@@ -307,146 +269,13 @@ func readLatestChangelogVersion(rootDir, changelogPath string) (string, bool, er
return "", false, fmt.Errorf("read changelog: %w", err) return "", false, fmt.Errorf("read changelog: %w", err)
} }
match := linkedReleasedSectionRe.FindStringSubmatch(string(contents)) match := releasedSectionRe.FindStringSubmatch(string(contents))
if match == nil { if match == nil {
return "", false, nil return "", false, nil
} }
return match[1], true, 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) { func parseSemver(version string) (semver, error) {
parts := strings.Split(strings.TrimSpace(version), ".") parts := strings.Split(strings.TrimSpace(version), ".")
if len(parts) != 3 { 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() { 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.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( require.NoError(s.T(), os.WriteFile(
filepath.Join(s.rootDir, "release-version"), filepath.Join(s.rootDir, "release-version"),
[]byte("1.1.6\n"), []byte("1.1.6\n"),
@@ -54,7 +45,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]\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() { func (s *PrepareSuite) TestPrepare_ReturnsErrorWhenUnreleasedSectionMissing() {
@@ -232,41 +223,3 @@ 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")
}

View File

@@ -52,9 +52,6 @@ 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
@@ -74,9 +71,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")"

View File

@@ -1 +1 @@
0.1.0 1.0.0