diff --git a/.github/workflows/nightly-release-v3.yml b/.github/workflows/nightly-release-v3.yml index b507beed96e..3cd992dd1fa 100644 --- a/.github/workflows/nightly-release-v3.yml +++ b/.github/workflows/nightly-release-v3.yml @@ -67,6 +67,34 @@ jobs: echo "has_tag=false" >> $GITHUB_OUTPUT echo "tag=" >> $GITHUB_OUTPUT fi + + - name: Check whether a tagged commit still needs publication + id: publication_check + env: + GH_TOKEN: ${{ secrets.WAILS_REPO_TOKEN || github.token }} + run: | + # A release-bookkeeping commit can advance master after the release tag. + # Select only active-series tags that are ancestors of this checkout so + # a failed publication can still be recovered on the following run. + tag=$(git tag --merged HEAD --list "v3.0.0-alpha2.*" "v3.0.0-beta.*" "v3.0.0-rc.*" | sort -V | tail -1) + echo "tag=$tag" >> "$GITHUB_OUTPUT" + if [ -z "$tag" ]; then + echo "needs_recovery=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + api_status=0 + response=$(gh api --include --silent "repos/$GITHUB_REPOSITORY/releases/tags/$tag" 2>&1) || api_status=$? + if [ "$api_status" -eq 0 ]; then + echo "needs_recovery=false" >> "$GITHUB_OUTPUT" + elif grep -Eq '^HTTP/[0-9.]+ 404 ' <<< "$response"; then + echo "needs_recovery=true" >> "$GITHUB_OUTPUT" + echo "Release $tag is missing; the desktop artifact workflow will be re-dispatched." + else + printf '%s\n' "$response" >&2 + echo "::error::Unable to check publication state for $tag (gh api exit $api_status)." + exit "$api_status" + fi - name: Check for unreleased changelog content id: changelog_check @@ -102,7 +130,7 @@ jobs: # 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) + LATEST_TAG=$(git tag --merged HEAD --list "v3.0.0-alpha2.*" "v3.0.0-beta.*" "v3.0.0-rc.*" | sort -V | tail -1) fi if [ -z "$LATEST_TAG" ]; then @@ -138,6 +166,7 @@ jobs: - name: Early exit - No changes detected if: | steps.quick_check.outputs.should_continue == 'false' && + steps.publication_check.outputs.needs_recovery != 'true' && github.event.inputs.force_release != 'true' run: | echo "🛑 EARLY EXIT: ${{ steps.quick_check.outputs.reason }}" @@ -155,8 +184,9 @@ jobs: - name: Continue with release process if: | - steps.quick_check.outputs.should_continue == 'true' || - github.event.inputs.force_release == 'true' + (steps.quick_check.outputs.should_continue == 'true' || + github.event.inputs.force_release == 'true') && + steps.publication_check.outputs.needs_recovery != 'true' run: | echo "✅ Proceeding with release process..." if [ "${{ github.event.inputs.force_release }}" == "true" ]; then @@ -166,8 +196,9 @@ jobs: - name: Run release script id: release if: | - steps.quick_check.outputs.should_continue == 'true' || - github.event.inputs.force_release == 'true' + (steps.quick_check.outputs.should_continue == 'true' || + github.event.inputs.force_release == 'true') && + steps.publication_check.outputs.needs_recovery != 'true' env: # Keep these DISTINCT: the release script validates WAILS_REPO_TOKEN # against the API before pushing anything and falls back to @@ -182,31 +213,81 @@ jobs: if [ "${{ github.event.inputs.dry_run }}" == "true" ]; then ARGS+=(--dry-run) fi + # Release v3 must create the GitHub release with its artifacts in the + # same operation. Publishing here first makes the release immutable and + # prevents the artifact workflow from uploading its outputs. + ARGS+=(--defer-github-release) go run release.go "${ARGS[@]}" # Tags created with GITHUB_TOKEN do not emit a push event that can trigger - # Release v3. Dispatch it explicitly so each published nightly also gets - # the platform binaries, checksums, and provenance attached to its release. - - name: Build and attach desktop artifacts + # Release v3. Dispatch it explicitly; this workflow is the sole publisher + # so the immutable release is created with its platform binaries, + # checksums, and provenance already attached. + - name: Build and publish release with desktop artifacts id: desktop_artifacts - if: steps.release.outcome == 'success' && steps.release.outputs.release_dry_run != 'true' + if: | + github.event.inputs.dry_run != 'true' && + ((steps.release.outputs.release_tag != '' && steps.release.outputs.release_dry_run != 'true') || + steps.publication_check.outputs.needs_recovery == 'true') env: GH_TOKEN: ${{ secrets.WAILS_REPO_TOKEN || github.token }} + RELEASE_NOTES: ${{ steps.release.outputs.release_notes }} + RELEASE_TAG: ${{ steps.release.outputs.release_tag }} + RECOVERY_TAG: ${{ steps.publication_check.outputs.tag }} run: | - tag="${{ steps.release.outputs.release_tag }}" + tag="${RELEASE_TAG:-$RECOVERY_TAG}" + if [ -z "$tag" ]; then + echo "::error::No release tag was produced or found for recovery." + exit 1 + fi + caller="$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" + run_title="Release v3 $tag ($caller)" pre_release=false if [[ "$tag" == *-* ]]; then pre_release=true fi + INPUTS=( + -f tag="$tag" + -f pre_release="$pre_release" + -f draft=false + -f orchestrator_run="$caller" + ) + if [ -n "$RELEASE_NOTES" ]; then + INPUTS+=(-f release_notes="$RELEASE_NOTES") + fi + gh workflow run release-v3.yml \ --repo "${{ github.repository }}" \ --ref master \ - -f tag="$tag" \ - -f pre_release="$pre_release" \ - -f draft=false + "${INPUTS[@]}" echo "dispatched=true" >> "$GITHUB_OUTPUT" - echo "Dispatched Release v3 for $tag" + echo "Dispatched Release v3 to publish $tag with desktop artifacts" + + run_id="" + for _ in $(seq 1 30); do + run_id=$(gh run list \ + --repo "${{ github.repository }}" \ + --workflow release-v3.yml \ + --event workflow_dispatch \ + --limit 20 \ + --json databaseId,displayTitle \ + --jq ".[] | select(.displayTitle == \"$run_title\") | .databaseId" \ + | head -n 1) + if [ -n "$run_id" ]; then + break + fi + sleep 2 + done + if [ -z "$run_id" ]; then + echo "::error::Unable to identify the dispatched Release v3 run for $tag." + exit 1 + fi + + echo "run_id=$run_id" >> "$GITHUB_OUTPUT" + echo "Waiting for Release v3 run $run_id" + gh run watch "$run_id" --repo "${{ github.repository }}" --exit-status + echo "published=true" >> "$GITHUB_OUTPUT" - name: Summary if: always() @@ -226,9 +307,6 @@ jobs: if [ -n "${{ steps.release.outputs.release_url }}" ]; then echo "- **Release URL:** ${{ steps.release.outputs.release_url }}" >> $GITHUB_STEP_SUMMARY fi - if [ "${{ steps.desktop_artifacts.outputs.dispatched }}" == "true" ]; then - echo "- **Desktop artifacts:** Release v3 dispatched" >> $GITHUB_STEP_SUMMARY - fi echo "" >> $GITHUB_STEP_SUMMARY echo "### Changelog" >> $GITHUB_STEP_SUMMARY if [ "${{ steps.changelog_check.outputs.has_unreleased_content }}" == "true" ]; then @@ -239,4 +317,9 @@ jobs: else echo "- Release script did not run (skipped or failed before execution)." >> $GITHUB_STEP_SUMMARY fi + if [ "${{ steps.desktop_artifacts.outputs.published }}" == "true" ]; then + echo "- **Publication:** Release v3 completed with desktop artifacts" >> $GITHUB_STEP_SUMMARY + elif [ "${{ steps.desktop_artifacts.outputs.dispatched }}" == "true" ]; then + echo "- **Publication:** Release v3 run ${{ steps.desktop_artifacts.outputs.run_id }} failed or could not be tracked" >> $GITHUB_STEP_SUMMARY + fi diff --git a/.github/workflows/release-v3.yml b/.github/workflows/release-v3.yml index daaffc8686d..750d459fba3 100644 --- a/.github/workflows/release-v3.yml +++ b/.github/workflows/release-v3.yml @@ -1,4 +1,5 @@ name: Release v3 +run-name: Release v3 ${{ inputs.tag || github.ref_name }} (${{ inputs.orchestrator_run || 'direct' }}) on: push: @@ -20,6 +21,14 @@ on: required: false default: false type: boolean + release_notes: + description: 'Optional curated release notes supplied by the release orchestrator' + required: false + type: string + orchestrator_run: + description: 'Optional caller run identifier used to track this workflow' + required: false + type: string env: GO_VERSION: '1.25' @@ -241,12 +250,17 @@ jobs: TAG: ${{ needs.preflight.outputs.tag }} PRE: ${{ needs.preflight.outputs.pre_release }} DRAFT: ${{ inputs.draft }} + RELEASE_NOTES: ${{ inputs.release_notes }} run: | FLAGS=( --repo "${{ github.repository }}" --title "Wails $TAG" - --generate-notes ) + if [ -n "$RELEASE_NOTES" ]; then + FLAGS+=(--notes "$RELEASE_NOTES") + else + FLAGS+=(--generate-notes) + fi [[ "$PRE" == "true" ]] && FLAGS+=(--prerelease) # A draft release is visible only to maintainers and can be deleted, # which is what makes rehearsing the whole path possible without diff --git a/v3/UNRELEASED_CHANGELOG.md b/v3/UNRELEASED_CHANGELOG.md index 7fd94333899..5fd5c28c44c 100644 --- a/v3/UNRELEASED_CHANGELOG.md +++ b/v3/UNRELEASED_CHANGELOG.md @@ -23,6 +23,7 @@ After processing, the content will be moved to the main changelog and this file ## Fixed +- Prevent nightly releases from becoming immutable before desktop binaries, checksums, and provenance are attached (#5876) - macOS app activation now respects activation policy for regular apps only in [PR](https://github.com/wailsapp/wails/pull/5897) by @julianstorer - Guard uninitialized GTK windows in Linux builds in [PR](https://github.com/wailsapp/wails/pull/5898) by @julianstorer - Set explicit opaque background color for Linux WebKit windows before URL load in [PR](https://github.com/wailsapp/wails/pull/5899) by @julianstorer diff --git a/v3/tasks/release/release.go b/v3/tasks/release/release.go index a4279080f4f..9d23bdd0307 100644 --- a/v3/tasks/release/release.go +++ b/v3/tasks/release/release.go @@ -32,10 +32,11 @@ var ( ) type releaseOptions struct { - version string - dryRun bool - branch string - target string + version string + dryRun bool + deferGitHubRelease bool + branch string + target string } var errNoUnreleasedContent = errors.New("No unreleased changelog content found.") @@ -453,6 +454,7 @@ func parseReleaseArgs(args []string) (releaseOptions, error) { fs.SetOutput(io.Discard) dryRun := fs.Bool("dry-run", false, "simulate the release without pushing changes or creating a GitHub release") + deferGitHubRelease := fs.Bool("defer-github-release", false, "push the release commit and tag, but let the artifact workflow create the GitHub release") branch := fs.String("branch", defaultReleaseBranch, "git branch to push release changes to") target := fs.String("target", defaultReleaseTarget, "target reference for the GitHub release (usually the same as branch)") versionFlag := fs.String("version", "", "explicit release version (overrides automatic increment)") @@ -473,10 +475,11 @@ func parseReleaseArgs(args []string) (releaseOptions, error) { } return releaseOptions{ - version: version, - dryRun: *dryRun, - branch: *branch, - target: *target, + version: version, + dryRun: *dryRun, + deferGitHubRelease: *deferGitHubRelease, + branch: *branch, + target: *target, }, nil } @@ -562,6 +565,7 @@ func runRelease(opts releaseOptions) error { writeGitHubOutput("release_version", newVersion) writeGitHubOutput("release_tag", newVersion) writeGitHubOutput("release_target", opts.target) + writeGitHubMultilineOutput("release_notes", releaseBody) if opts.dryRun { writeGitHubOutput("release_dry_run", "true") @@ -612,6 +616,17 @@ func runRelease(opts releaseOptions) error { return err } + // The nightly workflow delegates publication to release-v3.yml so that + // GitHub receives the binaries, checksums, and provenance before the + // release becomes immutable. Publishing here first would leave the + // artifact workflow unable to attach anything to the release. + if opts.deferGitHubRelease { + writeGitHubOutput("release_deferred", "true") + writeGitHubOutput("release_outcome", "success") + fmt.Println("📦 GitHub release creation deferred to the desktop artifact workflow.") + return nil + } + releaseTitle := fmt.Sprintf(defaultReleaseTitle, newVersion) releaseInfo, err := createGitHubRelease(token, repoSlug, opts.target, newVersion, releaseTitle, releaseBody) if err != nil { @@ -1094,3 +1109,25 @@ func writeGitHubOutput(key, value string) { fmt.Printf("Warning: unable to persist %s to GITHUB_OUTPUT: %v\n", key, err) } } + +func writeGitHubMultilineOutput(key, value string) { + outputPath := strings.TrimSpace(os.Getenv("GITHUB_OUTPUT")) + if outputPath == "" || key == "" || value == "" { + return + } + + delimiter := "WAILS_RELEASE_NOTES_EOF" + for strings.Contains(value, delimiter) { + delimiter += "_X" + } + + f, err := os.OpenFile(outputPath, os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + fmt.Printf("Warning: unable to write %s to GITHUB_OUTPUT: %v\n", key, err) + return + } + defer f.Close() + if _, err := fmt.Fprintf(f, "%s<<%s\n%s\n%s\n", key, delimiter, value, delimiter); err != nil { + fmt.Printf("Warning: unable to persist %s to GITHUB_OUTPUT: %v\n", key, err) + } +} diff --git a/v3/tasks/release/release_test.go b/v3/tasks/release/release_test.go index 5edc6c0c1af..492953b1798 100644 --- a/v3/tasks/release/release_test.go +++ b/v3/tasks/release/release_test.go @@ -3,6 +3,7 @@ package main import ( "fmt" "os" + "os/exec" "path/filepath" "strconv" "strings" @@ -253,6 +254,142 @@ func TestGetUnreleasedChangelogTemplate(t *testing.T) { } } +func TestParseReleaseArgs_DeferGitHubRelease(t *testing.T) { + opts, err := parseReleaseArgs([]string{ + "--defer-github-release", + "--branch", "master", + "--target", "master", + "--version", "v3.0.0-beta.4", + }) + if err != nil { + t.Fatalf("parseReleaseArgs() failed: %v", err) + } + + if !opts.deferGitHubRelease { + t.Fatal("expected GitHub release creation to be deferred") + } + if opts.dryRun { + t.Fatal("defer-github-release must not implicitly enable dry-run") + } + if opts.version != "v3.0.0-beta.4" { + t.Fatalf("version = %q, want v3.0.0-beta.4", opts.version) + } +} + +func TestNightlyDefersPublicationToArtifactWorkflow(t *testing.T) { + workflow, err := os.ReadFile("../../../.github/workflows/nightly-release-v3.yml") + if err != nil { + t.Fatalf("read nightly release workflow: %v", err) + } + contents := strings.ReplaceAll(string(workflow), "\r\n", "\n") + + deferFlag := strings.Index(contents, "ARGS+=(--defer-github-release)") + dispatch := strings.Index(contents, "gh workflow run release-v3.yml") + if deferFlag == -1 { + t.Fatal("nightly release must defer GitHub release creation") + } + if dispatch == -1 { + t.Fatal("nightly release must dispatch the desktop artifact workflow") + } + if deferFlag > dispatch { + t.Fatal("nightly release must defer publication before dispatching the artifact workflow") + } + if !strings.Contains(contents, "release_notes=\"$RELEASE_NOTES\"") { + t.Fatal("nightly release must pass curated notes to the artifact workflow") + } + if !strings.Contains(contents, "gh run watch \"$run_id\"") { + t.Fatal("nightly release must wait for the dispatched publication workflow") + } + if !strings.Contains(contents, "steps.publication_check.outputs.needs_recovery == 'true'") { + t.Fatal("nightly release must recover a tagged commit whose publication failed") + } + if !strings.Contains(contents, "steps.release.outputs.release_tag != ''") { + t.Fatal("nightly release must not dispatch publication when the release task produces no tag") + } + if !strings.Contains(contents, `RECOVERY_TAG: ${{ steps.publication_check.outputs.tag }}`) { + t.Fatal("nightly release must dispatch the reachable recovery tag after bookkeeping commits") + } + newReleaseGate := `(steps.quick_check.outputs.should_continue == 'true' || + github.event.inputs.force_release == 'true') && + steps.publication_check.outputs.needs_recovery != 'true'` + if strings.Count(contents, newReleaseGate) != 2 { + t.Fatal("nightly release must recover an unpublished tag before creating a newer tag") + } + if !strings.Contains(contents, `gh api --include --silent "repos/$GITHUB_REPOSITORY/releases/tags/$tag"`) || + !strings.Contains(contents, `grep -Eq '^HTTP/[0-9.]+ 404 '`) { + t.Fatal("publication recovery must distinguish a missing release from API failures") + } + if !strings.Contains(contents, "github.event.inputs.dry_run != 'true' &&") { + t.Fatal("manual dry runs must gate all artifact publication, including recovery") + } +} + +func TestNightlyPublicationRecoveryFindsReachableTagAfterBookkeeping(t *testing.T) { + workflow, err := os.ReadFile("../../../.github/workflows/nightly-release-v3.yml") + if err != nil { + t.Fatalf("read nightly release workflow: %v", err) + } + contents := strings.ReplaceAll(string(workflow), "\r\n", "\n") + selector := `git tag --merged HEAD --list "v3.0.0-alpha2.*" "v3.0.0-beta.*" "v3.0.0-rc.*" | sort -V | tail -1` + if !strings.Contains(contents, "tag=$("+selector+")") { + t.Fatal("publication recovery must select the latest active v3 tag reachable from HEAD") + } + if strings.Contains(contents, "id: publication_check\n if: steps.check_tag.outputs.has_tag == 'true'") { + t.Fatal("publication recovery must run when bookkeeping commits follow the release tag") + } + + repo := t.TempDir() + runGit := func(args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = repo + if output, runErr := cmd.CombinedOutput(); runErr != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), runErr, output) + } + } + + runGit("init", "--initial-branch=master") + runGit("config", "user.name", "release-test") + runGit("config", "user.email", "release-test@example.invalid") + runGit("commit", "--allow-empty", "-m", "release commit") + runGit("tag", "v3.0.0-beta.3") + runGit("checkout", "-b", "unreachable-release") + runGit("commit", "--allow-empty", "-m", "unreachable release") + runGit("tag", "v3.0.0-rc.9") + runGit("checkout", "master") + runGit("commit", "--allow-empty", "-m", "release bookkeeping [skip ci]") + + cmd := exec.Command("bash", "-c", selector) + cmd.Dir = repo + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("select reachable release tag: %v\n%s", err, output) + } + if got := strings.TrimSpace(string(output)); got != "v3.0.0-beta.3" { + t.Fatalf("reachable release tag = %q, want v3.0.0-beta.3", got) + } +} + +func TestWriteGitHubMultilineOutput(t *testing.T) { + output := filepath.Join(t.TempDir(), "github-output") + if err := os.WriteFile(output, nil, 0o644); err != nil { + t.Fatalf("create output file: %v", err) + } + t.Setenv("GITHUB_OUTPUT", output) + + value := "first line\nWAILS_RELEASE_NOTES_EOF\nlast line" + writeGitHubMultilineOutput("release_notes", value) + + data, err := os.ReadFile(output) + if err != nil { + t.Fatalf("read output file: %v", err) + } + want := "release_notes<