From bfbacd64f4c56cce2a6fdab853669e8c3cfc77e5 Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Tue, 7 Jul 2026 11:13:50 -0400 Subject: [PATCH] refactor: consolidate release tooling into a single releaser command (#27034) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Consolidates the two separate release programs into a single command at `scripts/releaser`: - `scripts/releaser/v1/` — the former interactive releaser (package `v1`). - `scripts/releaser/v2/` — the former `scripts/release-action` CI tool (package `v2`). - `scripts/releaser/main.go` — new entrypoint. Runs the **v2** tooling by default and the **v1** interactive wizard with `--legacy`. ## CLI shape Three documented subcommands, each backed by v2 `prepare-release` with the release type baked in: - `releaser rc` — tag a release candidate - `releaser branch` — cut a new release branch and tag its first RC - `releaser release` — tag a stable release or patch The former release-action verbs (`calculate-version`, `prepare-release`, `generate-notes`, `publish`) are retained as **hidden** top-level commands with identical flags and stdout, so `tag-and-release.yaml` migrates with a path-only change (`scripts/release-action` -> `scripts/releaser`). `--legacy` runs the v1 wizard and is mutually exclusive with the subcommands. `scripts/release.sh` now launches `releaser --legacy`. All file moves are rename-detected by git, so the per-file diff is just the package declaration. ## Testing - `go build ./scripts/...`, `go vet ./scripts/releaser/...`, `go test ./scripts/releaser/...` - `golangci-lint run ./scripts/releaser/...`, `make lint/emdash`, `shellcheck`, `actionlint` - Smoke: `releaser --help` shows only rc/branch/release; hidden verbs still run; `releaser rc --ref main --dry-run` emits the same JSON contract; `--legacy rc` errors cleanly.
Implementation plan # Plan: Consolidate release tooling into a single `scripts/releaser` command ## Goal Merge the two separate release programs into one binary at `scripts/releaser`: - `scripts/releaser/v1/` — the current interactive releaser (package `v1`). - `scripts/releaser/v2/` — the current CI `scripts/release-action` (package `v2`). - `scripts/releaser/main.go` — new entrypoint (package `main`). - Uses v2 by default, v1 with `--legacy`. - Exposes 3 subcommands: `rc`, `branch` (cut release branch), `release`. ## Design decision (Option A, chosen) The workflow needs `prepare-release`, `generate-notes`, and `publish` invokable separately (a build happens between prepare and publish). The latter two are version-driven and type-agnostic, so they do not map cleanly onto `rc`/`branch`/`release`. - Visible subcommands `rc`, `branch`, `release` run v2 `prepare-release` with the type baked in and print the same JSON. - Hidden verbs `calculate-version`, `prepare-release`, `generate-notes`, `publish` keep byte-identical flags/stdout, so the workflow change is path-only. Lowest risk; honors "3 subcommands" from a UX perspective. ## `--legacy` semantics - `releaser --legacy` runs the v1 interactive wizard (preserves today's behavior; the wizard auto-detects RC vs release from the branch). - `--legacy` is mutually exclusive with the subcommands (clear error if combined), because v1 auto-detects type and cannot cut a branch. ## Work items 1. Create `v1` and `v2` packages via `git mv`, renaming `package main`. Move the `owner`/`repo` consts into each package. Add `v1.Run(inv, dryRun)` (old wizard `main()` body) and v2 command builders (`CICommands`, `TypeCommand`) so internals stay unexported. 2. New `scripts/releaser/main.go`: top-level `releaser` with `--legacy`, the 3 subcommands, and the hidden compat verbs; delegates to `v1.Run` for legacy. 3. Update references: `tag-and-release.yaml` (3 command paths + header comment) and `scripts/release.sh` (`--legacy`). 4. Verify: build, vet, test, `go run` smoke tests, fmt, lint. 5. Open a single PR from a feature branch. ## Risks / notes - stdout contract for rc/branch/release and the hidden verbs must stay identical (workflow parses stdout); logs go to stderr. - Patch releases from pre-existing `release/X.Y` branches run those branches' own (old) workflow + `scripts/release-action`, so they stay self-consistent. New releases cut from branches containing this change get the new workflow + `scripts/releaser`. No forwarding stub needed since code and workflow ship together.
--- This PR was created by Coder Agents on behalf of @f0ssel. --- .github/workflows/tag-and-release.yaml | 10 +- scripts/release-action/main.go | 204 -------------- scripts/release.sh | 8 +- scripts/releaser/main.go | 108 +++---- scripts/releaser/{ => v1}/commit.go | 2 +- scripts/releaser/{ => v1}/docs.go | 2 +- scripts/releaser/{ => v1}/executor.go | 2 +- scripts/releaser/{ => v1}/git.go | 2 +- scripts/releaser/{ => v1}/github.go | 2 +- scripts/releaser/{ => v1}/release.go | 2 +- scripts/releaser/v1/run.go | 67 +++++ scripts/releaser/{ => v1}/ui.go | 2 +- scripts/releaser/{ => v1}/version.go | 2 +- scripts/releaser/{ => v1}/version_test.go | 2 +- .../v2}/calculate.go | 2 +- .../v2}/calculate_test.go | 2 +- .../v2}/cmdexec.go | 2 +- .../v2}/cmdexec_test.go | 2 +- scripts/releaser/v2/commands.go | 264 ++++++++++++++++++ .../{release-action => releaser/v2}/commit.go | 2 +- .../v2}/commit_test.go | 2 +- .../{release-action => releaser/v2}/git.go | 2 +- .../{release-action => releaser/v2}/github.go | 2 +- .../{release-action => releaser/v2}/notes.go | 2 +- .../v2}/prepare.go | 2 +- .../v2}/prepare_test.go | 2 +- .../v2}/publish.go | 2 +- .../v2}/version.go | 2 +- .../v2}/version_test.go | 2 +- 29 files changed, 420 insertions(+), 287 deletions(-) delete mode 100644 scripts/release-action/main.go rename scripts/releaser/{ => v1}/commit.go (99%) rename scripts/releaser/{ => v1}/docs.go (99%) rename scripts/releaser/{ => v1}/executor.go (99%) rename scripts/releaser/{ => v1}/git.go (97%) rename scripts/releaser/{ => v1}/github.go (99%) rename scripts/releaser/{ => v1}/release.go (99%) create mode 100644 scripts/releaser/v1/run.go rename scripts/releaser/{ => v1}/ui.go (99%) rename scripts/releaser/{ => v1}/version.go (99%) rename scripts/releaser/{ => v1}/version_test.go (98%) rename scripts/{release-action => releaser/v2}/calculate.go (99%) rename scripts/{release-action => releaser/v2}/calculate_test.go (99%) rename scripts/{release-action => releaser/v2}/cmdexec.go (99%) rename scripts/{release-action => releaser/v2}/cmdexec_test.go (98%) create mode 100644 scripts/releaser/v2/commands.go rename scripts/{release-action => releaser/v2}/commit.go (99%) rename scripts/{release-action => releaser/v2}/commit_test.go (98%) rename scripts/{release-action => releaser/v2}/git.go (98%) rename scripts/{release-action => releaser/v2}/github.go (99%) rename scripts/{release-action => releaser/v2}/notes.go (99%) rename scripts/{release-action => releaser/v2}/prepare.go (99%) rename scripts/{release-action => releaser/v2}/prepare_test.go (98%) rename scripts/{release-action => releaser/v2}/publish.go (99%) rename scripts/{release-action => releaser/v2}/version.go (99%) rename scripts/{release-action => releaser/v2}/version_test.go (96%) diff --git a/.github/workflows/tag-and-release.yaml b/.github/workflows/tag-and-release.yaml index 1c0f49243c..d2d20bba45 100644 --- a/.github/workflows/tag-and-release.yaml +++ b/.github/workflows/tag-and-release.yaml @@ -1,9 +1,9 @@ # 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 +# scripts/releaser 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. +# triggered by the legacy interactive tool (scripts/releaser --legacy). name: Tag and Release on: workflow_dispatch: @@ -116,7 +116,7 @@ jobs: args+=(--dry-run) fi - output=$(go run ./scripts/release-action prepare-release "${args[@]}") + output=$(go run ./scripts/releaser prepare-release "${args[@]}") echo "Raw output: $output" version=$(echo "$output" | jq -r '.version') @@ -165,7 +165,7 @@ jobs: PREV_VERSION: ${{ steps.prepare.outputs.previous_version }} run: | set -euo pipefail - go run ./scripts/release-action generate-notes \ + go run ./scripts/releaser generate-notes \ --version "$VERSION" \ --previous-version "$PREV_VERSION" > /tmp/release_notes.md @@ -643,7 +643,7 @@ jobs: stable_flag=(--stable) fi - go run ./scripts/release-action publish \ + go run ./scripts/releaser publish \ --version "v${VERSION}" \ "${stable_flag[@]}" \ --release-notes-file "$CODER_RELEASE_NOTES_FILE" \ diff --git a/scripts/release-action/main.go b/scripts/release-action/main.go deleted file mode 100644 index ce0d87f671..0000000000 --- a/scripts/release-action/main.go +++ /dev/null @@ -1,204 +0,0 @@ -package main - -import ( - "errors" - "fmt" - "os" - - "golang.org/x/xerrors" - - "github.com/coder/serpent" -) - -const ( - owner = "coder" - repo = "coder" -) - -func main() { - var ( - releaseType string - ref string - commitSHA string - versionStr string - 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.", - Children: []*serpent.Command{ - { - Use: "calculate-version", - Short: "Calculate the next release version from git state.", - 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 := calculateNextVersion(newExecutor(), releaseType, ref, commitSHA) - if err != nil { - return err - } - _, _ = fmt.Fprintln(inv.Stdout, result.String()) - 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.", - Options: serpent.OptionSet{ - { - Name: "version", - Flag: "version", - Description: "New release version (e.g. v2.21.0).", - Value: serpent.StringOf(&versionStr), - Required: true, - }, - { - Name: "previous-version", - Flag: "previous-version", - Description: "Previous release version (e.g. v2.20.0).", - Value: serpent.StringOf(&prevVersionStr), - Required: true, - }, - dryRunOption, - }, - Handler: func(inv *serpent.Invocation) error { - newVer, err := parseVersion(versionStr) - if err != nil { - return xerrors.Errorf("parse --version: %w", err) - } - prevVer, err := parseVersion(prevVersionStr) - if err != nil { - return xerrors.Errorf("parse --previous-version: %w", err) - } - notes, err := generateReleaseNotes(newExecutor(), newVer, prevVer) - if err != nil { - return err - } - _, _ = fmt.Fprint(inv.Stdout, notes) - return nil - }, - }, - { - Use: "publish", - Short: "Publish a GitHub release with assets and checksums.", - Options: serpent.OptionSet{ - { - Name: "version", - Flag: "version", - Description: "Release version tag (e.g. v2.21.0).", - Value: serpent.StringOf(&versionStr), - Required: true, - }, - { - Name: "stable", - Flag: "stable", - Description: "Mark this release as the latest stable release.", - Value: serpent.BoolOf(&stable), - }, - { - Name: "release-notes-file", - Flag: "release-notes-file", - Description: "Path to release notes markdown file.", - 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(newExecutor(), versionStr, stable, notesFile, assets) - }, - }, - }, - } - - err := cmd.Invoke().WithOS().Run() - if err != nil { - // Unwrap serpent's "running command ..." wrapper to keep output clean. - var runErr *serpent.RunCommandError - if errors.As(err, &runErr) { - err = runErr.Err - } - _, _ = fmt.Fprintf(os.Stderr, "error: %s\n", err) - os.Exit(1) - } -} diff --git a/scripts/release.sh b/scripts/release.sh index 0f44a81543..9e7e4173b2 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -2,11 +2,13 @@ set -euo pipefail -# Thin wrapper that invokes the Go release tool. +# Thin wrapper that launches the interactive release wizard. # Usage: ./scripts/release.sh [flags] # -# Flags are passed directly to the Go program. +# Flags are passed directly to the Go program. The wizard is the legacy +# (v1) release tool; the non-interactive tooling is available directly +# via `go run ./scripts/releaser `. # Run ./scripts/release.sh --help for details. cd "$(dirname "${BASH_SOURCE[0]}")/.." -exec go run ./scripts/releaser "$@" +exec go run ./scripts/releaser --legacy "$@" diff --git a/scripts/releaser/main.go b/scripts/releaser/main.go index 6394602f9e..e5f26e6f4a 100644 --- a/scripts/releaser/main.go +++ b/scripts/releaser/main.go @@ -2,76 +2,80 @@ package main import ( "errors" - "fmt" "os" - "os/exec" "golang.org/x/xerrors" "github.com/coder/coder/v2/cli/cliui" + releaserv1 "github.com/coder/coder/v2/scripts/releaser/v1" + releaserv2 "github.com/coder/coder/v2/scripts/releaser/v2" "github.com/coder/pretty" "github.com/coder/serpent" ) -const ( - owner = "coder" - repo = "coder" -) - func main() { - var dryRun bool + var ( + legacy bool + dryRun bool + ) + + // Default (v2) subcommands. rc, branch, and release run the + // non-interactive prepare-release logic with the release type baked + // in. + children := []*serpent.Command{ + releaserv2.TypeCommand("rc", "Tag a release candidate from main or a release branch.", "rc"), + releaserv2.TypeCommand("branch", "Cut a new release branch and tag its first release candidate.", "create-release-branch"), + releaserv2.TypeCommand("release", "Tag a stable release or patch from a release branch.", "release"), + } + + // Hidden compatibility verbs. These preserve the exact names, flags, + // and stdout contract of the former scripts/release-action tool so + // GitHub Actions workflows migrate with a path-only change. + for _, c := range releaserv2.CICommands() { + c.Hidden = true + children = append(children, c) + } + + // --legacy selects the v1 interactive wizard and cannot be combined + // with a subcommand, which v1 does not understand. + for _, c := range children { + next := c.Handler + c.Handler = func(inv *serpent.Invocation) error { + if legacy { + return xerrors.New("--legacy cannot be combined with a subcommand; run 'releaser --legacy' for the interactive tool") + } + return next(inv) + } + } + cmd := &serpent.Command{ - Use: "releaser", - Short: "Interactive release tagging for coder/coder.", - Long: "Tag RCs from main, releases/patches from release/X.Y. The tool detects the branch, infers the next version, and walks you through tagging, pushing, and triggering the release workflow.", + Use: "releaser ", + Short: "Release tooling for coder/coder.", + Long: "Tag and publish releases for coder/coder.\n\n" + + "By default releaser runs the non-interactive tooling via the rc,\n" + + "branch, and release subcommands. Pass --legacy to run the older\n" + + "interactive release wizard instead.", Options: serpent.OptionSet{ + { + Name: "legacy", + Flag: "legacy", + Description: "Run the legacy interactive release wizard.", + Value: serpent.BoolOf(&legacy), + }, { Name: "dry-run", Flag: "dry-run", - Description: "Print write commands instead of executing them.", + Description: "Print mutating commands instead of executing them (legacy wizard only).", Value: serpent.BoolOf(&dryRun), }, }, + Children: children, Handler: func(inv *serpent.Invocation) error { - ctx := inv.Context() - w := inv.Stderr - - // --- Check dependencies --- - if _, err := exec.LookPath("git"); err != nil { - return xerrors.New("git is required but not found in PATH") + if legacy { + return releaserv1.Run(inv, dryRun) } - - // --- Check GPG signing --- - signingKey, _ := gitOutput("config", "--get", "user.signingkey") - gpgFormat, _ := gitOutput("config", "--get", "gpg.format") - gpgConfigured := signingKey != "" || gpgFormat != "" - if !gpgConfigured { - warnf(w, "GPG signing is not configured. Tags will be unsigned — there will be no way to verify who pushed the tag.") - _, _ = fmt.Fprintf(w, " To fix: set git config user.signingkey or gpg.format\n") - if err := confirmWithDefault(inv, "Continue without signing?", cliui.ConfirmNo); err != nil { - return err - } - _, _ = fmt.Fprintln(w) - } - - // --- Check gh CLI auth --- - ghAvailable := checkGHAuth() - if !ghAvailable { - warnf(w, "gh CLI is not available or not authenticated.") - infof(w, "Continuing without GitHub features (PR checks, label lookups, workflow trigger).") - _, _ = fmt.Fprintln(w) - } - - // --- Wire up executor --- - var executor ReleaseExecutor - if dryRun { - outputPrefix = "[DRYRUN] " - executor = &dryRunExecutor{w: w} - } else { - executor = &liveExecutor{} - } - - return runRelease(ctx, inv, executor, ghAvailable, gpgConfigured, dryRun) + // No subcommand given and not in legacy mode: show help. + return serpent.DefaultHelpFn()(inv) }, } @@ -80,8 +84,8 @@ func main() { if errors.Is(err, cliui.ErrCanceled) { os.Exit(1) } - // Unwrap serpent's "running command ..." wrapper to - // keep output clean. + // Unwrap serpent's "running command ..." wrapper to keep output + // clean. var runErr *serpent.RunCommandError if errors.As(err, &runErr) { err = runErr.Err diff --git a/scripts/releaser/commit.go b/scripts/releaser/v1/commit.go similarity index 99% rename from scripts/releaser/commit.go rename to scripts/releaser/v1/commit.go index 37bdfcb7b5..a42b1e4ebf 100644 --- a/scripts/releaser/commit.go +++ b/scripts/releaser/v1/commit.go @@ -1,4 +1,4 @@ -package main +package v1 import ( "regexp" diff --git a/scripts/releaser/docs.go b/scripts/releaser/v1/docs.go similarity index 99% rename from scripts/releaser/docs.go rename to scripts/releaser/v1/docs.go index e605d365bf..19e59e7695 100644 --- a/scripts/releaser/docs.go +++ b/scripts/releaser/v1/docs.go @@ -1,4 +1,4 @@ -package main +package v1 import ( "fmt" diff --git a/scripts/releaser/executor.go b/scripts/releaser/v1/executor.go similarity index 99% rename from scripts/releaser/executor.go rename to scripts/releaser/v1/executor.go index 6c92f67aa3..4293372185 100644 --- a/scripts/releaser/executor.go +++ b/scripts/releaser/v1/executor.go @@ -1,4 +1,4 @@ -package main +package v1 import ( "context" diff --git a/scripts/releaser/git.go b/scripts/releaser/v1/git.go similarity index 97% rename from scripts/releaser/git.go rename to scripts/releaser/v1/git.go index 3974e21582..cf6af3cf63 100644 --- a/scripts/releaser/git.go +++ b/scripts/releaser/v1/git.go @@ -1,4 +1,4 @@ -package main +package v1 import ( "errors" diff --git a/scripts/releaser/github.go b/scripts/releaser/v1/github.go similarity index 99% rename from scripts/releaser/github.go rename to scripts/releaser/v1/github.go index 75df80960f..13b99bea8b 100644 --- a/scripts/releaser/github.go +++ b/scripts/releaser/v1/github.go @@ -1,4 +1,4 @@ -package main +package v1 import ( "errors" diff --git a/scripts/releaser/release.go b/scripts/releaser/v1/release.go similarity index 99% rename from scripts/releaser/release.go rename to scripts/releaser/v1/release.go index 9d9723c7c3..f9635680da 100644 --- a/scripts/releaser/release.go +++ b/scripts/releaser/v1/release.go @@ -1,4 +1,4 @@ -package main +package v1 import ( "context" diff --git a/scripts/releaser/v1/run.go b/scripts/releaser/v1/run.go new file mode 100644 index 0000000000..a925972a27 --- /dev/null +++ b/scripts/releaser/v1/run.go @@ -0,0 +1,67 @@ +package v1 + +import ( + "fmt" + "os/exec" + + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/serpent" +) + +const ( + owner = "coder" + repo = "coder" +) + +// Run executes the legacy interactive release wizard. +// +// It mirrors the behavior of the original standalone releaser tool: it +// verifies dependencies, warns when GPG signing or the gh CLI are not +// configured, wires up a live or dry-run executor, and then walks the +// operator through tagging, pushing, and triggering the release +// workflow. +// +//nolint:revive // dryRun selects the dry-run executor for the wizard. +func Run(inv *serpent.Invocation, dryRun bool) error { + ctx := inv.Context() + w := inv.Stderr + + // --- Check dependencies --- + if _, err := exec.LookPath("git"); err != nil { + return xerrors.New("git is required but not found in PATH") + } + + // --- Check GPG signing --- + signingKey, _ := gitOutput("config", "--get", "user.signingkey") + gpgFormat, _ := gitOutput("config", "--get", "gpg.format") + gpgConfigured := signingKey != "" || gpgFormat != "" + if !gpgConfigured { + warnf(w, "GPG signing is not configured. Tags will be unsigned, so there will be no way to verify who pushed the tag.") + _, _ = fmt.Fprintf(w, " To fix: set git config user.signingkey or gpg.format\n") + if err := confirmWithDefault(inv, "Continue without signing?", cliui.ConfirmNo); err != nil { + return err + } + _, _ = fmt.Fprintln(w) + } + + // --- Check gh CLI auth --- + ghAvailable := checkGHAuth() + if !ghAvailable { + warnf(w, "gh CLI is not available or not authenticated.") + infof(w, "Continuing without GitHub features (PR checks, label lookups, workflow trigger).") + _, _ = fmt.Fprintln(w) + } + + // --- Wire up executor --- + var executor ReleaseExecutor + if dryRun { + outputPrefix = "[DRYRUN] " + executor = &dryRunExecutor{w: w} + } else { + executor = &liveExecutor{} + } + + return runRelease(ctx, inv, executor, ghAvailable, gpgConfigured, dryRun) +} diff --git a/scripts/releaser/ui.go b/scripts/releaser/v1/ui.go similarity index 99% rename from scripts/releaser/ui.go rename to scripts/releaser/v1/ui.go index b178e60c0d..e924c917e3 100644 --- a/scripts/releaser/ui.go +++ b/scripts/releaser/v1/ui.go @@ -1,4 +1,4 @@ -package main +package v1 import ( "io" diff --git a/scripts/releaser/version.go b/scripts/releaser/v1/version.go similarity index 99% rename from scripts/releaser/version.go rename to scripts/releaser/v1/version.go index f1e8107190..6ada481da9 100644 --- a/scripts/releaser/version.go +++ b/scripts/releaser/v1/version.go @@ -1,4 +1,4 @@ -package main +package v1 import ( "fmt" diff --git a/scripts/releaser/version_test.go b/scripts/releaser/v1/version_test.go similarity index 98% rename from scripts/releaser/version_test.go rename to scripts/releaser/v1/version_test.go index 914094a2e5..ab8abe2815 100644 --- a/scripts/releaser/version_test.go +++ b/scripts/releaser/v1/version_test.go @@ -1,4 +1,4 @@ -package main +package v1 //nolint:testpackage // Tests unexported release helpers. import ( "testing" diff --git a/scripts/release-action/calculate.go b/scripts/releaser/v2/calculate.go similarity index 99% rename from scripts/release-action/calculate.go rename to scripts/releaser/v2/calculate.go index 2dc2ce4b98..a365cf17e2 100644 --- a/scripts/release-action/calculate.go +++ b/scripts/releaser/v2/calculate.go @@ -1,4 +1,4 @@ -package main +package v2 import ( "encoding/json" diff --git a/scripts/release-action/calculate_test.go b/scripts/releaser/v2/calculate_test.go similarity index 99% rename from scripts/release-action/calculate_test.go rename to scripts/releaser/v2/calculate_test.go index 219b525ef9..40b74ae775 100644 --- a/scripts/release-action/calculate_test.go +++ b/scripts/releaser/v2/calculate_test.go @@ -1,4 +1,4 @@ -package main +package v2 //nolint:testpackage // Tests unexported release helpers. import ( "testing" diff --git a/scripts/release-action/cmdexec.go b/scripts/releaser/v2/cmdexec.go similarity index 99% rename from scripts/release-action/cmdexec.go rename to scripts/releaser/v2/cmdexec.go index 6af75b7376..40e2468d80 100644 --- a/scripts/release-action/cmdexec.go +++ b/scripts/releaser/v2/cmdexec.go @@ -1,4 +1,4 @@ -package main +package v2 import ( "bytes" diff --git a/scripts/release-action/cmdexec_test.go b/scripts/releaser/v2/cmdexec_test.go similarity index 98% rename from scripts/release-action/cmdexec_test.go rename to scripts/releaser/v2/cmdexec_test.go index b75e0363b8..9555e5a576 100644 --- a/scripts/release-action/cmdexec_test.go +++ b/scripts/releaser/v2/cmdexec_test.go @@ -1,4 +1,4 @@ -package main +package v2 //nolint:testpackage // Tests unexported release helpers. import ( "bytes" diff --git a/scripts/releaser/v2/commands.go b/scripts/releaser/v2/commands.go new file mode 100644 index 0000000000..51f3ab5847 --- /dev/null +++ b/scripts/releaser/v2/commands.go @@ -0,0 +1,264 @@ +package v2 + +import ( + "fmt" + "os" + + "golang.org/x/xerrors" + + "github.com/coder/serpent" +) + +const ( + owner = "coder" + repo = "coder" +) + +// newExecutor returns the appropriate CommandExecutor based on the +// dry-run setting. +// +//nolint:revive // dryRun selects the dry-run executor. +func newExecutor(dryRun bool) CommandExecutor { + if dryRun { + return newDryRunExecutor(os.Stderr) + } + return realExecutor{} +} + +// dryRunOption returns the shared --dry-run option bound to dryRun. +func dryRunOption(dryRun *bool) serpent.Option { + return serpent.Option{ + Name: "dry-run", + Flag: "dry-run", + Description: "Print mutating commands instead of executing them.", + Value: serpent.BoolOf(dryRun), + } +} + +// CICommands returns the low-level, CI-oriented release subcommands +// (calculate-version, prepare-release, generate-notes, publish). Their +// names, flags, and stdout output match the former scripts/release-action +// tool so GitHub Actions workflows can invoke them unchanged. +func CICommands() []*serpent.Command { + return []*serpent.Command{ + calculateVersionCommand(), + prepareReleaseCommand(), + generateNotesCommand(), + publishCommand(), + } +} + +// TypeCommand returns a command that runs prepare-release for a fixed +// release type. It backs the top-level rc, branch, and release +// subcommands, printing the same JSON as prepare-release. +func TypeCommand(use, short, releaseType string) *serpent.Command { + var ( + ref string + commitSHA string + dryRun bool + ) + return &serpent.Command{ + Use: use, + Short: short, + Options: serpent.OptionSet{ + { + Name: "ref", + Flag: "ref", + Description: "Git ref (branch name) to release from.", + 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(&dryRun), + }, + Handler: func(inv *serpent.Invocation) error { + result, err := prepareRelease(newExecutor(dryRun), releaseType, ref, commitSHA) + if err != nil { + return err + } + _, _ = fmt.Fprintln(inv.Stdout, result.String()) + return nil + }, + } +} + +func calculateVersionCommand() *serpent.Command { + var ( + releaseType string + ref string + commitSHA string + dryRun bool + ) + return &serpent.Command{ + Use: "calculate-version", + Short: "Calculate the next release version from git state.", + 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(&dryRun), + }, + Handler: func(inv *serpent.Invocation) error { + result, err := calculateNextVersion(newExecutor(dryRun), releaseType, ref, commitSHA) + if err != nil { + return err + } + _, _ = fmt.Fprintln(inv.Stdout, result.String()) + return nil + }, + } +} + +func prepareReleaseCommand() *serpent.Command { + var ( + releaseType string + ref string + commitSHA string + dryRun bool + ) + return &serpent.Command{ + 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(&dryRun), + }, + Handler: func(inv *serpent.Invocation) error { + result, err := prepareRelease(newExecutor(dryRun), releaseType, ref, commitSHA) + if err != nil { + return err + } + _, _ = fmt.Fprintln(inv.Stdout, result.String()) + return nil + }, + } +} + +func generateNotesCommand() *serpent.Command { + var ( + versionStr string + prevVersionStr string + dryRun bool + ) + return &serpent.Command{ + Use: "generate-notes", + Short: "Generate release notes from commit log and PR metadata.", + Options: serpent.OptionSet{ + { + Name: "version", + Flag: "version", + Description: "New release version (e.g. v2.21.0).", + Value: serpent.StringOf(&versionStr), + Required: true, + }, + { + Name: "previous-version", + Flag: "previous-version", + Description: "Previous release version (e.g. v2.20.0).", + Value: serpent.StringOf(&prevVersionStr), + Required: true, + }, + dryRunOption(&dryRun), + }, + Handler: func(inv *serpent.Invocation) error { + newVer, err := parseVersion(versionStr) + if err != nil { + return xerrors.Errorf("parse --version: %w", err) + } + prevVer, err := parseVersion(prevVersionStr) + if err != nil { + return xerrors.Errorf("parse --previous-version: %w", err) + } + notes, err := generateReleaseNotes(newExecutor(dryRun), newVer, prevVer) + if err != nil { + return err + } + _, _ = fmt.Fprint(inv.Stdout, notes) + return nil + }, + } +} + +func publishCommand() *serpent.Command { + var ( + versionStr string + stable bool + notesFile string + dryRun bool + ) + return &serpent.Command{ + Use: "publish", + Short: "Publish a GitHub release with assets and checksums.", + Options: serpent.OptionSet{ + { + Name: "version", + Flag: "version", + Description: "Release version tag (e.g. v2.21.0).", + Value: serpent.StringOf(&versionStr), + Required: true, + }, + { + Name: "stable", + Flag: "stable", + Description: "Mark this release as the latest stable release.", + Value: serpent.BoolOf(&stable), + }, + { + Name: "release-notes-file", + Flag: "release-notes-file", + Description: "Path to release notes markdown file.", + Value: serpent.StringOf(¬esFile), + Required: true, + }, + dryRunOption(&dryRun), + }, + Handler: func(inv *serpent.Invocation) error { + assets := inv.Args + if len(assets) == 0 { + return xerrors.New("no asset files provided as arguments") + } + return publishRelease(newExecutor(dryRun), versionStr, stable, notesFile, assets) + }, + } +} diff --git a/scripts/release-action/commit.go b/scripts/releaser/v2/commit.go similarity index 99% rename from scripts/release-action/commit.go rename to scripts/releaser/v2/commit.go index 16101a57db..466c1a0b53 100644 --- a/scripts/release-action/commit.go +++ b/scripts/releaser/v2/commit.go @@ -1,4 +1,4 @@ -package main +package v2 import ( "regexp" diff --git a/scripts/release-action/commit_test.go b/scripts/releaser/v2/commit_test.go similarity index 98% rename from scripts/release-action/commit_test.go rename to scripts/releaser/v2/commit_test.go index f9d01b77bb..35169b28f1 100644 --- a/scripts/release-action/commit_test.go +++ b/scripts/releaser/v2/commit_test.go @@ -1,4 +1,4 @@ -package main +package v2 //nolint:testpackage // Tests unexported release helpers. import ( "testing" diff --git a/scripts/release-action/git.go b/scripts/releaser/v2/git.go similarity index 98% rename from scripts/release-action/git.go rename to scripts/releaser/v2/git.go index 15ecc7c43f..1b40315e08 100644 --- a/scripts/release-action/git.go +++ b/scripts/releaser/v2/git.go @@ -1,4 +1,4 @@ -package main +package v2 // gitOutput runs a read-only git command and returns trimmed stdout. func gitOutput(exec CommandExecutor, args ...string) (string, error) { diff --git a/scripts/release-action/github.go b/scripts/releaser/v2/github.go similarity index 99% rename from scripts/release-action/github.go rename to scripts/releaser/v2/github.go index db5dd6beef..76e4fefaf6 100644 --- a/scripts/release-action/github.go +++ b/scripts/releaser/v2/github.go @@ -1,4 +1,4 @@ -package main +package v2 import ( "encoding/json" diff --git a/scripts/release-action/notes.go b/scripts/releaser/v2/notes.go similarity index 99% rename from scripts/release-action/notes.go rename to scripts/releaser/v2/notes.go index 342af28e51..44037eead8 100644 --- a/scripts/release-action/notes.go +++ b/scripts/releaser/v2/notes.go @@ -1,4 +1,4 @@ -package main +package v2 import ( "fmt" diff --git a/scripts/release-action/prepare.go b/scripts/releaser/v2/prepare.go similarity index 99% rename from scripts/release-action/prepare.go rename to scripts/releaser/v2/prepare.go index 7ee7f11cf9..aa23fa6a11 100644 --- a/scripts/release-action/prepare.go +++ b/scripts/releaser/v2/prepare.go @@ -1,4 +1,4 @@ -package main +package v2 import ( "fmt" diff --git a/scripts/release-action/prepare_test.go b/scripts/releaser/v2/prepare_test.go similarity index 98% rename from scripts/release-action/prepare_test.go rename to scripts/releaser/v2/prepare_test.go index cb86e0f8d2..c57c111720 100644 --- a/scripts/release-action/prepare_test.go +++ b/scripts/releaser/v2/prepare_test.go @@ -1,4 +1,4 @@ -package main +package v2 //nolint:testpackage // Tests unexported release helpers. import ( "bytes" diff --git a/scripts/release-action/publish.go b/scripts/releaser/v2/publish.go similarity index 99% rename from scripts/release-action/publish.go rename to scripts/releaser/v2/publish.go index a20098db7a..9323f49eb2 100644 --- a/scripts/release-action/publish.go +++ b/scripts/releaser/v2/publish.go @@ -1,4 +1,4 @@ -package main +package v2 import ( "crypto/sha256" diff --git a/scripts/release-action/version.go b/scripts/releaser/v2/version.go similarity index 99% rename from scripts/release-action/version.go rename to scripts/releaser/v2/version.go index 28c77975d6..506e7c792a 100644 --- a/scripts/release-action/version.go +++ b/scripts/releaser/v2/version.go @@ -1,4 +1,4 @@ -package main +package v2 import ( "fmt" diff --git a/scripts/release-action/version_test.go b/scripts/releaser/v2/version_test.go similarity index 96% rename from scripts/release-action/version_test.go rename to scripts/releaser/v2/version_test.go index e93bed09f3..162e9a8dee 100644 --- a/scripts/release-action/version_test.go +++ b/scripts/releaser/v2/version_test.go @@ -1,4 +1,4 @@ -package main +package v2 //nolint:testpackage // Tests unexported release helpers. import ( "testing"