Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/auto-changelog-v3.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ jobs:
with:
go-version: '1.25'

- name: Test changelog URL generation
if: steps.check.outputs.skip == 'false'
working-directory: v3
run: go test scripts/auto-changelog.go scripts/auto-changelog_test.go

- name: Auto-fill changelog entry
if: steps.check.outputs.skip == 'false'
env:
Expand Down
90 changes: 42 additions & 48 deletions .github/workflows/nightly-release-v3.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ on:
required: false
default: true
type: boolean
release_version:
description: 'Optional explicit version for an ad-hoc release (e.g. v3.0.0-beta.8)'
required: false
default: ''
type: string

jobs:
nightly-release:
Expand Down Expand Up @@ -76,69 +81,51 @@ jobs:
# Run the release script in check mode to see if there's content
cd v3/tasks/release

# Use the release script itself to check for content
if go run release.go --check-only 2>/dev/null; then
# Use the release script itself to check for content. The script uses
# exit status 1 for the expected "no content" result, so preserve that
# result while allowing read/parse failures to fail this workflow.
set +e
CHECK_OUTPUT=$(go run release.go --check-only 2>&1)
CHECK_STATUS=$?
set -e

if [ "$CHECK_STATUS" -eq 0 ]; then
echo "has_unreleased_content=true" >> $GITHUB_OUTPUT
echo "✅ Found unreleased changelog content"
else
elif [ "$CHECK_STATUS" -eq 1 ] && grep -Fq "No unreleased changelog content found." <<< "$CHECK_OUTPUT"; then
echo "has_unreleased_content=false" >> $GITHUB_OUTPUT
echo "ℹ️ No unreleased changelog content found"
else
echo "$CHECK_OUTPUT" >&2
echo "❌ Changelog check failed (exit status $CHECK_STATUS)" >&2
exit "$CHECK_STATUS"
fi

- name: Quick change detection and early exit
id: quick_check
run: |
echo "🔍 Quick check for changes to determine if we should continue..."

# Release bookkeeping can be committed after a tag is created. In
# particular, the auto-changelog workflow may add an entry to
# UNRELEASED_CHANGELOG.md after the release has already completed.
# That is not a new release input and must not create another nightly
# release on its own.
if CURRENT_TAG=$(git describe --tags --exact-match HEAD 2>/dev/null); then
LATEST_TAG="$CURRENT_TAG"
echo "Current commit has release tag: $LATEST_TAG"
# The unreleased changelog is the release input. Do not infer that a
# release is needed from arbitrary commits after the previous tag:
# release bookkeeping, documentation automation, and other unrelated
# commits must not create a release by themselves.
if [ "${{ steps.changelog_check.outputs.has_unreleased_content }}" == "true" ]; then
echo "✅ Found unreleased changelog content, proceeding with release"
echo "has_changes=true" >> $GITHUB_OUTPUT
echo "should_continue=true" >> $GITHUB_OUTPUT
echo "reason=Found unreleased changelog content" >> $GITHUB_OUTPUT
else
# Only consider the active release series (alpha2/beta/rc): they
# outrank legacy alpha tags, and explicit patterns avoid stray tags
# such as v3.0.0-alpha.98-tui being selected as the baseline.
LATEST_TAG=$(git tag --list "v3.0.0-alpha2.*" "v3.0.0-beta.*" "v3.0.0-rc.*" | sort -V | tail -1)
fi

if [ -z "$LATEST_TAG" ]; then
echo "No previous release found; a release is eligible if changelog content exists."
if [ "${{ steps.changelog_check.outputs.has_unreleased_content }}" == "true" ]; then
echo "has_changes=true" >> $GITHUB_OUTPUT
echo "should_continue=true" >> $GITHUB_OUTPUT
echo "reason=Unreleased changelog content with no previous release" >> $GITHUB_OUTPUT
else
echo "has_changes=false" >> $GITHUB_OUTPUT
echo "should_continue=false" >> $GITHUB_OUTPUT
echo "reason=No previous release and no unreleased changelog content" >> $GITHUB_OUTPUT
fi
elif git diff --quiet "${LATEST_TAG}..HEAD" -- \
. \
':(exclude)v3/internal/version/version.txt' \
':(exclude)v3/UNRELEASED_CHANGELOG.md' \
':(exclude)docs/src/content/docs/changelog.mdx' \
':(exclude)v3/internal/runtime/desktop/@wailsio/runtime/package.json' \
':(exclude)v3/internal/runtime/desktop/@wailsio/runtime/package-lock.json'; then
echo "No release inputs changed since $LATEST_TAG; skipping release."
echo "ℹ️ No unreleased changelog content found; skipping release."
echo "has_changes=false" >> $GITHUB_OUTPUT
echo "should_continue=false" >> $GITHUB_OUTPUT
echo "reason=Only release bookkeeping changed since $LATEST_TAG" >> $GITHUB_OUTPUT
else
CHANGE_COUNT=$(git rev-list "${LATEST_TAG}..HEAD" --count)
echo "Found release inputs in $CHANGE_COUNT commits since $LATEST_TAG"
echo "has_changes=true" >> $GITHUB_OUTPUT
echo "should_continue=true" >> $GITHUB_OUTPUT
echo "reason=Release inputs changed since $LATEST_TAG" >> $GITHUB_OUTPUT
echo "reason=No unreleased changelog content" >> $GITHUB_OUTPUT
Comment thread
coderabbitai[bot] marked this conversation as resolved.
fi

- name: Early exit - No changes detected
if: |
steps.quick_check.outputs.should_continue == 'false' &&
github.event.inputs.force_release != 'true'
github.event.inputs.force_release != 'true' &&
inputs.release_version == ''
run: |
echo "🛑 EARLY EXIT: ${{ steps.quick_check.outputs.reason }}"
echo ""
Expand All @@ -156,7 +143,8 @@ jobs:
- name: Continue with release process
if: |
steps.quick_check.outputs.should_continue == 'true' ||
github.event.inputs.force_release == 'true'
github.event.inputs.force_release == 'true' ||
inputs.release_version != ''
run: |
echo "✅ Proceeding with release process..."
if [ "${{ github.event.inputs.force_release }}" == "true" ]; then
Expand All @@ -167,7 +155,8 @@ jobs:
id: release
if: |
steps.quick_check.outputs.should_continue == 'true' ||
github.event.inputs.force_release == 'true'
github.event.inputs.force_release == 'true' ||
inputs.release_version != ''
env:
# Keep these DISTINCT: the release script validates WAILS_REPO_TOKEN
# against the API before pushing anything and falls back to
Expand All @@ -176,12 +165,17 @@ jobs:
# PAT produced a half-release: tag pushed, no GitHub release.
WAILS_REPO_TOKEN: ${{ secrets.WAILS_REPO_TOKEN }}
GITHUB_TOKEN: ${{ github.token }}
DRY_RUN: ${{ inputs.dry_run }}
RELEASE_VERSION: ${{ inputs.release_version }}
run: |
cd v3/tasks/release
ARGS=()
if [ "${{ github.event.inputs.dry_run }}" == "true" ]; then
if [ "$DRY_RUN" == "true" ]; then
ARGS+=(--dry-run)
fi
if [ -n "$RELEASE_VERSION" ]; then
ARGS+=(--version "$RELEASE_VERSION")
fi
go run release.go "${ARGS[@]}"

- name: Summary
Expand Down
169 changes: 168 additions & 1 deletion v3/scripts/auto-changelog.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,26 @@ package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"strings"
"time"
)

const changelogPath = "v3/UNRELEASED_CHANGELOG.md"

const (
docsContentPrefix = "docs/src/content/docs/"
docsSiteURL = "https://v3.wails.io"
)

// httpClient bounds every outbound request so a stalled GitHub API or model
// response can't hang the changelog job until the runner timeout.
var httpClient = &http.Client{Timeout: 30 * time.Second}
Expand Down Expand Up @@ -71,6 +80,18 @@ func main() {
fmt.Printf("✅ Section: %s\n", section)
fmt.Printf("✅ Entry: %s\n", entry)

// Documentation is still being built when this job runs, so the public URL
// must be derived from the source path rather than checked against the live
// site. Added entries and conventional-commit feat PRs get the link; the
// generated prose remains model controlled, while the URL is deterministic
// and cannot be hallucinated.
docURLs, err := documentationURLs(pr.Files)
if err != nil {
fmt.Printf("⚠️ Could not calculate documentation URL: %v — continuing without a documentation link\n", err)
} else if section == "Added" || isFeatureChange(pr.Title) {
entry = appendDocumentationLinks(entry, docURLs)
}

prURL := fmt.Sprintf("https://github.com/%s/pull/%s", repo, prNumber)
bullet := fmt.Sprintf("- %s in [PR](%s) by @%s", entry, prURL, pr.Author)

Expand Down Expand Up @@ -111,6 +132,7 @@ func fetchCodeRabbitSummary(repo, prNumber, token string) (string, error) {
type prInfo struct {
Title string
Author string
Files []string
}

func fetchPR(repo, prNumber, token string) (prInfo, error) {
Expand All @@ -128,7 +150,152 @@ func fetchPR(repo, prNumber, token string) (prInfo, error) {
if err := json.Unmarshal(body, &raw); err != nil {
return prInfo{}, fmt.Errorf("parse PR: %w", err)
}
return prInfo{Title: raw.Title, Author: raw.User.Login}, nil

files, err := fetchPRFiles(repo, prNumber, token)
if err != nil {
// The changelog entry is still useful if the files endpoint is
// temporarily unavailable. Documentation links are an enhancement, not
// a reason to fail the whole merge automation.
fmt.Printf("⚠️ Could not fetch changed files: %v — continuing without documentation link\n", err)
}
return prInfo{Title: raw.Title, Author: raw.User.Login, Files: files}, nil
}

func fetchPRFiles(repo, prNumber, token string) ([]string, error) {
var files []string
for page := 1; page <= 30; page++ {
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/pulls/%s/files?per_page=100&page=%d", repo, prNumber, page)
body, err := githubGet(apiURL, token)
if err != nil {
return nil, err
}

var raw []struct {
Filename string `json:"filename"`
Status string `json:"status"`
}
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("parse changed files: %w", err)
}
for _, file := range raw {
if file.Status == "removed" {
continue
}
files = append(files, file.Filename)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
if len(raw) < 100 {
break
}
}
return files, nil
}

func documentationURLs(files []string) ([]string, error) {
var urls []string
seen := make(map[string]bool)
for _, file := range files {
if !isDocumentationPage(file) {
continue
}
docURL, err := documentationURLForFile(file)
if err != nil {
return nil, err
}
Comment on lines +200 to +203
if docURL == "" || seen[docURL] {
continue
}
seen[docURL] = true
urls = append(urls, docURL)
}
return urls, nil
}

func isDocumentationPage(file string) bool {
file = filepath.ToSlash(file)
if !strings.HasPrefix(file, docsContentPrefix) {
return false
}
base := path.Base(file)
return base != "changelog.md" && base != "changelog.mdx"
}

func documentationURLForFile(file string) (string, error) {
file = filepath.ToSlash(file)
if !isDocumentationPage(file) {
return "", nil
}

slug, err := readFrontmatterSlug(file)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
fmt.Printf("⚠️ Documentation file %q is not present in the checkout; skipping link\n", file)
return "", nil
}
return "", err
}
return documentationURLFromPath(file, slug)
}

func documentationURLFromPath(file, slug string) (string, error) {
file = filepath.ToSlash(file)
if !isDocumentationPage(file) {
return "", nil
}

relative := strings.TrimPrefix(file, docsContentPrefix)
if slug != "" {
relative = strings.TrimPrefix(slug, "/")
} else {
ext := path.Ext(relative)
relative = strings.TrimSuffix(relative, ext)
if path.Base(relative) == "index" {
relative = path.Dir(relative)
}
}

if relative == "." || relative == "" {
relative = "/"
} else {
relative = "/" + strings.Trim(relative, "/")
}
return (&url.URL{Scheme: "https", Host: strings.TrimPrefix(docsSiteURL, "https://"), Path: relative}).String(), nil
}

func readFrontmatterSlug(file string) (string, error) {
data, err := os.ReadFile(file)
if err != nil {
return "", err
}
content := string(data)
if !strings.HasPrefix(content, "---") {
return "", nil
}
end := strings.Index(content[3:], "\n---")
if end == -1 {
return "", nil
}
frontmatter := content[3 : end+3]
match := regexp.MustCompile(`(?m)^slug:\s*["']?([^"'\n]+?)["']?\s*$`).FindStringSubmatch(frontmatter)
if len(match) == 2 {
return strings.TrimSpace(match[1]), nil
}
return "", nil
}

func appendDocumentationLinks(entry string, docURLs []string) string {
if len(docURLs) == 0 {
return entry
}
links := make([]string, 0, len(docURLs))
for _, docURL := range docURLs {
links = append(links, fmt.Sprintf("[documentation](%s)", docURL))
}
return entry + " — see " + strings.Join(links, " and ")
}

func isFeatureChange(title string) bool {
m := internalTitleRe.FindStringSubmatch(strings.TrimSpace(title))
return m != nil && strings.EqualFold(m[1], "feat")
}

func githubGet(url, token string) ([]byte, error) {
Expand Down
Loading
Loading