From 591f35757463a15575117b2482d5438302a2378e Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Fri, 24 Jul 2026 12:52:01 -0400 Subject: [PATCH] chore: remove releaser v2 flow and drop v1 naming (#27421) ## Summary Removes the GitHub Actions-driven releaser **v2** pipeline so the interactive release wizard is the only release path, and drops the `v1` naming now that it is the sole implementation. ## Changes - Delete `.github/workflows/tag-and-release.yaml` (the v2 workflow). - Delete `scripts/releaser/v2/`. - Move `scripts/releaser/v1/` into `scripts/releaser/` as `package main`. - Rewrite `scripts/releaser/main.go` to a single wizard command: drop the `--legacy` flag and the v2 `rc`/`branch`/`release` subcommands and hidden CI compat commands. `--dry-run` is preserved. - Update `scripts/release.sh` to run `go run ./scripts/releaser "$@"` (no `--legacy`). The legacy `release.yaml` workflow (triggered by `scripts/release.sh`) is unchanged and remains the release pipeline. ## Validation - `go build ./scripts/releaser/...` - `go test ./scripts/releaser/...` - `go vet` + `golangci-lint run ./scripts/releaser/...` - `gofmt -l` clean > [!NOTE] > The GPG signing key check removal is handled in a stacked follow-up PR based on this branch.
Implementation plan - v2 flow = `scripts/releaser/v2/` + `.github/workflows/tag-and-release.yaml` (uses `go run ./scripts/releaser prepare-release|generate-notes`). `v2` was imported only by `main.go`; the workflow was referenced nowhere else. - v1 flow = interactive wizard in `scripts/releaser/v1/`, reached via `--legacy`, driving `release.yaml` (triggered by `scripts/release.sh`). - No `docs/` referenced the releaser tool or these workflows. - Steps: delete the v2 workflow and package; move `v1/*` up to `scripts/releaser/` (`package main`, including test files); rewrite `main.go` to a single wizard command; update `release.sh`.
--- Generated by Coder Agents on behalf of @f0ssel. --- .github/workflows/tag-and-release.yaml | 936 ---------------------- scripts/release.sh | 6 +- scripts/releaser/{v1 => }/commit.go | 2 +- scripts/releaser/{v1 => }/docs.go | 2 +- scripts/releaser/{v1 => }/docs_test.go | 2 +- scripts/releaser/{v1 => }/executor.go | 25 +- scripts/releaser/{v1 => }/git.go | 2 +- scripts/releaser/{v1 => }/github.go | 2 +- scripts/releaser/main.go | 61 +- scripts/releaser/{v1 => }/release.go | 6 +- scripts/releaser/run.go | 51 ++ scripts/releaser/{v1 => }/ui.go | 2 +- scripts/releaser/v1/run.go | 67 -- scripts/releaser/v2/calculate.go | 453 ----------- scripts/releaser/v2/calculate_test.go | 477 ----------- scripts/releaser/v2/cmdexec.go | 128 --- scripts/releaser/v2/cmdexec_test.go | 134 ---- scripts/releaser/v2/commands.go | 264 ------ scripts/releaser/v2/commit.go | 221 ----- scripts/releaser/v2/commit_test.go | 352 -------- scripts/releaser/v2/git.go | 20 - scripts/releaser/v2/github.go | 109 --- scripts/releaser/v2/notes.go | 160 ---- scripts/releaser/v2/prepare.go | 94 --- scripts/releaser/v2/prepare_test.go | 187 ----- scripts/releaser/v2/publish.go | 148 ---- scripts/releaser/v2/version.go | 71 -- scripts/releaser/v2/version_test.go | 96 --- scripts/releaser/{v1 => }/version.go | 2 +- scripts/releaser/{v1 => }/version_test.go | 2 +- 30 files changed, 78 insertions(+), 4004 deletions(-) delete mode 100644 .github/workflows/tag-and-release.yaml rename scripts/releaser/{v1 => }/commit.go (99%) rename scripts/releaser/{v1 => }/docs.go (99%) rename scripts/releaser/{v1 => }/docs_test.go (96%) rename scripts/releaser/{v1 => }/executor.go (73%) rename scripts/releaser/{v1 => }/git.go (97%) rename scripts/releaser/{v1 => }/github.go (99%) rename scripts/releaser/{v1 => }/release.go (99%) create mode 100644 scripts/releaser/run.go rename scripts/releaser/{v1 => }/ui.go (99%) delete mode 100644 scripts/releaser/v1/run.go delete mode 100644 scripts/releaser/v2/calculate.go delete mode 100644 scripts/releaser/v2/calculate_test.go delete mode 100644 scripts/releaser/v2/cmdexec.go delete mode 100644 scripts/releaser/v2/cmdexec_test.go delete mode 100644 scripts/releaser/v2/commands.go delete mode 100644 scripts/releaser/v2/commit.go delete mode 100644 scripts/releaser/v2/commit_test.go delete mode 100644 scripts/releaser/v2/git.go delete mode 100644 scripts/releaser/v2/github.go delete mode 100644 scripts/releaser/v2/notes.go delete mode 100644 scripts/releaser/v2/prepare.go delete mode 100644 scripts/releaser/v2/prepare_test.go delete mode 100644 scripts/releaser/v2/publish.go delete mode 100644 scripts/releaser/v2/version.go delete mode 100644 scripts/releaser/v2/version_test.go rename scripts/releaser/{v1 => }/version.go (99%) rename scripts/releaser/{v1 => }/version_test.go (98%) diff --git a/.github/workflows/tag-and-release.yaml b/.github/workflows/tag-and-release.yaml deleted file mode 100644 index c786a1fa8d..0000000000 --- a/.github/workflows/tag-and-release.yaml +++ /dev/null @@ -1,936 +0,0 @@ -# Tag and release workflow (GitHub Actions-driven, manual). -# -# This is the newer release pipeline driven entirely by the -# scripts/releaser Go tool. It is triggered manually from the -# Actions UI. The legacy release.yaml workflow remains in place and is -# triggered by the legacy interactive tool (scripts/releaser --legacy). -name: Tag and Release -on: - workflow_dispatch: - inputs: - release_type: - type: choice - description: "Type of release (use 'Use workflow from' to pick the branch)" - required: true - options: - - rc - - release - - create-release-branch - commit_sha: - description: "Optional: commit SHA to tag (defaults to HEAD of selected branch)" - type: string - default: "" - dry_run: - description: "Dry run: calculate the version and print the release plan without creating tags, branches, or publishing." - type: boolean - default: false - -permissions: - contents: read - -concurrency: ${{ github.workflow }}-${{ github.ref }} - -jobs: - # Only allow maintainers/admins to release. - check-perms: - runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }} - steps: - - name: Allow only maintainers/admins - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const {data} = await github.rest.repos.getCollaboratorPermissionLevel({ - owner: context.repo.owner, - repo: context.repo.repo, - username: context.actor - }); - const role = data.role_name || data.user?.role_name || data.permission; - const perms = data.user?.permissions || {}; - core.info(`Actor ${context.actor} permission=${data.permission}, role_name=${role}`); - - const allowed = - role === 'admin' || - role === 'maintain' || - perms.admin === true || - perms.maintain === true; - - 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@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - fetch-depth: 0 - persist-credentials: true - - - name: Fetch git tags - run: git fetch --tags --force - - # prepare-release creates an annotated tag, which records a tagger - # identity. Runners have none configured, so git tag -a fails - # without this step. - - name: Configure git identity - run: | - git config --global user.email "ci@coder.com" - git config --global user.name "Coder CI" - - - name: Set up mise tools - uses: ./.github/actions/setup-mise - with: - install-args: "go" - - - name: Prepare release (calculate version, create tag and branch) - id: prepare - env: - RELEASE_TYPE: ${{ inputs.release_type }} - REF_NAME: ${{ github.ref_name }} - COMMIT_SHA: ${{ inputs.commit_sha }} - DRY_RUN: ${{ inputs.dry_run }} - run: | - set -euo pipefail - - args=(--type "$RELEASE_TYPE" --ref "$REF_NAME") - if [[ -n "$COMMIT_SHA" ]]; then - args+=(--commit "$COMMIT_SHA") - fi - if [[ "$DRY_RUN" == "true" ]]; then - args+=(--dry-run) - fi - - output=$(go run ./scripts/releaser prepare-release "${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::prepare-release 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" - - if [[ "$DRY_RUN" == "true" ]]; then - echo "> **Dry run:** no tags, branches, or releases were created. The build and publish jobs are skipped." >> "$GITHUB_STEP_SUMMARY" - fi - - - name: Generate release notes - env: - VERSION: ${{ steps.prepare.outputs.version }} - PREV_VERSION: ${{ steps.prepare.outputs.previous_version }} - run: | - set -euo pipefail - go run ./scripts/releaser 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, prepare-release] - # Skip the build and publish steps entirely in dry-run mode. This - # cascades to publish-homebrew, publish-winget, and update-docs, - # which all depend on this job. - if: ${{ !inputs.dry_run }} - runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }} - permissions: - # Required to publish a release - contents: write - # Necessary to push docker images to ghcr.io. - packages: write - # Necessary for GCP authentication (https://github.com/google-github-actions/setup-gcloud#usage) - # Also necessary for keyless cosign (https://docs.sigstore.dev/cosign/signing/overview/) - # And for GitHub Actions attestation - id-token: write - # 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: - version: ${{ steps.version.outputs.version }} - steps: - - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - fetch-depth: 0 - persist-credentials: false - - # If the event that triggered the build was an annotated tag (which our - # tags are supposed to be), actions/checkout has a bug where the tag in - # question is only a lightweight tag and not a full annotated tag. This - # command seems to fix it. - # https://github.com/actions/checkout/issues/290 - - name: Fetch git tags - run: git fetch --tags --force - - - name: Checkout release commit - env: - VERSION: ${{ needs.prepare-release.outputs.version }} - run: | - set -euo pipefail - 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" - - - name: Download release notes - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: release-notes - path: /tmp - - - name: Set release notes env - run: echo CODER_RELEASE_NOTES_FILE=/tmp/release_notes.md >> "$GITHUB_ENV" - - - name: Show release notes - run: | - set -euo pipefail - cat "$CODER_RELEASE_NOTES_FILE" - - - name: Docker Login - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Set up mise tools - uses: ./.github/actions/setup-mise - with: - install-args: "go node pnpm helm cosign syft" - - - name: Install pnpm dependencies - uses: ./.github/actions/pnpm-install - - - name: Install Go mise tools - run: ./.github/scripts/retry.sh -- mise install --locked go:github.com/tc-hib/go-winres go:github.com/goreleaser/nfpm/v2/cmd/nfpm - - # Necessary for signing Windows binaries. - - name: Setup Java - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 - with: - distribution: "zulu" - java-version: "11.0" - - - name: Install nsis and zstd - run: sudo apt-get install -y nsis zstd - - - name: Install rcodesign - run: | - set -euo pipefail - wget -O /tmp/rcodesign.tar.gz https://github.com/indygreg/apple-platform-rs/releases/download/apple-codesign%2F0.22.0/apple-codesign-0.22.0-x86_64-unknown-linux-musl.tar.gz - sudo tar -xzf /tmp/rcodesign.tar.gz \ - -C /usr/bin \ - --strip-components=1 \ - apple-codesign-0.22.0-x86_64-unknown-linux-musl/rcodesign - rm /tmp/rcodesign.tar.gz - - - name: Setup Apple Developer certificate and API key - run: | - set -euo pipefail - touch /tmp/{apple_cert.p12,apple_cert_password.txt,apple_apikey.p8} - chmod 600 /tmp/{apple_cert.p12,apple_cert_password.txt,apple_apikey.p8} - echo "$AC_CERTIFICATE_P12_BASE64" | base64 -d > /tmp/apple_cert.p12 - echo "$AC_CERTIFICATE_PASSWORD" > /tmp/apple_cert_password.txt - echo "$AC_APIKEY_P8_BASE64" | base64 -d > /tmp/apple_apikey.p8 - env: - AC_CERTIFICATE_P12_BASE64: ${{ secrets.AC_CERTIFICATE_P12_BASE64 }} - AC_CERTIFICATE_PASSWORD: ${{ secrets.AC_CERTIFICATE_PASSWORD }} - AC_APIKEY_P8_BASE64: ${{ secrets.AC_APIKEY_P8_BASE64 }} - - - name: Setup Windows EV Signing Certificate - run: | - set -euo pipefail - touch /tmp/ev_cert.pem - chmod 600 /tmp/ev_cert.pem - echo "$EV_SIGNING_CERT" > /tmp/ev_cert.pem - wget https://github.com/ebourg/jsign/releases/download/6.0/jsign-6.0.jar -O /tmp/jsign-6.0.jar - env: - EV_SIGNING_CERT: ${{ secrets.EV_SIGNING_CERT }} - - - name: Test migrations from current ref to main - run: | - POSTGRES_VERSION=13 make test-migrations - - # Setup GCloud for signing Windows binaries. - - name: Authenticate to Google Cloud - id: gcloud_auth - uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0 - with: - workload_identity_provider: ${{ vars.GCP_CODE_SIGNING_WORKLOAD_ID_PROVIDER }} - service_account: ${{ vars.GCP_CODE_SIGNING_SERVICE_ACCOUNT }} - token_format: "access_token" - - - name: Setup GCloud SDK - uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3.0.1 - - - name: Build binaries - run: | - set -euo pipefail - ./.github/scripts/retry.sh -- go mod download - - version="$(./scripts/version.sh)" - make gen/mark-fresh - make -j \ - build/coder_"$version"_linux_{amd64,armv7,arm64}.{tar.gz,apk,deb,rpm} \ - build/coder_"$version"_{darwin,windows}_{amd64,arm64}.zip \ - build/coder_"$version"_windows_amd64_installer.exe \ - build/coder_helm_"$version".tgz \ - build/provisioner_helm_"$version".tgz \ - build/ai-gateway_helm_"$version".tgz - env: - CODER_SIGN_WINDOWS: "1" - CODER_SIGN_DARWIN: "1" - CODER_SIGN_GPG: "1" - CODER_GPG_RELEASE_KEY_BASE64: ${{ secrets.GPG_RELEASE_KEY_BASE64 }} - CODER_WINDOWS_RESOURCES: "1" - AC_CERTIFICATE_FILE: /tmp/apple_cert.p12 - AC_CERTIFICATE_PASSWORD_FILE: /tmp/apple_cert_password.txt - AC_APIKEY_ISSUER_ID: ${{ secrets.AC_APIKEY_ISSUER_ID }} - AC_APIKEY_ID: ${{ secrets.AC_APIKEY_ID }} - AC_APIKEY_FILE: /tmp/apple_apikey.p8 - EV_KEY: ${{ secrets.EV_KEY }} - EV_KEYSTORE: ${{ secrets.EV_KEYSTORE }} - EV_TSA_URL: ${{ secrets.EV_TSA_URL }} - EV_CERTIFICATE_PATH: /tmp/ev_cert.pem - GCLOUD_ACCESS_TOKEN: ${{ steps.gcloud_auth.outputs.access_token }} - JSIGN_PATH: /tmp/jsign-6.0.jar - - - name: Delete Apple Developer certificate and API key - run: rm -f /tmp/{apple_cert.p12,apple_cert_password.txt,apple_apikey.p8} - - - name: Delete Windows EV Signing Cert - run: rm /tmp/ev_cert.pem - - - name: Determine base image tag - id: image-base-tag - run: | - set -euo pipefail - # 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 != '' - run: mkdir base-build-context - - - name: Install depot.dev CLI - if: steps.image-base-tag.outputs.tag != '' - uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1 - - # This uses OIDC authentication, so no auth variables are required. - - name: Build base Docker image via depot.dev - id: build_base_image - if: steps.image-base-tag.outputs.tag != '' - uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.18.0 - with: - project: wl5hnrrkns - context: base-build-context - file: scripts/Dockerfile.base - platforms: linux/amd64,linux/arm64,linux/arm/v7 - provenance: true - sbom: true - pull: true - no-cache: true - push: true - tags: | - ${{ steps.image-base-tag.outputs.tag }} - - - name: Verify that images are pushed properly - if: steps.image-base-tag.outputs.tag != '' - run: | - # retry 10 times with a 5 second delay as the images may not be - # available immediately - for i in {1..10}; do - rc=0 - raw_manifests=$(docker buildx imagetools inspect --raw "${IMAGE_TAG}") || rc=$? - if [[ "$rc" -eq 0 ]]; then - break - fi - if [[ "$i" -eq 10 ]]; then - echo "Failed to pull manifests after 10 retries" - exit 1 - fi - echo "Failed to pull manifests, retrying in 5 seconds" - sleep 5 - done - - manifests=$( - echo "$raw_manifests" | \ - jq -r '.manifests[].platform | .os + "/" + .architecture + (if .variant then "/" + .variant else "" end)' - ) - - # Verify all 3 platforms are present. - set -euxo pipefail - echo "$manifests" | grep -q linux/amd64 - echo "$manifests" | grep -q linux/arm64 - echo "$manifests" | grep -q linux/arm/v7 - env: - IMAGE_TAG: ${{ steps.image-base-tag.outputs.tag }} - - - name: GitHub Attestation for Base Docker image - id: attest_base - if: ${{ steps.build_base_image.outputs.digest != '' }} - continue-on-error: true - uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 - with: - subject-name: ghcr.io/coder/coder-base - subject-digest: ${{ steps.build_base_image.outputs.digest }} - push-to-registry: true - - - name: Build Linux Docker images - id: build_docker - run: | - set -euxo pipefail - - # build Docker images for each architecture - version="$(./scripts/version.sh)" - make build/coder_"$version"_linux_{amd64,arm64,armv7}.tag - - # build and push multi-arch manifest, this depends on the other images - # being pushed so will automatically push them. - make push/build/coder_"$version"_linux.tag - - multiarch_image="$(./scripts/image_tag.sh)" - echo "multiarch_image=${multiarch_image}" >> "$GITHUB_OUTPUT" - - # For debugging, print all docker image tags - docker images - - # if the current version is equal to the highest (according to semver) - # version in the repo, also create a multi-arch image as ":latest" and - # push it - if [[ "$(git tag | grep '^v' | grep -vE '(rc|dev|-|\+|\/)' | sort -r --version-sort | head -n1)" == "v$(./scripts/version.sh)" ]]; then - latest_target="$(./scripts/image_tag.sh --version latest)" - # shellcheck disable=SC2046 - ./scripts/build_docker_multiarch.sh \ - --push \ - --target "${latest_target}" \ - $(cat build/coder_"$version"_linux_{amd64,arm64,armv7}.tag) - echo "created_latest_tag=true" >> "$GITHUB_OUTPUT" - echo "latest_target=${latest_target}" >> "$GITHUB_OUTPUT" - else - echo "created_latest_tag=false" >> "$GITHUB_OUTPUT" - fi - env: - CODER_BASE_IMAGE_TAG: ${{ steps.image-base-tag.outputs.tag }} - - - name: SBOM Generation and Attestation - env: - COSIGN_EXPERIMENTAL: '1' - MULTIARCH_IMAGE: ${{ steps.build_docker.outputs.multiarch_image }} - VERSION: ${{ steps.version.outputs.version }} - CREATED_LATEST_TAG: ${{ steps.build_docker.outputs.created_latest_tag }} - run: | - set -euxo pipefail - - # Generate SBOM for multi-arch image with version in filename - echo "Generating SBOM for multi-arch image: ${MULTIARCH_IMAGE}" - syft "${MULTIARCH_IMAGE}" -o spdx-json > "coder_${VERSION}_sbom.spdx.json" - - echo "Attesting SBOM to multi-arch image: ${MULTIARCH_IMAGE}" - cosign clean --force=true "${MULTIARCH_IMAGE}" - cosign attest --type spdxjson \ - --predicate "coder_${VERSION}_sbom.spdx.json" \ - --yes \ - "${MULTIARCH_IMAGE}" - - # If latest tag was created, also attest it - if [[ "${CREATED_LATEST_TAG}" == "true" ]]; then - latest_tag="$(./scripts/image_tag.sh --version latest)" - echo "Generating SBOM for latest image: ${latest_tag}" - syft "${latest_tag}" -o spdx-json > coder_latest_sbom.spdx.json - - echo "Attesting SBOM to latest image: ${latest_tag}" - cosign clean --force=true "${latest_tag}" - cosign attest --type spdxjson \ - --predicate coder_latest_sbom.spdx.json \ - --yes \ - "${latest_tag}" - fi - - - name: Resolve Docker image digests for attestation - id: docker_digests - continue-on-error: true - env: - MULTIARCH_IMAGE: ${{ steps.build_docker.outputs.multiarch_image }} - LATEST_TARGET: ${{ steps.build_docker.outputs.latest_target }} - run: | - set -euxo pipefail - if [[ -n "${MULTIARCH_IMAGE}" ]]; then - multiarch_digest=$(docker buildx imagetools inspect --raw "${MULTIARCH_IMAGE}" | sha256sum | awk '{print "sha256:"$1}') - echo "multiarch_digest=${multiarch_digest}" >> "$GITHUB_OUTPUT" - fi - if [[ -n "${LATEST_TARGET}" ]]; then - latest_digest=$(docker buildx imagetools inspect --raw "${LATEST_TARGET}" | sha256sum | awk '{print "sha256:"$1}') - echo "latest_digest=${latest_digest}" >> "$GITHUB_OUTPUT" - fi - - - name: GitHub Attestation for Docker image - id: attest_main - if: ${{ steps.docker_digests.outputs.multiarch_digest != '' }} - continue-on-error: true - uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 - with: - subject-name: ghcr.io/coder/coder - subject-digest: ${{ steps.docker_digests.outputs.multiarch_digest }} - push-to-registry: true - - - name: GitHub Attestation for "latest" Docker image - id: attest_latest - if: ${{ steps.docker_digests.outputs.latest_digest != '' }} - continue-on-error: true - uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 - with: - subject-name: ghcr.io/coder/coder - subject-digest: ${{ steps.docker_digests.outputs.latest_digest }} - push-to-registry: true - - - name: GitHub Attestation for release binaries - id: attest_binaries - continue-on-error: true - uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 - with: - subject-path: | - ./build/*.tar.gz - ./build/*.zip - ./build/*.deb - ./build/*.rpm - ./build/*.apk - ./build/*_installer.exe - ./build/*_helm_*.tgz - ./build/provisioner_helm_*.tgz - - # Report attestation failures but don't fail the workflow - - name: Check attestation status - 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" - fi - if [[ "${{ steps.attest_main.outcome }}" == "failure" ]]; then - echo "::warning::GitHub attestation for main image failed" - fi - if [[ "${{ steps.attest_latest.outcome }}" == "failure" && "${{ steps.attest_latest.conclusion }}" != "skipped" ]]; then - echo "::warning::GitHub attestation for latest image failed" - fi - if [[ "${{ steps.attest_binaries.outcome }}" == "failure" && "${{ steps.attest_binaries.conclusion }}" != "skipped" ]]; then - echo "::warning::GitHub attestation for release binaries failed" - fi - - - name: Generate offline docs - run: | - version="$(./scripts/version.sh)" - make -j build/coder_docs_"$version".tgz - - - name: ls build - run: ls -lh build - - - name: Publish Coder CLI binaries and detached signatures to GCS - run: | - set -euxo pipefail - - version="$(./scripts/version.sh)" - - # Source array of slim binaries - declare -A binaries - binaries["coder-darwin-amd64"]="coder-slim_${version}_darwin_amd64" - binaries["coder-darwin-arm64"]="coder-slim_${version}_darwin_arm64" - binaries["coder-linux-amd64"]="coder-slim_${version}_linux_amd64" - binaries["coder-linux-arm64"]="coder-slim_${version}_linux_arm64" - binaries["coder-linux-armv7"]="coder-slim_${version}_linux_armv7" - binaries["coder-windows-amd64.exe"]="coder-slim_${version}_windows_amd64.exe" - binaries["coder-windows-arm64.exe"]="coder-slim_${version}_windows_arm64.exe" - - for cli_name in "${!binaries[@]}"; do - slim_binary="${binaries[$cli_name]}" - detached_signature="${slim_binary}.asc" - gcloud storage cp "./build/${slim_binary}" "gs://releases.coder.com/coder-cli/${version}/${cli_name}" - gcloud storage cp "./build/${detached_signature}" "gs://releases.coder.com/coder-cli/${version}/${cli_name}.asc" - done - - - name: Publish release - run: | - set -euo pipefail - - # Build the list of files to publish. - files=( - ./build/*_installer.exe - ./build/*.zip - ./build/*.tar.gz - ./build/*.tgz - ./build/*.apk - ./build/*.deb - ./build/*.rpm - "./coder_${VERSION}_sbom.spdx.json" - ) - - # Only include the latest SBOM file if it was created. - if [[ "${CREATED_LATEST_TAG}" == "true" ]]; then - files+=(./coder_latest_sbom.spdx.json) - fi - - stable_flag=() - if [[ "$CODER_RELEASE_STABLE" == "true" ]]; then - stable_flag=(--stable) - fi - - go run ./scripts/releaser publish \ - --version "v${VERSION}" \ - "${stable_flag[@]}" \ - --release-notes-file "$CODER_RELEASE_NOTES_FILE" \ - "${files[@]}" - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - 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 - id: linear_version - run: | - # Skip RC releases. They must not complete the Linear release. - if [[ "$VERSION" == *-rc* ]]; then - echo "RC release (${VERSION}), skipping Linear release completion." - echo "skip=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - # Strip patch to get the Linear release version (e.g. 2.32.0 -> 2.32). - linear_version=$(echo "$VERSION" | cut -d. -f1,2) - echo "version=$linear_version" >> "$GITHUB_OUTPUT" - echo "skip=false" >> "$GITHUB_OUTPUT" - echo "Completing Linear release ${linear_version}" - env: - VERSION: ${{ steps.version.outputs.version }} - - - name: Complete Linear release - if: ${{ steps.linear_version.outputs.skip != 'true' }} - continue-on-error: true - uses: linear/linear-release-action@c0cb8354a362c24c6d3e0948f37fd66d07588e3f # v0.14.5 - with: - access_key: ${{ secrets.LINEAR_ACCESS_KEY }} - command: complete - version: ${{ steps.linear_version.outputs.version }} - timeout: 300 - - - name: Authenticate to Google Cloud - uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0 - with: - workload_identity_provider: ${{ vars.GCP_WORKLOAD_ID_PROVIDER }} - service_account: ${{ vars.GCP_SERVICE_ACCOUNT }} - - - name: Setup GCloud SDK - uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # 3.0.1 - - - name: Publish Helm Chart - run: | - set -euo pipefail - version="$(./scripts/version.sh)" - mkdir -p build/helm - cp "build/coder_helm_${version}.tgz" build/helm - cp "build/provisioner_helm_${version}.tgz" build/helm - cp "build/ai-gateway_helm_${version}.tgz" build/helm - gsutil cp gs://helm.coder.com/v2/index.yaml build/helm/index.yaml - helm repo index build/helm --url https://helm.coder.com/v2 --merge build/helm/index.yaml - gsutil -h "Cache-Control:no-cache,max-age=0" cp "build/helm/coder_helm_${version}.tgz" gs://helm.coder.com/v2 - gsutil -h "Cache-Control:no-cache,max-age=0" cp "build/helm/provisioner_helm_${version}.tgz" gs://helm.coder.com/v2 - gsutil -h "Cache-Control:no-cache,max-age=0" cp "build/helm/ai-gateway_helm_${version}.tgz" gs://helm.coder.com/v2 - gsutil -h "Cache-Control:no-cache,max-age=0" cp "build/helm/index.yaml" gs://helm.coder.com/v2 - gsutil -h "Cache-Control:no-cache,max-age=0" cp "helm/artifacthub-repo.yml" gs://helm.coder.com/v2 - helm push "build/coder_helm_${version}.tgz" oci://ghcr.io/coder/chart - helm push "build/provisioner_helm_${version}.tgz" oci://ghcr.io/coder/chart - helm push "build/ai-gateway_helm_${version}.tgz" oci://ghcr.io/coder/chart - - - name: Send repository-dispatch event - 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 }}"}' - - publish-homebrew: - name: Publish to Homebrew tap - runs-on: ubuntu-latest - 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 - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - - name: Update homebrew - env: - GH_REPO: coder/homebrew-coder - GH_TOKEN: ${{ secrets.CDRCI_GITHUB_TOKEN }} - VERSION: ${{ needs.release.outputs.version }} - run: | - # Keep version number around for reference, removing any potential leading v - coder_version="$(echo "${VERSION}" | tr -d v)" - - set -euxo pipefail - - # Setup Git - git config --global user.email "ci@coder.com" - git config --global user.name "Coder CI" - git config --global credential.helper "store" - - temp_dir="$(mktemp -d)" - cd "$temp_dir" - - # Download checksums - checksums_url="$(gh release view --repo coder/coder "v$coder_version" --json assets \ - | jq -r ".assets | map(.url) | .[]" \ - | grep -e ".checksums.txt\$")" - wget "$checksums_url" -O checksums.txt - - # Get the SHAs - darwin_arm_sha="$(grep "darwin_arm64.zip" checksums.txt | awk '{ print $1 }')" - darwin_intel_sha="$(grep "darwin_amd64.zip" checksums.txt | awk '{ print $1 }')" - linux_sha="$(grep "linux_amd64.tar.gz" checksums.txt | awk '{ print $1 }')" - - echo "macOS arm64: $darwin_arm_sha" - echo "macOS amd64: $darwin_intel_sha" - echo "Linux amd64: $linux_sha" - - # Check out the homebrew repo - git clone "https://github.com/$GH_REPO" homebrew-coder - brew_branch="auto-release/$coder_version" - cd homebrew-coder - - # Check if a PR already exists. - pr_count="$(gh pr list --search "head:$brew_branch" --json id,closed | jq -r ".[] | select(.closed == false) | .id" | wc -l)" - if [ "$pr_count" -gt 0 ]; then - echo "Bailing out as PR already exists" 2>&1 - exit 0 - fi - - # Set up cdrci credentials for pushing to homebrew-coder - echo "https://x-access-token:$GH_TOKEN@github.com" >> ~/.git-credentials - # Update the formulae and push - git checkout -b "$brew_branch" - ./scripts/update-v2.sh "$coder_version" "$darwin_arm_sha" "$darwin_intel_sha" "$linux_sha" - git add . - git commit -m "coder $coder_version" - git push -u origin -f "$brew_branch" - - # Create PR - gh pr create \ - -B master -H "$brew_branch" \ - -t "coder $coder_version" \ - -b "" \ - -r "${GITHUB_ACTOR}" \ - -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, prepare-release] - if: ${{ inputs.release_type != 'rc' && inputs.release_type != 'create-release-branch' }} - - steps: - - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - - name: Sync fork - run: gh repo sync cdrci/winget-pkgs -b master - env: - GH_TOKEN: ${{ secrets.CDRCI_GITHUB_TOKEN }} - - - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - fetch-depth: 0 - persist-credentials: false - - # If the event that triggered the build was an annotated tag (which our - # tags are supposed to be), actions/checkout has a bug where the tag in - # question is only a lightweight tag and not a full annotated tag. This - # command seems to fix it. - # https://github.com/actions/checkout/issues/290 - - name: Fetch git tags - run: git fetch --tags --force - - - name: Install wingetcreate - run: | - Invoke-WebRequest https://aka.ms/wingetcreate/latest -OutFile wingetcreate.exe - - - name: Submit updated manifest to winget-pkgs - run: | - # The package version is the same as the tag minus the leading "v". - # The version in this output already has the leading "v" removed but - # we do it again to be safe. - $version = $env:VERSION.Trim('v') - - $release_assets = gh release view --repo coder/coder "v${version}" --json assets | ` - ConvertFrom-Json - # Get the installer URLs from the release assets. - $amd64_installer_url = $release_assets.assets | ` - Where-Object name -Match ".*_windows_amd64_installer.exe$" | ` - Select -ExpandProperty url - $amd64_zip_url = $release_assets.assets | ` - Where-Object name -Match ".*_windows_amd64.zip$" | ` - Select -ExpandProperty url - $arm64_zip_url = $release_assets.assets | ` - Where-Object name -Match ".*_windows_arm64.zip$" | ` - Select -ExpandProperty url - - echo "amd64 Installer URL: ${amd64_installer_url}" - echo "amd64 zip URL: ${amd64_zip_url}" - echo "arm64 zip URL: ${arm64_zip_url}" - echo "Package version: ${version}" - - .\wingetcreate.exe update Coder.Coder ` - --submit ` - --version "${version}" ` - --urls "${amd64_installer_url}" "${amd64_zip_url}" "${arm64_zip_url}" - - env: - # For gh CLI: - GH_TOKEN: ${{ github.token }} - # For wingetcreate. We need a real token since we're pushing a commit - # to GitHub and then making a PR in a different repo. - # wingetcreate will read the token from the environment variable defined below. - # Reference: https://aka.ms/winget-create-token - WINGET_CREATE_GITHUB_TOKEN: ${{ secrets.CDRCI_GITHUB_TOKEN }} - VERSION: ${{ needs.release.outputs.version }} - - - name: Comment on PR - run: | - # wait 30 seconds - Start-Sleep -Seconds 30.0 - # Find the PR that wingetcreate just made. - $version = $env:VERSION.Trim('v') - $pr_list = gh pr list --repo microsoft/winget-pkgs --search "author:cdrci Coder.Coder version ${version}" --limit 1 --json number | ` - ConvertFrom-Json - $pr_number = $pr_list[0].number - - gh pr comment --repo microsoft/winget-pkgs "${pr_number}" --body "🤖 cc: @deansheather @matifali" - - env: - # For gh CLI. We need a real token since we're commenting on a PR in a - # 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@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: main - fetch-depth: 0 - persist-credentials: true - - - name: Fetch git tags - run: git fetch --tags --force - - - name: Set up mise tools - uses: ./.github/actions/setup-mise - with: - install-args: "node pnpm" - - - name: Install pnpm dependencies - uses: ./.github/actions/pnpm-install - - - name: Update release calendar - run: ./scripts/update-release-calendar.sh - - - name: Create docs update PR - uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 - 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/scripts/release.sh b/scripts/release.sh index 9e7e4173b2..f0b6d1742a 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -5,10 +5,8 @@ set -euo pipefail # Thin wrapper that launches the interactive release wizard. # Usage: ./scripts/release.sh [flags] # -# Flags are passed directly to the Go program. The wizard is the legacy -# (v1) release tool; the non-interactive tooling is available directly -# via `go run ./scripts/releaser `. +# Flags are passed directly to the Go program. # Run ./scripts/release.sh --help for details. cd "$(dirname "${BASH_SOURCE[0]}")/.." -exec go run ./scripts/releaser --legacy "$@" +exec go run ./scripts/releaser "$@" diff --git a/scripts/releaser/v1/commit.go b/scripts/releaser/commit.go similarity index 99% rename from scripts/releaser/v1/commit.go rename to scripts/releaser/commit.go index a42b1e4ebf..37bdfcb7b5 100644 --- a/scripts/releaser/v1/commit.go +++ b/scripts/releaser/commit.go @@ -1,4 +1,4 @@ -package v1 +package main import ( "regexp" diff --git a/scripts/releaser/v1/docs.go b/scripts/releaser/docs.go similarity index 99% rename from scripts/releaser/v1/docs.go rename to scripts/releaser/docs.go index 359a9fb117..33fbfd79a0 100644 --- a/scripts/releaser/v1/docs.go +++ b/scripts/releaser/docs.go @@ -1,4 +1,4 @@ -package v1 +package main import ( "fmt" diff --git a/scripts/releaser/v1/docs_test.go b/scripts/releaser/docs_test.go similarity index 96% rename from scripts/releaser/v1/docs_test.go rename to scripts/releaser/docs_test.go index 052b07169d..1b890d0a8f 100644 --- a/scripts/releaser/v1/docs_test.go +++ b/scripts/releaser/docs_test.go @@ -1,4 +1,4 @@ -package v1 //nolint:testpackage // Tests unexported release helpers. +package main //nolint:testpackage // Tests unexported release helpers. import "testing" diff --git a/scripts/releaser/v1/executor.go b/scripts/releaser/executor.go similarity index 73% rename from scripts/releaser/v1/executor.go rename to scripts/releaser/executor.go index 4293372185..8487328e47 100644 --- a/scripts/releaser/v1/executor.go +++ b/scripts/releaser/executor.go @@ -1,4 +1,4 @@ -package v1 +package main import ( "context" @@ -18,8 +18,8 @@ import ( // here. Safe operations (file writes, fetches, editor) are // called directly. type ReleaseExecutor interface { - // CreateTag creates an annotated (optionally signed) git tag. - CreateTag(ctx context.Context, tag, ref, message string, sign bool) error + // CreateTag creates an annotated git tag. + CreateTag(ctx context.Context, tag, ref, message string) error // PushTag pushes a tag to the origin remote. PushTag(ctx context.Context, tag string) error // TriggerWorkflow dispatches the release.yaml GitHub Actions @@ -30,14 +30,8 @@ type ReleaseExecutor interface { // liveExecutor performs real operations. type liveExecutor struct{} -//nolint:revive // sign flag is part of the ReleaseExecutor interface contract. -func (e *liveExecutor) CreateTag(_ context.Context, tag, ref, message string, sign bool) error { - args := []string{"tag", "-a"} - if sign { - args = append(args, "-s") - } - args = append(args, tag, "-m", message, ref) - return gitRun(args...) +func (*liveExecutor) CreateTag(_ context.Context, tag, ref, message string) error { + return gitRun("tag", "-a", tag, "-m", message, ref) } func (*liveExecutor) PushTag(_ context.Context, tag string) error { @@ -70,13 +64,8 @@ type dryRunExecutor struct { w io.Writer } -//nolint:revive // sign flag is part of the ReleaseExecutor interface contract. -func (e *dryRunExecutor) CreateTag(_ context.Context, tag, ref, message string, sign bool) error { - signFlag := "" - if sign { - signFlag = "-s " - } - _, _ = fmt.Fprintf(e.w, "[DRYRUN] would run: git tag %s-a %s -m %q %s\n", signFlag, tag, message, ref) +func (e *dryRunExecutor) CreateTag(_ context.Context, tag, ref, message string) error { + _, _ = fmt.Fprintf(e.w, "[DRYRUN] would run: git tag -a %s -m %q %s\n", tag, message, ref) return nil } diff --git a/scripts/releaser/v1/git.go b/scripts/releaser/git.go similarity index 97% rename from scripts/releaser/v1/git.go rename to scripts/releaser/git.go index cf6af3cf63..3974e21582 100644 --- a/scripts/releaser/v1/git.go +++ b/scripts/releaser/git.go @@ -1,4 +1,4 @@ -package v1 +package main import ( "errors" diff --git a/scripts/releaser/v1/github.go b/scripts/releaser/github.go similarity index 99% rename from scripts/releaser/v1/github.go rename to scripts/releaser/github.go index 13b99bea8b..75df80960f 100644 --- a/scripts/releaser/v1/github.go +++ b/scripts/releaser/github.go @@ -1,4 +1,4 @@ -package v1 +package main import ( "errors" diff --git a/scripts/releaser/main.go b/scripts/releaser/main.go index e5f26e6f4a..332703a05c 100644 --- a/scripts/releaser/main.go +++ b/scripts/releaser/main.go @@ -4,78 +4,31 @@ import ( "errors" "os" - "golang.org/x/xerrors" - "github.com/coder/coder/v2/cli/cliui" - releaserv1 "github.com/coder/coder/v2/scripts/releaser/v1" - releaserv2 "github.com/coder/coder/v2/scripts/releaser/v2" "github.com/coder/pretty" "github.com/coder/serpent" ) func main() { - var ( - legacy bool - dryRun bool - ) - - // Default (v2) subcommands. rc, branch, and release run the - // non-interactive prepare-release logic with the release type baked - // in. - children := []*serpent.Command{ - releaserv2.TypeCommand("rc", "Tag a release candidate from main or a release branch.", "rc"), - releaserv2.TypeCommand("branch", "Cut a new release branch and tag its first release candidate.", "create-release-branch"), - releaserv2.TypeCommand("release", "Tag a stable release or patch from a release branch.", "release"), - } - - // Hidden compatibility verbs. These preserve the exact names, flags, - // and stdout contract of the former scripts/release-action tool so - // GitHub Actions workflows migrate with a path-only change. - for _, c := range releaserv2.CICommands() { - c.Hidden = true - children = append(children, c) - } - - // --legacy selects the v1 interactive wizard and cannot be combined - // with a subcommand, which v1 does not understand. - for _, c := range children { - next := c.Handler - c.Handler = func(inv *serpent.Invocation) error { - if legacy { - return xerrors.New("--legacy cannot be combined with a subcommand; run 'releaser --legacy' for the interactive tool") - } - return next(inv) - } - } + var dryRun bool cmd := &serpent.Command{ - Use: "releaser ", + Use: "releaser", Short: "Release tooling for coder/coder.", Long: "Tag and publish releases for coder/coder.\n\n" + - "By default releaser runs the non-interactive tooling via the rc,\n" + - "branch, and release subcommands. Pass --legacy to run the older\n" + - "interactive release wizard instead.", + "releaser runs the interactive release wizard: it walks the\n" + + "operator through tagging, pushing, and triggering the release\n" + + "workflow.", Options: serpent.OptionSet{ - { - Name: "legacy", - Flag: "legacy", - Description: "Run the legacy interactive release wizard.", - Value: serpent.BoolOf(&legacy), - }, { Name: "dry-run", Flag: "dry-run", - Description: "Print mutating commands instead of executing them (legacy wizard only).", + Description: "Print mutating commands instead of executing them.", Value: serpent.BoolOf(&dryRun), }, }, - Children: children, Handler: func(inv *serpent.Invocation) error { - if legacy { - return releaserv1.Run(inv, dryRun) - } - // No subcommand given and not in legacy mode: show help. - return serpent.DefaultHelpFn()(inv) + return Run(inv, dryRun) }, } diff --git a/scripts/releaser/v1/release.go b/scripts/releaser/release.go similarity index 99% rename from scripts/releaser/v1/release.go rename to scripts/releaser/release.go index f9635680da..4af28da103 100644 --- a/scripts/releaser/v1/release.go +++ b/scripts/releaser/release.go @@ -1,4 +1,4 @@ -package v1 +package main import ( "context" @@ -19,7 +19,7 @@ import ( ) //nolint:revive // Long function is fine for a sequential release flow. -func runRelease(ctx context.Context, inv *serpent.Invocation, executor ReleaseExecutor, ghAvailable, gpgConfigured, dryRun bool) error { +func runRelease(ctx context.Context, inv *serpent.Invocation, executor ReleaseExecutor, ghAvailable, dryRun bool) error { w := inv.Stderr // --- Release landscape --- @@ -767,7 +767,7 @@ func runRelease(ctx context.Context, inv *serpent.Invocation, executor ReleaseEx if err := confirm(inv, "Create tag?"); err != nil { return xerrors.New("cannot proceed without a tag") } - if err := executor.CreateTag(ctx, newVersion.String(), ref, "Release "+newVersion.String(), gpgConfigured); err != nil { + if err := executor.CreateTag(ctx, newVersion.String(), ref, "Release "+newVersion.String()); err != nil { return xerrors.Errorf("creating tag: %w", err) } successf(w, "Tag %s created.", newVersion) diff --git a/scripts/releaser/run.go b/scripts/releaser/run.go new file mode 100644 index 0000000000..3a7e51fc5c --- /dev/null +++ b/scripts/releaser/run.go @@ -0,0 +1,51 @@ +package main + +import ( + "fmt" + "os/exec" + + "golang.org/x/xerrors" + + "github.com/coder/serpent" +) + +const ( + owner = "coder" + repo = "coder" +) + +// Run executes the interactive release wizard. +// +// It verifies dependencies, warns when the gh CLI is not configured, wires +// up a live or dry-run executor, and then walks the operator through +// tagging, pushing, and triggering the release workflow. +// +//nolint:revive // dryRun selects the dry-run executor for the wizard. +func Run(inv *serpent.Invocation, dryRun bool) error { + ctx := inv.Context() + w := inv.Stderr + + // --- Check dependencies --- + if _, err := exec.LookPath("git"); err != nil { + return xerrors.New("git is required but not found in PATH") + } + + // --- Check gh CLI auth --- + ghAvailable := checkGHAuth() + if !ghAvailable { + warnf(w, "gh CLI is not available or not authenticated.") + infof(w, "Continuing without GitHub features (PR checks, label lookups, workflow trigger).") + _, _ = fmt.Fprintln(w) + } + + // --- Wire up executor --- + var executor ReleaseExecutor + if dryRun { + outputPrefix = "[DRYRUN] " + executor = &dryRunExecutor{w: w} + } else { + executor = &liveExecutor{} + } + + return runRelease(ctx, inv, executor, ghAvailable, dryRun) +} diff --git a/scripts/releaser/v1/ui.go b/scripts/releaser/ui.go similarity index 99% rename from scripts/releaser/v1/ui.go rename to scripts/releaser/ui.go index e924c917e3..b178e60c0d 100644 --- a/scripts/releaser/v1/ui.go +++ b/scripts/releaser/ui.go @@ -1,4 +1,4 @@ -package v1 +package main import ( "io" diff --git a/scripts/releaser/v1/run.go b/scripts/releaser/v1/run.go deleted file mode 100644 index a925972a27..0000000000 --- a/scripts/releaser/v1/run.go +++ /dev/null @@ -1,67 +0,0 @@ -package v1 - -import ( - "fmt" - "os/exec" - - "golang.org/x/xerrors" - - "github.com/coder/coder/v2/cli/cliui" - "github.com/coder/serpent" -) - -const ( - owner = "coder" - repo = "coder" -) - -// Run executes the legacy interactive release wizard. -// -// It mirrors the behavior of the original standalone releaser tool: it -// verifies dependencies, warns when GPG signing or the gh CLI are not -// configured, wires up a live or dry-run executor, and then walks the -// operator through tagging, pushing, and triggering the release -// workflow. -// -//nolint:revive // dryRun selects the dry-run executor for the wizard. -func Run(inv *serpent.Invocation, dryRun bool) error { - ctx := inv.Context() - w := inv.Stderr - - // --- Check dependencies --- - if _, err := exec.LookPath("git"); err != nil { - return xerrors.New("git is required but not found in PATH") - } - - // --- Check GPG signing --- - signingKey, _ := gitOutput("config", "--get", "user.signingkey") - gpgFormat, _ := gitOutput("config", "--get", "gpg.format") - gpgConfigured := signingKey != "" || gpgFormat != "" - if !gpgConfigured { - warnf(w, "GPG signing is not configured. Tags will be unsigned, so there will be no way to verify who pushed the tag.") - _, _ = fmt.Fprintf(w, " To fix: set git config user.signingkey or gpg.format\n") - if err := confirmWithDefault(inv, "Continue without signing?", cliui.ConfirmNo); err != nil { - return err - } - _, _ = fmt.Fprintln(w) - } - - // --- Check gh CLI auth --- - ghAvailable := checkGHAuth() - if !ghAvailable { - warnf(w, "gh CLI is not available or not authenticated.") - infof(w, "Continuing without GitHub features (PR checks, label lookups, workflow trigger).") - _, _ = fmt.Fprintln(w) - } - - // --- Wire up executor --- - var executor ReleaseExecutor - if dryRun { - outputPrefix = "[DRYRUN] " - executor = &dryRunExecutor{w: w} - } else { - executor = &liveExecutor{} - } - - return runRelease(ctx, inv, executor, ghAvailable, gpgConfigured, dryRun) -} diff --git a/scripts/releaser/v2/calculate.go b/scripts/releaser/v2/calculate.go deleted file mode 100644 index a365cf17e2..0000000000 --- a/scripts/releaser/v2/calculate.go +++ /dev/null @@ -1,453 +0,0 @@ -package v2 - -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(exec CommandExecutor, releaseType, ref, commitSHA string) (calculateResult, error) { - // Ensure we have up-to-date remote state. Fetching only updates - // local remote-tracking refs, so it runs even in dry-run mode to - // keep version calculation accurate. - if _, err := gitOutput(exec, "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(exec, ref, commitSHA) - } - return calculateRCFromBranchReleaseRequest(exec, 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(exec, ref) - - case "create-release-branch": - if !isMain { - return nil, xerrors.Errorf("create-release-branch must be run from main, got %q", ref) - } - return calculateCreateBranchRequest(exec, 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(exec CommandExecutor, ref, commitSHA string) (string, error) { - if commitSHA != "" { - if !isHexSHA(commitSHA) { - return "", xerrors.Errorf("invalid commit SHA %q: must be a hex string", commitSHA) - } - // Resolve to a full commit SHA. The idempotency checks in - // createAndPushTag/createAndPushBranch compare targetRef - // against full SHAs (git rev-parse of an existing tag, - // ls-remote branch output), so a short SHA passed via the - // commit input would never match and would break re-runs. - sha, err := gitOutput(exec, "rev-parse", "--verify", commitSHA+"^{commit}") - if err != nil { - return "", xerrors.Errorf("resolve commit %s: %w", commitSHA, err) - } - return sha, nil - } - sha, err := gitOutput(exec, "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(exec CommandExecutor, ref, commitSHA string) (ReleaseRequest, error) { - targetRef, err := resolveCommit(exec, ref, commitSHA) - if err != nil { - return ReleaseRequest{}, err - } - - // Verify commit is an ancestor of origin/main. - if err := gitRun(exec, "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(exec) - 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(exec CommandExecutor, 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(exec, ref, commitSHA) - if err != nil { - return ReleaseRequest{}, err - } - - // Fail if there are open PRs targeting this release branch. - if err := checkOpenPRs(exec, ref); err != nil { - return ReleaseRequest{}, err - } - - allTags, err := listSemverTags(exec) - 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(exec CommandExecutor, 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(exec, "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(exec, ref); err != nil { - return ReleaseRequest{}, err - } - - allTags, err := listSemverTags(exec) - 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(exec CommandExecutor, ref, commitSHA string) (CreateBranchRequest, error) { - targetRef, err := resolveCommit(exec, ref, commitSHA) - if err != nil { - return CreateBranchRequest{}, err - } - - // Verify commit is an ancestor of origin/main. - if err := gitRun(exec, "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(exec) - 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(exec, "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(exec CommandExecutor) ([]version, error) { - out, err := gitOutput(exec, "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/releaser/v2/calculate_test.go b/scripts/releaser/v2/calculate_test.go deleted file mode 100644 index 40b74ae775..0000000000 --- a/scripts/releaser/v2/calculate_test.go +++ /dev/null @@ -1,477 +0,0 @@ -package v2 //nolint:testpackage // Tests unexported release helpers. - -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)) - }) - } -} - -func Test_resolveCommit(t *testing.T) { - t.Parallel() - - t.Run("ShortSHAResolvedToFull", func(t *testing.T) { - t.Parallel() - const full = "1234567890abcdef1234567890abcdef12345678" - var gotArgs []string - mock := &mockExecutor{ - RunOutputFunc: func(_ string, args ...string) (string, error) { - gotArgs = args - return full, nil - }, - } - got, err := resolveCommit(mock, "main", "1234567") - require.NoError(t, err) - require.Equal(t, full, got) - require.Equal(t, []string{"rev-parse", "--verify", "1234567^{commit}"}, gotArgs) - }) - - t.Run("EmptyResolvesRefHead", func(t *testing.T) { - t.Parallel() - const full = "abcdef1234567890abcdef1234567890abcdef12" - var gotArgs []string - mock := &mockExecutor{ - RunOutputFunc: func(_ string, args ...string) (string, error) { - gotArgs = args - return full, nil - }, - } - got, err := resolveCommit(mock, "main", "") - require.NoError(t, err) - require.Equal(t, full, got) - require.Equal(t, []string{"rev-parse", "origin/main"}, gotArgs) - }) - - t.Run("InvalidSHANotResolved", func(t *testing.T) { - t.Parallel() - called := false - mock := &mockExecutor{ - RunOutputFunc: func(_ string, _ ...string) (string, error) { - called = true - return "", nil - }, - } - _, err := resolveCommit(mock, "main", "zzzzzzz") - require.Error(t, err) - require.False(t, called, "git should not be invoked for an invalid SHA") - }) -} diff --git a/scripts/releaser/v2/cmdexec.go b/scripts/releaser/v2/cmdexec.go deleted file mode 100644 index 40e2468d80..0000000000 --- a/scripts/releaser/v2/cmdexec.go +++ /dev/null @@ -1,128 +0,0 @@ -package v2 - -import ( - "bytes" - "errors" - "fmt" - "io" - "os/exec" - "strings" - - "golang.org/x/xerrors" -) - -// CommandExecutor abstracts running CLI commands so that a dry-run -// implementation can print commands instead of executing them. -// -// Read-only methods (RunOutput, Run) execute unconditionally in both -// real and dry-run modes. Mutating methods (RunMutation, -// RunMutationStdout) are printed instead of executed in dry-run mode. -// Callers must choose the correct method at the call site so that -// dry-run behavior is explicit and reviewable. -type CommandExecutor interface { - // RunOutput executes the named program with args and returns - // trimmed stdout. Use for read-only commands. - RunOutput(name string, args ...string) (string, error) - - // Run executes the named program with args, discarding output. - // Use for read-only commands where only the exit code matters. - Run(name string, args ...string) error - - // RunMutation executes a command that modifies remote state - // (e.g. git push, git tag). In dry-run mode it prints instead - // of executing. - RunMutation(name string, args ...string) error - - // RunMutationStdout executes a mutating command, streaming - // stdout and stderr to the provided writers. Stdin is set to - // empty to prevent interactive prompts. In dry-run mode it - // prints instead of executing. - RunMutationStdout(stdout, stderr io.Writer, name string, args ...string) error -} - -// realExecutor runs all commands for real via os/exec. -type realExecutor struct{} - -func (realExecutor) RunOutput(name string, args ...string) (string, error) { - cmd := exec.Command(name, 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 -} - -func (realExecutor) Run(name string, args ...string) error { - cmd := exec.Command(name, args...) - return cmd.Run() -} - -func (realExecutor) RunMutation(name string, args ...string) error { - cmd := exec.Command(name, args...) - // Capture stderr so that a failing mutation surfaces the command's - // error output (e.g. git's "fatal:" message) in the returned error - // instead of only the exit status. - var stderr bytes.Buffer - cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - if msg := strings.TrimSpace(stderr.String()); msg != "" { - return xerrors.Errorf("%s: %w", msg, err) - } - return err - } - return nil -} - -func (realExecutor) RunMutationStdout(stdout, stderr io.Writer, name string, args ...string) error { - cmd := exec.Command(name, args...) - cmd.Stdout = stdout - cmd.Stderr = stderr - cmd.Stdin = strings.NewReader("") // prevent interactive prompts - return cmd.Run() -} - -// dryRunExecutor delegates read-only commands to the real executor -// and prints mutating commands instead of executing them. -type dryRunExecutor struct { - w io.Writer - real realExecutor -} - -func newDryRunExecutor(w io.Writer) *dryRunExecutor { - return &dryRunExecutor{w: w} -} - -func (d *dryRunExecutor) RunOutput(name string, args ...string) (string, error) { - return d.real.RunOutput(name, args...) -} - -func (d *dryRunExecutor) Run(name string, args ...string) error { - return d.real.Run(name, args...) -} - -func (d *dryRunExecutor) RunMutation(name string, args ...string) error { - _, _ = fmt.Fprintf(d.w, "[dry-run] would run: %s %s\n", name, shelljoin(args)) - return nil -} - -func (d *dryRunExecutor) RunMutationStdout(_, _ io.Writer, name string, args ...string) error { - _, _ = fmt.Fprintf(d.w, "[dry-run] would run: %s %s\n", name, shelljoin(args)) - return nil -} - -// shelljoin produces a shell-safe representation of args for display. -func shelljoin(args []string) string { - quoted := make([]string, len(args)) - for i, a := range args { - if strings.ContainsAny(a, " \t\n\"'\\$") { - quoted[i] = "'" + strings.ReplaceAll(a, "'", "'\"'\"'") + "'" - continue - } - quoted[i] = a - } - return strings.Join(quoted, " ") -} diff --git a/scripts/releaser/v2/cmdexec_test.go b/scripts/releaser/v2/cmdexec_test.go deleted file mode 100644 index 9555e5a576..0000000000 --- a/scripts/releaser/v2/cmdexec_test.go +++ /dev/null @@ -1,134 +0,0 @@ -package v2 //nolint:testpackage // Tests unexported release helpers. - -import ( - "bytes" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestRealExecutor_RunOutput(t *testing.T) { - t.Parallel() - exec := realExecutor{} - out, err := exec.RunOutput("echo", "hello") - require.NoError(t, err) - assert.Equal(t, "hello", out) -} - -func TestRealExecutor_Run(t *testing.T) { - t.Parallel() - exec := realExecutor{} - err := exec.Run("true") - require.NoError(t, err) - - err = exec.Run("false") - require.Error(t, err) -} - -func TestRealExecutor_RunMutation(t *testing.T) { - t.Parallel() - exec := realExecutor{} - err := exec.RunMutation("true") - require.NoError(t, err) - - err = exec.RunMutation("false") - require.Error(t, err) -} - -func TestRealExecutor_RunMutationSurfacesStderr(t *testing.T) { - t.Parallel() - exec := realExecutor{} - err := exec.RunMutation("sh", "-c", "echo 'fatal: boom' 1>&2; exit 1") - require.Error(t, err) - assert.Contains(t, err.Error(), "fatal: boom") -} - -func TestRealExecutor_RunMutationStdout(t *testing.T) { - t.Parallel() - exec := realExecutor{} - var stdout, stderr bytes.Buffer - err := exec.RunMutationStdout(&stdout, &stderr, "echo", "output") - require.NoError(t, err) - assert.Equal(t, "output\n", stdout.String()) -} - -func TestDryRunExecutor_RunOutputDelegates(t *testing.T) { - t.Parallel() - var buf bytes.Buffer - exec := newDryRunExecutor(&buf) - - // RunOutput should still execute (read-only commands). - out, err := exec.RunOutput("echo", "real-output") - require.NoError(t, err) - assert.Equal(t, "real-output", out) - assert.Empty(t, buf.String(), "RunOutput should not produce dry-run output") -} - -func TestDryRunExecutor_RunDelegates(t *testing.T) { - t.Parallel() - var buf bytes.Buffer - exec := newDryRunExecutor(&buf) - - err := exec.Run("true") - require.NoError(t, err) - assert.Empty(t, buf.String(), "Run should not produce dry-run output") -} - -func TestDryRunExecutor_RunMutationPrints(t *testing.T) { - t.Parallel() - var buf bytes.Buffer - exec := newDryRunExecutor(&buf) - - err := exec.RunMutation("git", "push", "origin", "refs/tags/v2.21.0:refs/tags/v2.21.0") - require.NoError(t, err) - assert.Contains(t, buf.String(), "[dry-run] would run: git push origin refs/tags/v2.21.0:refs/tags/v2.21.0") -} - -func TestDryRunExecutor_RunMutationStdoutPrints(t *testing.T) { - t.Parallel() - var buf bytes.Buffer - exec := newDryRunExecutor(&buf) - - var stdout, stderr bytes.Buffer - err := exec.RunMutationStdout(&stdout, &stderr, "gh", "release", "create", "--repo", "coder/coder", "--title", "v2.21.0") - require.NoError(t, err) - - assert.Empty(t, stdout.String(), "RunMutationStdout should not produce real output in dry-run") - assert.Contains(t, buf.String(), "[dry-run] would run: gh release create --repo coder/coder --title v2.21.0") -} - -func TestDryRunExecutor_RunMutationStdoutQuotesArgs(t *testing.T) { - t.Parallel() - var buf bytes.Buffer - exec := newDryRunExecutor(&buf) - - var stdout, stderr bytes.Buffer - err := exec.RunMutationStdout(&stdout, &stderr, "gh", "release", "create", "--title", "has space") - require.NoError(t, err) - - assert.Contains(t, buf.String(), "'has space'") -} - -func TestShelljoin(t *testing.T) { - t.Parallel() - tests := []struct { - name string - args []string - want string - }{ - {"simple", []string{"a", "b"}, "a b"}, - {"space", []string{"a", "has space"}, "a 'has space'"}, - // shelljoin uses POSIX single-quote escaping, so an embedded - // single quote becomes '"'"'. - {"quote", []string{"it's"}, `'it'"'"'s'`}, - {"empty", []string{}, ""}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - got := shelljoin(tt.args) - assert.Equal(t, tt.want, got) - }) - } -} diff --git a/scripts/releaser/v2/commands.go b/scripts/releaser/v2/commands.go deleted file mode 100644 index 51f3ab5847..0000000000 --- a/scripts/releaser/v2/commands.go +++ /dev/null @@ -1,264 +0,0 @@ -package v2 - -import ( - "fmt" - "os" - - "golang.org/x/xerrors" - - "github.com/coder/serpent" -) - -const ( - owner = "coder" - repo = "coder" -) - -// newExecutor returns the appropriate CommandExecutor based on the -// dry-run setting. -// -//nolint:revive // dryRun selects the dry-run executor. -func newExecutor(dryRun bool) CommandExecutor { - if dryRun { - return newDryRunExecutor(os.Stderr) - } - return realExecutor{} -} - -// dryRunOption returns the shared --dry-run option bound to dryRun. -func dryRunOption(dryRun *bool) serpent.Option { - return serpent.Option{ - Name: "dry-run", - Flag: "dry-run", - Description: "Print mutating commands instead of executing them.", - Value: serpent.BoolOf(dryRun), - } -} - -// CICommands returns the low-level, CI-oriented release subcommands -// (calculate-version, prepare-release, generate-notes, publish). Their -// names, flags, and stdout output match the former scripts/release-action -// tool so GitHub Actions workflows can invoke them unchanged. -func CICommands() []*serpent.Command { - return []*serpent.Command{ - calculateVersionCommand(), - prepareReleaseCommand(), - generateNotesCommand(), - publishCommand(), - } -} - -// TypeCommand returns a command that runs prepare-release for a fixed -// release type. It backs the top-level rc, branch, and release -// subcommands, printing the same JSON as prepare-release. -func TypeCommand(use, short, releaseType string) *serpent.Command { - var ( - ref string - commitSHA string - dryRun bool - ) - return &serpent.Command{ - Use: use, - Short: short, - Options: serpent.OptionSet{ - { - Name: "ref", - Flag: "ref", - Description: "Git ref (branch name) to release from.", - 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), - }, - dryRunOption(&dryRun), - }, - Handler: func(inv *serpent.Invocation) error { - result, err := prepareRelease(newExecutor(dryRun), releaseType, ref, commitSHA) - if err != nil { - return err - } - _, _ = fmt.Fprintln(inv.Stdout, result.String()) - return nil - }, - } -} - -func calculateVersionCommand() *serpent.Command { - var ( - releaseType string - ref string - commitSHA string - dryRun bool - ) - return &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), - }, - dryRunOption(&dryRun), - }, - Handler: func(inv *serpent.Invocation) error { - result, err := calculateNextVersion(newExecutor(dryRun), releaseType, ref, commitSHA) - if err != nil { - return err - } - _, _ = fmt.Fprintln(inv.Stdout, result.String()) - return nil - }, - } -} - -func prepareReleaseCommand() *serpent.Command { - var ( - releaseType string - ref string - commitSHA string - dryRun bool - ) - return &serpent.Command{ - Use: "prepare-release", - Short: "Calculate version, create and push tag (and optionally release branch).", - 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), - }, - dryRunOption(&dryRun), - }, - Handler: func(inv *serpent.Invocation) error { - result, err := prepareRelease(newExecutor(dryRun), releaseType, ref, commitSHA) - if err != nil { - return err - } - _, _ = fmt.Fprintln(inv.Stdout, result.String()) - return nil - }, - } -} - -func generateNotesCommand() *serpent.Command { - var ( - versionStr string - prevVersionStr string - dryRun bool - ) - return &serpent.Command{ - 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, - }, - dryRunOption(&dryRun), - }, - 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(newExecutor(dryRun), newVer, prevVer) - if err != nil { - return err - } - _, _ = fmt.Fprint(inv.Stdout, notes) - return nil - }, - } -} - -func publishCommand() *serpent.Command { - var ( - versionStr string - stable bool - notesFile string - dryRun bool - ) - return &serpent.Command{ - 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, - }, - dryRunOption(&dryRun), - }, - Handler: func(inv *serpent.Invocation) error { - assets := inv.Args - if len(assets) == 0 { - return xerrors.New("no asset files provided as arguments") - } - return publishRelease(newExecutor(dryRun), versionStr, stable, notesFile, assets) - }, - } -} diff --git a/scripts/releaser/v2/commit.go b/scripts/releaser/v2/commit.go deleted file mode 100644 index 466c1a0b53..0000000000 --- a/scripts/releaser/v2/commit.go +++ /dev/null @@ -1,221 +0,0 @@ -package v2 - -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(exec CommandExecutor, commitRange string) ([]commitEntry, error) { - // Use --left-right --cherry-mark to identify equivalent - // (cherry-picked) commits and left-side-only commits. - out, err := gitOutput(exec, "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/releaser/v2/commit_test.go b/scripts/releaser/v2/commit_test.go deleted file mode 100644 index 35169b28f1..0000000000 --- a/scripts/releaser/v2/commit_test.go +++ /dev/null @@ -1,352 +0,0 @@ -package v2 //nolint:testpackage // Tests unexported release helpers. - -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/releaser/v2/git.go b/scripts/releaser/v2/git.go deleted file mode 100644 index 1b40315e08..0000000000 --- a/scripts/releaser/v2/git.go +++ /dev/null @@ -1,20 +0,0 @@ -package v2 - -// gitOutput runs a read-only git command and returns trimmed stdout. -func gitOutput(exec CommandExecutor, args ...string) (string, error) { - return exec.RunOutput("git", args...) -} - -// gitRun runs a read-only git command, discarding stdout/stderr. -// Use this for commands where only the exit code matters (e.g. -// merge-base --is-ancestor). -func gitRun(exec CommandExecutor, args ...string) error { - return exec.Run("git", args...) -} - -// gitMutate runs a git command that modifies remote state (e.g. -// push, tag). In dry-run mode the command is printed instead of -// executed. -func gitMutate(exec CommandExecutor, args ...string) error { - return exec.RunMutation("git", args...) -} diff --git a/scripts/releaser/v2/github.go b/scripts/releaser/v2/github.go deleted file mode 100644 index 76e4fefaf6..0000000000 --- a/scripts/releaser/v2/github.go +++ /dev/null @@ -1,109 +0,0 @@ -package v2 - -import ( - "encoding/json" - "fmt" - "os" - "strings" - - "golang.org/x/xerrors" -) - -// ghOutput runs a gh CLI command and returns trimmed stdout. -func ghOutput(exec CommandExecutor, args ...string) (string, error) { - return exec.RunOutput("gh", args...) -} - -// 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(exec CommandExecutor, prNumbers []int) pullRequestMap { - m := make(pullRequestMap) - - for _, prNum := range prNumbers { - out, err := ghOutput(exec, "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(exec CommandExecutor, branch string) error { - out, err := ghOutput(exec, "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/releaser/v2/notes.go b/scripts/releaser/v2/notes.go deleted file mode 100644 index 44037eead8..0000000000 --- a/scripts/releaser/v2/notes.go +++ /dev/null @@ -1,160 +0,0 @@ -package v2 - -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(exec CommandExecutor, 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(exec, "rev-parse", "--verify", newTag); err != nil { - commitRange = fmt.Sprintf("%s..HEAD", previousVersion.String()) - } - - commits, err := commitLog(exec, commitRange) - if err != nil { - return "", xerrors.Errorf("commit log: %w", err) - } - - // Extract PR numbers from commit titles and fetch metadata. - prMeta := ghBuildPullRequestMap(exec, 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/releaser/v2/prepare.go b/scripts/releaser/v2/prepare.go deleted file mode 100644 index aa23fa6a11..0000000000 --- a/scripts/releaser/v2/prepare.go +++ /dev/null @@ -1,94 +0,0 @@ -package v2 - -import ( - "fmt" - "os" - "strings" - - "golang.org/x/xerrors" -) - -// prepareRelease computes the next release version, then creates and -// pushes the annotated tag and (optionally) the release branch. -// It emits the same JSON as calculateNextVersion so the workflow -// can consume it identically. -func prepareRelease(exec CommandExecutor, releaseType, ref, commitSHA string) (calculateResult, error) { - result, err := calculateNextVersion(exec, releaseType, ref, commitSHA) - if err != nil { - return nil, err - } - - switch v := result.(type) { - case CreateBranchRequest: - if err := createAndPushTag(exec, v.Version, v.TargetRef); err != nil { - return nil, err - } - if err := createAndPushBranch(exec, v.BranchName, v.TargetRef); err != nil { - return nil, err - } - case ReleaseRequest: - if err := createAndPushTag(exec, v.Version, v.TargetRef); err != nil { - return nil, err - } - default: - return nil, xerrors.Errorf("unexpected result type %T", result) - } - - return result, nil -} - -// createAndPushTag creates an annotated tag at targetRef and pushes -// it. If the tag already exists at the correct commit, it is a -// no-op. If it exists at a different commit, it returns an error. -func createAndPushTag(exec CommandExecutor, versionTag, targetRef string) error { - // Check if the tag already exists locally. Dereference the tag - // object to the underlying commit with ^{}. - existing, err := gitOutput(exec, "rev-parse", "--verify", fmt.Sprintf("refs/tags/%s^{}", versionTag)) - if err == nil { - if existing == targetRef { - _, _ = fmt.Fprintf(os.Stderr, "tag %s already exists at %s, skipping\n", versionTag, targetRef) - return nil - } - return xerrors.Errorf("tag %s already exists at %s, expected %s", versionTag, existing, targetRef) - } - - // Create annotated tag. - if err := gitMutate(exec, "tag", "-a", versionTag, "-m", fmt.Sprintf("Release %s", versionTag), targetRef); err != nil { - return xerrors.Errorf("create tag %s: %w", versionTag, err) - } - - // Push tag using explicit refspec. - refspec := fmt.Sprintf("refs/tags/%s:refs/tags/%s", versionTag, versionTag) - if err := gitMutate(exec, "push", "origin", refspec); err != nil { - return xerrors.Errorf("push tag %s: %w", versionTag, err) - } - - return nil -} - -// createAndPushBranch creates a branch at targetRef and pushes it. -// If the branch already exists at the correct commit on the remote, -// it is a no-op. If it exists at a different commit, it returns an -// error. -func createAndPushBranch(exec CommandExecutor, branchName, targetRef string) error { - // Check if the branch already exists on the remote. - existing, err := gitOutput(exec, "ls-remote", "--exit-code", "origin", fmt.Sprintf("refs/heads/%s", branchName)) - if err == nil && existing != "" { - // ls-remote output format: "\trefs/heads/" - remoteSHA, _, _ := strings.Cut(existing, "\t") - if remoteSHA == targetRef { - _, _ = fmt.Fprintf(os.Stderr, "branch %s already exists at %s, skipping\n", branchName, targetRef) - return nil - } - return xerrors.Errorf("branch %s already exists at %s, expected %s", branchName, remoteSHA, targetRef) - } - - // Push the commit directly to create the remote branch, without - // needing a local branch. - refspec := fmt.Sprintf("%s:refs/heads/%s", targetRef, branchName) - if err := gitMutate(exec, "push", "origin", refspec); err != nil { - return xerrors.Errorf("push branch %s: %w", branchName, err) - } - - return nil -} diff --git a/scripts/releaser/v2/prepare_test.go b/scripts/releaser/v2/prepare_test.go deleted file mode 100644 index c57c111720..0000000000 --- a/scripts/releaser/v2/prepare_test.go +++ /dev/null @@ -1,187 +0,0 @@ -package v2 //nolint:testpackage // Tests unexported release helpers. - -import ( - "bytes" - "io" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// mockExecutor records mutating calls and delegates read-only calls -// to configurable functions. -type mockExecutor struct { - // MutationCalls records all calls to RunMutation and - // RunMutationStdout as "name arg1 arg2 ..." strings. - MutationCalls []string - - // RunOutputFunc is called for RunOutput. If nil, returns ("", error). - RunOutputFunc func(name string, args ...string) (string, error) - - // RunFunc is called for Run. If nil, returns nil. - RunFunc func(name string, args ...string) error -} - -func (m *mockExecutor) RunOutput(name string, args ...string) (string, error) { - if m.RunOutputFunc != nil { - return m.RunOutputFunc(name, args...) - } - return "", nil -} - -func (m *mockExecutor) Run(name string, args ...string) error { - if m.RunFunc != nil { - return m.RunFunc(name, args...) - } - return nil -} - -func (m *mockExecutor) RunMutation(name string, args ...string) error { - call := name - for _, a := range args { - call += " " + a - } - m.MutationCalls = append(m.MutationCalls, call) - return nil -} - -func (m *mockExecutor) RunMutationStdout(_, _ io.Writer, name string, args ...string) error { - call := name - for _, a := range args { - call += " " + a - } - m.MutationCalls = append(m.MutationCalls, call) - return nil -} - -func TestCreateAndPushTag_New(t *testing.T) { - t.Parallel() - - mock := &mockExecutor{ - RunOutputFunc: func(name string, args ...string) (string, error) { - // Simulate tag not existing: git rev-parse --verify fails. - if len(args) >= 2 && args[0] == "rev-parse" && args[1] == "--verify" { - return "", assert.AnError - } - return "", nil - }, - } - - err := createAndPushTag(mock, "v2.21.0", "abc123") - require.NoError(t, err) - - require.Len(t, mock.MutationCalls, 2) - assert.Contains(t, mock.MutationCalls[0], "git tag -a v2.21.0 -m Release v2.21.0 abc123") - assert.Contains(t, mock.MutationCalls[1], "git push origin refs/tags/v2.21.0:refs/tags/v2.21.0") -} - -func TestCreateAndPushTag_AlreadyExistsMatching(t *testing.T) { - t.Parallel() - - mock := &mockExecutor{ - RunOutputFunc: func(name string, args ...string) (string, error) { - // Simulate tag existing at the correct commit. - if len(args) >= 2 && args[0] == "rev-parse" && args[1] == "--verify" { - return "abc123", nil - } - return "", nil - }, - } - - err := createAndPushTag(mock, "v2.21.0", "abc123") - require.NoError(t, err) - - // No mutations should happen. - assert.Empty(t, mock.MutationCalls) -} - -func TestCreateAndPushTag_AlreadyExistsMismatch(t *testing.T) { - t.Parallel() - - mock := &mockExecutor{ - RunOutputFunc: func(name string, args ...string) (string, error) { - if len(args) >= 2 && args[0] == "rev-parse" && args[1] == "--verify" { - return "different_sha", nil - } - return "", nil - }, - } - - err := createAndPushTag(mock, "v2.21.0", "abc123") - require.Error(t, err) - assert.Contains(t, err.Error(), "already exists at different_sha") - assert.Empty(t, mock.MutationCalls) -} - -func TestCreateAndPushBranch_New(t *testing.T) { - t.Parallel() - - mock := &mockExecutor{ - RunOutputFunc: func(name string, args ...string) (string, error) { - // Simulate branch not existing: ls-remote fails. - if len(args) >= 1 && args[0] == "ls-remote" { - return "", assert.AnError - } - return "", nil - }, - } - - err := createAndPushBranch(mock, "release/2.21", "abc123") - require.NoError(t, err) - - require.Len(t, mock.MutationCalls, 1) - assert.Contains(t, mock.MutationCalls[0], "git push origin abc123:refs/heads/release/2.21") -} - -func TestCreateAndPushBranch_AlreadyExistsMatching(t *testing.T) { - t.Parallel() - - mock := &mockExecutor{ - RunOutputFunc: func(name string, args ...string) (string, error) { - if len(args) >= 1 && args[0] == "ls-remote" { - return "abc123\trefs/heads/release/2.21", nil - } - return "", nil - }, - } - - err := createAndPushBranch(mock, "release/2.21", "abc123") - require.NoError(t, err) - assert.Empty(t, mock.MutationCalls) -} - -func TestCreateAndPushBranch_AlreadyExistsMismatch(t *testing.T) { - t.Parallel() - - mock := &mockExecutor{ - RunOutputFunc: func(name string, args ...string) (string, error) { - if len(args) >= 1 && args[0] == "ls-remote" { - return "other_sha\trefs/heads/release/2.21", nil - } - return "", nil - }, - } - - err := createAndPushBranch(mock, "release/2.21", "abc123") - require.Error(t, err) - assert.Contains(t, err.Error(), "already exists at other_sha") - assert.Empty(t, mock.MutationCalls) -} - -func TestDryRunExecutor_SkipsMutations(t *testing.T) { - t.Parallel() - var buf bytes.Buffer - exec := newDryRunExecutor(&buf) - - // RunMutation should print, not execute. - err := exec.RunMutation("git", "tag", "-a", "v2.21.0", "-m", "Release v2.21.0", "abc123") - require.NoError(t, err) - assert.Contains(t, buf.String(), "[dry-run] would run: git tag -a v2.21.0") - - buf.Reset() - - err = exec.RunMutation("git", "push", "origin", "refs/tags/v2.21.0:refs/tags/v2.21.0") - require.NoError(t, err) - assert.Contains(t, buf.String(), "[dry-run] would run: git push origin refs/tags/v2.21.0:refs/tags/v2.21.0") -} diff --git a/scripts/releaser/v2/publish.go b/scripts/releaser/v2/publish.go deleted file mode 100644 index 9323f49eb2..0000000000 --- a/scripts/releaser/v2/publish.go +++ /dev/null @@ -1,148 +0,0 @@ -package v2 - -import ( - "crypto/sha256" - "encoding/hex" - "fmt" - "io" - "os" - "path/filepath" - "strings" - - "golang.org/x/xerrors" -) - -// publishRelease creates a GitHub release with the given assets -// and generates checksums. -func publishRelease(exec CommandExecutor, 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(exec, "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(exec, "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())) - } - - if err := exec.RunMutationStdout(os.Stdout, os.Stderr, "gh", ghArgs...); 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/releaser/v2/version.go b/scripts/releaser/v2/version.go deleted file mode 100644 index 506e7c792a..0000000000 --- a/scripts/releaser/v2/version.go +++ /dev/null @@ -1,71 +0,0 @@ -package v2 - -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/releaser/v2/version_test.go b/scripts/releaser/v2/version_test.go deleted file mode 100644 index 162e9a8dee..0000000000 --- a/scripts/releaser/v2/version_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package v2 //nolint:testpackage // Tests unexported release helpers. - -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()) -} diff --git a/scripts/releaser/v1/version.go b/scripts/releaser/version.go similarity index 99% rename from scripts/releaser/v1/version.go rename to scripts/releaser/version.go index 6ada481da9..f1e8107190 100644 --- a/scripts/releaser/v1/version.go +++ b/scripts/releaser/version.go @@ -1,4 +1,4 @@ -package v1 +package main import ( "fmt" diff --git a/scripts/releaser/v1/version_test.go b/scripts/releaser/version_test.go similarity index 98% rename from scripts/releaser/v1/version_test.go rename to scripts/releaser/version_test.go index ab8abe2815..301ef88463 100644 --- a/scripts/releaser/v1/version_test.go +++ b/scripts/releaser/version_test.go @@ -1,4 +1,4 @@ -package v1 //nolint:testpackage // Tests unexported release helpers. +package main //nolint:testpackage // Tests unexported release helpers. import ( "testing"