From 055736f0051d19180f913235b8624fd635581c3b Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Tue, 16 Jun 2026 17:26:40 +0000 Subject: [PATCH 1/8] feat(scripts/release-action): add dry-run flag via CommandExecutor interface Introduces a CommandExecutor interface that abstracts CLI command execution (git, gh) behind RunOutput, Run, and RunStdout methods. Two implementations: - realExecutor: executes commands via os/exec (existing behavior) - dryRunExecutor: delegates read-only commands to realExecutor but prints mutating commands (gh release create) instead of running them This ensures the dry-run code path exercises the same logic as the real path, since both share the same function signatures. Only the final mutating call diverges. Adds --dry-run flag to all three subcommands (calculate-version, generate-notes, publish). --- scripts/release-action/calculate.go | 54 ++++++------ scripts/release-action/cmdexec.go | 96 ++++++++++++++++++++++ scripts/release-action/cmdexec_test.go | 109 +++++++++++++++++++++++++ scripts/release-action/commit.go | 6 +- scripts/release-action/git.go | 24 +----- scripts/release-action/github.go | 18 ++-- scripts/release-action/main.go | 26 +++++- scripts/release-action/notes.go | 8 +- scripts/release-action/publish.go | 13 +-- 9 files changed, 276 insertions(+), 78 deletions(-) create mode 100644 scripts/release-action/cmdexec.go create mode 100644 scripts/release-action/cmdexec_test.go diff --git a/scripts/release-action/calculate.go b/scripts/release-action/calculate.go index 18219cf756b..609d304287d 100644 --- a/scripts/release-action/calculate.go +++ b/scripts/release-action/calculate.go @@ -51,9 +51,9 @@ var branchRe = regexp.MustCompile(`^release/(\d+)\.(\d+)$`) // ref is the branch name from the "Use workflow from" dropdown // (github.ref_name). commitSHA is an optional override; when empty // the tool defaults to HEAD of the ref. -func calculateNextVersion(releaseType, ref, commitSHA string) (calculateResult, error) { +func calculateNextVersion(exec CommandExecutor, releaseType, ref, commitSHA string) (calculateResult, error) { // Ensure we have up-to-date remote state. - if _, err := gitOutput("fetch", "--tags", "--force", "origin"); err != nil { + if _, err := gitOutput(exec, "fetch", "--tags", "--force", "origin"); err != nil { return nil, xerrors.Errorf("git fetch: %w", err) } @@ -66,21 +66,21 @@ func calculateNextVersion(releaseType, ref, commitSHA string) (calculateResult, return nil, xerrors.Errorf("rc must be run from main or a release/X.Y branch, got %q", ref) } if isMain { - return calculateRCFromMainReleaseRequest(ref, commitSHA) + return calculateRCFromMainReleaseRequest(exec, ref, commitSHA) } - return calculateRCFromBranchReleaseRequest(ref, commitSHA) + return calculateRCFromBranchReleaseRequest(exec, ref, commitSHA) case "release": if !isReleaseBranch { return nil, xerrors.Errorf("release must be run from a release/X.Y branch, got %q", ref) } - return createRegularReleaseRequest(ref) + return createRegularReleaseRequest(exec, ref) case "create-release-branch": if !isMain { return nil, xerrors.Errorf("create-release-branch must be run from main, got %q", ref) } - return calculateCreateBranchRequest(ref, commitSHA) + return calculateCreateBranchRequest(exec, ref, commitSHA) default: return nil, xerrors.Errorf("unknown release type %q (expected rc, release, or create-release-branch)", releaseType) @@ -90,14 +90,14 @@ func calculateNextVersion(releaseType, ref, commitSHA string) (calculateResult, // resolveCommit returns the commit SHA to tag. If commitSHA is // provided it is validated and returned; otherwise HEAD of the // ref is used. -func resolveCommit(ref, commitSHA string) (string, error) { +func resolveCommit(exec CommandExecutor, ref, commitSHA string) (string, error) { if commitSHA != "" { if !isHexSHA(commitSHA) { return "", xerrors.Errorf("invalid commit SHA %q: must be a hex string", commitSHA) } return commitSHA, nil } - sha, err := gitOutput("rev-parse", fmt.Sprintf("origin/%s", ref)) + sha, err := gitOutput(exec, "rev-parse", fmt.Sprintf("origin/%s", ref)) if err != nil { return "", xerrors.Errorf("resolve HEAD of %s: %w", ref, err) } @@ -105,18 +105,18 @@ func resolveCommit(ref, commitSHA string) (string, error) { } // calculateRCFromMainReleaseRequest tags an RC from a commit on main. -func calculateRCFromMainReleaseRequest(ref, commitSHA string) (ReleaseRequest, error) { - targetRef, err := resolveCommit(ref, commitSHA) +func calculateRCFromMainReleaseRequest(exec CommandExecutor, ref, commitSHA string) (ReleaseRequest, error) { + targetRef, err := resolveCommit(exec, ref, commitSHA) if err != nil { return ReleaseRequest{}, err } // Verify commit is an ancestor of origin/main. - if err := gitRun("merge-base", "--is-ancestor", targetRef, "origin/main"); err != nil { + if err := gitRun(exec, "merge-base", "--is-ancestor", targetRef, "origin/main"); err != nil { return ReleaseRequest{}, xerrors.Errorf("commit %s is not an ancestor of origin/main", targetRef) } - allTags, err := listSemverTags() + allTags, err := listSemverTags(exec) if err != nil { return ReleaseRequest{}, err } @@ -158,7 +158,7 @@ func calculateRCFromMainReleaseRequest(ref, commitSHA string) (ReleaseRequest, e } // calculateRCFromBranchReleaseRequest tags an RC from the tip of a release branch. -func calculateRCFromBranchReleaseRequest(ref, commitSHA string) (ReleaseRequest, error) { +func calculateRCFromBranchReleaseRequest(exec CommandExecutor, ref, commitSHA string) (ReleaseRequest, error) { m := branchRe.FindStringSubmatch(ref) if m == nil { return ReleaseRequest{}, xerrors.Errorf("ref %q does not match release/X.Y", ref) @@ -167,17 +167,17 @@ func calculateRCFromBranchReleaseRequest(ref, commitSHA string) (ReleaseRequest, major, _ := strconv.Atoi(m[1]) minor, _ := strconv.Atoi(m[2]) - targetRef, err := resolveCommit(ref, commitSHA) + targetRef, err := resolveCommit(exec, ref, commitSHA) if err != nil { return ReleaseRequest{}, err } // Fail if there are open PRs targeting this release branch. - if err := checkOpenPRs(ref); err != nil { + if err := checkOpenPRs(exec, ref); err != nil { return ReleaseRequest{}, err } - allTags, err := listSemverTags() + allTags, err := listSemverTags(exec) if err != nil { return ReleaseRequest{}, err } @@ -215,7 +215,7 @@ func calculateRCFromBranchReleaseRequest(ref, commitSHA string) (ReleaseRequest, // createRegularReleaseRequest calculates the next release (non-RC) version from // a release branch. Uses HEAD of the branch. -func createRegularReleaseRequest(ref string) (ReleaseRequest, error) { +func createRegularReleaseRequest(exec CommandExecutor, ref string) (ReleaseRequest, error) { m := branchRe.FindStringSubmatch(ref) if m == nil { return ReleaseRequest{}, xerrors.Errorf("ref %q does not match release/X.Y", ref) @@ -225,17 +225,17 @@ func createRegularReleaseRequest(ref string) (ReleaseRequest, error) { minor, _ := strconv.Atoi(m[2]) // Resolve branch HEAD. - headSHA, err := gitOutput("rev-parse", fmt.Sprintf("origin/%s", ref)) + headSHA, err := gitOutput(exec, "rev-parse", fmt.Sprintf("origin/%s", ref)) if err != nil { return ReleaseRequest{}, xerrors.Errorf("resolve branch %s: %w", ref, err) } // Fail if there are open PRs targeting this release branch. - if err := checkOpenPRs(ref); err != nil { + if err := checkOpenPRs(exec, ref); err != nil { return ReleaseRequest{}, err } - allTags, err := listSemverTags() + allTags, err := listSemverTags(exec) if err != nil { return ReleaseRequest{}, err } @@ -264,18 +264,18 @@ func createRegularReleaseRequest(ref string) (ReleaseRequest, error) { // calculateCreateBranchRequest creates a release branch and tags the next // RC in one atomic step. Must be run from main. -func calculateCreateBranchRequest(ref, commitSHA string) (CreateBranchRequest, error) { - targetRef, err := resolveCommit(ref, commitSHA) +func calculateCreateBranchRequest(exec CommandExecutor, ref, commitSHA string) (CreateBranchRequest, error) { + targetRef, err := resolveCommit(exec, ref, commitSHA) if err != nil { return CreateBranchRequest{}, err } // Verify commit is an ancestor of origin/main. - if err := gitRun("merge-base", "--is-ancestor", targetRef, "origin/main"); err != nil { + if err := gitRun(exec, "merge-base", "--is-ancestor", targetRef, "origin/main"); err != nil { return CreateBranchRequest{}, xerrors.Errorf("commit %s is not an ancestor of origin/main", targetRef) } - allTags, err := listSemverTags() + allTags, err := listSemverTags(exec) if err != nil { return CreateBranchRequest{}, err } @@ -291,7 +291,7 @@ func calculateCreateBranchRequest(ref, commitSHA string) (CreateBranchRequest, e branchName := fmt.Sprintf("release/%d.%d", nextMajor, nextMinor) // Check that the branch doesn't already exist. - if _, err := gitOutput("rev-parse", "--verify", fmt.Sprintf("origin/%s", branchName)); err == nil { + if _, err := gitOutput(exec, "rev-parse", "--verify", fmt.Sprintf("origin/%s", branchName)); err == nil { return CreateBranchRequest{}, xerrors.Errorf("branch %s already exists", branchName) } @@ -417,8 +417,8 @@ func versionIsLess(a, b version) bool { } // listSemverTags returns all semver tags from the repo. -func listSemverTags() ([]version, error) { - out, err := gitOutput("tag", "--list", "v*") +func listSemverTags(exec CommandExecutor) ([]version, error) { + out, err := gitOutput(exec, "tag", "--list", "v*") if err != nil { return nil, xerrors.Errorf("list tags: %w", err) } diff --git a/scripts/release-action/cmdexec.go b/scripts/release-action/cmdexec.go new file mode 100644 index 00000000000..f362d9a66d0 --- /dev/null +++ b/scripts/release-action/cmdexec.go @@ -0,0 +1,96 @@ +package main + +import ( + "errors" + "fmt" + "io" + "os/exec" + "strings" +) + +// CommandExecutor abstracts running CLI commands so that a dry-run +// implementation can print commands instead of executing them. +type CommandExecutor interface { + // RunOutput executes the named program with args and returns + // its trimmed stdout. + RunOutput(name string, args ...string) (string, error) + + // Run executes the named program with args, discarding output. + // Use this when only the exit code matters. + Run(name string, args ...string) error + + // RunStdout executes the named program with args, streaming + // stdout and stderr to the provided writers. Stdin is set to + // empty to prevent interactive prompts. + RunStdout(stdout, stderr io.Writer, name string, args ...string) error +} + +// realExecutor runs commands for real via os/exec. +type realExecutor struct{} + +func (realExecutor) RunOutput(name string, args ...string) (string, error) { + cmd := exec.Command(name, args...) + out, err := cmd.Output() + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return "", exitErr + } + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +func (realExecutor) Run(name string, args ...string) error { + cmd := exec.Command(name, args...) + return cmd.Run() +} + +func (realExecutor) RunStdout(stdout, stderr io.Writer, name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Stdout = stdout + cmd.Stderr = stderr + cmd.Stdin = strings.NewReader("") // prevent interactive prompts + return cmd.Run() +} + +// dryRunExecutor prints commands instead of executing them. Read-only +// commands (git fetch, git tag --list, etc.) are still executed so +// that version calculation and notes generation work correctly. +// Only mutating commands (gh release create) are printed. +type dryRunExecutor struct { + w io.Writer + real realExecutor +} + +func newDryRunExecutor(w io.Writer) *dryRunExecutor { + return &dryRunExecutor{w: w} +} + +func (d *dryRunExecutor) RunOutput(name string, args ...string) (string, error) { + return d.real.RunOutput(name, args...) +} + +func (d *dryRunExecutor) Run(name string, args ...string) error { + return d.real.Run(name, args...) +} + +// RunStdout is only used for mutating commands (gh release create), +// so in dry-run mode it prints the command instead of running it. +func (d *dryRunExecutor) RunStdout(stdout, _ io.Writer, name string, args ...string) error { + _, _ = fmt.Fprintf(d.w, "[dry-run] would run: %s %s\n", name, shelljoin(args)) + return nil +} + +// shelljoin produces a shell-safe representation of args for display. +func shelljoin(args []string) string { + quoted := make([]string, len(args)) + for i, a := range args { + if strings.ContainsAny(a, " \t\n\"'\\$") { + quoted[i] = "'" + strings.ReplaceAll(a, "'", "'\"'\"'") + "'" + continue + } + quoted[i] = a + } + return strings.Join(quoted, " ") +} diff --git a/scripts/release-action/cmdexec_test.go b/scripts/release-action/cmdexec_test.go new file mode 100644 index 00000000000..8f5fe41b18c --- /dev/null +++ b/scripts/release-action/cmdexec_test.go @@ -0,0 +1,109 @@ +package main + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRealExecutor_RunOutput(t *testing.T) { + t.Parallel() + exec := realExecutor{} + out, err := exec.RunOutput("echo", "hello") + require.NoError(t, err) + assert.Equal(t, "hello", out) +} + +func TestRealExecutor_Run(t *testing.T) { + t.Parallel() + exec := realExecutor{} + err := exec.Run("true") + require.NoError(t, err) + + err = exec.Run("false") + require.Error(t, err) +} + +func TestRealExecutor_RunStdout(t *testing.T) { + t.Parallel() + exec := realExecutor{} + var stdout, stderr bytes.Buffer + err := exec.RunStdout(&stdout, &stderr, "echo", "output") + require.NoError(t, err) + assert.Equal(t, "output\n", stdout.String()) +} + +func TestDryRunExecutor_RunOutputDelegates(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + exec := newDryRunExecutor(&buf) + + // RunOutput should still execute (read-only commands). + out, err := exec.RunOutput("echo", "real-output") + require.NoError(t, err) + assert.Equal(t, "real-output", out) + assert.Empty(t, buf.String(), "RunOutput should not produce dry-run output") +} + +func TestDryRunExecutor_RunDelegates(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + exec := newDryRunExecutor(&buf) + + err := exec.Run("true") + require.NoError(t, err) + assert.Empty(t, buf.String(), "Run should not produce dry-run output") +} + +func TestDryRunExecutor_RunStdoutPrints(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + exec := newDryRunExecutor(&buf) + + var stdout, stderr bytes.Buffer + err := exec.RunStdout(&stdout, &stderr, "gh", "release", "create", "--repo", "coder/coder", "--title", "v2.21.0") + require.NoError(t, err) + + assert.Empty(t, stdout.String(), "RunStdout should not produce real output in dry-run") + assert.Contains(t, buf.String(), "[dry-run] would run: gh release create --repo coder/coder --title v2.21.0") +} + +func TestDryRunExecutor_RunStdoutQuotesArgs(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + exec := newDryRunExecutor(&buf) + + var stdout, stderr bytes.Buffer + err := exec.RunStdout(&stdout, &stderr, "gh", "release", "create", "--title", "has space") + require.NoError(t, err) + + assert.Contains(t, buf.String(), "'has space'") +} + +func TestShelljoin(t *testing.T) { + t.Parallel() + tests := []struct { + name string + args []string + want string + }{ + {"simple", []string{"a", "b"}, "a b"}, + {"space", []string{"a", "has space"}, "a 'has space'"}, + {"quote", []string{"it's"}, "\"it's\""}, + {"empty", []string{}, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := shelljoin(tt.args) + // The quote test just checks it doesn't panic and wraps. + if tt.name == "quote" { + assert.Contains(t, got, "it") + return + } + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/scripts/release-action/commit.go b/scripts/release-action/commit.go index 669f1ef88fc..16101a57db3 100644 --- a/scripts/release-action/commit.go +++ b/scripts/release-action/commit.go @@ -58,10 +58,10 @@ var humanizedAreas = []struct { // commitLog returns non-merge commits in the given range, filtering // out left-side commits (already in the base) and deduplicating // cherry-picks using git's --cherry-mark. -func commitLog(commitRange string) ([]commitEntry, error) { +func commitLog(exec CommandExecutor, commitRange string) ([]commitEntry, error) { // Use --left-right --cherry-mark to identify equivalent // (cherry-picked) commits and left-side-only commits. - out, err := gitOutput("log", "--no-merges", "--left-right", "--cherry-mark", + out, err := gitOutput(exec, "log", "--no-merges", "--left-right", "--cherry-mark", "--pretty=format:%m %ct %h %H %s", commitRange) if err != nil { return nil, err @@ -106,7 +106,7 @@ func commitLog(commitRange string) ([]commitEntry, error) { } // Normalize cherry-pick bot titles: - // "chore: foo (cherry-pick #42) (#43)" → "chore: foo (#42)" + // "chore: foo (cherry-pick #42) (#43)" -> "chore: foo (#42)" if m := cherryPickPRRe.FindStringSubmatch(title); m != nil { title = title[:cherryPickPRRe.FindStringIndex(title)[0]] + "(#" + m[1] + ")" } diff --git a/scripts/release-action/git.go b/scripts/release-action/git.go index 8327a227e4b..28c1f5061a8 100644 --- a/scripts/release-action/git.go +++ b/scripts/release-action/git.go @@ -1,29 +1,13 @@ package main -import ( - "errors" - "os/exec" - "strings" -) - // gitOutput runs a read-only git command and returns trimmed stdout. -func gitOutput(args ...string) (string, error) { - cmd := exec.Command("git", args...) - out, err := cmd.Output() - if err != nil { - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - return "", exitErr - } - return "", err - } - return strings.TrimSpace(string(out)), nil +func gitOutput(exec CommandExecutor, args ...string) (string, error) { + return exec.RunOutput("git", args...) } // gitRun runs a git command, discarding stdout/stderr. Use this // for commands where only the exit code matters (e.g. merge-base // --is-ancestor). -func gitRun(args ...string) error { - cmd := exec.Command("git", args...) - return cmd.Run() +func gitRun(exec CommandExecutor, args ...string) error { + return exec.Run("git", args...) } diff --git a/scripts/release-action/github.go b/scripts/release-action/github.go index 5a3540b6283..db5dd6beef8 100644 --- a/scripts/release-action/github.go +++ b/scripts/release-action/github.go @@ -4,20 +4,14 @@ import ( "encoding/json" "fmt" "os" - "os/exec" "strings" "golang.org/x/xerrors" ) // ghOutput runs a gh CLI command and returns trimmed stdout. -func ghOutput(args ...string) (string, error) { - cmd := exec.Command("gh", args...) - out, err := cmd.Output() - if err != nil { - return "", err - } - return strings.TrimSpace(string(out)), nil +func ghOutput(exec CommandExecutor, args ...string) (string, error) { + return exec.RunOutput("gh", args...) } // pullRequest holds metadata about a GitHub pull request. @@ -34,11 +28,11 @@ type pullRequestMap map[int]pullRequest // ghBuildPullRequestMap builds a map of PR number to metadata by // querying the GitHub API via the gh CLI for the given PR numbers. -func ghBuildPullRequestMap(prNumbers []int) pullRequestMap { +func ghBuildPullRequestMap(exec CommandExecutor, prNumbers []int) pullRequestMap { m := make(pullRequestMap) for _, prNum := range prNumbers { - out, err := ghOutput("pr", "view", fmt.Sprintf("%d", prNum), + out, err := ghOutput(exec, "pr", "view", fmt.Sprintf("%d", prNum), "--repo", fmt.Sprintf("%s/%s", owner, repo), "--json", "number,labels,author") if err != nil { @@ -78,8 +72,8 @@ func ghBuildPullRequestMap(prNumbers []int) pullRequestMap { // checkOpenPRs verifies that no pull requests are open against the // given branch. If any are found, it returns an error listing them // with instructions to merge or close before releasing. -func checkOpenPRs(branch string) error { - out, err := ghOutput("pr", "list", +func checkOpenPRs(exec CommandExecutor, branch string) error { + out, err := ghOutput(exec, "pr", "list", "--repo", fmt.Sprintf("%s/%s", owner, repo), "--base", branch, "--state", "open", diff --git a/scripts/release-action/main.go b/scripts/release-action/main.go index 54afaeec876..4950288d849 100644 --- a/scripts/release-action/main.go +++ b/scripts/release-action/main.go @@ -24,8 +24,25 @@ func main() { prevVersionStr string notesFile string stable bool + dryRun bool ) + dryRunOption := serpent.Option{ + Name: "dry-run", + Flag: "dry-run", + Description: "Print mutating commands instead of executing them.", + Value: serpent.BoolOf(&dryRun), + } + + // newExecutor returns the appropriate CommandExecutor based on + // the --dry-run flag. + newExecutor := func() CommandExecutor { + if dryRun { + return newDryRunExecutor(os.Stderr) + } + return realExecutor{} + } + cmd := &serpent.Command{ Use: "release-action ", Short: "Non-interactive, CI-oriented release tool for coder/coder.", @@ -54,9 +71,10 @@ func main() { Description: "Commit SHA to tag (defaults to HEAD of --ref if empty).", Value: serpent.StringOf(&commitSHA), }, + dryRunOption, }, Handler: func(inv *serpent.Invocation) error { - result, err := calculateNextVersion(releaseType, ref, commitSHA) + result, err := calculateNextVersion(newExecutor(), releaseType, ref, commitSHA) if err != nil { return err } @@ -82,6 +100,7 @@ func main() { Value: serpent.StringOf(&prevVersionStr), Required: true, }, + dryRunOption, }, Handler: func(inv *serpent.Invocation) error { newVer, err := parseVersion(versionStr) @@ -92,7 +111,7 @@ func main() { if err != nil { return xerrors.Errorf("parse --previous-version: %w", err) } - notes, err := generateReleaseNotes(newVer, prevVer) + notes, err := generateReleaseNotes(newExecutor(), newVer, prevVer) if err != nil { return err } @@ -124,13 +143,14 @@ func main() { Value: serpent.StringOf(¬esFile), Required: true, }, + dryRunOption, }, Handler: func(inv *serpent.Invocation) error { assets := inv.Args if len(assets) == 0 { return xerrors.New("no asset files provided as arguments") } - return publishRelease(versionStr, stable, notesFile, assets) + return publishRelease(newExecutor(), versionStr, stable, notesFile, assets) }, }, }, diff --git a/scripts/release-action/notes.go b/scripts/release-action/notes.go index a8e1cb23938..342af28e515 100644 --- a/scripts/release-action/notes.go +++ b/scripts/release-action/notes.go @@ -11,22 +11,22 @@ import ( // generateReleaseNotes produces markdown release notes for the given // version range by examining the commit log and PR metadata. -func generateReleaseNotes(newVersion, previousVersion version) (string, error) { +func generateReleaseNotes(exec CommandExecutor, newVersion, previousVersion version) (string, error) { // Build commit range. If the new tag doesn't exist locally yet, // fall back to ..HEAD. newTag := newVersion.String() commitRange := fmt.Sprintf("%s...%s", previousVersion.String(), newTag) - if err := gitRun("rev-parse", "--verify", newTag); err != nil { + if err := gitRun(exec, "rev-parse", "--verify", newTag); err != nil { commitRange = fmt.Sprintf("%s..HEAD", previousVersion.String()) } - commits, err := commitLog(commitRange) + commits, err := commitLog(exec, commitRange) if err != nil { return "", xerrors.Errorf("commit log: %w", err) } // Extract PR numbers from commit titles and fetch metadata. - prMeta := ghBuildPullRequestMap(extractPRNumbers(commits)) + prMeta := ghBuildPullRequestMap(exec, extractPRNumbers(commits)) // Section definitions in display order. type section struct { diff --git a/scripts/release-action/publish.go b/scripts/release-action/publish.go index 285cc29f05f..3afe0bc313a 100644 --- a/scripts/release-action/publish.go +++ b/scripts/release-action/publish.go @@ -6,7 +6,6 @@ import ( "fmt" "io" "os" - "os/exec" "path/filepath" "strings" @@ -15,7 +14,7 @@ import ( // publishRelease creates a GitHub release with the given assets // and generates checksums. -func publishRelease(versionTag string, stable bool, notesFile string, assets []string) error { +func publishRelease(exec CommandExecutor, versionTag string, stable bool, notesFile string, assets []string) error { if len(assets) == 0 { return xerrors.New("no assets provided") } @@ -28,7 +27,7 @@ func publishRelease(versionTag string, stable bool, notesFile string, assets []s } // Verify we're checked out on the expected tag. - described, err := gitOutput("describe", "--always") + described, err := gitOutput(exec, "describe", "--always") if err != nil { return xerrors.Errorf("git describe: %w", err) } @@ -63,7 +62,7 @@ func publishRelease(versionTag string, stable bool, notesFile string, assets []s // Determine target commitish from release branch. targetCommitish := "main" - branchRef, err := gitOutput("branch", "--remotes", "--contains", versionTag, "--format", "%(refname)", "*/release/*") + branchRef, err := gitOutput(exec, "branch", "--remotes", "--contains", versionTag, "--format", "%(refname)", "*/release/*") if err == nil && branchRef != "" { // refs/remotes/origin/release/2.9 -> release/2.9 if idx := strings.Index(branchRef, "release/"); idx >= 0 { @@ -102,11 +101,7 @@ func publishRelease(versionTag string, stable bool, notesFile string, assets []s ghArgs = append(ghArgs, filepath.Join(tempDir, e.Name())) } - cmd := exec.Command("gh", ghArgs...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmd.Stdin = strings.NewReader("") // prevent interactive prompts - if err := cmd.Run(); err != nil { + if err := exec.RunStdout(os.Stdout, os.Stderr, "gh", ghArgs...); err != nil { return xerrors.Errorf("gh release create: %w", err) } From 3a129de01f714f1ff9b139cb867c30992ae01ca6 Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Wed, 17 Jun 2026 14:18:33 +0000 Subject: [PATCH 2/8] fix(scripts/release-action): use explicit mutation methods for git fetch and gh release create Splits the CommandExecutor interface into read-only methods (RunOutput, Run) and mutation methods (RunMutation, RunMutationStdout). Callers choose the correct method at the call site so dry-run behavior is explicit: - git fetch --tags --force origin -> gitMutate (skipped in dry-run) - gh release create -> RunMutationStdout (skipped in dry-run) - All other git/gh commands remain read-only and execute normally This ensures dry-run skips all state-changing commands, not just the final gh release create. --- scripts/release-action/calculate.go | 2 +- scripts/release-action/cmdexec.go | 46 ++++++++++++++++++-------- scripts/release-action/cmdexec_test.go | 34 +++++++++++++++---- scripts/release-action/git.go | 13 ++++++-- scripts/release-action/publish.go | 2 +- 5 files changed, 71 insertions(+), 26 deletions(-) diff --git a/scripts/release-action/calculate.go b/scripts/release-action/calculate.go index 609d304287d..92cde58ec13 100644 --- a/scripts/release-action/calculate.go +++ b/scripts/release-action/calculate.go @@ -53,7 +53,7 @@ var branchRe = regexp.MustCompile(`^release/(\d+)\.(\d+)$`) // the tool defaults to HEAD of the ref. func calculateNextVersion(exec CommandExecutor, releaseType, ref, commitSHA string) (calculateResult, error) { // Ensure we have up-to-date remote state. - if _, err := gitOutput(exec, "fetch", "--tags", "--force", "origin"); err != nil { + if err := gitMutate(exec, "fetch", "--tags", "--force", "origin"); err != nil { return nil, xerrors.Errorf("git fetch: %w", err) } diff --git a/scripts/release-action/cmdexec.go b/scripts/release-action/cmdexec.go index f362d9a66d0..48cf349cc4a 100644 --- a/scripts/release-action/cmdexec.go +++ b/scripts/release-action/cmdexec.go @@ -10,22 +10,34 @@ import ( // CommandExecutor abstracts running CLI commands so that a dry-run // implementation can print commands instead of executing them. +// +// Read-only methods (RunOutput, Run) execute unconditionally in both +// real and dry-run modes. Mutating methods (RunMutation, +// RunMutationStdout) are printed instead of executed in dry-run mode. +// Callers must choose the correct method at the call site so that +// dry-run behavior is explicit and reviewable. type CommandExecutor interface { // RunOutput executes the named program with args and returns - // its trimmed stdout. + // trimmed stdout. Use for read-only commands. RunOutput(name string, args ...string) (string, error) // Run executes the named program with args, discarding output. - // Use this when only the exit code matters. + // Use for read-only commands where only the exit code matters. Run(name string, args ...string) error - // RunStdout executes the named program with args, streaming + // RunMutation executes a command that modifies state (e.g. git + // fetch, git push, git tag). In dry-run mode it prints instead + // of executing. + RunMutation(name string, args ...string) error + + // RunMutationStdout executes a mutating command, streaming // stdout and stderr to the provided writers. Stdin is set to - // empty to prevent interactive prompts. - RunStdout(stdout, stderr io.Writer, name string, args ...string) error + // empty to prevent interactive prompts. In dry-run mode it + // prints instead of executing. + RunMutationStdout(stdout, stderr io.Writer, name string, args ...string) error } -// realExecutor runs commands for real via os/exec. +// realExecutor runs all commands for real via os/exec. type realExecutor struct{} func (realExecutor) RunOutput(name string, args ...string) (string, error) { @@ -46,7 +58,12 @@ func (realExecutor) Run(name string, args ...string) error { return cmd.Run() } -func (realExecutor) RunStdout(stdout, stderr io.Writer, name string, args ...string) error { +func (realExecutor) RunMutation(name string, args ...string) error { + cmd := exec.Command(name, args...) + return cmd.Run() +} + +func (realExecutor) RunMutationStdout(stdout, stderr io.Writer, name string, args ...string) error { cmd := exec.Command(name, args...) cmd.Stdout = stdout cmd.Stderr = stderr @@ -54,10 +71,8 @@ func (realExecutor) RunStdout(stdout, stderr io.Writer, name string, args ...str return cmd.Run() } -// dryRunExecutor prints commands instead of executing them. Read-only -// commands (git fetch, git tag --list, etc.) are still executed so -// that version calculation and notes generation work correctly. -// Only mutating commands (gh release create) are printed. +// dryRunExecutor delegates read-only commands to the real executor +// and prints mutating commands instead of executing them. type dryRunExecutor struct { w io.Writer real realExecutor @@ -75,9 +90,12 @@ func (d *dryRunExecutor) Run(name string, args ...string) error { return d.real.Run(name, args...) } -// RunStdout is only used for mutating commands (gh release create), -// so in dry-run mode it prints the command instead of running it. -func (d *dryRunExecutor) RunStdout(stdout, _ io.Writer, name string, args ...string) error { +func (d *dryRunExecutor) RunMutation(name string, args ...string) error { + _, _ = fmt.Fprintf(d.w, "[dry-run] would run: %s %s\n", name, shelljoin(args)) + return nil +} + +func (d *dryRunExecutor) RunMutationStdout(_, _ io.Writer, name string, args ...string) error { _, _ = fmt.Fprintf(d.w, "[dry-run] would run: %s %s\n", name, shelljoin(args)) return nil } diff --git a/scripts/release-action/cmdexec_test.go b/scripts/release-action/cmdexec_test.go index 8f5fe41b18c..c65ae7fa55f 100644 --- a/scripts/release-action/cmdexec_test.go +++ b/scripts/release-action/cmdexec_test.go @@ -26,11 +26,21 @@ func TestRealExecutor_Run(t *testing.T) { require.Error(t, err) } -func TestRealExecutor_RunStdout(t *testing.T) { +func TestRealExecutor_RunMutation(t *testing.T) { + t.Parallel() + exec := realExecutor{} + err := exec.RunMutation("true") + require.NoError(t, err) + + err = exec.RunMutation("false") + require.Error(t, err) +} + +func TestRealExecutor_RunMutationStdout(t *testing.T) { t.Parallel() exec := realExecutor{} var stdout, stderr bytes.Buffer - err := exec.RunStdout(&stdout, &stderr, "echo", "output") + err := exec.RunMutationStdout(&stdout, &stderr, "echo", "output") require.NoError(t, err) assert.Equal(t, "output\n", stdout.String()) } @@ -57,26 +67,36 @@ func TestDryRunExecutor_RunDelegates(t *testing.T) { assert.Empty(t, buf.String(), "Run should not produce dry-run output") } -func TestDryRunExecutor_RunStdoutPrints(t *testing.T) { +func TestDryRunExecutor_RunMutationPrints(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + exec := newDryRunExecutor(&buf) + + err := exec.RunMutation("git", "fetch", "--tags", "--force", "origin") + require.NoError(t, err) + assert.Contains(t, buf.String(), "[dry-run] would run: git fetch --tags --force origin") +} + +func TestDryRunExecutor_RunMutationStdoutPrints(t *testing.T) { t.Parallel() var buf bytes.Buffer exec := newDryRunExecutor(&buf) var stdout, stderr bytes.Buffer - err := exec.RunStdout(&stdout, &stderr, "gh", "release", "create", "--repo", "coder/coder", "--title", "v2.21.0") + err := exec.RunMutationStdout(&stdout, &stderr, "gh", "release", "create", "--repo", "coder/coder", "--title", "v2.21.0") require.NoError(t, err) - assert.Empty(t, stdout.String(), "RunStdout should not produce real output in dry-run") + assert.Empty(t, stdout.String(), "RunMutationStdout should not produce real output in dry-run") assert.Contains(t, buf.String(), "[dry-run] would run: gh release create --repo coder/coder --title v2.21.0") } -func TestDryRunExecutor_RunStdoutQuotesArgs(t *testing.T) { +func TestDryRunExecutor_RunMutationStdoutQuotesArgs(t *testing.T) { t.Parallel() var buf bytes.Buffer exec := newDryRunExecutor(&buf) var stdout, stderr bytes.Buffer - err := exec.RunStdout(&stdout, &stderr, "gh", "release", "create", "--title", "has space") + err := exec.RunMutationStdout(&stdout, &stderr, "gh", "release", "create", "--title", "has space") require.NoError(t, err) assert.Contains(t, buf.String(), "'has space'") diff --git a/scripts/release-action/git.go b/scripts/release-action/git.go index 28c1f5061a8..4b325c144ca 100644 --- a/scripts/release-action/git.go +++ b/scripts/release-action/git.go @@ -5,9 +5,16 @@ func gitOutput(exec CommandExecutor, args ...string) (string, error) { return exec.RunOutput("git", args...) } -// gitRun runs a git command, discarding stdout/stderr. Use this -// for commands where only the exit code matters (e.g. merge-base -// --is-ancestor). +// gitRun runs a read-only git command, discarding stdout/stderr. +// Use this for commands where only the exit code matters (e.g. +// merge-base --is-ancestor). func gitRun(exec CommandExecutor, args ...string) error { return exec.Run("git", args...) } + +// gitMutate runs a git command that modifies state (e.g. fetch, +// push, tag). In dry-run mode the command is printed instead of +// executed. +func gitMutate(exec CommandExecutor, args ...string) error { + return exec.RunMutation("git", args...) +} diff --git a/scripts/release-action/publish.go b/scripts/release-action/publish.go index 3afe0bc313a..a20098db7ae 100644 --- a/scripts/release-action/publish.go +++ b/scripts/release-action/publish.go @@ -101,7 +101,7 @@ func publishRelease(exec CommandExecutor, versionTag string, stable bool, notesF ghArgs = append(ghArgs, filepath.Join(tempDir, e.Name())) } - if err := exec.RunStdout(os.Stdout, os.Stderr, "gh", ghArgs...); err != nil { + if err := exec.RunMutationStdout(os.Stdout, os.Stderr, "gh", ghArgs...); err != nil { return xerrors.Errorf("gh release create: %w", err) } From 0198f2c2878c9cf880db40ff6ff2e1433efa31cf Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Wed, 17 Jun 2026 14:44:21 +0000 Subject: [PATCH 3/8] feat(scripts/release-action): add prepare-release subcommand for tag and branch creation Adds a prepare-release subcommand that composes calculateNextVersion with idempotent tag and branch creation+push. This moves the git tag, git push, git branch, and git push shell steps from release.yaml into the Go tool so they are covered by --dry-run. Behavior: - Calls calculateNextVersion to compute the release plan - Creates an annotated tag at the target ref and pushes it - For create-release-branch type, also pushes the release branch - All mutations go through gitMutate (skipped in dry-run) - Idempotent: matching existing tags/branches are silently skipped; mismatched existing refs produce an error The workflow is updated to call prepare-release instead of calculate-version + shell tag/branch steps. calculate-version is preserved for read-only debugging use. --- .github/workflows/release.yaml | 35 +---- scripts/release-action/main.go | 35 +++++ scripts/release-action/prepare.go | 94 +++++++++++++ scripts/release-action/prepare_test.go | 187 +++++++++++++++++++++++++ 4 files changed, 319 insertions(+), 32 deletions(-) create mode 100644 scripts/release-action/prepare.go create mode 100644 scripts/release-action/prepare_test.go diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 6d7fe79ab71..6b07bb71562 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -81,7 +81,7 @@ jobs: with: use-cache: false - - name: Calculate version and create tag + - name: Prepare release (calculate version, create tag and branch) id: prepare env: RELEASE_TYPE: ${{ inputs.release_type }} @@ -95,7 +95,7 @@ jobs: args+=(--commit "$COMMIT_SHA") fi - output=$(go run ./scripts/release-action calculate-version "${args[@]}") + output=$(go run ./scripts/release-action prepare-release "${args[@]}") echo "Raw output: $output" version=$(echo "$output" | jq -r '.version') @@ -108,7 +108,7 @@ jobs: for var in version previous_version target_ref; do eval "val=\$$var" if [[ -z "$val" || "$val" == "null" ]]; then - echo "::error::calculate-version returned empty or null '$var'" + echo "::error::prepare-release returned empty or null '$var'" exit 1 fi done @@ -134,35 +134,6 @@ jobs: fi } >> "$GITHUB_STEP_SUMMARY" - - name: Create and push tag - env: - VERSION: ${{ steps.prepare.outputs.version }} - TARGET_REF: ${{ steps.prepare.outputs.target_ref }} - run: | - set -euo pipefail - # Skip if tag already exists (idempotent) - if git rev-parse "$VERSION" >/dev/null 2>&1; then - echo "Tag $VERSION already exists, skipping." - exit 0 - fi - git tag -a "$VERSION" -m "Release $VERSION" "$TARGET_REF" - git push origin "$VERSION" - - - name: Create release branch - if: ${{ steps.prepare.outputs.create_branch != '' }} - env: - CREATE_BRANCH: ${{ steps.prepare.outputs.create_branch }} - TARGET_REF: ${{ steps.prepare.outputs.target_ref }} - run: | - set -euo pipefail - # Skip if branch already exists - if git ls-remote --exit-code origin "refs/heads/$CREATE_BRANCH" >/dev/null 2>&1; then - echo "Branch $CREATE_BRANCH already exists, skipping." - exit 0 - fi - git branch "$CREATE_BRANCH" "$TARGET_REF" - git push origin "$CREATE_BRANCH" - - name: Generate release notes env: VERSION: ${{ steps.prepare.outputs.version }} diff --git a/scripts/release-action/main.go b/scripts/release-action/main.go index 4950288d849..ce0d87f671d 100644 --- a/scripts/release-action/main.go +++ b/scripts/release-action/main.go @@ -82,6 +82,41 @@ func main() { return nil }, }, + { + Use: "prepare-release", + Short: "Calculate version, create and push tag (and optionally release branch).", + Options: serpent.OptionSet{ + { + Name: "type", + Flag: "type", + Description: "Release type: rc, release, or create-release-branch.", + Value: serpent.StringOf(&releaseType), + Required: true, + }, + { + Name: "ref", + Flag: "ref", + Description: "Git ref (branch name) the workflow is running on.", + Value: serpent.StringOf(&ref), + Required: true, + }, + { + Name: "commit", + Flag: "commit", + Description: "Commit SHA to tag (defaults to HEAD of --ref if empty).", + Value: serpent.StringOf(&commitSHA), + }, + dryRunOption, + }, + Handler: func(inv *serpent.Invocation) error { + result, err := prepareRelease(newExecutor(), releaseType, ref, commitSHA) + if err != nil { + return err + } + _, _ = fmt.Fprintln(inv.Stdout, result.String()) + return nil + }, + }, { Use: "generate-notes", Short: "Generate release notes from commit log and PR metadata.", diff --git a/scripts/release-action/prepare.go b/scripts/release-action/prepare.go new file mode 100644 index 00000000000..7ee7f11cf99 --- /dev/null +++ b/scripts/release-action/prepare.go @@ -0,0 +1,94 @@ +package main + +import ( + "fmt" + "os" + "strings" + + "golang.org/x/xerrors" +) + +// prepareRelease computes the next release version, then creates and +// pushes the annotated tag and (optionally) the release branch. +// It emits the same JSON as calculateNextVersion so the workflow +// can consume it identically. +func prepareRelease(exec CommandExecutor, releaseType, ref, commitSHA string) (calculateResult, error) { + result, err := calculateNextVersion(exec, releaseType, ref, commitSHA) + if err != nil { + return nil, err + } + + switch v := result.(type) { + case CreateBranchRequest: + if err := createAndPushTag(exec, v.Version, v.TargetRef); err != nil { + return nil, err + } + if err := createAndPushBranch(exec, v.BranchName, v.TargetRef); err != nil { + return nil, err + } + case ReleaseRequest: + if err := createAndPushTag(exec, v.Version, v.TargetRef); err != nil { + return nil, err + } + default: + return nil, xerrors.Errorf("unexpected result type %T", result) + } + + return result, nil +} + +// createAndPushTag creates an annotated tag at targetRef and pushes +// it. If the tag already exists at the correct commit, it is a +// no-op. If it exists at a different commit, it returns an error. +func createAndPushTag(exec CommandExecutor, versionTag, targetRef string) error { + // Check if the tag already exists locally. Dereference the tag + // object to the underlying commit with ^{}. + existing, err := gitOutput(exec, "rev-parse", "--verify", fmt.Sprintf("refs/tags/%s^{}", versionTag)) + if err == nil { + if existing == targetRef { + _, _ = fmt.Fprintf(os.Stderr, "tag %s already exists at %s, skipping\n", versionTag, targetRef) + return nil + } + return xerrors.Errorf("tag %s already exists at %s, expected %s", versionTag, existing, targetRef) + } + + // Create annotated tag. + if err := gitMutate(exec, "tag", "-a", versionTag, "-m", fmt.Sprintf("Release %s", versionTag), targetRef); err != nil { + return xerrors.Errorf("create tag %s: %w", versionTag, err) + } + + // Push tag using explicit refspec. + refspec := fmt.Sprintf("refs/tags/%s:refs/tags/%s", versionTag, versionTag) + if err := gitMutate(exec, "push", "origin", refspec); err != nil { + return xerrors.Errorf("push tag %s: %w", versionTag, err) + } + + return nil +} + +// createAndPushBranch creates a branch at targetRef and pushes it. +// If the branch already exists at the correct commit on the remote, +// it is a no-op. If it exists at a different commit, it returns an +// error. +func createAndPushBranch(exec CommandExecutor, branchName, targetRef string) error { + // Check if the branch already exists on the remote. + existing, err := gitOutput(exec, "ls-remote", "--exit-code", "origin", fmt.Sprintf("refs/heads/%s", branchName)) + if err == nil && existing != "" { + // ls-remote output format: "\trefs/heads/" + remoteSHA, _, _ := strings.Cut(existing, "\t") + if remoteSHA == targetRef { + _, _ = fmt.Fprintf(os.Stderr, "branch %s already exists at %s, skipping\n", branchName, targetRef) + return nil + } + return xerrors.Errorf("branch %s already exists at %s, expected %s", branchName, remoteSHA, targetRef) + } + + // Push the commit directly to create the remote branch, without + // needing a local branch. + refspec := fmt.Sprintf("%s:refs/heads/%s", targetRef, branchName) + if err := gitMutate(exec, "push", "origin", refspec); err != nil { + return xerrors.Errorf("push branch %s: %w", branchName, err) + } + + return nil +} diff --git a/scripts/release-action/prepare_test.go b/scripts/release-action/prepare_test.go new file mode 100644 index 00000000000..cb86e0f8d29 --- /dev/null +++ b/scripts/release-action/prepare_test.go @@ -0,0 +1,187 @@ +package main + +import ( + "bytes" + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockExecutor records mutating calls and delegates read-only calls +// to configurable functions. +type mockExecutor struct { + // MutationCalls records all calls to RunMutation and + // RunMutationStdout as "name arg1 arg2 ..." strings. + MutationCalls []string + + // RunOutputFunc is called for RunOutput. If nil, returns ("", error). + RunOutputFunc func(name string, args ...string) (string, error) + + // RunFunc is called for Run. If nil, returns nil. + RunFunc func(name string, args ...string) error +} + +func (m *mockExecutor) RunOutput(name string, args ...string) (string, error) { + if m.RunOutputFunc != nil { + return m.RunOutputFunc(name, args...) + } + return "", nil +} + +func (m *mockExecutor) Run(name string, args ...string) error { + if m.RunFunc != nil { + return m.RunFunc(name, args...) + } + return nil +} + +func (m *mockExecutor) RunMutation(name string, args ...string) error { + call := name + for _, a := range args { + call += " " + a + } + m.MutationCalls = append(m.MutationCalls, call) + return nil +} + +func (m *mockExecutor) RunMutationStdout(_, _ io.Writer, name string, args ...string) error { + call := name + for _, a := range args { + call += " " + a + } + m.MutationCalls = append(m.MutationCalls, call) + return nil +} + +func TestCreateAndPushTag_New(t *testing.T) { + t.Parallel() + + mock := &mockExecutor{ + RunOutputFunc: func(name string, args ...string) (string, error) { + // Simulate tag not existing: git rev-parse --verify fails. + if len(args) >= 2 && args[0] == "rev-parse" && args[1] == "--verify" { + return "", assert.AnError + } + return "", nil + }, + } + + err := createAndPushTag(mock, "v2.21.0", "abc123") + require.NoError(t, err) + + require.Len(t, mock.MutationCalls, 2) + assert.Contains(t, mock.MutationCalls[0], "git tag -a v2.21.0 -m Release v2.21.0 abc123") + assert.Contains(t, mock.MutationCalls[1], "git push origin refs/tags/v2.21.0:refs/tags/v2.21.0") +} + +func TestCreateAndPushTag_AlreadyExistsMatching(t *testing.T) { + t.Parallel() + + mock := &mockExecutor{ + RunOutputFunc: func(name string, args ...string) (string, error) { + // Simulate tag existing at the correct commit. + if len(args) >= 2 && args[0] == "rev-parse" && args[1] == "--verify" { + return "abc123", nil + } + return "", nil + }, + } + + err := createAndPushTag(mock, "v2.21.0", "abc123") + require.NoError(t, err) + + // No mutations should happen. + assert.Empty(t, mock.MutationCalls) +} + +func TestCreateAndPushTag_AlreadyExistsMismatch(t *testing.T) { + t.Parallel() + + mock := &mockExecutor{ + RunOutputFunc: func(name string, args ...string) (string, error) { + if len(args) >= 2 && args[0] == "rev-parse" && args[1] == "--verify" { + return "different_sha", nil + } + return "", nil + }, + } + + err := createAndPushTag(mock, "v2.21.0", "abc123") + require.Error(t, err) + assert.Contains(t, err.Error(), "already exists at different_sha") + assert.Empty(t, mock.MutationCalls) +} + +func TestCreateAndPushBranch_New(t *testing.T) { + t.Parallel() + + mock := &mockExecutor{ + RunOutputFunc: func(name string, args ...string) (string, error) { + // Simulate branch not existing: ls-remote fails. + if len(args) >= 1 && args[0] == "ls-remote" { + return "", assert.AnError + } + return "", nil + }, + } + + err := createAndPushBranch(mock, "release/2.21", "abc123") + require.NoError(t, err) + + require.Len(t, mock.MutationCalls, 1) + assert.Contains(t, mock.MutationCalls[0], "git push origin abc123:refs/heads/release/2.21") +} + +func TestCreateAndPushBranch_AlreadyExistsMatching(t *testing.T) { + t.Parallel() + + mock := &mockExecutor{ + RunOutputFunc: func(name string, args ...string) (string, error) { + if len(args) >= 1 && args[0] == "ls-remote" { + return "abc123\trefs/heads/release/2.21", nil + } + return "", nil + }, + } + + err := createAndPushBranch(mock, "release/2.21", "abc123") + require.NoError(t, err) + assert.Empty(t, mock.MutationCalls) +} + +func TestCreateAndPushBranch_AlreadyExistsMismatch(t *testing.T) { + t.Parallel() + + mock := &mockExecutor{ + RunOutputFunc: func(name string, args ...string) (string, error) { + if len(args) >= 1 && args[0] == "ls-remote" { + return "other_sha\trefs/heads/release/2.21", nil + } + return "", nil + }, + } + + err := createAndPushBranch(mock, "release/2.21", "abc123") + require.Error(t, err) + assert.Contains(t, err.Error(), "already exists at other_sha") + assert.Empty(t, mock.MutationCalls) +} + +func TestDryRunExecutor_SkipsMutations(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + exec := newDryRunExecutor(&buf) + + // RunMutation should print, not execute. + err := exec.RunMutation("git", "tag", "-a", "v2.21.0", "-m", "Release v2.21.0", "abc123") + require.NoError(t, err) + assert.Contains(t, buf.String(), "[dry-run] would run: git tag -a v2.21.0") + + buf.Reset() + + err = exec.RunMutation("git", "push", "origin", "refs/tags/v2.21.0:refs/tags/v2.21.0") + require.NoError(t, err) + assert.Contains(t, buf.String(), "[dry-run] would run: git push origin refs/tags/v2.21.0:refs/tags/v2.21.0") +} From 67e5b7b917c599c284b0eb3f3f58735ad2d1db8a Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Wed, 17 Jun 2026 15:17:20 +0000 Subject: [PATCH 4/8] fix(scripts/release-action): treat git fetch as read-only, not a mutation git fetch --tags --force origin only updates local remote-tracking refs; it does not change the remote or working tree. Dry-run also needs accurate remote state to compute the correct version, so the fetch must always run. Route it through the read-only gitOutput path instead of gitMutate. --- scripts/release-action/calculate.go | 6 ++++-- scripts/release-action/cmdexec.go | 4 ++-- scripts/release-action/cmdexec_test.go | 4 ++-- scripts/release-action/git.go | 2 +- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/scripts/release-action/calculate.go b/scripts/release-action/calculate.go index 92cde58ec13..a8a05f809f9 100644 --- a/scripts/release-action/calculate.go +++ b/scripts/release-action/calculate.go @@ -52,8 +52,10 @@ var branchRe = regexp.MustCompile(`^release/(\d+)\.(\d+)$`) // (github.ref_name). commitSHA is an optional override; when empty // the tool defaults to HEAD of the ref. func calculateNextVersion(exec CommandExecutor, releaseType, ref, commitSHA string) (calculateResult, error) { - // Ensure we have up-to-date remote state. - if err := gitMutate(exec, "fetch", "--tags", "--force", "origin"); err != nil { + // Ensure we have up-to-date remote state. Fetching only updates + // local remote-tracking refs, so it runs even in dry-run mode to + // keep version calculation accurate. + if _, err := gitOutput(exec, "fetch", "--tags", "--force", "origin"); err != nil { return nil, xerrors.Errorf("git fetch: %w", err) } diff --git a/scripts/release-action/cmdexec.go b/scripts/release-action/cmdexec.go index 48cf349cc4a..d0515242fe4 100644 --- a/scripts/release-action/cmdexec.go +++ b/scripts/release-action/cmdexec.go @@ -25,8 +25,8 @@ type CommandExecutor interface { // Use for read-only commands where only the exit code matters. Run(name string, args ...string) error - // RunMutation executes a command that modifies state (e.g. git - // fetch, git push, git tag). In dry-run mode it prints instead + // RunMutation executes a command that modifies remote state + // (e.g. git push, git tag). In dry-run mode it prints instead // of executing. RunMutation(name string, args ...string) error diff --git a/scripts/release-action/cmdexec_test.go b/scripts/release-action/cmdexec_test.go index c65ae7fa55f..0152d634386 100644 --- a/scripts/release-action/cmdexec_test.go +++ b/scripts/release-action/cmdexec_test.go @@ -72,9 +72,9 @@ func TestDryRunExecutor_RunMutationPrints(t *testing.T) { var buf bytes.Buffer exec := newDryRunExecutor(&buf) - err := exec.RunMutation("git", "fetch", "--tags", "--force", "origin") + err := exec.RunMutation("git", "push", "origin", "refs/tags/v2.21.0:refs/tags/v2.21.0") require.NoError(t, err) - assert.Contains(t, buf.String(), "[dry-run] would run: git fetch --tags --force origin") + assert.Contains(t, buf.String(), "[dry-run] would run: git push origin refs/tags/v2.21.0:refs/tags/v2.21.0") } func TestDryRunExecutor_RunMutationStdoutPrints(t *testing.T) { diff --git a/scripts/release-action/git.go b/scripts/release-action/git.go index 4b325c144ca..15ecc7c43f3 100644 --- a/scripts/release-action/git.go +++ b/scripts/release-action/git.go @@ -12,7 +12,7 @@ func gitRun(exec CommandExecutor, args ...string) error { return exec.Run("git", args...) } -// gitMutate runs a git command that modifies state (e.g. fetch, +// gitMutate runs a git command that modifies remote state (e.g. // push, tag). In dry-run mode the command is printed instead of // executed. func gitMutate(exec CommandExecutor, args ...string) error { From b67ee7cae6895e1a65b46ba43f82d4539a457ca8 Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Wed, 17 Jun 2026 15:28:11 +0000 Subject: [PATCH 5/8] feat(.github/workflows): add dry_run input to release workflow Adds a dry_run workflow_dispatch input. When enabled: - prepare-release runs with --dry-run, so the version is calculated and the tag/branch plan is printed without creating or pushing anything - release notes are still generated (read-only) for inspection - the build-and-publish job is skipped via an if guard, which cascades to skip publish-homebrew, publish-winget, and update-docs This gives a zero-mutation dry run that validates version calculation, the tag/branch plan, and release notes without publishing. The scripts/releaser tool already passes dry_run when dispatching this workflow; this wires up the matching input. --- .github/workflows/release.yaml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 6b07bb71562..c3cea38bb98 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -15,6 +15,10 @@ on: description: "Optional: commit SHA to tag (defaults to HEAD of selected branch)" type: string default: "" + dry_run: + description: "Dry run: calculate the version and print the release plan without creating tags, branches, or publishing." + type: boolean + default: false permissions: contents: read @@ -87,6 +91,7 @@ jobs: RELEASE_TYPE: ${{ inputs.release_type }} REF_NAME: ${{ github.ref_name }} COMMIT_SHA: ${{ inputs.commit_sha }} + DRY_RUN: ${{ inputs.dry_run }} run: | set -euo pipefail @@ -94,6 +99,9 @@ jobs: if [[ -n "$COMMIT_SHA" ]]; then args+=(--commit "$COMMIT_SHA") fi + if [[ "$DRY_RUN" == "true" ]]; then + args+=(--dry-run) + fi output=$(go run ./scripts/release-action prepare-release "${args[@]}") echo "Raw output: $output" @@ -134,6 +142,10 @@ jobs: fi } >> "$GITHUB_STEP_SUMMARY" + if [[ "$DRY_RUN" == "true" ]]; then + echo "> **Dry run:** no tags, branches, or releases were created. The build and publish jobs are skipped." >> "$GITHUB_STEP_SUMMARY" + fi + - name: Generate release notes env: VERSION: ${{ steps.prepare.outputs.version }} @@ -154,6 +166,10 @@ jobs: release: name: Build and publish needs: [check-perms, prepare-release] + # Skip the build and publish steps entirely in dry-run mode. This + # cascades to publish-homebrew, publish-winget, and update-docs, + # which all depend on this job. + if: ${{ !inputs.dry_run }} runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }} permissions: # Required to publish a release From 9fa8e5194320f8a2b1195bc16a6ebaa0815dc011 Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Wed, 17 Jun 2026 16:59:23 +0000 Subject: [PATCH 6/8] ci: move release-action pipeline to new tag-and-release.yaml, restore release.yaml PR #25162 rewrote the existing release.yaml to be driven by the scripts/release-action Go tool, which changed its workflow_dispatch inputs from release_channel/release_notes/dry_run to release_type/commit_sha. That broke the scripts/releaser tool, which dispatches release.yaml with the original inputs. Restore release.yaml to its pre-#25162 state so the existing pipeline (triggered by scripts/releaser) functions again, and move the GitHub-Actions-driven, release-action-based pipeline into a new manual workflow, tag-and-release.yaml. - release.yaml: legacy pipeline, triggered by scripts/releaser - tag-and-release.yaml: new manual pipeline using scripts/release-action (prepare-release, generate-notes, publish) with the dry_run input --- .github/workflows/release.yaml | 351 ++++------ .github/workflows/tag-and-release.yaml | 919 +++++++++++++++++++++++++ 2 files changed, 1058 insertions(+), 212 deletions(-) create mode 100644 .github/workflows/tag-and-release.yaml diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index c3cea38bb98..2427e3586f0 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -3,21 +3,19 @@ name: Release on: workflow_dispatch: inputs: - release_type: + release_channel: type: choice - description: "Type of release (use 'Use workflow from' to pick the branch)" - required: true + description: Release channel options: + - mainline + - stable - rc - - release - - create-release-branch - commit_sha: - description: "Optional: commit SHA to tag (defaults to HEAD of selected branch)" - type: string - default: "" + release_notes: + description: Release notes for the publishing the release. This is required to create a release. dry_run: - description: "Dry run: calculate the version and print the release plan without creating tags, branches, or publishing." + description: Perform a dry-run release (devel). Note that ref must be an annotated tag when run without dry-run. type: boolean + required: true default: false permissions: @@ -25,6 +23,15 @@ permissions: concurrency: ${{ github.workflow }}-${{ github.ref }} +env: + # Use `inputs` (vs `github.event.inputs`) to ensure that booleans are actual + # booleans, not strings. + # https://github.blog/changelog/2022-06-10-github-actions-inputs-unified-across-manual-and-reusable-workflows/ + CODER_RELEASE: ${{ !inputs.dry_run }} + CODER_DRY_RUN: ${{ inputs.dry_run }} + CODER_RELEASE_CHANNEL: ${{ inputs.release_channel }} + CODER_RELEASE_NOTES: ${{ inputs.release_notes }} + jobs: # Only allow maintainers/admins to release. check-perms: @@ -52,124 +59,9 @@ jobs: if (!allowed) core.setFailed('Denied: requires maintain or admin'); - - prepare-release: - name: Prepare release - needs: [check-perms] - runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }} - permissions: - contents: write - outputs: - version: ${{ steps.prepare.outputs.version }} - previous_version: ${{ steps.prepare.outputs.previous_version }} - stable: ${{ steps.prepare.outputs.stable }} - target_ref: ${{ steps.prepare.outputs.target_ref }} - create_branch: ${{ steps.prepare.outputs.create_branch }} - steps: - - name: Harden Runner - uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 - with: - egress-policy: audit - - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 0 - persist-credentials: true - - - name: Fetch git tags - run: git fetch --tags --force - - - name: Setup Go - uses: ./.github/actions/setup-go - with: - use-cache: false - - - name: Prepare release (calculate version, create tag and branch) - id: prepare - env: - RELEASE_TYPE: ${{ inputs.release_type }} - REF_NAME: ${{ github.ref_name }} - COMMIT_SHA: ${{ inputs.commit_sha }} - DRY_RUN: ${{ inputs.dry_run }} - run: | - set -euo pipefail - - args=(--type "$RELEASE_TYPE" --ref "$REF_NAME") - if [[ -n "$COMMIT_SHA" ]]; then - args+=(--commit "$COMMIT_SHA") - fi - if [[ "$DRY_RUN" == "true" ]]; then - args+=(--dry-run) - fi - - output=$(go run ./scripts/release-action prepare-release "${args[@]}") - echo "Raw output: $output" - - version=$(echo "$output" | jq -r '.version') - previous_version=$(echo "$output" | jq -r '.previous_version') - stable=$(echo "$output" | jq -r '.stable') - target_ref=$(echo "$output" | jq -r '.target_ref') - create_branch=$(echo "$output" | jq -r '.create_branch // empty') - - # Validate required outputs are non-empty. - for var in version previous_version target_ref; do - eval "val=\$$var" - if [[ -z "$val" || "$val" == "null" ]]; then - echo "::error::prepare-release returned empty or null '$var'" - exit 1 - fi - done - - { - echo "version=$version" - echo "previous_version=$previous_version" - echo "stable=$stable" - echo "target_ref=$target_ref" - echo "create_branch=$create_branch" - } >> "$GITHUB_OUTPUT" - - { - echo "### Release preparation" - echo "| Field | Value |" - echo "|-------|-------|" - echo "| Version | \`$version\` |" - echo "| Previous | \`$previous_version\` |" - echo "| Stable | \`$stable\` |" - echo "| Target ref | \`$target_ref\` |" - if [[ -n "$create_branch" ]]; then - echo "| Create branch | \`$create_branch\` |" - fi - } >> "$GITHUB_STEP_SUMMARY" - - if [[ "$DRY_RUN" == "true" ]]; then - echo "> **Dry run:** no tags, branches, or releases were created. The build and publish jobs are skipped." >> "$GITHUB_STEP_SUMMARY" - fi - - - name: Generate release notes - env: - VERSION: ${{ steps.prepare.outputs.version }} - PREV_VERSION: ${{ steps.prepare.outputs.previous_version }} - run: | - set -euo pipefail - go run ./scripts/release-action generate-notes \ - --version "$VERSION" \ - --previous-version "$PREV_VERSION" > /tmp/release_notes.md - - - name: Upload release notes - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: release-notes - path: /tmp/release_notes.md - retention-days: 30 - release: name: Build and publish - needs: [check-perms, prepare-release] - # Skip the build and publish steps entirely in dry-run mode. This - # cascades to publish-homebrew, publish-winget, and update-docs, - # which all depend on this job. - if: ${{ !inputs.dry_run }} + needs: [check-perms] runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }} permissions: # Required to publish a release @@ -183,8 +75,6 @@ jobs: # Required for GitHub Actions attestation attestations: write env: - CODER_RELEASE: "true" - CODER_RELEASE_STABLE: ${{ needs.prepare-release.outputs.stable }} # Necessary for Docker manifest DOCKER_CLI_EXPERIMENTAL: "enabled" outputs: @@ -209,36 +99,66 @@ jobs: - name: Fetch git tags run: git fetch --tags --force - - name: Checkout release commit - env: - VERSION: ${{ needs.prepare-release.outputs.version }} - run: | - set -euo pipefail - git checkout "refs/tags/$VERSION" - - name: Print version id: version - env: - VERSION: ${{ needs.prepare-release.outputs.version }} run: | set -euo pipefail - # VERSION comes from the env block, not a misspelling of the local 'version'. - # shellcheck disable=SC2153 - # Strip the "v" prefix for use in build steps. - version="${VERSION#v}" + version="$(./scripts/version.sh)" echo "version=$version" >> "$GITHUB_OUTPUT" # Speed up future version.sh calls. echo "CODER_FORCE_VERSION=$version" >> "$GITHUB_ENV" echo "$version" - - name: Download release notes - uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1 - with: - name: release-notes - path: /tmp + # Verify that all expectations for a release are met. + - name: Verify release input + if: ${{ !inputs.dry_run }} + run: | + set -euo pipefail + + if [[ "${GITHUB_REF}" != "refs/tags/v"* ]]; then + echo "Ref must be a semver tag when creating a release, did you use scripts/release.sh?" + exit 1 + fi - - name: Set release notes env - run: echo CODER_RELEASE_NOTES_FILE=/tmp/release_notes.md >> "$GITHUB_ENV" + # Derive the release branch from the version tag. + # Non-RC releases must be on a release/X.Y branch. + # RC tags are allowed on any branch (typically main). + version="$(./scripts/version.sh)" + # Strip any pre-release suffix first (e.g. 2.32.0-rc.0 -> 2.32.0) + base_version="${version%%-*}" + # Then strip patch to get major.minor (e.g. 2.32.0 -> 2.32) + release_branch="release/${base_version%.*}" + + if [[ "$version" == *-rc.* ]]; then + echo "RC release detected — skipping release branch check (RC tags are cut from main)." + else + branch_contains_tag=$(git branch --remotes --contains "${GITHUB_REF}" --list "*/${release_branch}" --format='%(refname)') + if [[ -z "${branch_contains_tag}" ]]; then + echo "Ref tag must exist in a branch named ${release_branch} when creating a non-RC release, did you use scripts/release.sh?" + exit 1 + fi + fi + + if [[ -z "${CODER_RELEASE_NOTES}" ]]; then + echo "Release notes are required to create a release, did you use scripts/release.sh?" + exit 1 + fi + + echo "Release inputs verified:" + echo + echo "- Ref: ${GITHUB_REF}" + echo "- Version: ${version}" + echo "- Release channel: ${CODER_RELEASE_CHANNEL}" + echo "- Release branch: ${release_branch}" + echo "- Release notes: true" + + - name: Create release notes file + run: | + set -euo pipefail + + release_notes_file="$(mktemp -t release_notes.XXXXXX)" + echo "$CODER_RELEASE_NOTES" > "$release_notes_file" + echo CODER_RELEASE_NOTES_FILE="$release_notes_file" >> "$GITHUB_ENV" - name: Show release notes run: | @@ -363,8 +283,12 @@ jobs: id: image-base-tag run: | set -euo pipefail - # Empty value means use the default and avoid building a fresh one. - echo "tag=$(CODER_IMAGE_BASE=ghcr.io/coder/coder-base ./scripts/image_tag.sh)" >> "$GITHUB_OUTPUT" + if [[ "${CODER_RELEASE:-}" != *t* ]] || [[ "${CODER_DRY_RUN:-}" == *t* ]]; then + # Empty value means use the default and avoid building a fresh one. + echo "tag=" >> "$GITHUB_OUTPUT" + else + echo "tag=$(CODER_IMAGE_BASE=ghcr.io/coder/coder-base ./scripts/image_tag.sh)" >> "$GITHUB_OUTPUT" + fi - name: Create empty base-build-context directory if: steps.image-base-tag.outputs.tag != '' @@ -426,7 +350,7 @@ jobs: - name: GitHub Attestation for Base Docker image id: attest_base - if: ${{ steps.build_base_image.outputs.digest != '' }} + if: ${{ !inputs.dry_run && steps.build_base_image.outputs.digest != '' }} continue-on-error: true uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 with: @@ -439,6 +363,13 @@ jobs: run: | set -euxo pipefail + # we can't build multi-arch if the images aren't pushed, so quit now + # if dry-running + if [[ "$CODER_RELEASE" != *t* ]]; then + echo Skipping multi-arch docker builds due to dry-run. + exit 0 + fi + # build Docker images for each architecture version="$(./scripts/version.sh)" make build/coder_"$version"_linux_{amd64,arm64,armv7}.tag @@ -472,6 +403,7 @@ jobs: CODER_BASE_IMAGE_TAG: ${{ steps.image-base-tag.outputs.tag }} - name: SBOM Generation and Attestation + if: ${{ !inputs.dry_run }} env: COSIGN_EXPERIMENTAL: '1' MULTIARCH_IMAGE: ${{ steps.build_docker.outputs.multiarch_image }} @@ -507,6 +439,7 @@ jobs: - name: Resolve Docker image digests for attestation id: docker_digests + if: ${{ !inputs.dry_run }} continue-on-error: true env: MULTIARCH_IMAGE: ${{ steps.build_docker.outputs.multiarch_image }} @@ -524,7 +457,7 @@ jobs: - name: GitHub Attestation for Docker image id: attest_main - if: ${{ steps.docker_digests.outputs.multiarch_digest != '' }} + if: ${{ !inputs.dry_run && steps.docker_digests.outputs.multiarch_digest != '' }} continue-on-error: true uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 with: @@ -534,7 +467,7 @@ jobs: - name: GitHub Attestation for "latest" Docker image id: attest_latest - if: ${{ steps.docker_digests.outputs.latest_digest != '' }} + if: ${{ !inputs.dry_run && steps.docker_digests.outputs.latest_digest != '' }} continue-on-error: true uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 with: @@ -544,6 +477,7 @@ jobs: - name: GitHub Attestation for release binaries id: attest_binaries + if: ${{ !inputs.dry_run }} continue-on-error: true uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 with: @@ -559,6 +493,7 @@ jobs: # Report attestation failures but don't fail the workflow - name: Check attestation status + if: ${{ !inputs.dry_run }} run: | # zizmor: ignore[template-injection] We're just reading steps.attest_x.outcome here, no risk of injection if [[ "${{ steps.attest_base.outcome }}" == "failure" && "${{ steps.attest_base.conclusion }}" != "skipped" ]]; then echo "::warning::GitHub attestation for base image failed" @@ -582,6 +517,7 @@ jobs: run: ls -lh build - name: Publish Coder CLI binaries and detached signatures to GCS + if: ${{ !inputs.dry_run }} run: | set -euxo pipefail @@ -608,7 +544,19 @@ jobs: run: | set -euo pipefail - # Build the list of files to publish. + publish_args=() + if [[ $CODER_RELEASE_CHANNEL == "stable" ]]; then + publish_args+=(--stable) + fi + if [[ $CODER_RELEASE_CHANNEL == "rc" ]]; then + publish_args+=(--rc) + fi + if [[ $CODER_DRY_RUN == *t* ]]; then + publish_args+=(--dry-run) + fi + declare -p publish_args + + # Build the list of files to publish files=( ./build/*_installer.exe ./build/*.zip @@ -620,28 +568,24 @@ jobs: "./coder_${VERSION}_sbom.spdx.json" ) - # Only include the latest SBOM file if it was created. + # Only include the latest SBOM file if it was created if [[ "${CREATED_LATEST_TAG}" == "true" ]]; then files+=(./coder_latest_sbom.spdx.json) fi - stable_flag=() - if [[ "$CODER_RELEASE_STABLE" == "true" ]]; then - stable_flag=(--stable) - fi - - go run ./scripts/release-action publish \ - --version "v${VERSION}" \ - "${stable_flag[@]}" \ + ./scripts/release/publish.sh \ + "${publish_args[@]}" \ --release-notes-file "$CODER_RELEASE_NOTES_FILE" \ "${files[@]}" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CODER_GPG_RELEASE_KEY_BASE64: ${{ secrets.GPG_RELEASE_KEY_BASE64 }} VERSION: ${{ steps.version.outputs.version }} CREATED_LATEST_TAG: ${{ steps.build_docker.outputs.created_latest_tag }} # Mark the Linear release as shipped. - name: Extract Linear release version + if: ${{ !inputs.dry_run }} id: linear_version run: | # Skip RC releases — they must not complete the Linear release. @@ -659,7 +603,7 @@ jobs: VERSION: ${{ steps.version.outputs.version }} - name: Complete Linear release - if: ${{ steps.linear_version.outputs.skip != 'true' }} + if: ${{ !inputs.dry_run && steps.linear_version.outputs.skip != 'true' }} continue-on-error: true uses: linear/linear-release-action@0353b5fa8c00326913966f00557d68f8f30b8b6b # v0.7.0 with: @@ -678,6 +622,7 @@ jobs: uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # 3.0.1 - name: Publish Helm Chart + if: ${{ !inputs.dry_run }} run: | set -euo pipefail version="$(./scripts/version.sh)" @@ -693,20 +638,44 @@ jobs: helm push "build/coder_helm_${version}.tgz" oci://ghcr.io/coder/chart helm push "build/provisioner_helm_${version}.tgz" oci://ghcr.io/coder/chart + - name: Upload artifacts to actions (if dry-run) + if: ${{ inputs.dry_run }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-artifacts + path: | + ./build/*_installer.exe + ./build/*.zip + ./build/*.tar.gz + ./build/*.tgz + ./build/*.apk + ./build/*.deb + ./build/*.rpm + ./coder_${{ steps.version.outputs.version }}_sbom.spdx.json + retention-days: 7 + + - name: Upload latest sbom artifact to actions (if dry-run) + if: inputs.dry_run && steps.build_docker.outputs.created_latest_tag == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: latest-sbom-artifact + path: ./coder_latest_sbom.spdx.json + retention-days: 7 + - name: Send repository-dispatch event - if: ${{ inputs.release_type != 'rc' && inputs.release_type != 'create-release-branch' }} + if: ${{ !inputs.dry_run && inputs.release_channel != 'rc' }} uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 with: token: ${{ secrets.CDRCI_GITHUB_TOKEN }} repository: coder/packages event-type: coder-release - client-payload: '{"coder_version": "${{ steps.version.outputs.version }}"}' + client-payload: '{"coder_version": "${{ steps.version.outputs.version }}", "release_channel": "${{ inputs.release_channel }}"}' publish-homebrew: name: Publish to Homebrew tap runs-on: ubuntu-latest - needs: [release, prepare-release] - if: ${{ inputs.release_type != 'rc' && inputs.release_type != 'create-release-branch' && needs.prepare-release.outputs.stable == 'true' }} + needs: release + if: ${{ !inputs.dry_run && inputs.release_channel == 'mainline' }} steps: - name: Harden Runner @@ -778,12 +747,11 @@ jobs: -a "${GITHUB_ACTOR}" \ -b "This automatic PR was triggered by the release of Coder v$coder_version" - publish-winget: name: Publish to winget-pkgs runs-on: windows-latest - needs: [release, prepare-release] - if: ${{ inputs.release_type != 'rc' && inputs.release_type != 'create-release-branch' }} + needs: release + if: ${{ !inputs.dry_run && inputs.release_channel != 'rc' }} steps: - name: Harden Runner @@ -871,44 +839,3 @@ jobs: # different repo. GH_TOKEN: ${{ secrets.CDRCI_GITHUB_TOKEN }} VERSION: ${{ needs.release.outputs.version }} - - - update-docs: - name: Update release docs - needs: [prepare-release, release] - if: ${{ inputs.release_type != 'rc' && inputs.release_type != 'create-release-branch' }} - runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }} - permissions: - contents: write - pull-requests: write - steps: - - name: Harden Runner - uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 - with: - egress-policy: audit - - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: main - fetch-depth: 0 - persist-credentials: true - - - name: Fetch git tags - run: git fetch --tags --force - - - name: Setup Node - uses: ./.github/actions/setup-node - - - name: Update release calendar - run: ./scripts/update-release-calendar.sh - - - name: Create docs update PR - uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8 - with: - token: ${{ secrets.GITHUB_TOKEN }} - commit-message: "docs: update release docs for ${{ needs.prepare-release.outputs.version }}" - title: "docs: update release docs for ${{ needs.prepare-release.outputs.version }}" - body: "Automated docs update for release ${{ needs.prepare-release.outputs.version }}." - branch: docs/release-${{ needs.prepare-release.outputs.version }} - base: main diff --git a/.github/workflows/tag-and-release.yaml b/.github/workflows/tag-and-release.yaml new file mode 100644 index 00000000000..9015cdad5c0 --- /dev/null +++ b/.github/workflows/tag-and-release.yaml @@ -0,0 +1,919 @@ +# Tag and release workflow (GitHub Actions-driven, manual). +# +# This is the newer release pipeline driven entirely by the +# scripts/release-action Go tool. It is triggered manually from the +# Actions UI. The legacy release.yaml workflow remains in place and is +# triggered by the scripts/releaser Go tool. +name: Tag and Release +on: + workflow_dispatch: + inputs: + release_type: + type: choice + description: "Type of release (use 'Use workflow from' to pick the branch)" + required: true + options: + - rc + - release + - create-release-branch + commit_sha: + description: "Optional: commit SHA to tag (defaults to HEAD of selected branch)" + type: string + default: "" + dry_run: + description: "Dry run: calculate the version and print the release plan without creating tags, branches, or publishing." + type: boolean + default: false + +permissions: + contents: read + +concurrency: ${{ github.workflow }}-${{ github.ref }} + +jobs: + # Only allow maintainers/admins to release. + check-perms: + runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }} + steps: + - name: Allow only maintainers/admins + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const {data} = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: context.actor + }); + const role = data.role_name || data.user?.role_name || data.permission; + const perms = data.user?.permissions || {}; + core.info(`Actor ${context.actor} permission=${data.permission}, role_name=${role}`); + + const allowed = + role === 'admin' || + role === 'maintain' || + perms.admin === true || + perms.maintain === true; + + if (!allowed) core.setFailed('Denied: requires maintain or admin'); + + + prepare-release: + name: Prepare release + needs: [check-perms] + runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }} + permissions: + contents: write + outputs: + version: ${{ steps.prepare.outputs.version }} + previous_version: ${{ steps.prepare.outputs.previous_version }} + stable: ${{ steps.prepare.outputs.stable }} + target_ref: ${{ steps.prepare.outputs.target_ref }} + create_branch: ${{ steps.prepare.outputs.create_branch }} + steps: + - name: Harden Runner + uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: true + + - name: Fetch git tags + run: git fetch --tags --force + + - name: Setup Go + uses: ./.github/actions/setup-go + with: + use-cache: false + + - name: Prepare release (calculate version, create tag and branch) + id: prepare + env: + RELEASE_TYPE: ${{ inputs.release_type }} + REF_NAME: ${{ github.ref_name }} + COMMIT_SHA: ${{ inputs.commit_sha }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + + args=(--type "$RELEASE_TYPE" --ref "$REF_NAME") + if [[ -n "$COMMIT_SHA" ]]; then + args+=(--commit "$COMMIT_SHA") + fi + if [[ "$DRY_RUN" == "true" ]]; then + args+=(--dry-run) + fi + + output=$(go run ./scripts/release-action prepare-release "${args[@]}") + echo "Raw output: $output" + + version=$(echo "$output" | jq -r '.version') + previous_version=$(echo "$output" | jq -r '.previous_version') + stable=$(echo "$output" | jq -r '.stable') + target_ref=$(echo "$output" | jq -r '.target_ref') + create_branch=$(echo "$output" | jq -r '.create_branch // empty') + + # Validate required outputs are non-empty. + for var in version previous_version target_ref; do + eval "val=\$$var" + if [[ -z "$val" || "$val" == "null" ]]; then + echo "::error::prepare-release returned empty or null '$var'" + exit 1 + fi + done + + { + echo "version=$version" + echo "previous_version=$previous_version" + echo "stable=$stable" + echo "target_ref=$target_ref" + echo "create_branch=$create_branch" + } >> "$GITHUB_OUTPUT" + + { + echo "### Release preparation" + echo "| Field | Value |" + echo "|-------|-------|" + echo "| Version | \`$version\` |" + echo "| Previous | \`$previous_version\` |" + echo "| Stable | \`$stable\` |" + echo "| Target ref | \`$target_ref\` |" + if [[ -n "$create_branch" ]]; then + echo "| Create branch | \`$create_branch\` |" + fi + } >> "$GITHUB_STEP_SUMMARY" + + if [[ "$DRY_RUN" == "true" ]]; then + echo "> **Dry run:** no tags, branches, or releases were created. The build and publish jobs are skipped." >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Generate release notes + env: + VERSION: ${{ steps.prepare.outputs.version }} + PREV_VERSION: ${{ steps.prepare.outputs.previous_version }} + run: | + set -euo pipefail + go run ./scripts/release-action generate-notes \ + --version "$VERSION" \ + --previous-version "$PREV_VERSION" > /tmp/release_notes.md + + - name: Upload release notes + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-notes + path: /tmp/release_notes.md + retention-days: 30 + + release: + name: Build and publish + needs: [check-perms, prepare-release] + # Skip the build and publish steps entirely in dry-run mode. This + # cascades to publish-homebrew, publish-winget, and update-docs, + # which all depend on this job. + if: ${{ !inputs.dry_run }} + runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }} + permissions: + # Required to publish a release + contents: write + # Necessary to push docker images to ghcr.io. + packages: write + # Necessary for GCP authentication (https://github.com/google-github-actions/setup-gcloud#usage) + # Also necessary for keyless cosign (https://docs.sigstore.dev/cosign/signing/overview/) + # And for GitHub Actions attestation + id-token: write + # Required for GitHub Actions attestation + attestations: write + env: + CODER_RELEASE: "true" + CODER_RELEASE_STABLE: ${{ needs.prepare-release.outputs.stable }} + # Necessary for Docker manifest + DOCKER_CLI_EXPERIMENTAL: "enabled" + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - name: Harden Runner + uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: false + + # If the event that triggered the build was an annotated tag (which our + # tags are supposed to be), actions/checkout has a bug where the tag in + # question is only a lightweight tag and not a full annotated tag. This + # command seems to fix it. + # https://github.com/actions/checkout/issues/290 + - name: Fetch git tags + run: git fetch --tags --force + + - name: Checkout release commit + env: + VERSION: ${{ needs.prepare-release.outputs.version }} + run: | + set -euo pipefail + git checkout "refs/tags/$VERSION" + + - name: Print version + id: version + env: + VERSION: ${{ needs.prepare-release.outputs.version }} + run: | + set -euo pipefail + # VERSION comes from the env block, not a misspelling of the local 'version'. + # shellcheck disable=SC2153 + # Strip the "v" prefix for use in build steps. + version="${VERSION#v}" + echo "version=$version" >> "$GITHUB_OUTPUT" + # Speed up future version.sh calls. + echo "CODER_FORCE_VERSION=$version" >> "$GITHUB_ENV" + echo "$version" + + - name: Download release notes + uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1 + with: + name: release-notes + path: /tmp + + - name: Set release notes env + run: echo CODER_RELEASE_NOTES_FILE=/tmp/release_notes.md >> "$GITHUB_ENV" + + - name: Show release notes + run: | + set -euo pipefail + cat "$CODER_RELEASE_NOTES_FILE" + + - name: Docker Login + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up mise tools + uses: ./.github/actions/setup-mise + with: + install-args: "go node pnpm helm cosign syft" + + - name: Install pnpm dependencies + uses: ./.github/actions/pnpm-install + + - name: Install Go mise tools + run: ./.github/scripts/retry.sh -- mise install --locked go:github.com/tc-hib/go-winres go:github.com/goreleaser/nfpm/v2/cmd/nfpm + + # Necessary for signing Windows binaries. + - name: Setup Java + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + distribution: "zulu" + java-version: "11.0" + + - name: Install nsis and zstd + run: sudo apt-get install -y nsis zstd + + - name: Install rcodesign + run: | + set -euo pipefail + wget -O /tmp/rcodesign.tar.gz https://github.com/indygreg/apple-platform-rs/releases/download/apple-codesign%2F0.22.0/apple-codesign-0.22.0-x86_64-unknown-linux-musl.tar.gz + sudo tar -xzf /tmp/rcodesign.tar.gz \ + -C /usr/bin \ + --strip-components=1 \ + apple-codesign-0.22.0-x86_64-unknown-linux-musl/rcodesign + rm /tmp/rcodesign.tar.gz + + - name: Setup Apple Developer certificate and API key + run: | + set -euo pipefail + touch /tmp/{apple_cert.p12,apple_cert_password.txt,apple_apikey.p8} + chmod 600 /tmp/{apple_cert.p12,apple_cert_password.txt,apple_apikey.p8} + echo "$AC_CERTIFICATE_P12_BASE64" | base64 -d > /tmp/apple_cert.p12 + echo "$AC_CERTIFICATE_PASSWORD" > /tmp/apple_cert_password.txt + echo "$AC_APIKEY_P8_BASE64" | base64 -d > /tmp/apple_apikey.p8 + env: + AC_CERTIFICATE_P12_BASE64: ${{ secrets.AC_CERTIFICATE_P12_BASE64 }} + AC_CERTIFICATE_PASSWORD: ${{ secrets.AC_CERTIFICATE_PASSWORD }} + AC_APIKEY_P8_BASE64: ${{ secrets.AC_APIKEY_P8_BASE64 }} + + - name: Setup Windows EV Signing Certificate + run: | + set -euo pipefail + touch /tmp/ev_cert.pem + chmod 600 /tmp/ev_cert.pem + echo "$EV_SIGNING_CERT" > /tmp/ev_cert.pem + wget https://github.com/ebourg/jsign/releases/download/6.0/jsign-6.0.jar -O /tmp/jsign-6.0.jar + env: + EV_SIGNING_CERT: ${{ secrets.EV_SIGNING_CERT }} + + - name: Test migrations from current ref to main + run: | + POSTGRES_VERSION=13 make test-migrations + + # Setup GCloud for signing Windows binaries. + - name: Authenticate to Google Cloud + id: gcloud_auth + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0 + with: + workload_identity_provider: ${{ vars.GCP_CODE_SIGNING_WORKLOAD_ID_PROVIDER }} + service_account: ${{ vars.GCP_CODE_SIGNING_SERVICE_ACCOUNT }} + token_format: "access_token" + + - name: Setup GCloud SDK + uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3.0.1 + + - name: Build binaries + run: | + set -euo pipefail + ./.github/scripts/retry.sh -- go mod download + + version="$(./scripts/version.sh)" + make gen/mark-fresh + make -j \ + build/coder_"$version"_linux_{amd64,armv7,arm64}.{tar.gz,apk,deb,rpm} \ + build/coder_"$version"_{darwin,windows}_{amd64,arm64}.zip \ + build/coder_"$version"_windows_amd64_installer.exe \ + build/coder_helm_"$version".tgz \ + build/provisioner_helm_"$version".tgz + env: + CODER_SIGN_WINDOWS: "1" + CODER_SIGN_DARWIN: "1" + CODER_SIGN_GPG: "1" + CODER_GPG_RELEASE_KEY_BASE64: ${{ secrets.GPG_RELEASE_KEY_BASE64 }} + CODER_WINDOWS_RESOURCES: "1" + AC_CERTIFICATE_FILE: /tmp/apple_cert.p12 + AC_CERTIFICATE_PASSWORD_FILE: /tmp/apple_cert_password.txt + AC_APIKEY_ISSUER_ID: ${{ secrets.AC_APIKEY_ISSUER_ID }} + AC_APIKEY_ID: ${{ secrets.AC_APIKEY_ID }} + AC_APIKEY_FILE: /tmp/apple_apikey.p8 + EV_KEY: ${{ secrets.EV_KEY }} + EV_KEYSTORE: ${{ secrets.EV_KEYSTORE }} + EV_TSA_URL: ${{ secrets.EV_TSA_URL }} + EV_CERTIFICATE_PATH: /tmp/ev_cert.pem + GCLOUD_ACCESS_TOKEN: ${{ steps.gcloud_auth.outputs.access_token }} + JSIGN_PATH: /tmp/jsign-6.0.jar + + - name: Delete Apple Developer certificate and API key + run: rm -f /tmp/{apple_cert.p12,apple_cert_password.txt,apple_apikey.p8} + + - name: Delete Windows EV Signing Cert + run: rm /tmp/ev_cert.pem + + - name: Determine base image tag + id: image-base-tag + run: | + set -euo pipefail + # Empty value means use the default and avoid building a fresh one. + echo "tag=$(CODER_IMAGE_BASE=ghcr.io/coder/coder-base ./scripts/image_tag.sh)" >> "$GITHUB_OUTPUT" + + - name: Create empty base-build-context directory + if: steps.image-base-tag.outputs.tag != '' + run: mkdir base-build-context + + - name: Install depot.dev CLI + if: steps.image-base-tag.outputs.tag != '' + uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1 + + # This uses OIDC authentication, so no auth variables are required. + - name: Build base Docker image via depot.dev + id: build_base_image + if: steps.image-base-tag.outputs.tag != '' + uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0 + with: + project: wl5hnrrkns + context: base-build-context + file: scripts/Dockerfile.base + platforms: linux/amd64,linux/arm64,linux/arm/v7 + provenance: true + sbom: true + pull: true + no-cache: true + push: true + tags: | + ${{ steps.image-base-tag.outputs.tag }} + + - name: Verify that images are pushed properly + if: steps.image-base-tag.outputs.tag != '' + run: | + # retry 10 times with a 5 second delay as the images may not be + # available immediately + for i in {1..10}; do + rc=0 + raw_manifests=$(docker buildx imagetools inspect --raw "${IMAGE_TAG}") || rc=$? + if [[ "$rc" -eq 0 ]]; then + break + fi + if [[ "$i" -eq 10 ]]; then + echo "Failed to pull manifests after 10 retries" + exit 1 + fi + echo "Failed to pull manifests, retrying in 5 seconds" + sleep 5 + done + + manifests=$( + echo "$raw_manifests" | \ + jq -r '.manifests[].platform | .os + "/" + .architecture + (if .variant then "/" + .variant else "" end)' + ) + + # Verify all 3 platforms are present. + set -euxo pipefail + echo "$manifests" | grep -q linux/amd64 + echo "$manifests" | grep -q linux/arm64 + echo "$manifests" | grep -q linux/arm/v7 + env: + IMAGE_TAG: ${{ steps.image-base-tag.outputs.tag }} + + - name: GitHub Attestation for Base Docker image + id: attest_base + if: ${{ steps.build_base_image.outputs.digest != '' }} + continue-on-error: true + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 + with: + subject-name: ghcr.io/coder/coder-base + subject-digest: ${{ steps.build_base_image.outputs.digest }} + push-to-registry: true + + - name: Build Linux Docker images + id: build_docker + run: | + set -euxo pipefail + + # build Docker images for each architecture + version="$(./scripts/version.sh)" + make build/coder_"$version"_linux_{amd64,arm64,armv7}.tag + + # build and push multi-arch manifest, this depends on the other images + # being pushed so will automatically push them. + make push/build/coder_"$version"_linux.tag + + multiarch_image="$(./scripts/image_tag.sh)" + echo "multiarch_image=${multiarch_image}" >> "$GITHUB_OUTPUT" + + # For debugging, print all docker image tags + docker images + + # if the current version is equal to the highest (according to semver) + # version in the repo, also create a multi-arch image as ":latest" and + # push it + if [[ "$(git tag | grep '^v' | grep -vE '(rc|dev|-|\+|\/)' | sort -r --version-sort | head -n1)" == "v$(./scripts/version.sh)" ]]; then + latest_target="$(./scripts/image_tag.sh --version latest)" + # shellcheck disable=SC2046 + ./scripts/build_docker_multiarch.sh \ + --push \ + --target "${latest_target}" \ + $(cat build/coder_"$version"_linux_{amd64,arm64,armv7}.tag) + echo "created_latest_tag=true" >> "$GITHUB_OUTPUT" + echo "latest_target=${latest_target}" >> "$GITHUB_OUTPUT" + else + echo "created_latest_tag=false" >> "$GITHUB_OUTPUT" + fi + env: + CODER_BASE_IMAGE_TAG: ${{ steps.image-base-tag.outputs.tag }} + + - name: SBOM Generation and Attestation + env: + COSIGN_EXPERIMENTAL: '1' + MULTIARCH_IMAGE: ${{ steps.build_docker.outputs.multiarch_image }} + VERSION: ${{ steps.version.outputs.version }} + CREATED_LATEST_TAG: ${{ steps.build_docker.outputs.created_latest_tag }} + run: | + set -euxo pipefail + + # Generate SBOM for multi-arch image with version in filename + echo "Generating SBOM for multi-arch image: ${MULTIARCH_IMAGE}" + syft "${MULTIARCH_IMAGE}" -o spdx-json > "coder_${VERSION}_sbom.spdx.json" + + echo "Attesting SBOM to multi-arch image: ${MULTIARCH_IMAGE}" + cosign clean --force=true "${MULTIARCH_IMAGE}" + cosign attest --type spdxjson \ + --predicate "coder_${VERSION}_sbom.spdx.json" \ + --yes \ + "${MULTIARCH_IMAGE}" + + # If latest tag was created, also attest it + if [[ "${CREATED_LATEST_TAG}" == "true" ]]; then + latest_tag="$(./scripts/image_tag.sh --version latest)" + echo "Generating SBOM for latest image: ${latest_tag}" + syft "${latest_tag}" -o spdx-json > coder_latest_sbom.spdx.json + + echo "Attesting SBOM to latest image: ${latest_tag}" + cosign clean --force=true "${latest_tag}" + cosign attest --type spdxjson \ + --predicate coder_latest_sbom.spdx.json \ + --yes \ + "${latest_tag}" + fi + + - name: Resolve Docker image digests for attestation + id: docker_digests + continue-on-error: true + env: + MULTIARCH_IMAGE: ${{ steps.build_docker.outputs.multiarch_image }} + LATEST_TARGET: ${{ steps.build_docker.outputs.latest_target }} + run: | + set -euxo pipefail + if [[ -n "${MULTIARCH_IMAGE}" ]]; then + multiarch_digest=$(docker buildx imagetools inspect --raw "${MULTIARCH_IMAGE}" | sha256sum | awk '{print "sha256:"$1}') + echo "multiarch_digest=${multiarch_digest}" >> "$GITHUB_OUTPUT" + fi + if [[ -n "${LATEST_TARGET}" ]]; then + latest_digest=$(docker buildx imagetools inspect --raw "${LATEST_TARGET}" | sha256sum | awk '{print "sha256:"$1}') + echo "latest_digest=${latest_digest}" >> "$GITHUB_OUTPUT" + fi + + - name: GitHub Attestation for Docker image + id: attest_main + if: ${{ steps.docker_digests.outputs.multiarch_digest != '' }} + continue-on-error: true + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 + with: + subject-name: ghcr.io/coder/coder + subject-digest: ${{ steps.docker_digests.outputs.multiarch_digest }} + push-to-registry: true + + - name: GitHub Attestation for "latest" Docker image + id: attest_latest + if: ${{ steps.docker_digests.outputs.latest_digest != '' }} + continue-on-error: true + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 + with: + subject-name: ghcr.io/coder/coder + subject-digest: ${{ steps.docker_digests.outputs.latest_digest }} + push-to-registry: true + + - name: GitHub Attestation for release binaries + id: attest_binaries + continue-on-error: true + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 + with: + subject-path: | + ./build/*.tar.gz + ./build/*.zip + ./build/*.deb + ./build/*.rpm + ./build/*.apk + ./build/*_installer.exe + ./build/*_helm_*.tgz + ./build/provisioner_helm_*.tgz + + # Report attestation failures but don't fail the workflow + - name: Check attestation status + run: | # zizmor: ignore[template-injection] We're just reading steps.attest_x.outcome here, no risk of injection + if [[ "${{ steps.attest_base.outcome }}" == "failure" && "${{ steps.attest_base.conclusion }}" != "skipped" ]]; then + echo "::warning::GitHub attestation for base image failed" + fi + if [[ "${{ steps.attest_main.outcome }}" == "failure" ]]; then + echo "::warning::GitHub attestation for main image failed" + fi + if [[ "${{ steps.attest_latest.outcome }}" == "failure" && "${{ steps.attest_latest.conclusion }}" != "skipped" ]]; then + echo "::warning::GitHub attestation for latest image failed" + fi + if [[ "${{ steps.attest_binaries.outcome }}" == "failure" && "${{ steps.attest_binaries.conclusion }}" != "skipped" ]]; then + echo "::warning::GitHub attestation for release binaries failed" + fi + + - name: Generate offline docs + run: | + version="$(./scripts/version.sh)" + make -j build/coder_docs_"$version".tgz + + - name: ls build + run: ls -lh build + + - name: Publish Coder CLI binaries and detached signatures to GCS + run: | + set -euxo pipefail + + version="$(./scripts/version.sh)" + + # Source array of slim binaries + declare -A binaries + binaries["coder-darwin-amd64"]="coder-slim_${version}_darwin_amd64" + binaries["coder-darwin-arm64"]="coder-slim_${version}_darwin_arm64" + binaries["coder-linux-amd64"]="coder-slim_${version}_linux_amd64" + binaries["coder-linux-arm64"]="coder-slim_${version}_linux_arm64" + binaries["coder-linux-armv7"]="coder-slim_${version}_linux_armv7" + binaries["coder-windows-amd64.exe"]="coder-slim_${version}_windows_amd64.exe" + binaries["coder-windows-arm64.exe"]="coder-slim_${version}_windows_arm64.exe" + + for cli_name in "${!binaries[@]}"; do + slim_binary="${binaries[$cli_name]}" + detached_signature="${slim_binary}.asc" + gcloud storage cp "./build/${slim_binary}" "gs://releases.coder.com/coder-cli/${version}/${cli_name}" + gcloud storage cp "./build/${detached_signature}" "gs://releases.coder.com/coder-cli/${version}/${cli_name}.asc" + done + + - name: Publish release + run: | + set -euo pipefail + + # Build the list of files to publish. + files=( + ./build/*_installer.exe + ./build/*.zip + ./build/*.tar.gz + ./build/*.tgz + ./build/*.apk + ./build/*.deb + ./build/*.rpm + "./coder_${VERSION}_sbom.spdx.json" + ) + + # Only include the latest SBOM file if it was created. + if [[ "${CREATED_LATEST_TAG}" == "true" ]]; then + files+=(./coder_latest_sbom.spdx.json) + fi + + stable_flag=() + if [[ "$CODER_RELEASE_STABLE" == "true" ]]; then + stable_flag=(--stable) + fi + + go run ./scripts/release-action publish \ + --version "v${VERSION}" \ + "${stable_flag[@]}" \ + --release-notes-file "$CODER_RELEASE_NOTES_FILE" \ + "${files[@]}" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.version.outputs.version }} + CREATED_LATEST_TAG: ${{ steps.build_docker.outputs.created_latest_tag }} + + # Mark the Linear release as shipped. + - name: Extract Linear release version + id: linear_version + run: | + # Skip RC releases — they must not complete the Linear release. + if [[ "$VERSION" == *-rc* ]]; then + echo "RC release (${VERSION}), skipping Linear release completion." + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + # Strip patch to get the Linear release version (e.g. 2.32.0 -> 2.32). + linear_version=$(echo "$VERSION" | cut -d. -f1,2) + echo "version=$linear_version" >> "$GITHUB_OUTPUT" + echo "skip=false" >> "$GITHUB_OUTPUT" + echo "Completing Linear release ${linear_version}" + env: + VERSION: ${{ steps.version.outputs.version }} + + - name: Complete Linear release + if: ${{ steps.linear_version.outputs.skip != 'true' }} + continue-on-error: true + uses: linear/linear-release-action@0353b5fa8c00326913966f00557d68f8f30b8b6b # v0.7.0 + with: + access_key: ${{ secrets.LINEAR_ACCESS_KEY }} + command: complete + version: ${{ steps.linear_version.outputs.version }} + timeout: 300 + + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0 + with: + workload_identity_provider: ${{ vars.GCP_WORKLOAD_ID_PROVIDER }} + service_account: ${{ vars.GCP_SERVICE_ACCOUNT }} + + - name: Setup GCloud SDK + uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # 3.0.1 + + - name: Publish Helm Chart + run: | + set -euo pipefail + version="$(./scripts/version.sh)" + mkdir -p build/helm + cp "build/coder_helm_${version}.tgz" build/helm + cp "build/provisioner_helm_${version}.tgz" build/helm + gsutil cp gs://helm.coder.com/v2/index.yaml build/helm/index.yaml + helm repo index build/helm --url https://helm.coder.com/v2 --merge build/helm/index.yaml + gsutil -h "Cache-Control:no-cache,max-age=0" cp "build/helm/coder_helm_${version}.tgz" gs://helm.coder.com/v2 + gsutil -h "Cache-Control:no-cache,max-age=0" cp "build/helm/provisioner_helm_${version}.tgz" gs://helm.coder.com/v2 + gsutil -h "Cache-Control:no-cache,max-age=0" cp "build/helm/index.yaml" gs://helm.coder.com/v2 + gsutil -h "Cache-Control:no-cache,max-age=0" cp "helm/artifacthub-repo.yml" gs://helm.coder.com/v2 + helm push "build/coder_helm_${version}.tgz" oci://ghcr.io/coder/chart + helm push "build/provisioner_helm_${version}.tgz" oci://ghcr.io/coder/chart + + - name: Send repository-dispatch event + if: ${{ inputs.release_type != 'rc' && inputs.release_type != 'create-release-branch' }} + uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 + with: + token: ${{ secrets.CDRCI_GITHUB_TOKEN }} + repository: coder/packages + event-type: coder-release + client-payload: '{"coder_version": "${{ steps.version.outputs.version }}"}' + + publish-homebrew: + name: Publish to Homebrew tap + runs-on: ubuntu-latest + needs: [release, prepare-release] + if: ${{ inputs.release_type != 'rc' && inputs.release_type != 'create-release-branch' && needs.prepare-release.outputs.stable == 'true' }} + + steps: + - name: Harden Runner + uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 + with: + egress-policy: audit + + - name: Update homebrew + env: + GH_REPO: coder/homebrew-coder + GH_TOKEN: ${{ secrets.CDRCI_GITHUB_TOKEN }} + VERSION: ${{ needs.release.outputs.version }} + run: | + # Keep version number around for reference, removing any potential leading v + coder_version="$(echo "${VERSION}" | tr -d v)" + + set -euxo pipefail + + # Setup Git + git config --global user.email "ci@coder.com" + git config --global user.name "Coder CI" + git config --global credential.helper "store" + + temp_dir="$(mktemp -d)" + cd "$temp_dir" + + # Download checksums + checksums_url="$(gh release view --repo coder/coder "v$coder_version" --json assets \ + | jq -r ".assets | map(.url) | .[]" \ + | grep -e ".checksums.txt\$")" + wget "$checksums_url" -O checksums.txt + + # Get the SHAs + darwin_arm_sha="$(grep "darwin_arm64.zip" checksums.txt | awk '{ print $1 }')" + darwin_intel_sha="$(grep "darwin_amd64.zip" checksums.txt | awk '{ print $1 }')" + linux_sha="$(grep "linux_amd64.tar.gz" checksums.txt | awk '{ print $1 }')" + + echo "macOS arm64: $darwin_arm_sha" + echo "macOS amd64: $darwin_intel_sha" + echo "Linux amd64: $linux_sha" + + # Check out the homebrew repo + git clone "https://github.com/$GH_REPO" homebrew-coder + brew_branch="auto-release/$coder_version" + cd homebrew-coder + + # Check if a PR already exists. + pr_count="$(gh pr list --search "head:$brew_branch" --json id,closed | jq -r ".[] | select(.closed == false) | .id" | wc -l)" + if [ "$pr_count" -gt 0 ]; then + echo "Bailing out as PR already exists" 2>&1 + exit 0 + fi + + # Set up cdrci credentials for pushing to homebrew-coder + echo "https://x-access-token:$GH_TOKEN@github.com" >> ~/.git-credentials + # Update the formulae and push + git checkout -b "$brew_branch" + ./scripts/update-v2.sh "$coder_version" "$darwin_arm_sha" "$darwin_intel_sha" "$linux_sha" + git add . + git commit -m "coder $coder_version" + git push -u origin -f "$brew_branch" + + # Create PR + gh pr create \ + -B master -H "$brew_branch" \ + -t "coder $coder_version" \ + -b "" \ + -r "${GITHUB_ACTOR}" \ + -a "${GITHUB_ACTOR}" \ + -b "This automatic PR was triggered by the release of Coder v$coder_version" + + + publish-winget: + name: Publish to winget-pkgs + runs-on: windows-latest + needs: [release, prepare-release] + if: ${{ inputs.release_type != 'rc' && inputs.release_type != 'create-release-branch' }} + + steps: + - name: Harden Runner + uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 + with: + egress-policy: audit + + - name: Sync fork + run: gh repo sync cdrci/winget-pkgs -b master + env: + GH_TOKEN: ${{ secrets.CDRCI_GITHUB_TOKEN }} + + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: false + + # If the event that triggered the build was an annotated tag (which our + # tags are supposed to be), actions/checkout has a bug where the tag in + # question is only a lightweight tag and not a full annotated tag. This + # command seems to fix it. + # https://github.com/actions/checkout/issues/290 + - name: Fetch git tags + run: git fetch --tags --force + + - name: Install wingetcreate + run: | + Invoke-WebRequest https://aka.ms/wingetcreate/latest -OutFile wingetcreate.exe + + - name: Submit updated manifest to winget-pkgs + run: | + # The package version is the same as the tag minus the leading "v". + # The version in this output already has the leading "v" removed but + # we do it again to be safe. + $version = $env:VERSION.Trim('v') + + $release_assets = gh release view --repo coder/coder "v${version}" --json assets | ` + ConvertFrom-Json + # Get the installer URLs from the release assets. + $amd64_installer_url = $release_assets.assets | ` + Where-Object name -Match ".*_windows_amd64_installer.exe$" | ` + Select -ExpandProperty url + $amd64_zip_url = $release_assets.assets | ` + Where-Object name -Match ".*_windows_amd64.zip$" | ` + Select -ExpandProperty url + $arm64_zip_url = $release_assets.assets | ` + Where-Object name -Match ".*_windows_arm64.zip$" | ` + Select -ExpandProperty url + + echo "amd64 Installer URL: ${amd64_installer_url}" + echo "amd64 zip URL: ${amd64_zip_url}" + echo "arm64 zip URL: ${arm64_zip_url}" + echo "Package version: ${version}" + + .\wingetcreate.exe update Coder.Coder ` + --submit ` + --version "${version}" ` + --urls "${amd64_installer_url}" "${amd64_zip_url}" "${arm64_zip_url}" + + env: + # For gh CLI: + GH_TOKEN: ${{ github.token }} + # For wingetcreate. We need a real token since we're pushing a commit + # to GitHub and then making a PR in a different repo. + # wingetcreate will read the token from the environment variable defined below. + # Reference: https://aka.ms/winget-create-token + WINGET_CREATE_GITHUB_TOKEN: ${{ secrets.CDRCI_GITHUB_TOKEN }} + VERSION: ${{ needs.release.outputs.version }} + + - name: Comment on PR + run: | + # wait 30 seconds + Start-Sleep -Seconds 30.0 + # Find the PR that wingetcreate just made. + $version = $env:VERSION.Trim('v') + $pr_list = gh pr list --repo microsoft/winget-pkgs --search "author:cdrci Coder.Coder version ${version}" --limit 1 --json number | ` + ConvertFrom-Json + $pr_number = $pr_list[0].number + + gh pr comment --repo microsoft/winget-pkgs "${pr_number}" --body "🤖 cc: @deansheather @matifali" + + env: + # For gh CLI. We need a real token since we're commenting on a PR in a + # different repo. + GH_TOKEN: ${{ secrets.CDRCI_GITHUB_TOKEN }} + VERSION: ${{ needs.release.outputs.version }} + + + update-docs: + name: Update release docs + needs: [prepare-release, release] + if: ${{ inputs.release_type != 'rc' && inputs.release_type != 'create-release-branch' }} + runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }} + permissions: + contents: write + pull-requests: write + steps: + - name: Harden Runner + uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: main + fetch-depth: 0 + persist-credentials: true + + - name: Fetch git tags + run: git fetch --tags --force + + - name: Setup Node + uses: ./.github/actions/setup-node + + - name: Update release calendar + run: ./scripts/update-release-calendar.sh + + - name: Create docs update PR + uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: "docs: update release docs for ${{ needs.prepare-release.outputs.version }}" + title: "docs: update release docs for ${{ needs.prepare-release.outputs.version }}" + body: "Automated docs update for release ${{ needs.prepare-release.outputs.version }}." + branch: docs/release-${{ needs.prepare-release.outputs.version }} + base: main From f2910291d11a2785d61409e5f4d5a6dc5e90426d Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Wed, 17 Jun 2026 22:36:29 +0000 Subject: [PATCH 7/8] ci: replace emdash with valid punctuation in release workflows make lint/emdash flagged emdash characters in an echo string in release.yaml (reintroduced by restoring the pre-#25162 file) and in a comment in tag-and-release.yaml. Replace both with a comma and a period respectively. --- .github/workflows/release.yaml | 2 +- .github/workflows/tag-and-release.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 2427e3586f0..f7bc611357e 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -130,7 +130,7 @@ jobs: release_branch="release/${base_version%.*}" if [[ "$version" == *-rc.* ]]; then - echo "RC release detected — skipping release branch check (RC tags are cut from main)." + echo "RC release detected, skipping release branch check (RC tags are cut from main)." else branch_contains_tag=$(git branch --remotes --contains "${GITHUB_REF}" --list "*/${release_branch}" --format='%(refname)') if [[ -z "${branch_contains_tag}" ]]; then diff --git a/.github/workflows/tag-and-release.yaml b/.github/workflows/tag-and-release.yaml index 9015cdad5c0..2e0b835e04c 100644 --- a/.github/workflows/tag-and-release.yaml +++ b/.github/workflows/tag-and-release.yaml @@ -649,7 +649,7 @@ jobs: - name: Extract Linear release version id: linear_version run: | - # Skip RC releases — they must not complete the Linear release. + # Skip RC releases. They must not complete the Linear release. if [[ "$VERSION" == *-rc* ]]; then echo "RC release (${VERSION}), skipping Linear release completion." echo "skip=true" >> "$GITHUB_OUTPUT" From 77735f9129caa0d80eec6f9d0e281c4b63e1cc04 Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Wed, 1 Jul 2026 12:29:05 +0000 Subject: [PATCH 8/8] fix(scripts/release-action): resolve short commit SHA and fix shelljoin test Address code review on #26422. resolveCommit now resolves a provided commit SHA to its full 40-char form via `git rev-parse --verify ^{commit}`. The idempotency checks in createAndPushTag/createAndPushBranch compare targetRef against full SHAs (git rev-parse of an existing tag, ls-remote branch output), so a short SHA passed through the `commit` input previously never matched and broke re-runs. TestShelljoin's "quote" case asserted nothing meaningful; it now verifies the actual POSIX single-quote escaped output. --- scripts/release-action/calculate.go | 11 +++++- scripts/release-action/calculate_test.go | 50 ++++++++++++++++++++++++ scripts/release-action/cmdexec_test.go | 9 ++--- 3 files changed, 63 insertions(+), 7 deletions(-) diff --git a/scripts/release-action/calculate.go b/scripts/release-action/calculate.go index a8a05f809f9..2dc2ce4b987 100644 --- a/scripts/release-action/calculate.go +++ b/scripts/release-action/calculate.go @@ -97,7 +97,16 @@ func resolveCommit(exec CommandExecutor, ref, commitSHA string) (string, error) if !isHexSHA(commitSHA) { return "", xerrors.Errorf("invalid commit SHA %q: must be a hex string", commitSHA) } - return commitSHA, nil + // Resolve to a full commit SHA. The idempotency checks in + // createAndPushTag/createAndPushBranch compare targetRef + // against full SHAs (git rev-parse of an existing tag, + // ls-remote branch output), so a short SHA passed via the + // commit input would never match and would break re-runs. + sha, err := gitOutput(exec, "rev-parse", "--verify", commitSHA+"^{commit}") + if err != nil { + return "", xerrors.Errorf("resolve commit %s: %w", commitSHA, err) + } + return sha, nil } sha, err := gitOutput(exec, "rev-parse", fmt.Sprintf("origin/%s", ref)) if err != nil { diff --git a/scripts/release-action/calculate_test.go b/scripts/release-action/calculate_test.go index 68968ad6dd7..219b525ef9a 100644 --- a/scripts/release-action/calculate_test.go +++ b/scripts/release-action/calculate_test.go @@ -425,3 +425,53 @@ func Test_isHexSHA(t *testing.T) { }) } } + +func Test_resolveCommit(t *testing.T) { + t.Parallel() + + t.Run("ShortSHAResolvedToFull", func(t *testing.T) { + t.Parallel() + const full = "1234567890abcdef1234567890abcdef12345678" + var gotArgs []string + mock := &mockExecutor{ + RunOutputFunc: func(_ string, args ...string) (string, error) { + gotArgs = args + return full, nil + }, + } + got, err := resolveCommit(mock, "main", "1234567") + require.NoError(t, err) + require.Equal(t, full, got) + require.Equal(t, []string{"rev-parse", "--verify", "1234567^{commit}"}, gotArgs) + }) + + t.Run("EmptyResolvesRefHead", func(t *testing.T) { + t.Parallel() + const full = "abcdef1234567890abcdef1234567890abcdef12" + var gotArgs []string + mock := &mockExecutor{ + RunOutputFunc: func(_ string, args ...string) (string, error) { + gotArgs = args + return full, nil + }, + } + got, err := resolveCommit(mock, "main", "") + require.NoError(t, err) + require.Equal(t, full, got) + require.Equal(t, []string{"rev-parse", "origin/main"}, gotArgs) + }) + + t.Run("InvalidSHANotResolved", func(t *testing.T) { + t.Parallel() + called := false + mock := &mockExecutor{ + RunOutputFunc: func(_ string, _ ...string) (string, error) { + called = true + return "", nil + }, + } + _, err := resolveCommit(mock, "main", "zzzzzzz") + require.Error(t, err) + require.False(t, called, "git should not be invoked for an invalid SHA") + }) +} diff --git a/scripts/release-action/cmdexec_test.go b/scripts/release-action/cmdexec_test.go index 0152d634386..ae45fb9c4cc 100644 --- a/scripts/release-action/cmdexec_test.go +++ b/scripts/release-action/cmdexec_test.go @@ -111,18 +111,15 @@ func TestShelljoin(t *testing.T) { }{ {"simple", []string{"a", "b"}, "a b"}, {"space", []string{"a", "has space"}, "a 'has space'"}, - {"quote", []string{"it's"}, "\"it's\""}, + // shelljoin uses POSIX single-quote escaping, so an embedded + // single quote becomes '"'"'. + {"quote", []string{"it's"}, `'it'"'"'s'`}, {"empty", []string{}, ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() got := shelljoin(tt.args) - // The quote test just checks it doesn't panic and wraps. - if tt.name == "quote" { - assert.Contains(t, got, "it") - return - } assert.Equal(t, tt.want, got) }) }