2 Commits

Author SHA1 Message Date
Micheal Wilkinson
8ea9acdebc feat: add changelog heading links for unreleased and releases
Some checks failed
Push Validation / validate (push) Failing after 42s
- Link Unreleased heading to repository main branch.
- Link release headings to release tag pages.
- Derive repository URL from CI metadata or origin git remote.
- Keep plain headings when repository URL cannot be resolved.
- Update tests and README usage docs for linked heading behavior.
2026-03-20 21:46:36 +00:00
Micheal Wilkinson
be4f3833a1 feat: chain do-release from prepare workflow
- Update prepare-release to call do-release via workflow_call after tag creation.
- Update README examples and release-flow docs to reflect direct invocation
  instead of relying only on tag-push triggers.
2026-03-20 21:46:36 +00:00
4 changed files with 169 additions and 17 deletions

View File

@@ -18,6 +18,8 @@ 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
@@ -58,5 +60,12 @@ jobs:
echo "## Release Prepared" echo "## Release Prepared"
echo echo
echo "- Tag pushed: ${tag}" echo "- Tag pushed: ${tag}"
echo "- The tag-triggered Do Release workflow will create or update the release and publish binaries." echo "- Calling Do Release workflow for ${tag}."
} >> "$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

@@ -37,6 +37,13 @@ jobs:
- uses: git.hrafn.xyz/aether/vociferate/prepare@v1.0.0 - 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
@@ -63,17 +70,18 @@ so no token input is required.
name: Do Release name: Do Release
on: on:
push: workflow_dispatch:
tags: inputs:
- "v*.*.*" tag:
description: Semantic version to publish (for example v1.2.3)
required: true
jobs: jobs:
release: release:
runs-on: ubuntu-latest uses: aether/vociferate/.gitea/workflows/do-release.yml@main
steps: with:
- uses: actions/checkout@v4 tag: ${{ inputs.tag }}
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
@@ -154,6 +162,13 @@ 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):
- `## [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.
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.
@@ -169,9 +184,10 @@ 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.
- `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. - `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.
This split matters because release notes must be generated from the tagged commit that already contains the promoted changelog section. Calling `Do Release` directly avoids environments where tag pushes from workflow tokens do not emit a follow-up workflow trigger event.
## Release Artifacts ## Release Artifacts

View File

@@ -16,6 +16,9 @@ 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)]*\))? - `)
type Options struct { type Options struct {
VersionFile string VersionFile string
@@ -202,6 +205,10 @@ 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)
} }
@@ -245,13 +252,12 @@ func readChangelogState(rootDir, changelogPath string) (string, string, int, int
} }
text := string(contents) text := string(contents)
unreleasedHeader := "## [Unreleased]\n" headerLoc := unreleasedHeadingRe.FindStringIndex(text)
start := strings.Index(text, unreleasedHeader) if headerLoc == nil {
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 := start + len(unreleasedHeader) afterHeader := headerLoc[1]
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:])
@@ -269,13 +275,127 @@ 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 := releasedSectionRe.FindStringSubmatch(string(contents)) match := linkedReleasedSectionRe.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
}
mainURL := repoURL + "/src/branch/main"
text = unreleasedHeadingRe.ReplaceAllString(text, fmt.Sprintf("## [Unreleased](%s)\n", mainURL))
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](%s/releases/tag/v%s) - ", version, repoURL, version)
})
return text
}
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

@@ -21,6 +21,13 @@ func TestPrepareSuite(t *testing.T) {
func (s *PrepareSuite) SetupTest() { func (s *PrepareSuite) SetupTest() {
s.rootDir = s.T().TempDir() s.rootDir = s.T().TempDir()
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"),
@@ -45,7 +52,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", string(changelogBytes)) 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))
} }
func (s *PrepareSuite) TestPrepare_ReturnsErrorWhenUnreleasedSectionMissing() { func (s *PrepareSuite) TestPrepare_ReturnsErrorWhenUnreleasedSectionMissing() {