feat: add dry-run flag via CommandExecutor interface - #26422
#26422feat: add dry-run flag via CommandExecutor interface#26422f0ssel merged 10 commits intomaincoder/coder:mainfrom f0ssel/release-action-dry-runcoder/coder:f0ssel/release-action-dry-runCopy head branch name to clipboard
Conversation
…terface 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).
…tch 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.
…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.
…tion 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.
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.
… 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
| workflow_dispatch: | ||
| inputs: | ||
| release_type: | ||
| release_channel: |
There was a problem hiding this comment.
This entire file is a revert, and the changes that were previously made here have been moved to the tag-and-release.yaml workflow so we can continue to use both workflows as we transition.
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.
rowansmithau
left a comment
There was a problem hiding this comment.
Clean design on the CommandExecutor abstraction. The read/mutation split makes dry-run behavior explicit and reviewable at each call site, and the idempotency checks in createAndPushTag/createAndPushBranch are a solid improvement over the old shell-based approach.
A couple things to look at: 1 P2, 1 P2, 3 P3, 2 nits across 7 inline comments.
Note: Several reviewers flagged the *t* boolean pattern matching and the echo for release notes in release.yaml, but those are pre-existing patterns in the restored workflow (byte-for-byte revert to pre-#25162), so not raised as findings here.
This review was generated by Coder's AI code review agent on behalf of @rowansmithau. The findings represent a multi-reviewer analysis, not a single-pass review.
| if existing == targetRef { | ||
| _, _ = fmt.Fprintf(os.Stderr, "tag %s already exists at %s, skipping\n", versionTag, targetRef) | ||
| return nil | ||
| } |
There was a problem hiding this comment.
P2 Idempotency check breaks when targetRef is a short SHA (Contract Auditor P1, downgraded)
resolveCommitincalculate.goaccepts any hex string >= 7 chars viaisHexSHAand returns it unchanged.createAndPushTagthen comparesexisting == targetRefwhereexistingcomes fromgit rev-parse --verify refs/tags/<tag>^{}, which always returns a full 40-character SHA. If a user passes a 7-12 character short SHA via thecommit_shaworkflow input, the comparison fails even when both refer to the same commit.
The same issue affects createAndPushBranch where remoteSHA (full, from ls-remote) is compared against targetRef (possibly short).
Downgraded from P1 because commit_sha defaults to empty (which falls through to git rev-parse origin/<ref>, producing a full SHA). Short SHAs only happen when explicitly provided. But when they are, the consequence is a confusing error that breaks the idempotency promise: "tag already exists at , expected ".
Fix: resolve targetRef to a full SHA with git rev-parse in resolveCommit before returning it, or do a strings.HasPrefix comparison in the idempotency checks.
| described, err := gitOutput(exec, "describe", "--always") | ||
| if err != nil { | ||
| return xerrors.Errorf("git describe: %w", err) | ||
| } |
There was a problem hiding this comment.
P2 publishRelease validation will fail in dry-run mode (Go Architect)
publishReleaseperforms validation checks (asset existence,git describematch) before the mutation. If the dry-run executor is used withpublishRelease, thegit describecheck will fail because the checkout won't match the expected tag (since the tag was never actually created in dry-run). The function will error out before reaching thegh release createmutation.
In release.yaml, the publish step calls ./scripts/release/publish.sh (shell script), not go run ./scripts/release-action publish, so this is not a live bug in CI. But publishRelease in Go now accepts a CommandExecutor with --dry-run wired up in main.go, creating an API contract that won't actually work in dry-run mode. Worth either documenting that publish --dry-run is not expected to work standalone, or guarding the git describe check.
| var exitErr *exec.ExitError | ||
| if errors.As(err, &exitErr) { | ||
| return "", exitErr | ||
| } |
There was a problem hiding this comment.
P3 RunOutput swallows stderr, making command failures opaque (Contract Auditor P2, Go Architect Obs)
RunOutputcallscmd.Output()and on error returns only the*exec.ExitError. The caller gets an opaque exit code with no stderr context. Failures ingit fetch,git rev-parse,git ls-remote, etc. will produce errors like "exit status 128" with no indication of why.
This is pre-existing behavior (same code was in git.go before the refactoring), so not a regression. But the new abstraction is a good opportunity to attach stderr to the error. cmd.Output() already populates ExitError.Stderr in Go; consider including it in the error message.
| // 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. |
There was a problem hiding this comment.
P3 Mock comment says "returns ("", error)" but implementation returns ("", nil) (Test Auditor, Edge Case Analyst, Style Reviewer, Modernization Reviewer)
Four reviewers independently flagged this. The comment on RunOutputFunc says If nil, returns ("", error) but the fallback on line 29-30 is return "", nil. Anyone writing new tests that rely on the default mock behavior would be misled.
| {"quote", []string{"it's"}, "\"it's\""}, | ||
| {"empty", []string{}, ""}, | ||
| } | ||
| for _, tt := range tests { |
There was a problem hiding this comment.
P3 TestShelljoin "quote" case has a dead want field (Test Auditor, Style Reviewer, Modernization Reviewer)
The "quote" row has
want: "\"it's\""set, but the assertion short-circuits toassert.Contains(t, got, "it")with comment "just checks it doesn't panic and wraps." Thewantfield is never checked for this row, so the test would pass even ifshelljoinreturned"it"with no quoting at all.
shelljoin produces 'it'"'"'s' (POSIX single-quote escaping), not double-quoted "it's". Either fix want to match the actual output and assert with Equal, or remove the want field for this case.
| dryRunOption, | ||
| }, | ||
| Handler: func(inv *serpent.Invocation) error { | ||
| assets := inv.Args |
There was a problem hiding this comment.
Nit generate-notes subcommand has --dry-run option but it's a no-op (Structural Analyst)
dryRunOptionis added togenerate-notesinmain.go, butgenerateReleaseNotesonly usesRunOutput/Run(read-only methods). The dry-run executor delegates these to the real executor unchanged, so the flag has no observable effect.
Either remove dryRunOption from generate-notes or add a comment explaining it's a no-op for consistency.
| @@ -0,0 +1,114 @@ | ||
| package main |
There was a problem hiding this comment.
Nit The parameter name exec shadows the os/exec stdlib package (Style Reviewer)
Used consistently across every file (func gitOutput(exec CommandExecutor, ...), etc.), so this is a deliberate pattern choice rather than an accident. In cmdexec.go itself, os/exec is imported, so exec the parameter and exec the package coexist in the same file. A name like ce or cmdExec would avoid the ambiguity. Low priority since the pattern is consistent.
…in test Address code review on #26422. resolveCommit now resolves a provided commit SHA to its full 40-char form via `git rev-parse --verify <sha>^{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.
|
Addressed review feedback in 77735f9 (functional fixes only; nits/cosmetic items left as-is to keep the diff focused). Fixed
Intentionally not changed
Generated by Coder Agents. |
Resolve release.yaml conflict by keeping the revert (this PR restores the old channel-based release.yaml). main's new tag-driven pipeline lives in the new tag-and-release.yaml, so main's actions/checkout v6.0.2 -> v7.0.0 bump is applied there instead of the reverted release.yaml.
bfd807b to
0279310
Compare
Summary
Adds a
--dry-runcapability to therelease-actionGo tool and exposes it through a new manual workflow,tag-and-release.yaml, without disturbing the existingrelease.yamlpipeline.PR #25162 had rewritten
release.yamlin place to be driven byscripts/release-action, which changed itsworkflow_dispatchinputs fromrelease_channel/release_notes/dry_runtorelease_type/commit_sha. That brokescripts/releaser, which dispatchesrelease.yamlwith the original inputs. This PR restoresrelease.yamland moves the Go-driven pipeline to its own workflow.Workflow layout after this PR
release.yamlscripts/releaser(gh workflow run)tag-and-release.yamlscripts/release-actionGo toolprepare-release+dry_runrelease.yamlis restored byte-for-byte to its pre-#25162 version, so its inputs match whatscripts/releasersends again.release-actiondesignCommandExecutor interface
Abstracts CLI command execution behind read-only and mutating methods:
RunOutputRunRunMutationRunMutationStdoutTwo implementations:
realExecutor(executes viaos/exec) anddryRunExecutor(delegates read-only calls, prints mutating calls).prepare-releasesubcommandComposes
calculateNextVersionwith idempotent tag and branch creation+push, emitting the same JSON ascalculate-version. Matching existing refs are skipped; mismatched refs error.tag-and-release.yamldry_runinputWhen enabled:
prepare-releaseruns with--dry-run(version calculated, plan printed, nothing pushed), notes are generated for inspection, and the build+publish job is skipped via anifguard (cascading to homebrew/winget/docs).Mutating commands covered by
--dry-rungit tag -a <version> ...createAndPushTaggit push origin refs/tags/...createAndPushTaggit push origin <sha>:refs/heads/...createAndPushBranchgh release create ...publishReleasegit fetch --tags --force originis intentionally not a mutation; it only updates local remote-tracking refs and must run for accurate version calculation.Changes
scripts/release-action/cmdexec.go,prepare.go(+ tests)git.go,github.go,calculate.go,notes.go,commit.go,publish.goto threadCommandExecutor; addedgitMutatemain.goadds--dry-runflag andprepare-releasesubcommand.github/workflows/tag-and-release.yaml(manual, Go-driven, withdry_run).github/workflows/release.yamlto its pre-ci: rewrite release workflow to be fully GitHub Actions-driven #25162 stateNote
Generated by Coder Agents on behalf of @f0ssel