mirror of
https://github.com/coder/coder.git
synced 2026-09-23 22:20:22 +08:00
## 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. <details> <summary>Implementation plan</summary> # 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. </details> --- This PR was created by Coder Agents on behalf of @f0ssel.
129 lines
3.8 KiB
Go
129 lines
3.8 KiB
Go
package v2
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os/exec"
|
|
"strings"
|
|
|
|
"golang.org/x/xerrors"
|
|
)
|
|
|
|
// 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
|
|
// trimmed stdout. Use for read-only commands.
|
|
RunOutput(name string, args ...string) (string, error)
|
|
|
|
// Run executes the named program with args, discarding output.
|
|
// Use for read-only commands where only the exit code matters.
|
|
Run(name string, args ...string) error
|
|
|
|
// 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
|
|
|
|
// RunMutationStdout executes a mutating command, streaming
|
|
// stdout and stderr to the provided writers. Stdin is set to
|
|
// 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 all 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) RunMutation(name string, args ...string) error {
|
|
cmd := exec.Command(name, args...)
|
|
// Capture stderr so that a failing mutation surfaces the command's
|
|
// error output (e.g. git's "fatal:" message) in the returned error
|
|
// instead of only the exit status.
|
|
var stderr bytes.Buffer
|
|
cmd.Stderr = &stderr
|
|
if err := cmd.Run(); err != nil {
|
|
if msg := strings.TrimSpace(stderr.String()); msg != "" {
|
|
return xerrors.Errorf("%s: %w", msg, err)
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (realExecutor) RunMutationStdout(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 delegates read-only commands to the real executor
|
|
// and prints mutating commands instead of executing them.
|
|
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...)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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, " ")
|
|
}
|