From b95697a370a80d63a416d5fb686314e3120f754a Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Thu, 4 Jun 2026 14:38:48 -0400 Subject: [PATCH] ci: rewrite release workflow to be fully GitHub Actions-driven (#25162) Replace the local interactive release CLI and legacy shell scripts with a non-interactive Go tool (`scripts/release-action/`) and a rewritten `release.yaml` workflow. Release managers trigger releases from the GitHub Actions UI by selecting a branch, picking a release type (`rc`, `release`, or `create-release-branch`), and optionally providing a commit SHA. The Go tool has four subcommands: `calculate-version` (computes next version from git state), `generate-notes` (release notes from commit log and PR metadata), `publish` (creates GitHub release with checksums), and the workflow handles tag creation, branch creation, building, and downstream publishing. `scripts/version.sh` fallback now uses `git describe` (nearest ancestor tag) instead of global latest so dev builds on release branches show the correct version series. --- .github/workflows/release.yaml | 376 +++++++++++-------- .gitignore | 1 + docs/about/contributing/CONTRIBUTING.md | 71 ++-- scripts/release-action/calculate.go | 442 +++++++++++++++++++++++ scripts/release-action/calculate_test.go | 427 ++++++++++++++++++++++ scripts/release-action/commit.go | 221 ++++++++++++ scripts/release-action/commit_test.go | 352 ++++++++++++++++++ scripts/release-action/git.go | 29 ++ scripts/release-action/github.go | 115 ++++++ scripts/release-action/main.go | 149 ++++++++ scripts/release-action/notes.go | 160 ++++++++ scripts/release-action/publish.go | 153 ++++++++ scripts/release-action/version.go | 71 ++++ scripts/release-action/version_test.go | 96 +++++ 14 files changed, 2485 insertions(+), 178 deletions(-) create mode 100644 scripts/release-action/calculate.go create mode 100644 scripts/release-action/calculate_test.go create mode 100644 scripts/release-action/commit.go create mode 100644 scripts/release-action/commit_test.go create mode 100644 scripts/release-action/git.go create mode 100644 scripts/release-action/github.go create mode 100644 scripts/release-action/main.go create mode 100644 scripts/release-action/notes.go create mode 100644 scripts/release-action/publish.go create mode 100644 scripts/release-action/version.go create mode 100644 scripts/release-action/version_test.go diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 2427e3586f..6d7fe79ab7 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -3,35 +3,24 @@ name: Release on: workflow_dispatch: inputs: - release_channel: + release_type: type: choice - description: Release channel - options: - - mainline - - stable - - rc - release_notes: - description: Release notes for the publishing the release. This is required to create a release. - dry_run: - description: Perform a dry-run release (devel). Note that ref must be an annotated tag when run without dry-run. - type: boolean + description: "Type of release (use 'Use workflow from' to pick the branch)" required: true - default: false + options: + - rc + - release + - create-release-branch + commit_sha: + description: "Optional: commit SHA to tag (defaults to HEAD of selected branch)" + type: string + default: "" permissions: contents: read concurrency: ${{ github.workflow }}-${{ github.ref }} -env: - # Use `inputs` (vs `github.event.inputs`) to ensure that booleans are actual - # booleans, not strings. - # https://github.blog/changelog/2022-06-10-github-actions-inputs-unified-across-manual-and-reusable-workflows/ - CODER_RELEASE: ${{ !inputs.dry_run }} - CODER_DRY_RUN: ${{ inputs.dry_run }} - CODER_RELEASE_CHANNEL: ${{ inputs.release_channel }} - CODER_RELEASE_NOTES: ${{ inputs.release_notes }} - jobs: # Only allow maintainers/admins to release. check-perms: @@ -59,9 +48,141 @@ jobs: if (!allowed) core.setFailed('Denied: requires maintain or admin'); + + prepare-release: + name: Prepare release + needs: [check-perms] + runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }} + permissions: + contents: write + outputs: + version: ${{ steps.prepare.outputs.version }} + previous_version: ${{ steps.prepare.outputs.previous_version }} + stable: ${{ steps.prepare.outputs.stable }} + target_ref: ${{ steps.prepare.outputs.target_ref }} + create_branch: ${{ steps.prepare.outputs.create_branch }} + steps: + - name: Harden Runner + uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: true + + - name: Fetch git tags + run: git fetch --tags --force + + - name: Setup Go + uses: ./.github/actions/setup-go + with: + use-cache: false + + - name: Calculate version and create tag + id: prepare + env: + RELEASE_TYPE: ${{ inputs.release_type }} + REF_NAME: ${{ github.ref_name }} + COMMIT_SHA: ${{ inputs.commit_sha }} + run: | + set -euo pipefail + + args=(--type "$RELEASE_TYPE" --ref "$REF_NAME") + if [[ -n "$COMMIT_SHA" ]]; then + args+=(--commit "$COMMIT_SHA") + fi + + output=$(go run ./scripts/release-action calculate-version "${args[@]}") + echo "Raw output: $output" + + version=$(echo "$output" | jq -r '.version') + previous_version=$(echo "$output" | jq -r '.previous_version') + stable=$(echo "$output" | jq -r '.stable') + target_ref=$(echo "$output" | jq -r '.target_ref') + create_branch=$(echo "$output" | jq -r '.create_branch // empty') + + # Validate required outputs are non-empty. + for var in version previous_version target_ref; do + eval "val=\$$var" + if [[ -z "$val" || "$val" == "null" ]]; then + echo "::error::calculate-version returned empty or null '$var'" + exit 1 + fi + done + + { + echo "version=$version" + echo "previous_version=$previous_version" + echo "stable=$stable" + echo "target_ref=$target_ref" + echo "create_branch=$create_branch" + } >> "$GITHUB_OUTPUT" + + { + echo "### Release preparation" + echo "| Field | Value |" + echo "|-------|-------|" + echo "| Version | \`$version\` |" + echo "| Previous | \`$previous_version\` |" + echo "| Stable | \`$stable\` |" + echo "| Target ref | \`$target_ref\` |" + if [[ -n "$create_branch" ]]; then + echo "| Create branch | \`$create_branch\` |" + fi + } >> "$GITHUB_STEP_SUMMARY" + + - name: Create and push tag + env: + VERSION: ${{ steps.prepare.outputs.version }} + TARGET_REF: ${{ steps.prepare.outputs.target_ref }} + run: | + set -euo pipefail + # Skip if tag already exists (idempotent) + if git rev-parse "$VERSION" >/dev/null 2>&1; then + echo "Tag $VERSION already exists, skipping." + exit 0 + fi + git tag -a "$VERSION" -m "Release $VERSION" "$TARGET_REF" + git push origin "$VERSION" + + - name: Create release branch + if: ${{ steps.prepare.outputs.create_branch != '' }} + env: + CREATE_BRANCH: ${{ steps.prepare.outputs.create_branch }} + TARGET_REF: ${{ steps.prepare.outputs.target_ref }} + run: | + set -euo pipefail + # Skip if branch already exists + if git ls-remote --exit-code origin "refs/heads/$CREATE_BRANCH" >/dev/null 2>&1; then + echo "Branch $CREATE_BRANCH already exists, skipping." + exit 0 + fi + git branch "$CREATE_BRANCH" "$TARGET_REF" + git push origin "$CREATE_BRANCH" + + - name: Generate release notes + env: + VERSION: ${{ steps.prepare.outputs.version }} + PREV_VERSION: ${{ steps.prepare.outputs.previous_version }} + run: | + set -euo pipefail + go run ./scripts/release-action generate-notes \ + --version "$VERSION" \ + --previous-version "$PREV_VERSION" > /tmp/release_notes.md + + - name: Upload release notes + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-notes + path: /tmp/release_notes.md + retention-days: 30 + release: name: Build and publish - needs: [check-perms] + needs: [check-perms, prepare-release] runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }} permissions: # Required to publish a release @@ -75,6 +196,8 @@ jobs: # Required for GitHub Actions attestation attestations: write env: + CODER_RELEASE: "true" + CODER_RELEASE_STABLE: ${{ needs.prepare-release.outputs.stable }} # Necessary for Docker manifest DOCKER_CLI_EXPERIMENTAL: "enabled" outputs: @@ -99,66 +222,36 @@ jobs: - name: Fetch git tags run: git fetch --tags --force - - name: Print version - id: version + - name: Checkout release commit + env: + VERSION: ${{ needs.prepare-release.outputs.version }} run: | set -euo pipefail - version="$(./scripts/version.sh)" + git checkout "refs/tags/$VERSION" + + - name: Print version + id: version + env: + VERSION: ${{ needs.prepare-release.outputs.version }} + run: | + set -euo pipefail + # VERSION comes from the env block, not a misspelling of the local 'version'. + # shellcheck disable=SC2153 + # Strip the "v" prefix for use in build steps. + version="${VERSION#v}" echo "version=$version" >> "$GITHUB_OUTPUT" # Speed up future version.sh calls. echo "CODER_FORCE_VERSION=$version" >> "$GITHUB_ENV" echo "$version" - # Verify that all expectations for a release are met. - - name: Verify release input - if: ${{ !inputs.dry_run }} - run: | - set -euo pipefail + - name: Download release notes + uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1 + with: + name: release-notes + path: /tmp - if [[ "${GITHUB_REF}" != "refs/tags/v"* ]]; then - echo "Ref must be a semver tag when creating a release, did you use scripts/release.sh?" - exit 1 - fi - - # Derive the release branch from the version tag. - # 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 - echo "RC release detected — skipping release branch check (RC tags are cut from main)." - else - 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 - echo "Release notes are required to create a release, did you use scripts/release.sh?" - exit 1 - fi - - echo "Release inputs verified:" - echo - echo "- Ref: ${GITHUB_REF}" - echo "- Version: ${version}" - echo "- Release channel: ${CODER_RELEASE_CHANNEL}" - echo "- Release branch: ${release_branch}" - echo "- Release notes: true" - - - name: Create release notes file - run: | - set -euo pipefail - - release_notes_file="$(mktemp -t release_notes.XXXXXX)" - echo "$CODER_RELEASE_NOTES" > "$release_notes_file" - echo CODER_RELEASE_NOTES_FILE="$release_notes_file" >> "$GITHUB_ENV" + - name: Set release notes env + run: echo CODER_RELEASE_NOTES_FILE=/tmp/release_notes.md >> "$GITHUB_ENV" - name: Show release notes run: | @@ -283,12 +376,8 @@ jobs: id: image-base-tag run: | set -euo pipefail - if [[ "${CODER_RELEASE:-}" != *t* ]] || [[ "${CODER_DRY_RUN:-}" == *t* ]]; then - # Empty value means use the default and avoid building a fresh one. - echo "tag=" >> "$GITHUB_OUTPUT" - else - echo "tag=$(CODER_IMAGE_BASE=ghcr.io/coder/coder-base ./scripts/image_tag.sh)" >> "$GITHUB_OUTPUT" - fi + # Empty value means use the default and avoid building a fresh one. + echo "tag=$(CODER_IMAGE_BASE=ghcr.io/coder/coder-base ./scripts/image_tag.sh)" >> "$GITHUB_OUTPUT" - name: Create empty base-build-context directory if: steps.image-base-tag.outputs.tag != '' @@ -350,7 +439,7 @@ jobs: - name: GitHub Attestation for Base Docker image id: attest_base - if: ${{ !inputs.dry_run && steps.build_base_image.outputs.digest != '' }} + if: ${{ steps.build_base_image.outputs.digest != '' }} continue-on-error: true uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 with: @@ -363,13 +452,6 @@ jobs: run: | set -euxo pipefail - # we can't build multi-arch if the images aren't pushed, so quit now - # if dry-running - if [[ "$CODER_RELEASE" != *t* ]]; then - echo Skipping multi-arch docker builds due to dry-run. - exit 0 - fi - # build Docker images for each architecture version="$(./scripts/version.sh)" make build/coder_"$version"_linux_{amd64,arm64,armv7}.tag @@ -403,7 +485,6 @@ jobs: CODER_BASE_IMAGE_TAG: ${{ steps.image-base-tag.outputs.tag }} - name: SBOM Generation and Attestation - if: ${{ !inputs.dry_run }} env: COSIGN_EXPERIMENTAL: '1' MULTIARCH_IMAGE: ${{ steps.build_docker.outputs.multiarch_image }} @@ -439,7 +520,6 @@ jobs: - name: Resolve Docker image digests for attestation id: docker_digests - if: ${{ !inputs.dry_run }} continue-on-error: true env: MULTIARCH_IMAGE: ${{ steps.build_docker.outputs.multiarch_image }} @@ -457,7 +537,7 @@ jobs: - name: GitHub Attestation for Docker image id: attest_main - if: ${{ !inputs.dry_run && steps.docker_digests.outputs.multiarch_digest != '' }} + if: ${{ steps.docker_digests.outputs.multiarch_digest != '' }} continue-on-error: true uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 with: @@ -467,7 +547,7 @@ jobs: - name: GitHub Attestation for "latest" Docker image id: attest_latest - if: ${{ !inputs.dry_run && steps.docker_digests.outputs.latest_digest != '' }} + if: ${{ steps.docker_digests.outputs.latest_digest != '' }} continue-on-error: true uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 with: @@ -477,7 +557,6 @@ jobs: - name: GitHub Attestation for release binaries id: attest_binaries - if: ${{ !inputs.dry_run }} continue-on-error: true uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 with: @@ -493,7 +572,6 @@ jobs: # Report attestation failures but don't fail the workflow - name: Check attestation status - if: ${{ !inputs.dry_run }} run: | # zizmor: ignore[template-injection] We're just reading steps.attest_x.outcome here, no risk of injection if [[ "${{ steps.attest_base.outcome }}" == "failure" && "${{ steps.attest_base.conclusion }}" != "skipped" ]]; then echo "::warning::GitHub attestation for base image failed" @@ -517,7 +595,6 @@ jobs: run: ls -lh build - name: Publish Coder CLI binaries and detached signatures to GCS - if: ${{ !inputs.dry_run }} run: | set -euxo pipefail @@ -544,19 +621,7 @@ jobs: run: | set -euo pipefail - publish_args=() - if [[ $CODER_RELEASE_CHANNEL == "stable" ]]; then - publish_args+=(--stable) - fi - if [[ $CODER_RELEASE_CHANNEL == "rc" ]]; then - publish_args+=(--rc) - fi - if [[ $CODER_DRY_RUN == *t* ]]; then - publish_args+=(--dry-run) - fi - declare -p publish_args - - # Build the list of files to publish + # Build the list of files to publish. files=( ./build/*_installer.exe ./build/*.zip @@ -568,24 +633,28 @@ jobs: "./coder_${VERSION}_sbom.spdx.json" ) - # Only include the latest SBOM file if it was created + # Only include the latest SBOM file if it was created. if [[ "${CREATED_LATEST_TAG}" == "true" ]]; then files+=(./coder_latest_sbom.spdx.json) fi - ./scripts/release/publish.sh \ - "${publish_args[@]}" \ + stable_flag=() + if [[ "$CODER_RELEASE_STABLE" == "true" ]]; then + stable_flag=(--stable) + fi + + go run ./scripts/release-action publish \ + --version "v${VERSION}" \ + "${stable_flag[@]}" \ --release-notes-file "$CODER_RELEASE_NOTES_FILE" \ "${files[@]}" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - CODER_GPG_RELEASE_KEY_BASE64: ${{ secrets.GPG_RELEASE_KEY_BASE64 }} VERSION: ${{ steps.version.outputs.version }} CREATED_LATEST_TAG: ${{ steps.build_docker.outputs.created_latest_tag }} # Mark the Linear release as shipped. - name: Extract Linear release version - if: ${{ !inputs.dry_run }} id: linear_version run: | # Skip RC releases — they must not complete the Linear release. @@ -603,7 +672,7 @@ jobs: VERSION: ${{ steps.version.outputs.version }} - name: Complete Linear release - if: ${{ !inputs.dry_run && steps.linear_version.outputs.skip != 'true' }} + if: ${{ steps.linear_version.outputs.skip != 'true' }} continue-on-error: true uses: linear/linear-release-action@0353b5fa8c00326913966f00557d68f8f30b8b6b # v0.7.0 with: @@ -622,7 +691,6 @@ jobs: uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # 3.0.1 - name: Publish Helm Chart - if: ${{ !inputs.dry_run }} run: | set -euo pipefail version="$(./scripts/version.sh)" @@ -638,44 +706,20 @@ jobs: helm push "build/coder_helm_${version}.tgz" oci://ghcr.io/coder/chart helm push "build/provisioner_helm_${version}.tgz" oci://ghcr.io/coder/chart - - name: Upload artifacts to actions (if dry-run) - if: ${{ inputs.dry_run }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: release-artifacts - path: | - ./build/*_installer.exe - ./build/*.zip - ./build/*.tar.gz - ./build/*.tgz - ./build/*.apk - ./build/*.deb - ./build/*.rpm - ./coder_${{ steps.version.outputs.version }}_sbom.spdx.json - retention-days: 7 - - - name: Upload latest sbom artifact to actions (if dry-run) - if: inputs.dry_run && steps.build_docker.outputs.created_latest_tag == 'true' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: latest-sbom-artifact - path: ./coder_latest_sbom.spdx.json - retention-days: 7 - - name: Send repository-dispatch event - if: ${{ !inputs.dry_run && inputs.release_channel != 'rc' }} + if: ${{ inputs.release_type != 'rc' && inputs.release_type != 'create-release-branch' }} uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 with: token: ${{ secrets.CDRCI_GITHUB_TOKEN }} repository: coder/packages event-type: coder-release - client-payload: '{"coder_version": "${{ steps.version.outputs.version }}", "release_channel": "${{ inputs.release_channel }}"}' + client-payload: '{"coder_version": "${{ steps.version.outputs.version }}"}' publish-homebrew: name: Publish to Homebrew tap runs-on: ubuntu-latest - needs: release - if: ${{ !inputs.dry_run && inputs.release_channel == 'mainline' }} + needs: [release, prepare-release] + if: ${{ inputs.release_type != 'rc' && inputs.release_type != 'create-release-branch' && needs.prepare-release.outputs.stable == 'true' }} steps: - name: Harden Runner @@ -747,11 +791,12 @@ jobs: -a "${GITHUB_ACTOR}" \ -b "This automatic PR was triggered by the release of Coder v$coder_version" + publish-winget: name: Publish to winget-pkgs runs-on: windows-latest - needs: release - if: ${{ !inputs.dry_run && inputs.release_channel != 'rc' }} + needs: [release, prepare-release] + if: ${{ inputs.release_type != 'rc' && inputs.release_type != 'create-release-branch' }} steps: - name: Harden Runner @@ -839,3 +884,44 @@ jobs: # different repo. GH_TOKEN: ${{ secrets.CDRCI_GITHUB_TOKEN }} VERSION: ${{ needs.release.outputs.version }} + + + update-docs: + name: Update release docs + needs: [prepare-release, release] + if: ${{ inputs.release_type != 'rc' && inputs.release_type != 'create-release-branch' }} + runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }} + permissions: + contents: write + pull-requests: write + steps: + - name: Harden Runner + uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: main + fetch-depth: 0 + persist-credentials: true + + - name: Fetch git tags + run: git fetch --tags --force + + - name: Setup Node + uses: ./.github/actions/setup-node + + - name: Update release calendar + run: ./scripts/update-release-calendar.sh + + - name: Create docs update PR + uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: "docs: update release docs for ${{ needs.prepare-release.outputs.version }}" + title: "docs: update release docs for ${{ needs.prepare-release.outputs.version }}" + body: "Automated docs update for release ${{ needs.prepare-release.outputs.version }}." + branch: docs/release-${{ needs.prepare-release.outputs.version }} + base: main diff --git a/.gitignore b/.gitignore index 28e8f26c27..21da30a370 100644 --- a/.gitignore +++ b/.gitignore @@ -115,3 +115,4 @@ license.txt # Agent planning documents (local working files). docs/plans/ +/release-action diff --git a/docs/about/contributing/CONTRIBUTING.md b/docs/about/contributing/CONTRIBUTING.md index 16795b188a..97d1a82f95 100644 --- a/docs/about/contributing/CONTRIBUTING.md +++ b/docs/about/contributing/CONTRIBUTING.md @@ -211,55 +211,60 @@ be applied selectively or to discourage anyone from contributing. ## Releases -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 +Coder releases are managed entirely through the [`release.yaml`](https://github.com/coder/coder/blob/main/.github/workflows/release.yaml) -workflow. - -Release notes are automatically generated from commit titles and PR metadata. +GitHub Actions workflow, triggered manually via "Run workflow" in the Actions +tab. Release notes are automatically generated from commit titles and PR +metadata. ### Release types -| 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 | +| Type | Tag | Source | Purpose | +|------------------------|---------------|------------------|---------------------------------------| +| RC (release candidate) | `vX.Y.0-rc.W` | `main` or branch | Pre-release for testing | +| Create release branch | `vX.Y.0-rc.W` | `main` | Cut `release/X.Y` + tag RC atomically | +| 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 | ### 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. +RC tags can be created from `main` or from a release branch. The +`create-release-branch` type creates `release/X.Y` and tags the next RC in one +step, continuing the RC numbering sequence. ```text -main: ──●──●──●──●──●──●──●──●──●── - ↑ ↑ ↑ - rc.0 rc.1 cut release/2.34, tag v2.34.0 - \ - release/2.34: ──●── v2.34.1 (patch) +main: --*--*--*--*--*--*--*--*--*-- + | rc.0 rc.1 | + | +--- create-release-branch ---+ + | | + | release/2.34: --*-- rc.2 -- rc.3 -- v2.34.0 + | + +-- (more RCs on main for next cycle) ``` -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. +1. **RC:** Go to [Actions > Release](https://github.com/coder/coder/actions/workflows/release.yaml), + click "Run workflow", select `main` (or a release branch) from the "Use + workflow from" dropdown, choose `rc`, and optionally provide a commit SHA + (defaults to HEAD). The workflow calculates the next RC version + automatically. +2. **Create release branch:** Select `main` in the dropdown, choose + `create-release-branch`, and optionally provide a commit SHA. This creates + `release/X.Y` and tags the next RC atomically. +3. **Release:** Select the release branch (e.g. `release/2.34`) from the + dropdown and choose `release`. No other inputs needed. +4. **Patch:** Cherry-pick fixes onto `release/X.Y`, select that branch from + the dropdown, and choose `release`. -The release tool warns if you try to tag a non-RC on `main` or an RC on a -release branch. +The workflow validates that commits are on the expected branch for each release +type. -### Creating a release (via workflow dispatch) +### Retrying a failed release 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. +workflow fails after the tag has been pushed, re-run the failed jobs from the +GitHub Actions UI. The `prepare-release` job is idempotent and will detect +the existing tag. To test the workflow without publishing, select dry-run. diff --git a/scripts/release-action/calculate.go b/scripts/release-action/calculate.go new file mode 100644 index 0000000000..18219cf756 --- /dev/null +++ b/scripts/release-action/calculate.go @@ -0,0 +1,442 @@ +package main + +import ( + "encoding/json" + "fmt" + "regexp" + "strconv" + "strings" + + "golang.org/x/xerrors" +) + +// calculateResult is implemented by both ReleaseRequest and +// CreateBranchRequest so calculateNextVersion can return either. +type calculateResult interface { + String() string +} + +// ReleaseRequest is the JSON output of calculate-version for rc and +// release types. +type ReleaseRequest struct { + Version string `json:"version"` + PreviousVersion string `json:"previous_version"` + Stable bool `json:"stable"` + TargetRef string `json:"target_ref"` +} + +// String returns the result as indented JSON. +func (r ReleaseRequest) String() string { + b, _ := json.MarshalIndent(r, "", " ") + return string(b) +} + +// CreateBranchRequest is the JSON output of calculate-version for the +// create-release-branch type. +type CreateBranchRequest struct { + ReleaseRequest + BranchName string `json:"create_branch"` +} + +// String returns the result as indented JSON. +func (r CreateBranchRequest) String() string { + b, _ := json.MarshalIndent(r, "", " ") + return string(b) +} + +var branchRe = regexp.MustCompile(`^release/(\d+)\.(\d+)$`) + +// calculateNextVersion dispatches to the appropriate calculation. +// +// ref is the branch name from the "Use workflow from" dropdown +// (github.ref_name). commitSHA is an optional override; when empty +// the tool defaults to HEAD of the ref. +func calculateNextVersion(releaseType, ref, commitSHA string) (calculateResult, error) { + // Ensure we have up-to-date remote state. + if _, err := gitOutput("fetch", "--tags", "--force", "origin"); err != nil { + return nil, xerrors.Errorf("git fetch: %w", err) + } + + isReleaseBranch := branchRe.MatchString(ref) + isMain := ref == "main" + + switch releaseType { + case "rc": + if !isMain && !isReleaseBranch { + return nil, xerrors.Errorf("rc must be run from main or a release/X.Y branch, got %q", ref) + } + if isMain { + return calculateRCFromMainReleaseRequest(ref, commitSHA) + } + return calculateRCFromBranchReleaseRequest(ref, commitSHA) + + case "release": + if !isReleaseBranch { + return nil, xerrors.Errorf("release must be run from a release/X.Y branch, got %q", ref) + } + return createRegularReleaseRequest(ref) + + case "create-release-branch": + if !isMain { + return nil, xerrors.Errorf("create-release-branch must be run from main, got %q", ref) + } + return calculateCreateBranchRequest(ref, commitSHA) + + default: + return nil, xerrors.Errorf("unknown release type %q (expected rc, release, or create-release-branch)", releaseType) + } +} + +// resolveCommit returns the commit SHA to tag. If commitSHA is +// provided it is validated and returned; otherwise HEAD of the +// ref is used. +func resolveCommit(ref, commitSHA string) (string, error) { + if commitSHA != "" { + if !isHexSHA(commitSHA) { + return "", xerrors.Errorf("invalid commit SHA %q: must be a hex string", commitSHA) + } + return commitSHA, nil + } + sha, err := gitOutput("rev-parse", fmt.Sprintf("origin/%s", ref)) + if err != nil { + return "", xerrors.Errorf("resolve HEAD of %s: %w", ref, err) + } + return sha, nil +} + +// calculateRCFromMainReleaseRequest tags an RC from a commit on main. +func calculateRCFromMainReleaseRequest(ref, commitSHA string) (ReleaseRequest, error) { + targetRef, err := resolveCommit(ref, commitSHA) + if err != nil { + return ReleaseRequest{}, err + } + + // Verify commit is an ancestor of origin/main. + if err := gitRun("merge-base", "--is-ancestor", targetRef, "origin/main"); err != nil { + return ReleaseRequest{}, xerrors.Errorf("commit %s is not an ancestor of origin/main", targetRef) + } + + allTags, err := listSemverTags() + if err != nil { + return ReleaseRequest{}, err + } + + // Find latest RC globally to determine series. + latestRC := findLatestRC(allTags) + latestRelease := findLatestNonRC(allTags) + + var major, minor, rcNum int + switch { + case latestRC.original != "": + major = latestRC.major + minor = latestRC.minor + rcNum = latestRC.rc + 1 + + // If there is a final release for this series, bump minor. + if latestRelease.original != "" && + latestRelease.major == major && + latestRelease.minor == minor { + minor++ + rcNum = 0 + } + case latestRelease.original != "": + major = latestRelease.major + minor = latestRelease.minor + 1 + rcNum = 0 + default: + return ReleaseRequest{}, xerrors.New("no existing tags found to base RC on") + } + + newVer := version{major: major, minor: minor, patch: 0, rc: rcNum} + prevTag := findPreviousTag(allTags, newVer) + + return ReleaseRequest{ + Version: newVer.String(), + PreviousVersion: prevTag, + TargetRef: targetRef, + }, nil +} + +// calculateRCFromBranchReleaseRequest tags an RC from the tip of a release branch. +func calculateRCFromBranchReleaseRequest(ref, commitSHA string) (ReleaseRequest, error) { + m := branchRe.FindStringSubmatch(ref) + if m == nil { + return ReleaseRequest{}, xerrors.Errorf("ref %q does not match release/X.Y", ref) + } + + major, _ := strconv.Atoi(m[1]) + minor, _ := strconv.Atoi(m[2]) + + targetRef, err := resolveCommit(ref, commitSHA) + if err != nil { + return ReleaseRequest{}, err + } + + // Fail if there are open PRs targeting this release branch. + if err := checkOpenPRs(ref); err != nil { + return ReleaseRequest{}, err + } + + allTags, err := listSemverTags() + if err != nil { + return ReleaseRequest{}, err + } + + // Find tags for this series. + seriesTags := filterTagsForSeries(allTags, major, minor) + + // If the series already has a final release, this is an error; + // you should be cutting a new minor, not more RCs. + for _, t := range seriesTags { + if t.rc < 0 { + return ReleaseRequest{}, xerrors.Errorf( + "release %s already exists for this series; cut a new minor instead of another RC", + t.original, + ) + } + } + + rcNum := 0 + for _, t := range seriesTags { + if t.rc >= rcNum { + rcNum = t.rc + 1 + } + } + + newVer := version{major: major, minor: minor, patch: 0, rc: rcNum} + prevTag := findPreviousTag(allTags, newVer) + + return ReleaseRequest{ + Version: newVer.String(), + PreviousVersion: prevTag, + TargetRef: targetRef, + }, nil +} + +// createRegularReleaseRequest calculates the next release (non-RC) version from +// a release branch. Uses HEAD of the branch. +func createRegularReleaseRequest(ref string) (ReleaseRequest, error) { + m := branchRe.FindStringSubmatch(ref) + if m == nil { + return ReleaseRequest{}, xerrors.Errorf("ref %q does not match release/X.Y", ref) + } + + major, _ := strconv.Atoi(m[1]) + minor, _ := strconv.Atoi(m[2]) + + // Resolve branch HEAD. + headSHA, err := gitOutput("rev-parse", fmt.Sprintf("origin/%s", ref)) + if err != nil { + return ReleaseRequest{}, xerrors.Errorf("resolve branch %s: %w", ref, err) + } + + // Fail if there are open PRs targeting this release branch. + if err := checkOpenPRs(ref); err != nil { + return ReleaseRequest{}, err + } + + allTags, err := listSemverTags() + if err != nil { + return ReleaseRequest{}, err + } + + // Find tags for this series. + seriesTags := filterTagsForSeries(allTags, major, minor) + + // Determine next patch version. + nextPatch := 0 + for _, t := range seriesTags { + if t.rc < 0 && t.patch >= nextPatch { + nextPatch = t.patch + 1 + } + } + + newVer := version{major: major, minor: minor, patch: nextPatch, rc: -1} + prevTag := findPreviousTag(allTags, newVer) + + return ReleaseRequest{ + Version: newVer.String(), + PreviousVersion: prevTag, + Stable: isStable(major, minor, allTags), + TargetRef: headSHA, + }, nil +} + +// calculateCreateBranchRequest creates a release branch and tags the next +// RC in one atomic step. Must be run from main. +func calculateCreateBranchRequest(ref, commitSHA string) (CreateBranchRequest, error) { + targetRef, err := resolveCommit(ref, commitSHA) + if err != nil { + return CreateBranchRequest{}, err + } + + // Verify commit is an ancestor of origin/main. + if err := gitRun("merge-base", "--is-ancestor", targetRef, "origin/main"); err != nil { + return CreateBranchRequest{}, xerrors.Errorf("commit %s is not an ancestor of origin/main", targetRef) + } + + allTags, err := listSemverTags() + if err != nil { + return CreateBranchRequest{}, err + } + + // Find latest non-RC release. + latest := findLatestNonRC(allTags) + if latest.original == "" { + return CreateBranchRequest{}, xerrors.New("no existing releases found") + } + + nextMajor := latest.major + nextMinor := latest.minor + 1 + branchName := fmt.Sprintf("release/%d.%d", nextMajor, nextMinor) + + // Check that the branch doesn't already exist. + if _, err := gitOutput("rev-parse", "--verify", fmt.Sprintf("origin/%s", branchName)); err == nil { + return CreateBranchRequest{}, xerrors.Errorf("branch %s already exists", branchName) + } + + // Find existing RCs for this series to continue the sequence. + rcNum := 0 + seriesTags := filterTagsForSeries(allTags, nextMajor, nextMinor) + for _, t := range seriesTags { + if t.rc >= rcNum { + rcNum = t.rc + 1 + } + } + + newVer := version{major: nextMajor, minor: nextMinor, patch: 0, rc: rcNum} + prevTag := findPreviousTag(allTags, newVer) + + return CreateBranchRequest{ + ReleaseRequest: ReleaseRequest{ + Version: newVer.String(), + PreviousVersion: prevTag, + TargetRef: targetRef, + }, + BranchName: branchName, + }, nil +} + +// isStable returns true if this minor series is exactly one behind +// the latest released minor (i.e. it is the "stable" channel). +func isStable(major, minor int, allTags []version) bool { + latest := findLatestNonRC(allTags) + return latest.original != "" && latest.major == major && latest.minor == minor+1 +} + +// isHexSHA validates that s looks like a hex commit SHA. +func isHexSHA(s string) bool { + if len(s) < 7 { + return false + } + for _, c := range s { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { + return false + } + } + return true +} + +// findLatestRC returns the highest RC version from the tag list. +func findLatestRC(tags []version) version { + var best version + for _, t := range tags { + if t.rc < 0 { + continue + } + if best.original == "" || versionIsLess(best, t) { + best = t + } + } + return best +} + +// findLatestNonRC returns the highest non-RC version from the tag list. +func findLatestNonRC(tags []version) version { + var best version + for _, t := range tags { + if t.rc >= 0 { + continue + } + if best.original == "" || versionIsLess(best, t) { + best = t + } + } + return best +} + +// filterTagsForSeries returns tags matching the given major.minor. +func filterTagsForSeries(tags []version, major, minor int) []version { + var out []version + for _, t := range tags { + if t.major == major && t.minor == minor { + out = append(out, t) + } + } + return out +} + +// findPreviousTag returns the version string of the best previous +// tag for building a changelog range. It picks the highest tag that +// is strictly less than newVer. +func findPreviousTag(tags []version, newVer version) string { + var best version + for _, t := range tags { + if !versionIsLess(t, newVer) { + continue + } + if best.original == "" || versionIsLess(best, t) { + best = t + } + } + return best.original +} + +// versionIsLess returns true if a < b using semver ordering. +func versionIsLess(a, b version) bool { + if a.major != b.major { + return a.major < b.major + } + if a.minor != b.minor { + return a.minor < b.minor + } + if a.patch != b.patch { + return a.patch < b.patch + } + // Non-RC (rc == -1) is greater than any RC. + if a.rc < 0 && b.rc < 0 { + return false + } + if a.rc < 0 { + return false + } + if b.rc < 0 { + return true + } + return a.rc < b.rc +} + +// listSemverTags returns all semver tags from the repo. +func listSemverTags() ([]version, error) { + out, err := gitOutput("tag", "--list", "v*") + if err != nil { + return nil, xerrors.Errorf("list tags: %w", err) + } + if out == "" { + return nil, nil + } + + var tags []version + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + v, err := parseVersion(line) + if err != nil { + continue // skip non-semver tags + } + tags = append(tags, v) + } + return tags, nil +} diff --git a/scripts/release-action/calculate_test.go b/scripts/release-action/calculate_test.go new file mode 100644 index 0000000000..68968ad6dd --- /dev/null +++ b/scripts/release-action/calculate_test.go @@ -0,0 +1,427 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_versionIsLess(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + a, b version + want bool + }{ + { + name: "major_less", + a: version{major: 1, minor: 0, patch: 0, rc: -1, original: "v1.0.0"}, + b: version{major: 2, minor: 0, patch: 0, rc: -1, original: "v2.0.0"}, + want: true, + }, + { + name: "major_greater", + a: version{major: 3, minor: 0, patch: 0, rc: -1, original: "v3.0.0"}, + b: version{major: 2, minor: 0, patch: 0, rc: -1, original: "v2.0.0"}, + want: false, + }, + { + name: "minor_less", + a: version{major: 2, minor: 1, patch: 0, rc: -1, original: "v2.1.0"}, + b: version{major: 2, minor: 2, patch: 0, rc: -1, original: "v2.2.0"}, + want: true, + }, + { + name: "minor_greater", + a: version{major: 2, minor: 5, patch: 0, rc: -1, original: "v2.5.0"}, + b: version{major: 2, minor: 2, patch: 0, rc: -1, original: "v2.2.0"}, + want: false, + }, + { + name: "patch_less", + a: version{major: 2, minor: 1, patch: 0, rc: -1, original: "v2.1.0"}, + b: version{major: 2, minor: 1, patch: 3, rc: -1, original: "v2.1.3"}, + want: true, + }, + { + name: "patch_greater", + a: version{major: 2, minor: 1, patch: 5, rc: -1, original: "v2.1.5"}, + b: version{major: 2, minor: 1, patch: 3, rc: -1, original: "v2.1.3"}, + want: false, + }, + { + name: "rc_less_than_non_rc", + a: version{major: 2, minor: 1, patch: 0, rc: 5, original: "v2.1.0-rc.5"}, + b: version{major: 2, minor: 1, patch: 0, rc: -1, original: "v2.1.0"}, + want: true, + }, + { + name: "non_rc_not_less_than_rc", + a: version{major: 2, minor: 1, patch: 0, rc: -1, original: "v2.1.0"}, + b: version{major: 2, minor: 1, patch: 0, rc: 5, original: "v2.1.0-rc.5"}, + want: false, + }, + { + name: "equal_non_rc", + a: version{major: 2, minor: 1, patch: 0, rc: -1, original: "v2.1.0"}, + b: version{major: 2, minor: 1, patch: 0, rc: -1, original: "v2.1.0"}, + want: false, + }, + { + name: "equal_rc", + a: version{major: 2, minor: 1, patch: 0, rc: 3, original: "v2.1.0-rc.3"}, + b: version{major: 2, minor: 1, patch: 0, rc: 3, original: "v2.1.0-rc.3"}, + want: false, + }, + { + name: "rc_ordering", + a: version{major: 2, minor: 1, patch: 0, rc: 1, original: "v2.1.0-rc.1"}, + b: version{major: 2, minor: 1, patch: 0, rc: 3, original: "v2.1.0-rc.3"}, + want: true, + }, + { + name: "rc_ordering_reverse", + a: version{major: 2, minor: 1, patch: 0, rc: 3, original: "v2.1.0-rc.3"}, + b: version{major: 2, minor: 1, patch: 0, rc: 1, original: "v2.1.0-rc.1"}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.want, versionIsLess(tt.a, tt.b)) + }) + } +} + +func Test_findLatestRC(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + tags []version + want version + }{ + { + name: "empty_list", + tags: nil, + want: version{}, + }, + { + name: "no_rcs", + tags: []version{ + {major: 2, minor: 1, patch: 0, rc: -1, original: "v2.1.0"}, + {major: 2, minor: 2, patch: 0, rc: -1, original: "v2.2.0"}, + }, + want: version{}, + }, + { + name: "multiple_rcs_across_series", + tags: []version{ + {major: 2, minor: 1, patch: 0, rc: 0, original: "v2.1.0-rc.0"}, + {major: 2, minor: 2, patch: 0, rc: 3, original: "v2.2.0-rc.3"}, + {major: 2, minor: 2, patch: 0, rc: 1, original: "v2.2.0-rc.1"}, + {major: 2, minor: 1, patch: 0, rc: -1, original: "v2.1.0"}, + }, + want: version{major: 2, minor: 2, patch: 0, rc: 3, original: "v2.2.0-rc.3"}, + }, + { + name: "single_rc", + tags: []version{ + {major: 1, minor: 0, patch: 0, rc: 0, original: "v1.0.0-rc.0"}, + }, + want: version{major: 1, minor: 0, patch: 0, rc: 0, original: "v1.0.0-rc.0"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := findLatestRC(tt.tags) + require.Equal(t, tt.want, got) + }) + } +} + +func Test_findLatestNonRC(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + tags []version + want version + }{ + { + name: "empty_list", + tags: nil, + want: version{}, + }, + { + name: "no_non_rcs", + tags: []version{ + {major: 2, minor: 1, patch: 0, rc: 0, original: "v2.1.0-rc.0"}, + {major: 2, minor: 2, patch: 0, rc: 3, original: "v2.2.0-rc.3"}, + }, + want: version{}, + }, + { + name: "multiple_releases", + tags: []version{ + {major: 2, minor: 1, patch: 0, rc: -1, original: "v2.1.0"}, + {major: 2, minor: 2, patch: 0, rc: -1, original: "v2.2.0"}, + {major: 2, minor: 2, patch: 0, rc: 3, original: "v2.2.0-rc.3"}, + {major: 2, minor: 1, patch: 1, rc: -1, original: "v2.1.1"}, + }, + want: version{major: 2, minor: 2, patch: 0, rc: -1, original: "v2.2.0"}, + }, + { + name: "single_release", + tags: []version{ + {major: 1, minor: 0, patch: 0, rc: -1, original: "v1.0.0"}, + }, + want: version{major: 1, minor: 0, patch: 0, rc: -1, original: "v1.0.0"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := findLatestNonRC(tt.tags) + require.Equal(t, tt.want, got) + }) + } +} + +func Test_findPreviousTag(t *testing.T) { + t.Parallel() + + tags := []version{ + {major: 2, minor: 1, patch: 0, rc: -1, original: "v2.1.0"}, + {major: 2, minor: 2, patch: 0, rc: 0, original: "v2.2.0-rc.0"}, + {major: 2, minor: 2, patch: 0, rc: 1, original: "v2.2.0-rc.1"}, + {major: 2, minor: 2, patch: 0, rc: -1, original: "v2.2.0"}, + } + + tests := []struct { + name string + newVer version + want string + }{ + { + name: "normal_case", + newVer: version{major: 2, minor: 2, patch: 0, rc: 2, original: "v2.2.0-rc.2"}, + want: "v2.2.0-rc.1", + }, + { + name: "no_previous", + newVer: version{major: 1, minor: 0, patch: 0, rc: 0, original: "v1.0.0-rc.0"}, + want: "", + }, + { + name: "exact_match_excluded", + newVer: version{major: 2, minor: 2, patch: 0, rc: -1, original: "v2.2.0"}, + want: "v2.2.0-rc.1", + }, + { + name: "picks_highest_lesser", + newVer: version{major: 3, minor: 0, patch: 0, rc: -1, original: "v3.0.0"}, + want: "v2.2.0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := findPreviousTag(tags, tt.newVer) + require.Equal(t, tt.want, got) + }) + } +} + +func Test_filterTagsForSeries(t *testing.T) { + t.Parallel() + + tags := []version{ + {major: 2, minor: 1, patch: 0, rc: -1, original: "v2.1.0"}, + {major: 2, minor: 2, patch: 0, rc: 0, original: "v2.2.0-rc.0"}, + {major: 2, minor: 2, patch: 0, rc: -1, original: "v2.2.0"}, + {major: 3, minor: 2, patch: 0, rc: -1, original: "v3.2.0"}, + } + + tests := []struct { + name string + major int + minor int + wantCount int + wantFirst string + wantSecond string + }{ + { + name: "matching_tags", + major: 2, + minor: 2, + wantCount: 2, + wantFirst: "v2.2.0-rc.0", + wantSecond: "v2.2.0", + }, + { + name: "no_matching_tags", + major: 4, + minor: 0, + wantCount: 0, + }, + { + name: "single_match", + major: 2, + minor: 1, + wantCount: 1, + wantFirst: "v2.1.0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := filterTagsForSeries(tags, tt.major, tt.minor) + require.Len(t, got, tt.wantCount) + if tt.wantCount > 0 { + require.Equal(t, tt.wantFirst, got[0].original) + } + if tt.wantCount > 1 { + require.Equal(t, tt.wantSecond, got[1].original) + } + }) + } +} + +func Test_isStable(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + major int + minor int + tags []version + want bool + }{ + { + name: "latest_is_minor_plus_one_stable", + major: 2, + minor: 20, + tags: []version{ + {major: 2, minor: 21, patch: 0, rc: -1, original: "v2.21.0"}, + }, + want: true, + }, + { + name: "latest_is_same_minor_not_stable", + major: 2, + minor: 21, + tags: []version{ + {major: 2, minor: 21, patch: 0, rc: -1, original: "v2.21.0"}, + }, + want: false, + }, + { + name: "latest_is_minor_plus_two_not_stable", + major: 2, + minor: 19, + tags: []version{ + {major: 2, minor: 21, patch: 0, rc: -1, original: "v2.21.0"}, + }, + want: false, + }, + { + name: "no_tags", + major: 2, + minor: 20, + tags: nil, + want: false, + }, + { + name: "only_rcs_no_releases", + major: 2, + minor: 20, + tags: []version{ + {major: 2, minor: 21, patch: 0, rc: 0, original: "v2.21.0-rc.0"}, + }, + want: false, + }, + { + name: "different_major_not_stable", + major: 2, + minor: 20, + tags: []version{ + {major: 3, minor: 21, patch: 0, rc: -1, original: "v3.21.0"}, + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.want, isStable(tt.major, tt.minor, tt.tags)) + }) + } +} + +func Test_isHexSHA(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + s string + want bool + }{ + { + name: "valid_short_sha", + s: "abc1234", + want: true, + }, + { + name: "valid_long_sha", + s: "abc1234def5678901234567890abcdef12345678", + want: true, + }, + { + name: "valid_uppercase", + s: "ABCDEF1234567", + want: true, + }, + { + name: "too_short", + s: "abc12", + want: false, + }, + { + name: "exactly_six_chars", + s: "abc123", + want: false, + }, + { + name: "non_hex_chars", + s: "xyz1234", + want: false, + }, + { + name: "empty", + s: "", + want: false, + }, + { + name: "seven_chars_valid", + s: "abcdef1", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.want, isHexSHA(tt.s)) + }) + } +} diff --git a/scripts/release-action/commit.go b/scripts/release-action/commit.go new file mode 100644 index 0000000000..669f1ef88f --- /dev/null +++ b/scripts/release-action/commit.go @@ -0,0 +1,221 @@ +package main + +import ( + "regexp" + "sort" + "strconv" + "strings" +) + +// commitEntry represents a single non-merge commit. +type commitEntry struct { + SHA string + FullSHA string + Title string + Timestamp int64 +} + +// cherryPickPRRe matches cherry-pick bot titles like +// "chore: foo bar (cherry-pick #42) (#43)". +var cherryPickPRRe = regexp.MustCompile(`\(cherry-pick #(\d+)\)\s*\(#\d+\)$`) + +// humanizedAreas maps conventional commit scopes to human-readable area +// names. Order matters: more specific prefixes must come first so that +// the first partial match wins. +var humanizedAreas = []struct { + Prefix string + Area string +}{ + {"agent/agentssh", "Agent SSH"}, + {"coderd/database", "Database"}, + {"enterprise/audit", "Auditing"}, + {"enterprise/cli", "CLI"}, + {"enterprise/coderd", "Server"}, + {"enterprise/dbcrypt", "Database"}, + {"enterprise/derpmesh", "Networking"}, + {"enterprise/provisionerd", "Provisioner"}, + {"enterprise/tailnet", "Networking"}, + {"enterprise/wsproxy", "Workspace Proxy"}, + {"agent", "Agent"}, + {"cli", "CLI"}, + {"coderd", "Server"}, + {"codersdk", "SDK"}, + {"docs", "Documentation"}, + {"enterprise", "Enterprise"}, + {"examples", "Examples"}, + {"helm", "Helm"}, + {"install.sh", "Installer"}, + {"provisionersdk", "SDK"}, + {"provisionerd", "Provisioner"}, + {"provisioner", "Provisioner"}, + {"pty", "CLI"}, + {"scaletest", "Scale Testing"}, + {"site", "Dashboard"}, + {"support", "Support"}, + {"tailnet", "Networking"}, +} + +// commitLog returns non-merge commits in the given range, filtering +// out left-side commits (already in the base) and deduplicating +// cherry-picks using git's --cherry-mark. +func commitLog(commitRange string) ([]commitEntry, error) { + // Use --left-right --cherry-mark to identify equivalent + // (cherry-picked) commits and left-side-only commits. + out, err := gitOutput("log", "--no-merges", "--left-right", "--cherry-mark", + "--pretty=format:%m %ct %h %H %s", commitRange) + if err != nil { + return nil, err + } + if out == "" { + return nil, nil + } + + // Collect cherry-pick equivalent commits (marked with '=') so + // we can skip duplicates. We keep only the right-side version. + seen := make(map[string]bool) + + var entries []commitEntry + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + // Format: %m %ct %h %H %s + // mark timestamp shortSHA fullSHA title... + parts := strings.SplitN(line, " ", 5) + if len(parts) < 5 { + continue + } + mark := parts[0] + ts, _ := strconv.ParseInt(parts[1], 10, 64) + shortSHA := parts[2] + fullSHA := parts[3] + title := parts[4] + + // Skip left-side commits (already in the old version). + if mark == "<" { + continue + } + // Skip cherry-pick equivalents that we've already seen + // (marked '=' by --cherry-mark). + if mark == "=" { + if seen[title] { + continue + } + seen[title] = true + } + + // Normalize cherry-pick bot titles: + // "chore: foo (cherry-pick #42) (#43)" → "chore: foo (#42)" + if m := cherryPickPRRe.FindStringSubmatch(title); m != nil { + title = title[:cherryPickPRRe.FindStringIndex(title)[0]] + "(#" + m[1] + ")" + } + + entries = append(entries, commitEntry{ + SHA: shortSHA, + FullSHA: fullSHA, + Title: title, + Timestamp: ts, + }) + } + + // Sort by conventional commit prefix, then by timestamp + // (matching the bash script's sort -k3,3 -k1,1n). + sort.SliceStable(entries, func(i, j int) bool { + pi := commitSortPrefix(entries[i].Title) + pj := commitSortPrefix(entries[j].Title) + if pi != pj { + return pi < pj + } + return entries[i].Timestamp < entries[j].Timestamp + }) + + return entries, nil +} + +// commitSortPrefix extracts the first word of a title for sorting. +func commitSortPrefix(title string) string { + idx := strings.IndexAny(title, " (:") + if idx < 0 { + return title + } + return title[:idx] +} + +// conventionalPrefixRe extracts prefix, scope, and rest from a +// conventional commit title. Does NOT match breaking "!" suffix; +// those titles are left as-is (matching bash behavior). +var conventionalPrefixRe = regexp.MustCompile(`^([a-z]+)(\((.+)\))?:\s*(.*)$`) + +// humanizeTitle converts a conventional commit title to a +// human-readable form, e.g. "feat(site): add bar" -> "Dashboard: Add bar". +func humanizeTitle(title string) string { + m := conventionalPrefixRe.FindStringSubmatch(title) + if m == nil { + return title + } + scope := m[3] // may be empty + rest := m[4] + if rest == "" { + return title + } + // Capitalize the first letter of the rest. + rest = strings.ToUpper(rest[:1]) + rest[1:] + + if scope == "" { + return rest + } + + // Look up scope in humanizedAreas (first partial match wins). + for _, ha := range humanizedAreas { + if strings.HasPrefix(scope, ha.Prefix) { + return ha.Area + ": " + rest + } + } + // Scope not found in map; return as-is. + return title +} + +// breakingCommitRe matches conventional commit "!:" breaking changes. +var breakingCommitRe = regexp.MustCompile(`^[a-zA-Z]+(\(.+\))?!:`) + +// categorizeCommit determines the release note section for a commit. +// The priority order matches the bash script: breaking title first, +// then labels (breaking, security, experimental), then prefix. +func categorizeCommit(title string, labels []string) string { + // Check breaking title first (matches bash behavior). + if breakingCommitRe.MatchString(title) { + return "breaking" + } + + // Label-based categorization. + for _, l := range labels { + if l == "release/breaking" { + return "breaking" + } + if l == "security" { + return "security" + } + if l == "release/experimental" { + return "experimental" + } + } + + // Extract the conventional commit prefix (e.g. "feat", "fix(scope)"). + prefixRe := regexp.MustCompile(`^([a-z]+)(\(.+\))?[!]?:`) + m := prefixRe.FindStringSubmatch(title) + if m == nil { + return "other" + } + + validPrefixes := []string{ + "feat", "fix", "docs", "refactor", "perf", + "test", "build", "ci", "chore", "revert", + } + for _, p := range validPrefixes { + if m[1] == p { + return p + } + } + return "other" +} diff --git a/scripts/release-action/commit_test.go b/scripts/release-action/commit_test.go new file mode 100644 index 0000000000..f9d01b77bb --- /dev/null +++ b/scripts/release-action/commit_test.go @@ -0,0 +1,352 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_humanizeTitle(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + title string + want string + }{ + { + name: "feat_site_scope", + title: "feat(site): add bar", + want: "Dashboard: Add bar", + }, + { + name: "fix_coderd_scope", + title: "fix(coderd): thing", + want: "Server: Thing", + }, + { + name: "fix_agent_scope", + title: "fix(agent): reconnect", + want: "Agent: Reconnect", + }, + { + name: "feat_cli_scope", + title: "feat(cli): new flag", + want: "CLI: New flag", + }, + { + name: "fix_tailnet_scope", + title: "fix(tailnet): routing issue", + want: "Networking: Routing issue", + }, + { + name: "feat_codersdk_scope", + title: "feat(codersdk): new method", + want: "SDK: New method", + }, + { + name: "feat_docs_scope", + title: "feat(docs): add guide", + want: "Documentation: Add guide", + }, + { + name: "fix_enterprise_coderd_scope", + title: "fix(enterprise/coderd): auth bug", + want: "Server: Auth bug", + }, + { + name: "no_scope", + title: "feat: thing", + want: "Thing", + }, + { + name: "non_conventional_title", + title: "Update README", + want: "Update README", + }, + { + name: "breaking_with_bang_unchanged", + title: "feat!: thing", + want: "feat!: thing", + }, + { + name: "breaking_with_scope_and_bang_unchanged", + title: "feat(site)!: remove old api", + want: "feat(site)!: remove old api", + }, + { + name: "unknown_scope_returns_original", + title: "fix(unknownscope): something", + want: "fix(unknownscope): something", + }, + { + name: "agent_agentssh_more_specific", + title: "fix(agent/agentssh): session bug", + want: "Agent SSH: Session bug", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.want, humanizeTitle(tt.title)) + }) + } +} + +func Test_categorizeCommit(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + title string + labels []string + want string + }{ + { + name: "breaking_via_bang_in_title", + title: "feat!: remove old api", + want: "breaking", + }, + { + name: "breaking_via_scoped_bang", + title: "fix(coderd)!: breaking change", + want: "breaking", + }, + { + name: "breaking_via_label", + title: "feat(site): add thing", + labels: []string{"release/breaking"}, + want: "breaking", + }, + { + name: "security_label", + title: "fix(coderd): patch vuln", + labels: []string{"security"}, + want: "security", + }, + { + name: "experimental_label", + title: "feat(site): new feature", + labels: []string{"release/experimental"}, + want: "experimental", + }, + { + name: "feat_prefix", + title: "feat(site): add bar", + want: "feat", + }, + { + name: "fix_prefix", + title: "fix(coderd): thing", + want: "fix", + }, + { + name: "chore_prefix", + title: "chore: update deps", + want: "chore", + }, + { + name: "docs_prefix", + title: "docs: update readme", + want: "docs", + }, + { + name: "refactor_prefix", + title: "refactor(coderd): simplify", + want: "refactor", + }, + { + name: "unknown_prefix", + title: "yolo: do something", + want: "other", + }, + { + name: "no_prefix", + title: "Update README", + want: "other", + }, + { + name: "breaking_label_takes_priority_over_feat", + title: "feat(coderd): new api", + labels: []string{"release/breaking"}, + want: "breaking", + }, + { + name: "security_takes_priority_over_experimental", + title: "fix(coderd): vuln", + labels: []string{"security", "release/experimental"}, + want: "security", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.want, categorizeCommit(tt.title, tt.labels)) + }) + } +} + +func Test_commitSortPrefix(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + title string + want string + }{ + { + name: "space_delimiter", + title: "feat something", + want: "feat", + }, + { + name: "colon_delimiter", + title: "feat: something", + want: "feat", + }, + { + name: "paren_delimiter", + title: "feat(site): something", + want: "feat", + }, + { + name: "no_delimiter", + title: "single", + want: "single", + }, + { + name: "empty_string", + title: "", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.want, commitSortPrefix(tt.title)) + }) + } +} + +func Test_parsePRNumbers(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + title string + want []int + }{ + { + name: "single_pr", + title: "feat(site): add bar (#123)", + want: []int{123}, + }, + { + name: "multiple_prs", + title: "fix (#42) then (#43)", + want: []int{42, 43}, + }, + { + name: "no_pr_numbers", + title: "feat(site): add bar", + want: nil, + }, + { + name: "cherry_pick_only_matches_parens", + title: "chore: foo (cherry-pick #42) (#43)", + want: []int{43}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := parsePRNumbers(tt.title) + require.Equal(t, tt.want, got) + }) + } +} + +func Test_stripPRRef(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + title string + want string + }{ + { + name: "removes_trailing_pr_ref", + title: "Dashboard: Add bar (#123)", + want: "Dashboard: Add bar", + }, + { + name: "no_pr_ref", + title: "Dashboard: Add bar", + want: "Dashboard: Add bar", + }, + { + name: "multiple_pr_refs_strips_last", + title: "Foo (#42) (#43)", + want: "Foo (#42)", + }, + { + name: "pr_ref_with_whitespace", + title: "Title (#999)", + want: "Title", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.want, stripPRRef(tt.title)) + }) + } +} + +func Test_isDependabot(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + title string + want bool + }{ + { + name: "contains_dependabot", + title: "chore: bump dependabot/fetch-metadata (#456)", + want: true, + }, + { + name: "chore_deps_prefix", + title: "chore(deps): bump golang.org/x/net", + want: true, + }, + { + name: "normal_title", + title: "feat(site): add bar (#123)", + want: false, + }, + { + name: "case_insensitive_dependabot", + title: "Bump Dependabot thing", + want: true, + }, + { + name: "chore_deps_uppercase", + title: "Chore(Deps): update things", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.want, isDependabot(tt.title)) + }) + } +} diff --git a/scripts/release-action/git.go b/scripts/release-action/git.go new file mode 100644 index 0000000000..8327a227e4 --- /dev/null +++ b/scripts/release-action/git.go @@ -0,0 +1,29 @@ +package main + +import ( + "errors" + "os/exec" + "strings" +) + +// gitOutput runs a read-only git command and returns trimmed stdout. +func gitOutput(args ...string) (string, error) { + cmd := exec.Command("git", 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 +} + +// gitRun runs a git command, discarding stdout/stderr. Use this +// for commands where only the exit code matters (e.g. merge-base +// --is-ancestor). +func gitRun(args ...string) error { + cmd := exec.Command("git", args...) + return cmd.Run() +} diff --git a/scripts/release-action/github.go b/scripts/release-action/github.go new file mode 100644 index 0000000000..5a3540b628 --- /dev/null +++ b/scripts/release-action/github.go @@ -0,0 +1,115 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + + "golang.org/x/xerrors" +) + +// ghOutput runs a gh CLI command and returns trimmed stdout. +func ghOutput(args ...string) (string, error) { + cmd := exec.Command("gh", args...) + out, err := cmd.Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +// pullRequest holds metadata about a GitHub pull request. +type pullRequest struct { + Number int + Title string + Labels []string + Author string + URL string +} + +// pullRequestMap holds PR metadata indexed by PR number. +type pullRequestMap map[int]pullRequest + +// ghBuildPullRequestMap builds a map of PR number to metadata by +// querying the GitHub API via the gh CLI for the given PR numbers. +func ghBuildPullRequestMap(prNumbers []int) pullRequestMap { + m := make(pullRequestMap) + + for _, prNum := range prNumbers { + out, err := ghOutput("pr", "view", fmt.Sprintf("%d", prNum), + "--repo", fmt.Sprintf("%s/%s", owner, repo), + "--json", "number,labels,author") + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "warning: failed to fetch PR #%d metadata: %v\n", prNum, err) + continue + } + + var result struct { + Number int `json:"number"` + Labels []struct { + Name string `json:"name"` + } `json:"labels"` + Author struct { + Login string `json:"login"` + } `json:"author"` + } + if err := json.Unmarshal([]byte(out), &result); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "warning: failed to parse PR #%d metadata: %v\n", prNum, err) + continue + } + + var labels []string + for _, l := range result.Labels { + labels = append(labels, l.Name) + } + + m[result.Number] = pullRequest{ + Number: result.Number, + Labels: labels, + Author: result.Author.Login, + } + } + + return m +} + +// checkOpenPRs verifies that no pull requests are open against the +// given branch. If any are found, it returns an error listing them +// with instructions to merge or close before releasing. +func checkOpenPRs(branch string) error { + out, err := ghOutput("pr", "list", + "--repo", fmt.Sprintf("%s/%s", owner, repo), + "--base", branch, + "--state", "open", + "--json", "number,title,author,url", + "--limit", "100") + if err != nil { + return xerrors.Errorf("failed to list open PRs for branch %s: %w", branch, err) + } + + var rawPRs []struct { + Number int `json:"number"` + Title string `json:"title"` + Author struct { + Login string `json:"login"` + } `json:"author"` + URL string `json:"url"` + } + if err := json.Unmarshal([]byte(out), &rawPRs); err != nil { + return xerrors.Errorf("failed to parse open PRs response: %w", err) + } + + if len(rawPRs) == 0 { + return nil + } + + var b strings.Builder + _, _ = fmt.Fprintf(&b, "found %d open pull request(s) targeting %s that must be merged or closed before releasing:\n\n", len(rawPRs), branch) + for _, pr := range rawPRs { + _, _ = fmt.Fprintf(&b, " - #%d: %s (by @%s)\n %s\n", pr.Number, pr.Title, pr.Author.Login, pr.URL) + } + _, _ = fmt.Fprintf(&b, "\nMerge or close these pull requests, then re-run the release workflow.") + return xerrors.New(b.String()) +} diff --git a/scripts/release-action/main.go b/scripts/release-action/main.go new file mode 100644 index 0000000000..54afaeec87 --- /dev/null +++ b/scripts/release-action/main.go @@ -0,0 +1,149 @@ +package main + +import ( + "errors" + "fmt" + "os" + + "golang.org/x/xerrors" + + "github.com/coder/serpent" +) + +const ( + owner = "coder" + repo = "coder" +) + +func main() { + var ( + releaseType string + ref string + commitSHA string + versionStr string + prevVersionStr string + notesFile string + stable bool + ) + + cmd := &serpent.Command{ + Use: "release-action ", + Short: "Non-interactive, CI-oriented release tool for coder/coder.", + Children: []*serpent.Command{ + { + Use: "calculate-version", + Short: "Calculate the next release version from git state.", + Options: serpent.OptionSet{ + { + Name: "type", + Flag: "type", + Description: "Release type: rc, release, or create-release-branch.", + Value: serpent.StringOf(&releaseType), + Required: true, + }, + { + Name: "ref", + Flag: "ref", + Description: "Git ref (branch name) the workflow is running on.", + Value: serpent.StringOf(&ref), + Required: true, + }, + { + Name: "commit", + Flag: "commit", + Description: "Commit SHA to tag (defaults to HEAD of --ref if empty).", + Value: serpent.StringOf(&commitSHA), + }, + }, + Handler: func(inv *serpent.Invocation) error { + result, err := calculateNextVersion(releaseType, ref, commitSHA) + if err != nil { + return err + } + _, _ = fmt.Fprintln(inv.Stdout, result.String()) + return nil + }, + }, + { + Use: "generate-notes", + Short: "Generate release notes from commit log and PR metadata.", + Options: serpent.OptionSet{ + { + Name: "version", + Flag: "version", + Description: "New release version (e.g. v2.21.0).", + Value: serpent.StringOf(&versionStr), + Required: true, + }, + { + Name: "previous-version", + Flag: "previous-version", + Description: "Previous release version (e.g. v2.20.0).", + Value: serpent.StringOf(&prevVersionStr), + Required: true, + }, + }, + Handler: func(inv *serpent.Invocation) error { + newVer, err := parseVersion(versionStr) + if err != nil { + return xerrors.Errorf("parse --version: %w", err) + } + prevVer, err := parseVersion(prevVersionStr) + if err != nil { + return xerrors.Errorf("parse --previous-version: %w", err) + } + notes, err := generateReleaseNotes(newVer, prevVer) + if err != nil { + return err + } + _, _ = fmt.Fprint(inv.Stdout, notes) + return nil + }, + }, + { + Use: "publish", + Short: "Publish a GitHub release with assets and checksums.", + Options: serpent.OptionSet{ + { + Name: "version", + Flag: "version", + Description: "Release version tag (e.g. v2.21.0).", + Value: serpent.StringOf(&versionStr), + Required: true, + }, + { + Name: "stable", + Flag: "stable", + Description: "Mark this release as the latest stable release.", + Value: serpent.BoolOf(&stable), + }, + { + Name: "release-notes-file", + Flag: "release-notes-file", + Description: "Path to release notes markdown file.", + Value: serpent.StringOf(¬esFile), + Required: true, + }, + }, + Handler: func(inv *serpent.Invocation) error { + assets := inv.Args + if len(assets) == 0 { + return xerrors.New("no asset files provided as arguments") + } + return publishRelease(versionStr, stable, notesFile, assets) + }, + }, + }, + } + + err := cmd.Invoke().WithOS().Run() + if err != nil { + // Unwrap serpent's "running command ..." wrapper to keep output clean. + var runErr *serpent.RunCommandError + if errors.As(err, &runErr) { + err = runErr.Err + } + _, _ = fmt.Fprintf(os.Stderr, "error: %s\n", err) + os.Exit(1) + } +} diff --git a/scripts/release-action/notes.go b/scripts/release-action/notes.go new file mode 100644 index 0000000000..a8e1cb2393 --- /dev/null +++ b/scripts/release-action/notes.go @@ -0,0 +1,160 @@ +package main + +import ( + "fmt" + "regexp" + "strconv" + "strings" + + "golang.org/x/xerrors" +) + +// generateReleaseNotes produces markdown release notes for the given +// version range by examining the commit log and PR metadata. +func generateReleaseNotes(newVersion, previousVersion version) (string, error) { + // Build commit range. If the new tag doesn't exist locally yet, + // fall back to ..HEAD. + newTag := newVersion.String() + commitRange := fmt.Sprintf("%s...%s", previousVersion.String(), newTag) + if err := gitRun("rev-parse", "--verify", newTag); err != nil { + commitRange = fmt.Sprintf("%s..HEAD", previousVersion.String()) + } + + commits, err := commitLog(commitRange) + if err != nil { + return "", xerrors.Errorf("commit log: %w", err) + } + + // Extract PR numbers from commit titles and fetch metadata. + prMeta := ghBuildPullRequestMap(extractPRNumbers(commits)) + + // Section definitions in display order. + type section struct { + key string + title string + } + sections := []section{ + {"breaking", "BREAKING CHANGES"}, + {"security", "Security"}, + {"feat", "Features"}, + {"fix", "Bug fixes"}, + {"docs", "Documentation"}, + {"refactor", "Code refactoring"}, + {"perf", "Performance"}, + {"test", "Tests"}, + {"build", "Build"}, + {"ci", "CI"}, + {"chore", "Chores"}, + {"revert", "Reverts"}, + {"other", "Other changes"}, + {"experimental", "Experimental"}, + } + + // Categorize commits into sections. + buckets := make(map[string][]commitEntry) + for _, c := range commits { + // Skip dependabot commits. + if isDependabot(c.Title) { + continue + } + + var labels []string + for _, prNum := range parsePRNumbers(c.Title) { + if meta, ok := prMeta[prNum]; ok { + labels = append(labels, meta.Labels...) + } + } + cat := categorizeCommit(c.Title, labels) + buckets[cat] = append(buckets[cat], c) + } + + var b strings.Builder + + // RC note based on version. + if newVersion.IsRC() { + _, _ = b.WriteString("> [!NOTE]\n") + _, _ = b.WriteString("> This is a **release candidate** build of Coder. Release candidate builds are not intended for production use. Learn more about our [Release Schedule](https://coder.com/docs/install/releases).\n\n") + } + + _, _ = b.WriteString("## Changelog\n\n") + + for _, sec := range sections { + entries, ok := buckets[sec.key] + if !ok || len(entries) == 0 { + continue + } + _, _ = fmt.Fprintf(&b, "### %s\n\n", sec.title) + for _, e := range entries { + title := humanizeTitle(e.Title) + if prNums := parsePRNumbers(e.Title); len(prNums) > 0 { + // Strip the trailing PR reference from the title since + // we add it as a link. + title = stripPRRef(title) + _, _ = fmt.Fprintf(&b, "- %s (#%d)\n", title, prNums[0]) + } else { + _, _ = fmt.Fprintf(&b, "- %s\n", title) + } + } + _, _ = b.WriteString("\n") + } + + // Compare link. + _, _ = fmt.Fprintf(&b, "Compare: [`%s...%s`](https://github.com/%s/%s/compare/%s...%s)\n\n", + previousVersion.String(), newVersion.String(), + owner, repo, + previousVersion.String(), newVersion.String()) + + // Container image. + _, _ = b.WriteString("## Container image\n\n") + _, _ = fmt.Fprintf(&b, "- `docker pull ghcr.io/%s/%s:%s`\n\n", owner, repo, newVersion.String()) + + // Install/upgrade links. + _, _ = b.WriteString("## Install/upgrade\n\n") + _, _ = b.WriteString("Refer to our docs to [install](https://coder.com/docs/install) or [upgrade](https://coder.com/docs/admin/upgrade) Coder, or use a release asset below.\n") + + return b.String(), nil +} + +// isDependabot returns true if the commit title looks like it came +// from dependabot. +func isDependabot(title string) bool { + lower := strings.ToLower(title) + return strings.Contains(lower, "dependabot") || + strings.HasPrefix(lower, "chore(deps):") +} + +// prNumRe matches GitHub's "(#NNN)" PR reference convention. +var prNumRe = regexp.MustCompile(`\(#(\d+)\)`) + +// parsePRNumbers extracts all PR numbers from a commit title. +func parsePRNumbers(title string) []int { + var nums []int + for _, m := range prNumRe.FindAllStringSubmatch(title, -1) { + num, _ := strconv.Atoi(m[1]) + nums = append(nums, num) + } + return nums +} + +// extractPRNumbers collects all unique PR numbers from a list of commits. +func extractPRNumbers(commits []commitEntry) []int { + seen := make(map[int]bool) + var nums []int + for _, c := range commits { + for _, num := range parsePRNumbers(c.Title) { + if !seen[num] { + seen[num] = true + nums = append(nums, num) + } + } + } + return nums +} + +// stripPRRef removes a trailing (#NNN) from a title. +func stripPRRef(title string) string { + if idx := strings.LastIndex(title, "(#"); idx >= 0 { + return strings.TrimSpace(title[:idx]) + } + return title +} diff --git a/scripts/release-action/publish.go b/scripts/release-action/publish.go new file mode 100644 index 0000000000..285cc29f05 --- /dev/null +++ b/scripts/release-action/publish.go @@ -0,0 +1,153 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + + "golang.org/x/xerrors" +) + +// publishRelease creates a GitHub release with the given assets +// and generates checksums. +func publishRelease(versionTag string, stable bool, notesFile string, assets []string) error { + if len(assets) == 0 { + return xerrors.New("no assets provided") + } + + // Validate all asset files exist. + for _, f := range assets { + if _, err := os.Stat(f); err != nil { + return xerrors.Errorf("asset not found: %s", f) + } + } + + // Verify we're checked out on the expected tag. + described, err := gitOutput("describe", "--always") + if err != nil { + return xerrors.Errorf("git describe: %w", err) + } + if described != versionTag { + return xerrors.Errorf("checked-out ref %q does not match release tag %q", described, versionTag) + } + + // Create a temp directory with symlinks to all assets. + tempDir, err := os.MkdirTemp("", "release-publish-*") + if err != nil { + return xerrors.Errorf("create temp dir: %w", err) + } + defer os.RemoveAll(tempDir) + + for _, f := range assets { + abs, err := filepath.Abs(f) + if err != nil { + return xerrors.Errorf("abs path for %s: %w", f, err) + } + if err := os.Symlink(abs, filepath.Join(tempDir, filepath.Base(f))); err != nil { + return xerrors.Errorf("symlink %s: %w", f, err) + } + } + + // Generate checksums file. + version := strings.TrimPrefix(versionTag, "v") + checksumFile := fmt.Sprintf("coder_%s_checksums.txt", version) + checksumPath := filepath.Join(tempDir, checksumFile) + if err := generateChecksums(tempDir, checksumPath); err != nil { + return xerrors.Errorf("generate checksums: %w", err) + } + + // Determine target commitish from release branch. + targetCommitish := "main" + branchRef, err := gitOutput("branch", "--remotes", "--contains", versionTag, "--format", "%(refname)", "*/release/*") + if err == nil && branchRef != "" { + // refs/remotes/origin/release/2.9 -> release/2.9 + if idx := strings.Index(branchRef, "release/"); idx >= 0 { + targetCommitish = branchRef[idx:] + } + } + + // Build gh release create arguments. + ghArgs := []string{ + "release", "create", + "--repo", fmt.Sprintf("%s/%s", owner, repo), + "--title", versionTag, + "--target", targetCommitish, + "--notes-file", notesFile, + } + + // RC detection from the version tag. + isRC := strings.Contains(versionTag, "-rc.") + switch { + case isRC: + ghArgs = append(ghArgs, "--prerelease", "--latest=false") + case stable: + ghArgs = append(ghArgs, "--latest=true") + default: + ghArgs = append(ghArgs, "--latest=false") + } + + ghArgs = append(ghArgs, versionTag) + + // Add all files from the temp directory. + entries, err := os.ReadDir(tempDir) + if err != nil { + return xerrors.Errorf("read temp dir: %w", err) + } + for _, e := range entries { + ghArgs = append(ghArgs, filepath.Join(tempDir, e.Name())) + } + + cmd := exec.Command("gh", ghArgs...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = strings.NewReader("") // prevent interactive prompts + if err := cmd.Run(); err != nil { + return xerrors.Errorf("gh release create: %w", err) + } + + return nil +} + +// generateChecksums writes SHA256 checksums for all files in dir +// (excluding the output file itself) to outPath. +func generateChecksums(dir, outPath string) error { + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + + var lines []string + for _, e := range entries { + if e.IsDir() { + continue + } + path := filepath.Join(dir, e.Name()) + hash, err := sha256File(path) + if err != nil { + return xerrors.Errorf("hash %s: %w", e.Name(), err) + } + lines = append(lines, fmt.Sprintf("%s %s", hash, e.Name())) + } + + return os.WriteFile(outPath, []byte(strings.Join(lines, "\n")+"\n"), 0o600) +} + +// sha256File returns the hex-encoded SHA256 hash of a file. +func sha256File(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} diff --git a/scripts/release-action/version.go b/scripts/release-action/version.go new file mode 100644 index 0000000000..28c77975d6 --- /dev/null +++ b/scripts/release-action/version.go @@ -0,0 +1,71 @@ +package main + +import ( + "fmt" + "regexp" + "strconv" + "strings" + + "golang.org/x/xerrors" +) + +// version represents a parsed semantic version with optional RC +// suffix. When rc < 0 the version is a final release. The original +// field preserves the string that was parsed (including the leading +// "v"). +type version struct { + major int + minor int + patch int + rc int // -1 means not an RC + original string +} + +// String returns the canonical version string (e.g. "v2.21.0" or +// "v2.21.0-rc.3"). +func (v version) String() string { + if v.rc >= 0 { + return fmt.Sprintf("v%d.%d.%d-rc.%d", v.major, v.minor, v.patch, v.rc) + } + return fmt.Sprintf("v%d.%d.%d", v.major, v.minor, v.patch) +} + +// IsRC returns true if this is a release candidate. +func (v version) IsRC() bool { + return v.rc >= 0 +} + +// semverRe matches vMAJOR.MINOR.PATCH with optional -rc.N suffix. +var semverRe = regexp.MustCompile(`^v?(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$`) + +// parseVersion parses a version string like "v2.21.0" or +// "v2.21.0-rc.3". +func parseVersion(s string) (version, error) { + m := semverRe.FindStringSubmatch(s) + if m == nil { + return version{}, xerrors.Errorf("invalid version %q", s) + } + + major, _ := strconv.Atoi(m[1]) + minor, _ := strconv.Atoi(m[2]) + patch, _ := strconv.Atoi(m[3]) + + rc := -1 + if m[4] != "" { + rc, _ = strconv.Atoi(m[4]) + } + + // Preserve the original string with leading "v". + orig := s + if !strings.HasPrefix(orig, "v") { + orig = "v" + orig + } + + return version{ + major: major, + minor: minor, + patch: patch, + rc: rc, + original: orig, + }, nil +} diff --git a/scripts/release-action/version_test.go b/scripts/release-action/version_test.go new file mode 100644 index 0000000000..e93bed09f3 --- /dev/null +++ b/scripts/release-action/version_test.go @@ -0,0 +1,96 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_parseVersion(t *testing.T) { + t.Parallel() + + tests := []struct { + input string + wantErr bool + want version + }{ + { + input: "v2.21.0", + want: version{major: 2, minor: 21, patch: 0, rc: -1, original: "v2.21.0"}, + }, + { + input: "v2.21.0-rc.3", + want: version{major: 2, minor: 21, patch: 0, rc: 3, original: "v2.21.0-rc.3"}, + }, + { + input: "2.21.0", + want: version{major: 2, minor: 21, patch: 0, rc: -1, original: "v2.21.0"}, + }, + { + input: "v0.0.0", + want: version{major: 0, minor: 0, patch: 0, rc: -1, original: "v0.0.0"}, + }, + { + input: "v1.2.3-rc.0", + want: version{major: 1, minor: 2, patch: 3, rc: 0, original: "v1.2.3-rc.0"}, + }, + { + input: "not-a-version", + wantErr: true, + }, + { + input: "", + wantErr: true, + }, + { + input: "v1.2", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + t.Parallel() + got, err := parseVersion(tt.input) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tt.want.major, got.major, "major") + require.Equal(t, tt.want.minor, got.minor, "minor") + require.Equal(t, tt.want.patch, got.patch, "patch") + require.Equal(t, tt.want.rc, got.rc, "rc") + require.Equal(t, tt.want.original, got.original, "original") + }) + } +} + +func Test_versionString(t *testing.T) { + t.Parallel() + + tests := []struct { + v version + want string + }{ + {version{major: 2, minor: 21, patch: 0, rc: -1}, "v2.21.0"}, + {version{major: 2, minor: 21, patch: 0, rc: 3}, "v2.21.0-rc.3"}, + {version{major: 1, minor: 0, patch: 5, rc: -1}, "v1.0.5"}, + {version{major: 1, minor: 0, patch: 0, rc: 0}, "v1.0.0-rc.0"}, + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.want, tt.v.String()) + }) + } +} + +func Test_versionIsRC(t *testing.T) { + t.Parallel() + + require.True(t, version{rc: 0}.IsRC()) + require.True(t, version{rc: 3}.IsRC()) + require.False(t, version{rc: -1}.IsRC()) +}