diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 8d389a8f93..ae9b335332 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -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 diff --git a/docs/about/contributing/CONTRIBUTING.md b/docs/about/contributing/CONTRIBUTING.md index 4cec5eb0fb..07ad472be2 100644 --- a/docs/about/contributing/CONTRIBUTING.md +++ b/docs/about/contributing/CONTRIBUTING.md @@ -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 diff --git a/scripts/releaser/main.go b/scripts/releaser/main.go index 5ea47b64d0..6394602f9e 100644 --- a/scripts/releaser/main.go +++ b/scripts/releaser/main.go @@ -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", diff --git a/scripts/releaser/release.go b/scripts/releaser/release.go index 618766d7d9..14c5236bae 100644 --- a/scripts/releaser/release.go +++ b/scripts/releaser/release.go @@ -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) + // 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 + 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.