mirror of
https://github.com/coder/coder.git
synced 2026-09-23 05:43:53 +08:00
## What happened The [Tag and Release run](https://github.com/coder/coder/actions/runs/28549109434/job/84641825784) failed in the `prepare-release` job at the step "Prepare release (calculate version, create tag and branch)" with: ``` error: create tag v2.35.0-rc.0: exit status 128 ``` ## Root cause `prepare-release` creates an **annotated** tag via `git tag -a` (`scripts/release-action/prepare.go`), which records a tagger and therefore requires a git identity. The job never ran `git config user.name/user.email`, and runners have none configured, so git aborts with exit status 128. The real `fatal:` message was hidden because `realExecutor.RunMutation` discarded the command's stderr. ## Changes - **`.github/workflows/tag-and-release.yaml`**: add a "Configure git identity" step (`ci@coder.com` / `Coder CI`) to the `prepare-release` job, before the release tool runs. This matches the identity pattern already used later in the same workflow. - **`scripts/release-action/cmdexec.go`**: capture stderr in `RunMutation` and include it in the returned error, so a failing mutation surfaces the underlying command output (e.g. git's `fatal:` line) instead of only `exit status N`. - **`scripts/release-action/cmdexec_test.go`**: add a test asserting stderr is surfaced on failure. ## Testing - `go test ./scripts/release-action/...` passes. - `go vet ./scripts/release-action/...` and `gofmt` clean. - `actionlint .github/workflows/tag-and-release.yaml` clean. - Reproduced the failure locally: `git tag -a` with no usable identity exits 128 (`fatal: no email was given and auto-detection is disabled`); with an identity configured it succeeds. <details> <summary>Root-cause analysis / decision log</summary> **Failing step** runs `go run ./scripts/release-action prepare-release --type create-release-branch --ref main --commit cb1a87b…`. 1. The tool computes the next version `v2.35.0-rc.0` and calls `createAndPushTag`, which runs `git tag -a v2.35.0-rc.0 -m "Release v2.35.0-rc.0" <targetRef>` (`prepare.go:56`). 2. That git command exits **128**, wrapped as `error: create tag v2.35.0-rc.0: exit status 128`. **Why it's the identity, and not something else:** - No `git config user.name/user.email` step exists in the `prepare-release` job; the `setup-mise` action does not set it; and the tool itself never sets an identity. Annotated tags require a tagger, so `git tag -a` fails on runners whose auto-detected identity is bogus (`…@runner.(none)`), which is rejected under git's strict identity check. - Not a pre-existing tag collision: no `v2.35.0*` tag exists on the remote, and the code pre-checks for an existing tag (and would emit a different "already exists" error). - Not an unresolved ref: `targetRef` resolves to the provided commit SHA, checked out at `fetch-depth: 0`. - The log was unhelpful because `RunMutation` used `cmd.Run()` without wiring git's stderr (`cmdexec.go`), discarding the `fatal:` line and leaving only `exit status 128`. This PR fixes that too. - The sibling `release.yaml` explicitly sets `git config user.email/user.name` before its git mutations; that step was simply missing from the newer `tag-and-release.yaml` `prepare-release` job. </details> --- > Generated by Coder Agents on behalf of @f0ssel.
129 lines
3.8 KiB
Go
129 lines
3.8 KiB
Go
package main
|
|
|
|
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, " ")
|
|
}
|