mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
chore: tag RCs on main, cut release branch only for releases (#24001)
RC tags are now created directly on `main`. The `release/X.Y` branch is
only cut when the actual release is ready. This eliminates the need to
cherry-pick hundreds of commits from main onto the release branch
between the first RC and the release.
## Workflow
```
main: ──●──●──●──●──●──●──●──●──●──
↑ ↑ ↑
rc.0 rc.1 cut release/2.34, tag v2.34.0
\
release/2.34: ──●── v2.34.1 (patch)
```
1. **RC:** On `main`, run `./scripts/release.sh`. The tool detects main
(or a detached HEAD reachable from main), prompts for the commit SHA to
tag, suggests the next RC version, and tags it.
2. **Release:** When the RC is blessed, create `release/X.Y` from `main`
(or the specific RC commit). Switch to that branch and run
`./scripts/release.sh`, which suggests `vX.Y.0`.
3. **Patch:** Cherry-pick fixes onto `release/X.Y` and run
`./scripts/release.sh` from that branch.
## Changes
### `scripts/releaser/release.go`
- Two modes based on branch:
- **`main` (or detached HEAD from main)** — RC tagging. Prompts for the
commit SHA to tag (defaults to HEAD). Always checks out the target
commit so the flow operates in detached HEAD. Suggests the next RC based
on existing RC tags.
- **`release/X.Y`** — Release/patch mode. Suggests `vX.Y.0` if the
latest tag is an RC, or the next patch otherwise.
- Detached HEAD support: if `git branch --show-current` is empty, checks
whether HEAD is an ancestor of `origin/main` and enters RC mode
automatically.
- Commit selection prompt in RC mode: shows current commit, lets the
user confirm or provide a different SHA.
- Warns if you try to tag a non-RC on main, or an RC on a release
branch.
- Skips open-PR check and branch sync check in RC mode (not useful on
main).
### `scripts/releaser/main.go`
- Updated help text.
### `.github/workflows/release.yaml`
- RC tags (`*-rc.*`): skip the release-branch validation (they live on
main).
- Non-RC tags: still require the corresponding `release/X.Y` branch.
### `docs/about/contributing/CONTRIBUTING.md`
- Rewrote the Releases section with the new workflow, release types
table, and ASCII diagram.
- Replaced the old "Creating a release" / "Creating a release (via
workflow dispatch)" subsections.
<details><summary>Decision log</summary>
### Why this approach?
Previously, cutting a release branch early for an RC meant
cherry-picking all of main's progress onto that branch before the actual
release — often hundreds of commits. This approach avoids that entirely:
RCs are just tagged snapshots of main, and the release branch only
exists once you need it for stabilization and backports.
### Files NOT changed
- **`scripts/release/publish.sh`** — `--rc` flag controls GitHub
prerelease marking (tag-level, not branch-level). `target_commitish`
already defaults to `main` when the tag isn't on a release branch.
- **`scripts/release/tag_version.sh`** — No RC-specific branch logic.
- **`scripts/releaser/version.go`** — Version parsing/comparison
unchanged.
- **`docs/install/releases/index.md`** — Public-facing docs describe RC
as a release channel with no branch-level detail.
</details>
> Generated by Coder Agents
This commit is contained in:
@@ -121,22 +121,22 @@ jobs:
|
||||
fi
|
||||
|
||||
# Derive the release branch from the version tag.
|
||||
# Standard: 2.10.2 -> release/2.10
|
||||
# RC: 2.32.0-rc.0 -> release/2.32-rc.0
|
||||
# Non-RC releases must be on a release/X.Y branch.
|
||||
# RC tags are allowed on any branch (typically main).
|
||||
version="$(./scripts/version.sh)"
|
||||
# Strip any pre-release suffix first (e.g. 2.32.0-rc.0 -> 2.32.0)
|
||||
base_version="${version%%-*}"
|
||||
# Then strip patch to get major.minor (e.g. 2.32.0 -> 2.32)
|
||||
release_branch="release/${base_version%.*}"
|
||||
|
||||
if [[ "$version" == *-rc.* ]]; then
|
||||
# Extract major.minor and rc suffix from e.g. 2.32.0-rc.0
|
||||
base_version="${version%%-rc.*}" # 2.32.0
|
||||
major_minor="${base_version%.*}" # 2.32
|
||||
rc_suffix="${version##*-rc.}" # 0
|
||||
release_branch="release/${major_minor}-rc.${rc_suffix}"
|
||||
echo "RC release detected — skipping release branch check (RC tags are cut from main)."
|
||||
else
|
||||
release_branch=release/${version%.*}
|
||||
fi
|
||||
branch_contains_tag=$(git branch --remotes --contains "${GITHUB_REF}" --list "*/${release_branch}" --format='%(refname)')
|
||||
if [[ -z "${branch_contains_tag}" ]]; then
|
||||
echo "Ref tag must exist in a branch named ${release_branch} when creating a release, did you use scripts/release.sh?"
|
||||
exit 1
|
||||
branch_contains_tag=$(git branch --remotes --contains "${GITHUB_REF}" --list "*/${release_branch}" --format='%(refname)')
|
||||
if [[ -z "${branch_contains_tag}" ]]; then
|
||||
echo "Ref tag must exist in a branch named ${release_branch} when creating a non-RC release, did you use scripts/release.sh?"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "${CODER_RELEASE_NOTES}" ]]; then
|
||||
|
||||
@@ -211,33 +211,53 @@ Coder releases are initiated via
|
||||
[`./scripts/release.sh`](https://github.com/coder/coder/blob/main/scripts/release.sh)
|
||||
and automated via GitHub Actions. Specifically, the
|
||||
[`release.yaml`](https://github.com/coder/coder/blob/main/.github/workflows/release.yaml)
|
||||
workflow. They are created based on the current
|
||||
[`main`](https://github.com/coder/coder/tree/main) branch.
|
||||
workflow.
|
||||
|
||||
The release notes for a release are automatically generated from commit titles
|
||||
and metadata from PRs that are merged into `main`.
|
||||
Release notes are automatically generated from commit titles and PR metadata.
|
||||
|
||||
### Creating a release
|
||||
### Release types
|
||||
|
||||
The creation of a release is initiated via
|
||||
[`./scripts/release.sh`](https://github.com/coder/coder/blob/main/scripts/release.sh).
|
||||
This script will show a preview of the release that will be created, and if you
|
||||
choose to continue, create and push the tag which will trigger the creation of
|
||||
the release via GitHub Actions.
|
||||
| Type | Tag | Branch | Purpose |
|
||||
|------------------------|---------------|---------------|-----------------------------------------|
|
||||
| RC (release candidate) | `vX.Y.0-rc.W` | `main` | Ad-hoc pre-release for customer testing |
|
||||
| Release | `vX.Y.0` | `release/X.Y` | First release of a minor version |
|
||||
| Patch | `vX.Y.Z` | `release/X.Y` | Bug fixes and security patches |
|
||||
|
||||
See `./scripts/release.sh --help` for more information.
|
||||
### Workflow
|
||||
|
||||
RC tags are created directly on `main`. The `release/X.Y` branch is only cut
|
||||
when the release is ready. This avoids cherry-picking main's progress onto
|
||||
a release branch between the first RC and the release.
|
||||
|
||||
```text
|
||||
main: ──●──●──●──●──●──●──●──●──●──
|
||||
↑ ↑ ↑
|
||||
rc.0 rc.1 cut release/2.34, tag v2.34.0
|
||||
\
|
||||
release/2.34: ──●── v2.34.1 (patch)
|
||||
```
|
||||
|
||||
1. **RC:** On `main`, run `./scripts/release.sh`. The tool suggests the next
|
||||
RC version and tags it on `main`.
|
||||
2. **Release:** When the RC is blessed, create `release/X.Y` from `main` (or
|
||||
the specific RC commit). Switch to that branch and run
|
||||
`./scripts/release.sh`, which suggests `vX.Y.0`.
|
||||
3. **Patch:** Cherry-pick fixes onto `release/X.Y` and run
|
||||
`./scripts/release.sh` from that branch.
|
||||
|
||||
The release tool warns if you try to tag a non-RC on `main` or an RC on a
|
||||
release branch.
|
||||
|
||||
### Creating a release (via workflow dispatch)
|
||||
|
||||
Typically the workflow dispatch is only used to test (dry-run) a release,
|
||||
meaning no actual release will take place. The workflow can be dispatched
|
||||
manually from
|
||||
[Actions: Release](https://github.com/coder/coder/actions/workflows/release.yaml).
|
||||
Simply press "Run workflow" and choose dry-run.
|
||||
If the
|
||||
[`release.yaml`](https://github.com/coder/coder/actions/workflows/release.yaml)
|
||||
workflow fails after the tag has been pushed, retry it from the GitHub Actions
|
||||
UI: press "Run workflow", set "Use workflow from" to the tag (e.g.
|
||||
`Tag: v2.34.0`), select the correct release channel, and do **not** select
|
||||
dry-run.
|
||||
|
||||
If a release has failed after the tag has been created and pushed, it can be
|
||||
retried by again, pressing "Run workflow", changing "Use workflow from" from
|
||||
"Branch: main" to "Tag: vX.X.X" and not selecting dry-run.
|
||||
To test the workflow without publishing, select dry-run.
|
||||
|
||||
### Commit messages
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ func main() {
|
||||
cmd := &serpent.Command{
|
||||
Use: "releaser",
|
||||
Short: "Interactive release tagging for coder/coder.",
|
||||
Long: "Run this from a release branch (release/X.Y). The tool detects the branch, infers the next version, and walks you through tagging, pushing, and triggering the release workflow.",
|
||||
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.",
|
||||
Options: serpent.OptionSet{
|
||||
{
|
||||
Name: "dry-run",
|
||||
|
||||
+274
-100
@@ -68,30 +68,110 @@ func runRelease(ctx context.Context, inv *serpent.Invocation, executor ReleaseEx
|
||||
return xerrors.Errorf("detecting branch: %w", err)
|
||||
}
|
||||
|
||||
// Match release branches (release/X.Y). RCs are tagged
|
||||
// from main, not from release branches.
|
||||
// Two modes:
|
||||
// 1. On "main" — for tagging release candidates (RCs).
|
||||
// 2. On "release/X.Y" — for releases and patches.
|
||||
// RCs are tagged directly on main to avoid the toil of
|
||||
// cherry-picking hundreds of commits onto a release branch.
|
||||
// The release/X.Y branch is only cut when the release is
|
||||
// ready.
|
||||
//
|
||||
// Detached HEAD is common: the release manager checks out a
|
||||
// specific commit on main before running the tool. We detect
|
||||
// this by checking whether HEAD is an ancestor of origin/main.
|
||||
branchRe := regexp.MustCompile(`^release/(\d+)\.(\d+)$`)
|
||||
m := branchRe.FindStringSubmatch(currentBranch)
|
||||
if m == nil {
|
||||
warnf(w, "Current branch %q is not a release branch (release/X.Y).", currentBranch)
|
||||
onMain := currentBranch == "main"
|
||||
var branchMajor, branchMinor int
|
||||
|
||||
// Detached HEAD: currentBranch is empty. Check if HEAD is
|
||||
// reachable from origin/main.
|
||||
if currentBranch == "" {
|
||||
if err := gitRun("merge-base", "--is-ancestor", "HEAD", "origin/main"); err == nil {
|
||||
onMain = true
|
||||
currentBranch = "main"
|
||||
successf(w, "Detached HEAD is an ancestor of main — RC tagging mode.")
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case onMain:
|
||||
successf(w, "On main branch — RC tagging mode.")
|
||||
case branchRe.MatchString(currentBranch):
|
||||
m := branchRe.FindStringSubmatch(currentBranch)
|
||||
branchMajor, _ = strconv.Atoi(m[1])
|
||||
branchMinor, _ = strconv.Atoi(m[2])
|
||||
successf(w, "Using release branch: %s", currentBranch)
|
||||
default:
|
||||
if currentBranch == "" {
|
||||
warnf(w, "Detached HEAD is not reachable from origin/main.")
|
||||
} else {
|
||||
warnf(w, "Current branch %q is not 'main' or a release branch (release/X.Y).", currentBranch)
|
||||
}
|
||||
branchInput, err := cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "Enter the release branch to use (e.g. release/2.21)",
|
||||
Text: "Enter the branch to use (e.g. main, release/2.21)",
|
||||
Validate: func(s string) error {
|
||||
if !branchRe.MatchString(s) {
|
||||
return xerrors.New("must be in format release/X.Y (e.g. release/2.21)")
|
||||
if s == "main" || branchRe.MatchString(s) {
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
return xerrors.New("must be 'main' or release/X.Y (e.g. release/2.21)")
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
currentBranch = branchInput
|
||||
m = branchRe.FindStringSubmatch(currentBranch)
|
||||
if currentBranch == "main" {
|
||||
onMain = true
|
||||
successf(w, "On main branch — RC tagging mode.")
|
||||
} else {
|
||||
m := branchRe.FindStringSubmatch(currentBranch)
|
||||
branchMajor, _ = strconv.Atoi(m[1])
|
||||
branchMinor, _ = strconv.Atoi(m[2])
|
||||
successf(w, "Using release branch: %s", currentBranch)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Commit selection (RC mode) ---
|
||||
// RCs are always tagged at a specific commit. Show the current
|
||||
// HEAD and let the user confirm or provide a different SHA.
|
||||
// We always checkout the commit so the rest of the flow
|
||||
// operates in detached HEAD at the exact commit being tagged.
|
||||
if onMain {
|
||||
headSHA, err := gitOutput("rev-parse", "HEAD")
|
||||
if err != nil {
|
||||
return xerrors.Errorf("resolving HEAD: %w", err)
|
||||
}
|
||||
headShort := headSHA[:12]
|
||||
headTitle, _ := gitOutput("log", "-1", "--format=%s", "HEAD")
|
||||
fmt.Fprintf(w, " Current commit: %s %s\n", headShort, headTitle)
|
||||
fmt.Fprintln(w)
|
||||
|
||||
commitInput, err := cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "Commit SHA to tag (press Enter to use current)",
|
||||
Default: headShort,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
commitInput = strings.TrimSpace(commitInput)
|
||||
|
||||
// Resolve the input to a full SHA.
|
||||
targetSHA, err := gitOutput("rev-parse", commitInput)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("resolving %q: %w", commitInput, err)
|
||||
}
|
||||
|
||||
// Always checkout so we're in detached HEAD at the
|
||||
// target commit for the rest of the flow.
|
||||
if err := gitRun("checkout", "--quiet", targetSHA); err != nil {
|
||||
return xerrors.Errorf("checking out %s: %w", commitInput, err)
|
||||
}
|
||||
if targetSHA != headSHA {
|
||||
newTitle, _ := gitOutput("log", "-1", "--format=%s", "HEAD")
|
||||
successf(w, "Checked out %s %s", targetSHA[:12], newTitle)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
branchMajor, _ := strconv.Atoi(m[1])
|
||||
branchMinor, _ := strconv.Atoi(m[2])
|
||||
successf(w, "Using release branch: %s", currentBranch)
|
||||
|
||||
// --- Fetch & sync check ---
|
||||
infof(w, "Fetching latest from origin...")
|
||||
@@ -99,20 +179,24 @@ func runRelease(ctx context.Context, inv *serpent.Invocation, executor ReleaseEx
|
||||
return xerrors.Errorf("fetching: %w", err)
|
||||
}
|
||||
|
||||
localHead, err := gitOutput("rev-parse", "HEAD")
|
||||
if err != nil {
|
||||
return xerrors.Errorf("resolving HEAD: %w", err)
|
||||
}
|
||||
remoteHead, _ := gitOutput("rev-parse", "origin/"+currentBranch)
|
||||
|
||||
if remoteHead != "" && localHead != remoteHead {
|
||||
warnf(w, "Your local branch is not up to date with origin/%s.", currentBranch)
|
||||
fmt.Fprintf(w, " Local: %s\n", localHead[:12])
|
||||
fmt.Fprintf(w, " Remote: %s\n", remoteHead[:12])
|
||||
if err := confirmWithDefault(inv, "Continue anyway?", cliui.ConfirmNo); err != nil {
|
||||
return err
|
||||
// Skip the local-vs-remote sync check in RC mode because
|
||||
// we always checkout a specific commit (detached HEAD).
|
||||
if !onMain {
|
||||
localHead, err := gitOutput("rev-parse", "HEAD")
|
||||
if err != nil {
|
||||
return xerrors.Errorf("resolving HEAD: %w", err)
|
||||
}
|
||||
remoteHead, _ := gitOutput("rev-parse", "origin/"+currentBranch)
|
||||
|
||||
if remoteHead != "" && localHead != remoteHead {
|
||||
warnf(w, "Your local branch is not up to date with origin/%s.", currentBranch)
|
||||
fmt.Fprintf(w, " Local: %s\n", localHead[:12])
|
||||
fmt.Fprintf(w, " Remote: %s\n", remoteHead[:12])
|
||||
if err := confirmWithDefault(inv, "Continue anyway?", cliui.ConfirmNo); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
// --- Find previous version & suggest next ---
|
||||
@@ -121,57 +205,130 @@ func runRelease(ctx context.Context, inv *serpent.Invocation, executor ReleaseEx
|
||||
return xerrors.Errorf("listing merged tags: %w", err)
|
||||
}
|
||||
|
||||
// Find the latest tag matching this branch's major.minor.
|
||||
// Without this filter, tags from newer branches (e.g. v2.31.0)
|
||||
// that are reachable via merge history would be picked up
|
||||
// incorrectly on older release branches (e.g. release/2.30).
|
||||
var prevVersion *version
|
||||
for _, t := range mergedTags {
|
||||
if t.Major == branchMajor && t.Minor == branchMinor {
|
||||
v := t
|
||||
prevVersion = &v
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// changelogBaseRef is the git ref used as the starting point
|
||||
// for release notes generation. When a tag already exists in
|
||||
// this minor series we use it directly. For the first release
|
||||
// on a new minor no matching tag exists, so we compute the
|
||||
// merge-base with the previous minor's release branch instead.
|
||||
// This works even when that branch has no tags yet (it was
|
||||
// just cut and pushed). As a last resort we fall back to the
|
||||
// latest reachable tag from a previous minor.
|
||||
var suggested version
|
||||
var changelogBaseRef string
|
||||
if prevVersion != nil {
|
||||
changelogBaseRef = prevVersion.String()
|
||||
} else {
|
||||
prevReleaseBranch := fmt.Sprintf("release/%d.%d", branchMajor, branchMinor-1)
|
||||
if err := gitRun("fetch", "--quiet", "origin", prevReleaseBranch); err != nil {
|
||||
warnf(w, "Could not fetch %s: %v", prevReleaseBranch, err)
|
||||
|
||||
if onMain { //nolint:nestif // Sequential release flow with two distinct modes is inherently nested.
|
||||
// On main, suggest the next RC. Find the latest RC tag
|
||||
// across all tags, then suggest the next one. If no RC
|
||||
// tags exist, suggest rc.0 for the next minor after the
|
||||
// latest mainline release.
|
||||
var latestRC *version
|
||||
for _, t := range allTags {
|
||||
if t.IsRC() {
|
||||
v := t
|
||||
latestRC = &v
|
||||
break
|
||||
}
|
||||
}
|
||||
if mb, mbErr := gitOutput("merge-base", "HEAD", "origin/"+prevReleaseBranch); mbErr == nil && mb != "" {
|
||||
changelogBaseRef = mb
|
||||
infof(w, "Using merge-base with %s as changelog base: %s", prevReleaseBranch, mb[:12])
|
||||
} else {
|
||||
// No previous release branch found; fall back to
|
||||
// the latest reachable tag from a previous minor.
|
||||
for _, t := range mergedTags {
|
||||
if t.Major == branchMajor && t.Minor < branchMinor {
|
||||
changelogBaseRef = t.String()
|
||||
|
||||
switch {
|
||||
case latestRC != nil:
|
||||
prevVersion = latestRC
|
||||
infof(w, "Latest RC tag: %s", latestRC.String())
|
||||
|
||||
// Check if a final release already exists for this
|
||||
// RC's minor series. If so, the series is complete
|
||||
// and we should start the next minor's RC cycle.
|
||||
seriesComplete := false
|
||||
for _, t := range allTags {
|
||||
if t.Major == latestRC.Major && t.Minor == latestRC.Minor && t.Pre == "" {
|
||||
infof(w, "Final release %s already exists for this series, moving to next minor.", t.String())
|
||||
seriesComplete = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var suggested version
|
||||
if prevVersion == nil {
|
||||
infof(w, "No previous release tag found on this branch.")
|
||||
suggested = version{Major: branchMajor, Minor: branchMinor, Patch: 0}
|
||||
if seriesComplete {
|
||||
suggested = version{
|
||||
Major: latestRC.Major,
|
||||
Minor: latestRC.Minor + 1,
|
||||
Patch: 0,
|
||||
Pre: "rc.0",
|
||||
}
|
||||
} else {
|
||||
suggested = version{
|
||||
Major: latestRC.Major,
|
||||
Minor: latestRC.Minor,
|
||||
Patch: latestRC.Patch,
|
||||
Pre: fmt.Sprintf("rc.%d", latestRC.rcNumber()+1),
|
||||
}
|
||||
}
|
||||
case latestMainline != nil:
|
||||
infof(w, "No RC tags found. Latest mainline: %s", latestMainline.String())
|
||||
suggested = version{
|
||||
Major: latestMainline.Major,
|
||||
Minor: latestMainline.Minor + 1,
|
||||
Patch: 0,
|
||||
Pre: "rc.0",
|
||||
}
|
||||
default:
|
||||
infof(w, "No previous tags found.")
|
||||
suggested = version{Major: 2, Minor: 0, Patch: 0, Pre: "rc.0"}
|
||||
}
|
||||
} else {
|
||||
infof(w, "Previous release tag: %s", prevVersion.String())
|
||||
suggested = version{Major: prevVersion.Major, Minor: prevVersion.Minor, Patch: prevVersion.Patch + 1}
|
||||
// On a release branch, find the latest tag matching this
|
||||
// branch's major.minor. Without this filter, tags from
|
||||
// newer branches reachable via merge history would be
|
||||
// picked up incorrectly.
|
||||
for _, t := range mergedTags {
|
||||
if t.Major == branchMajor && t.Minor == branchMinor {
|
||||
v := t
|
||||
prevVersion = &v
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// changelogBaseRef is the git ref used as the starting
|
||||
// point for release notes. When a tag exists in this
|
||||
// minor series we use it directly. For the first release
|
||||
// on a new minor no matching tag exists, so we compute
|
||||
// the merge-base with the previous minor's release branch
|
||||
// instead. This works even when that branch has no tags
|
||||
// yet. As a last resort we fall back to the latest
|
||||
// reachable tag from a previous minor.
|
||||
if prevVersion == nil {
|
||||
prevReleaseBranch := fmt.Sprintf("release/%d.%d", branchMajor, branchMinor-1)
|
||||
if err := gitRun("fetch", "--quiet", "origin", prevReleaseBranch); err != nil {
|
||||
warnf(w, "Could not fetch %s: %v", prevReleaseBranch, err)
|
||||
}
|
||||
if mb, mbErr := gitOutput("merge-base", "HEAD", "origin/"+prevReleaseBranch); mbErr == nil && mb != "" {
|
||||
changelogBaseRef = mb
|
||||
infof(w, "Using merge-base with %s as changelog base: %s", prevReleaseBranch, mb[:12])
|
||||
} else {
|
||||
// No previous release branch; fall back to the
|
||||
// latest reachable tag from a previous minor.
|
||||
for _, t := range mergedTags {
|
||||
if t.Major == branchMajor && t.Minor < branchMinor {
|
||||
changelogBaseRef = t.String()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if prevVersion == nil {
|
||||
infof(w, "No previous release tag found on this branch.")
|
||||
suggested = version{Major: branchMajor, Minor: branchMinor, Patch: 0}
|
||||
} else {
|
||||
infof(w, "Previous release tag: %s", prevVersion.String())
|
||||
if prevVersion.IsRC() {
|
||||
// Branch has only RC tags; suggest the
|
||||
// release (same base, no pre-release suffix).
|
||||
suggested = version{
|
||||
Major: prevVersion.Major,
|
||||
Minor: prevVersion.Minor,
|
||||
Patch: prevVersion.Patch,
|
||||
}
|
||||
} else {
|
||||
suggested = version{
|
||||
Major: prevVersion.Major,
|
||||
Minor: prevVersion.Minor,
|
||||
Patch: prevVersion.Patch + 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintln(w)
|
||||
@@ -192,8 +349,13 @@ func runRelease(ctx context.Context, inv *serpent.Invocation, executor ReleaseEx
|
||||
}
|
||||
newVersion, _ := parseVersion(versionInput)
|
||||
|
||||
// Warn if version doesn't match branch.
|
||||
if newVersion.Major != branchMajor || newVersion.Minor != branchMinor {
|
||||
// Validate version against branch context.
|
||||
switch {
|
||||
case onMain && !newVersion.IsRC():
|
||||
return xerrors.Errorf("cannot tag a non-RC version (%s) on main; switch to a release/X.Y branch", newVersion)
|
||||
case !onMain && newVersion.IsRC():
|
||||
return xerrors.Errorf("cannot tag an RC (%s) on a release branch; switch to main", newVersion)
|
||||
case !onMain && (newVersion.Major != branchMajor || newVersion.Minor != branchMinor):
|
||||
warnf(w, "Version %s does not match branch %s (expected v%d.%d.X).",
|
||||
newVersion, currentBranch, branchMajor, branchMinor)
|
||||
if err := confirmWithDefault(inv, "Continue anyway?", cliui.ConfirmNo); err != nil {
|
||||
@@ -220,34 +382,37 @@ func runRelease(ctx context.Context, inv *serpent.Invocation, executor ReleaseEx
|
||||
|
||||
// --- Check open PRs ---
|
||||
// This runs before breaking changes so any last-minute merges
|
||||
// are caught by the subsequent checks.
|
||||
infof(w, "Checking for open PRs against %s...", currentBranch)
|
||||
var openPRs []ghPR
|
||||
if ghAvailable {
|
||||
openPRs, err = ghListOpenPRs(currentBranch)
|
||||
if err != nil {
|
||||
warnf(w, "Failed to check open PRs: %v", err)
|
||||
// are caught by the subsequent checks. Skipped on main since
|
||||
// there are always open PRs targeting main.
|
||||
if !onMain {
|
||||
infof(w, "Checking for open PRs against %s...", currentBranch)
|
||||
var openPRs []ghPR
|
||||
if ghAvailable {
|
||||
openPRs, err = ghListOpenPRs(currentBranch)
|
||||
if err != nil {
|
||||
warnf(w, "Failed to check open PRs: %v", err)
|
||||
}
|
||||
} else {
|
||||
infof(w, "Skipping (no gh CLI).")
|
||||
}
|
||||
} else {
|
||||
infof(w, "Skipping (no gh CLI).")
|
||||
}
|
||||
|
||||
if len(openPRs) > 0 {
|
||||
fmt.Fprintln(w)
|
||||
warnf(w, "There are open PRs targeting %s that may need merging first:", currentBranch)
|
||||
fmt.Fprintln(w)
|
||||
for _, pr := range openPRs {
|
||||
fmt.Fprintf(w, " #%d %s (@%s)\n", pr.Number, pr.Title, pr.Author)
|
||||
if len(openPRs) > 0 {
|
||||
fmt.Fprintln(w)
|
||||
warnf(w, "There are open PRs targeting %s that may need merging first:", currentBranch)
|
||||
fmt.Fprintln(w)
|
||||
for _, pr := range openPRs {
|
||||
fmt.Fprintf(w, " #%d %s (@%s)\n", pr.Number, pr.Title, pr.Author)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
if err := confirmWithDefault(inv, "Continue without merging these?", cliui.ConfirmNo); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
} else {
|
||||
successf(w, "No open PRs against %s.", currentBranch)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
if err := confirmWithDefault(inv, "Continue without merging these?", cliui.ConfirmNo); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
} else {
|
||||
successf(w, "No open PRs against %s.", currentBranch)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
|
||||
// --- Semver sanity checks ---
|
||||
if prevVersion != nil { //nolint:nestif // Sequential release checks are inherently nested.
|
||||
@@ -374,9 +539,14 @@ func runRelease(ctx context.Context, inv *serpent.Invocation, executor ReleaseEx
|
||||
// --- Generate release notes ---
|
||||
infof(w, "Generating release notes...")
|
||||
|
||||
commitRange := "HEAD"
|
||||
if changelogBaseRef != "" {
|
||||
var commitRange string
|
||||
switch {
|
||||
case prevVersion != nil:
|
||||
commitRange = prevVersion.String() + "..HEAD"
|
||||
case changelogBaseRef != "":
|
||||
commitRange = changelogBaseRef + "..HEAD"
|
||||
default:
|
||||
commitRange = "HEAD"
|
||||
}
|
||||
|
||||
commits, err := commitLog(commitRange)
|
||||
@@ -482,16 +652,20 @@ func runRelease(ctx context.Context, inv *serpent.Invocation, executor ReleaseEx
|
||||
}
|
||||
if !hasContent {
|
||||
prevStr := "the beginning of time"
|
||||
if changelogBaseRef != "" {
|
||||
prevStr = changelogBaseRef
|
||||
if prevVersion != nil {
|
||||
prevStr = prevVersion.String()
|
||||
}
|
||||
fmt.Fprintf(¬es, "\n_No changes since %s._\n", prevStr)
|
||||
}
|
||||
|
||||
// Compare link.
|
||||
if changelogBaseRef != "" {
|
||||
compareBase := changelogBaseRef
|
||||
if prevVersion != nil {
|
||||
compareBase = prevVersion.String()
|
||||
}
|
||||
if compareBase != "" {
|
||||
fmt.Fprintf(¬es, "\nCompare: [`%s...%s`](https://github.com/%s/%s/compare/%s...%s)\n",
|
||||
changelogBaseRef, newVersion, owner, repo, changelogBaseRef, newVersion)
|
||||
compareBase, newVersion, owner, repo, compareBase, newVersion)
|
||||
}
|
||||
|
||||
// Container image.
|
||||
|
||||
Reference in New Issue
Block a user