Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

feat: add dry-run flag via CommandExecutor interface - #26422

#26422
Merged
f0ssel merged 10 commits into
maincoder/coder:mainfrom
f0ssel/release-action-dry-runcoder/coder:f0ssel/release-action-dry-runCopy head branch name to clipboard
Jul 1, 2026
Merged

feat: add dry-run flag via CommandExecutor interface#26422
f0ssel merged 10 commits into
maincoder/coder:mainfrom
f0ssel/release-action-dry-runcoder/coder:f0ssel/release-action-dry-runCopy head branch name to clipboard

Conversation

@f0ssel

@f0ssel f0ssel commented Jun 16, 2026

Copy link
Copy Markdown
Member

Summary

Adds a --dry-run capability to the release-action Go tool and exposes it through a new manual workflow, tag-and-release.yaml, without disturbing the existing release.yaml pipeline.

PR #25162 had rewritten release.yaml in place to be driven by scripts/release-action, which changed its workflow_dispatch inputs from release_channel/release_notes/dry_run to release_type/commit_sha. That broke scripts/releaser, which dispatches release.yaml with the original inputs. This PR restores release.yaml and moves the Go-driven pipeline to its own workflow.

Workflow layout after this PR

Workflow Trigger Driven by Purpose
release.yaml scripts/releaser (gh workflow run) legacy inline shell Existing pipeline, restored to pre-#25162 state
tag-and-release.yaml Manual (Actions UI) scripts/release-action Go tool New pipeline with prepare-release + dry_run

release.yaml is restored byte-for-byte to its pre-#25162 version, so its inputs match what scripts/releaser sends again.

release-action design

CommandExecutor interface

Abstracts CLI command execution behind read-only and mutating methods:

Method Purpose Dry-run behavior
RunOutput Read-only, capture stdout Executes normally
Run Read-only, exit code only Executes normally
RunMutation Changes remote state, no output Prints command, skips execution
RunMutationStdout Changes remote state, streaming I/O Prints command, skips execution

Two implementations: realExecutor (executes via os/exec) and dryRunExecutor (delegates read-only calls, prints mutating calls).

prepare-release subcommand

Composes calculateNextVersion with idempotent tag and branch creation+push, emitting the same JSON as calculate-version. Matching existing refs are skipped; mismatched refs error.

tag-and-release.yaml dry_run input

When enabled: prepare-release runs with --dry-run (version calculated, plan printed, nothing pushed), notes are generated for inspection, and the build+publish job is skipped via an if guard (cascading to homebrew/winget/docs).

Mutating commands covered by --dry-run

Command Call site
git tag -a <version> ... createAndPushTag
git push origin refs/tags/... createAndPushTag
git push origin <sha>:refs/heads/... createAndPushBranch
gh release create ... publishRelease

git fetch --tags --force origin is intentionally not a mutation; it only updates local remote-tracking refs and must run for accurate version calculation.

Changes

  • New: scripts/release-action/cmdexec.go, prepare.go (+ tests)
  • Refactored: git.go, github.go, calculate.go, notes.go, commit.go, publish.go to thread CommandExecutor; added gitMutate
  • Updated: main.go adds --dry-run flag and prepare-release subcommand
  • New: .github/workflows/tag-and-release.yaml (manual, Go-driven, with dry_run)
  • Reverted: .github/workflows/release.yaml to its pre-ci: rewrite release workflow to be fully GitHub Actions-driven #25162 state

Note

Generated by Coder Agents on behalf of @f0ssel

…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).
f0ssel added 2 commits June 17, 2026 14:18
…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.
@f0ssel
f0ssel requested a review from jdomeracki-coder as a code owner June 17, 2026 14:44
@f0ssel f0ssel changed the title feat(scripts/release-action): add dry-run flag via CommandExecutor interface feat(scripts): add dry-run flag via CommandExecutor interface Jun 17, 2026
…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.
@f0ssel f0ssel changed the title feat(scripts): add dry-run flag via CommandExecutor interface feat: add dry-run flag via CommandExecutor interface Jun 17, 2026
f0ssel added 2 commits June 17, 2026 15:28
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:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 rowansmithau left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Idempotency check breaks when targetRef is a short SHA (Contract Auditor P1, downgraded)

resolveCommit in calculate.go accepts any hex string >= 7 chars via isHexSHA and returns it unchanged. createAndPushTag then compares existing == targetRef where existing comes from git 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 the commit_sha workflow 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)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 publishRelease validation will fail in dry-run mode (Go Architect)

publishRelease performs validation checks (asset existence, git describe match) before the mutation. If the dry-run executor is used with publishRelease, the git describe check 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 the gh release create mutation.

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
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 RunOutput swallows stderr, making command failures opaque (Contract Auditor P2, Go Architect Obs)

RunOutput calls cmd.Output() and on error returns only the *exec.ExitError. The caller gets an opaque exit code with no stderr context. Failures in git 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 to assert.Contains(t, got, "it") with comment "just checks it doesn't panic and wraps." The want field is never checked for this row, so the test would pass even if shelljoin returned "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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit generate-notes subcommand has --dry-run option but it's a no-op (Structural Analyst)

dryRunOption is added to generate-notes in main.go, but generateReleaseNotes only uses RunOutput/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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@github-actions github-actions Bot added the stale This issue is like stale bread. label Jun 27, 2026
@github-actions github-actions Bot closed this Jul 1, 2026
@f0ssel f0ssel reopened this Jul 1, 2026
@f0ssel f0ssel removed the stale This issue is like stale bread. label Jul 1, 2026
…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.

f0ssel commented Jul 1, 2026

Copy link
Copy Markdown
Member Author

Addressed review feedback in 77735f9 (functional fixes only; nits/cosmetic items left as-is to keep the diff focused).

Fixed

  • P2 short-SHA breaks idempotency (calculate.go): resolveCommit now resolves a provided commit SHA to its full 40-char form via git rev-parse --verify <sha>^{commit}. A short SHA from the commit input never matched the full SHAs used in the createAndPushTag/createAndPushBranch idempotency checks, so re-runs errored. Added Test_resolveCommit coverage.
  • P3 TestShelljoin dead want (cmdexec_test.go): the "quote" case no longer short-circuits; it now asserts the actual POSIX single-quote-escaped output ('it'"'"'s').

Intentionally not changed

  • P2 publishRelease dry-run: not a live bug (CI publishes via publish.sh, not the Go path). Making publish --dry-run work standalone is a separate design change on an unused code path; happy to follow up if wanted.
  • P3 RunOutput swallows stderr: pre-existing behavior, not a regression in this PR.
  • P3 mock comment, nit exec shadows os/exec, nit generate-notes --dry-run no-op: cosmetic.

Generated by Coder Agents.

f0ssel and others added 2 commits July 1, 2026 12:41
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.
@f0ssel
f0ssel force-pushed the f0ssel/release-action-dry-run branch 2 times, most recently from bfd807b to 0279310 Compare July 1, 2026 19:49
@f0ssel
f0ssel merged commit ff7e0bc into main Jul 1, 2026
60 of 61 checks passed
@f0ssel
f0ssel deleted the f0ssel/release-action-dry-run branch July 1, 2026 20:20
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 1, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

Morty Proxy This is a proxified and sanitized view of the page, visit original site.