diff --git a/.buildkite/pipeline.version-bump.yaml b/.buildkite/pipeline.version-bump.yaml index 32ff2a2204..6c61114e5c 100644 --- a/.buildkite/pipeline.version-bump.yaml +++ b/.buildkite/pipeline.version-bump.yaml @@ -19,8 +19,46 @@ notify: ${BUILDKITE_BUILD_URL} if: build.state == "blocked" +env: + IMAGE_CI_WITH_HOOKS_LATEST: "docker.elastic.co/ci-agent-images/platform-ingest/buildkite-agent-beats-ci-with-hooks:latest" + steps: - # TODO: replace this block step by real version bump logic + # BRANCH is major.minor (e.g. 9.4 for a 9.4.0 minor release, 9.3 for a 9.3.3 patch). + - label: "Bump version minor" + key: bump-version-minor + if: build.env("WORKFLOW") == "minor" + agents: + image: "${IMAGE_CI_WITH_HOOKS_LATEST}" + cpu: "4000m" + memory: "8Gi" + useCustomGlobalHooks: true + env: + CURRENT_RELEASE: "${NEW_VERSION}" + plugins: + - elastic/vault-github-token#v0.2.1: ~ + command: + - make mage + - mage release:runMajorMinor + timeout_in_minutes: 60 + + - label: "Bump version patch" + key: bump-version-patch + if: build.env("WORKFLOW") == "patch" + agents: + image: "${IMAGE_CI_WITH_HOOKS_LATEST}" + cpu: "4000m" + memory: "8Gi" + useCustomGlobalHooks: true + env: + CURRENT_RELEASE: "${NEW_VERSION}" + BASE_BRANCH: "${BRANCH}" + plugins: + - elastic/vault-github-token#v0.2.1: ~ + command: + - make mage + - mage release:runPatch + timeout_in_minutes: 60 + - block: "Ready to fetch for DRA artifacts?" prompt: | Unblock when your team is ready to proceed. diff --git a/.golangci.yml b/.golangci.yml index 104cf923ce..ce610eb8be 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -91,8 +91,8 @@ linters: min-occurrences: 2 gomoddirectives: - # Allow local `replace` directives. Default is false. - replace-local: false + # Allow local `replace` directives (e.g. dev-tools submodule in go.mod). + replace-local: true gomodguard: blocked: # List of blocked modules. diff --git a/Dockerfile b/Dockerfile index 04ef6931f8..ea683ba431 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,7 @@ WORKDIR /fleet-server # pre-copy/cache go.mod for pre-downloading dependencies and only redownloading them in subsequent builds if they change COPY go.mod go.sum ./ +COPY dev-tools/go.mod dev-tools/go.sum ./dev-tools/ RUN go mod download && go mod verify RUN go install github.com/magefile/mage # Uses version from go.mod implicitly ENV PATH="$PATH:/go/bin" diff --git a/Dockerfile.build b/Dockerfile.build index 4cb027053d..00ac2a8935 100644 --- a/Dockerfile.build +++ b/Dockerfile.build @@ -13,6 +13,7 @@ USER fleet-server WORKDIR /fleet-server/ # pre-copy/cache go.mod for pre-downloading dependencies and only redownloading them in subsequent builds if they change COPY go.mod go.sum ./ +COPY dev-tools/go.mod dev-tools/go.sum ./dev-tools/ RUN go mod download && go mod verify RUN go install github.com/magefile/mage # uses version in go.mod diff --git a/Dockerfile.fips b/Dockerfile.fips index 8adae79231..a773061d6e 100644 --- a/Dockerfile.fips +++ b/Dockerfile.fips @@ -10,6 +10,7 @@ WORKDIR /fleet-server/ # pre-copy/cache go.mod for pre-downloading dependencies and only redownloading them in subsequent builds if they change COPY go.mod go.sum ./ +COPY dev-tools/go.mod dev-tools/go.sum ./dev-tools/ # GOFIPS140 must be set before go mod download so that the golang.org/fips140 module is cached during image build. # Without this, the module would be fetched at build time by the host user who lacks write access to the module cache. ENV GOFIPS140=certified @@ -32,6 +33,7 @@ FROM docker.elastic.co/beats-dev/golang-crossbuild:${GO_VERSION}-${SUFFIX} AS bu WORKDIR /fleet-server ENV PATH="$PATH:/go/bin" COPY go.mod go.sum ./ +COPY dev-tools/go.mod dev-tools/go.sum ./dev-tools/ RUN go mod download && go mod verify RUN go install github.com/magefile/mage # uses version in go.mod diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000000..6cde026d4d --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,101 @@ +# Fleet Server Release Automation + +Mage-based release workflows for Fleet Server, replacing the former ingest-dev +[`fleet-server.mak`](https://github.com/elastic/ingest-dev/blob/main/release_scripts/fleet-server.mak) +Makefile process. Process shape matches beats and elastic-agent (feature-freeze +and patch merge-timing labels); file updates stay limited to `version/version.go` +and `.mergify.yml`. + +## Quick start + +```bash +export PROJECT_OWNER="your-user" +export CURRENT_RELEASE="9.6.0" # must already match version/version.go on main +export GITHUB_TOKEN=$(gh auth token) +export DRY_RUN=true + +# Feature freeze +mage release:runMajorMinor + +# Patch release on an existing release branch +export CURRENT_RELEASE="9.6.1" # must already match version on the release branch +mage release:runPatch + +go test ./dev-tools/mage/release/... -count=1 +``` + +Use plain `X.Y.Z` semver for `CURRENT_RELEASE` (no `-test` or `-SNAPSHOT` suffixes). + +## Feature freeze (`mage release:runMajorMinor`) + +`CURRENT_RELEASE` is the version **already on** `main` (the line being frozen). + +| Slot | Branch → base | Changes | Merge label | +|---|---|---|---| +| Release branch | `X.Y` from `main` | pushed | — | +| **PR-A** | `ff-prep-main-{CURRENT}` → `main` | Mergify backport + bump to next minor | `merge:1-ff-day` | +| **PR-B** | `ff-release-{CURRENT}` → `X.Y` | ensure `version.go` = CURRENT (often no-op) | `merge:2-after-branch` | +| **PR-C** | — | **omitted** (no docs/test-env) | — | +| **PR-D** | `ff-prep-next-patch-{NEXT}` → `X.Y` | bump to next patch | `merge:4-after-release` | + +## Patch (`mage release:runPatch`) + +`CURRENT_RELEASE` is the version **already on** the release branch. + +| Slot | Branch → base | Changes | Merge label | +|---|---|---|---| +| **PR-A** | `patch-release-{CURRENT}` → `X.Y` | ensure version (often skipped; no docs) | `merge:1-before-build` | +| **PR-B** | `ff-prep-next-patch-{NEXT}` → `X.Y` | bump to next patch | `merge:4-after-release` | + +## Environment variables + +| Variable | Required | Default | Description | +|---|---|---|---| +| `CURRENT_RELEASE` | yes | — | Version already on base/release branch | +| `GITHUB_TOKEN` | yes (unless dry run) | — | GitHub API token | +| `DRY_RUN` | no | `false` | Only `true` enables dry run | +| `BASE_BRANCH` | no | `main` | Base branch for feature freeze | +| `PROJECT_OWNER` | no | `elastic` | GitHub owner | +| `PROJECT_REPO` | no | `fleet-server` | GitHub repository | +| `PROJECT_REVIEWERS` | no | `elastic/elastic-agent-control-plane` | PR reviewers | + +Derived (not env-overridable): `NEXT_RELEASE` (patch+1), `NEXT_PROJECT_MINOR_VERSION` (minor+1), `RELEASE_BRANCH` (`X.Y`), `LATEST_RELEASE` (patch−1, or GitHub Releases lookup with git-tag fallback when releases are empty). + +## Files updated + +- `version/version.go` — `DefaultVersion` +- `.mergify.yml` — backport rule (PR-A) + +## Idempotency + +| Step | Re-run behavior | +|---|---| +| `UpdateVersion` | No-op when version already matches | +| `UpdateMergify` | No-op when backport rule already exists | +| Branch creation | Reuses existing branch | +| `CommitAll` | Skips when worktree is clean | +| `CreatePR` | Returns existing open PR for same head/base | + +## Package layout + +``` +dev-tools/mage/release/ +├── cmd/fleet-release/ # nested-module CLI +├── go.mod / go.sum # isolates go-git / go-github +├── config.go +├── release.go +├── mergify.go +├── workflows.go +├── git.go / github.go / issue.go / version.go +└── README.md +``` + +Root `mage release:*` targets invoke `go run -C dev-tools/mage/release ./cmd/fleet-release …`. + +## Testing + +```bash +cd dev-tools/mage/release && go test ./... -count=1 +``` + +Discard local workflow changes after review with `git reset --hard HEAD`. diff --git a/dev-tools/go.mod b/dev-tools/go.mod index c475b0fdae..4ec43c2701 100644 --- a/dev-tools/go.mod +++ b/dev-tools/go.mod @@ -29,8 +29,9 @@ require ( github.com/oapi-codegen/oapi-codegen/v2 v2.5.0 // indirect github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect + github.com/onsi/gomega v1.27.10 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect - github.com/sergi/go-diff v1.1.0 // indirect + github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/speakeasy-api/jsonpath v0.6.0 // indirect github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect @@ -38,7 +39,7 @@ require ( golang.org/x/mod v0.24.0 // indirect golang.org/x/perf v0.0.0-20250305200902-02a15fd477ba // indirect golang.org/x/sync v0.14.0 // indirect - golang.org/x/text v0.23.0 // indirect + golang.org/x/text v0.25.0 // indirect golang.org/x/tools v0.33.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/dev-tools/go.sum b/dev-tools/go.sum index 8355f27cc1..c12f8d7ce1 100644 --- a/dev-tools/go.sum +++ b/dev-tools/go.sum @@ -82,8 +82,9 @@ github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1Cpa github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= -github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw= github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= +github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= +github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -91,8 +92,9 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= -github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/speakeasy-api/jsonpath v0.6.0 h1:IhtFOV9EbXplhyRqsVhHoBmmYjblIRh5D1/g8DHMXJ8= github.com/speakeasy-api/jsonpath v0.6.0/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw= github.com/speakeasy-api/openapi-overlay v0.10.2 h1:VOdQ03eGKeiHnpb1boZCGm7x8Haj6gST0P3SGTX95GU= @@ -154,8 +156,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= diff --git a/dev-tools/mage/release/README.md b/dev-tools/mage/release/README.md new file mode 100644 index 0000000000..b4976d37ae --- /dev/null +++ b/dev-tools/mage/release/README.md @@ -0,0 +1,41 @@ +# Fleet Server release package + +Operator reference for `dev-tools/mage/release/`. Release managers should start with [`RELEASE.md`](../../../RELEASE.md) at the repository root. + +## Mage targets + +| Target | CLI command | Purpose | +|---|---|---| +| `mage release:runMajorMinor` | `run-major-minor` | Feature freeze: release branch + PR-A/B/D | +| `mage release:runPatch` | `run-patch` | Patch: PR-A (often skipped) + next-patch PR-B | +| `mage release:updateVersion` | `update-version` | Update `version/version.go` only | +| `mage release:updateMergify` | `update-mergify` | Append backport rule | +| `mage release:ensureIssueTracker` | `ensure-issue-tracker` | Create/update release checklist issue | + +There is **no** `runNextRelease` target; next-patch prep is PR-D inside `runMajorMinor` / PR-B inside `runPatch`. + +## Architecture + +- Nested Go module keeps `go-git` / `go-github` out of the root `go.mod` / `NOTICE.txt`. +- Root mage wrappers call `go run -C dev-tools/mage/release ./cmd/fleet-release …` with `FLEET_SERVER_REPO_ROOT` set. +- File allowlist: only `version/version.go` and `.mergify.yml`. + +## Alignment with beats / elastic-agent + +Shared process: `CURRENT_RELEASE` must match `version.go`, merge-timing labels, idempotent branches/PRs, issue tracker, nested module. + +Fleet Server differences (from former `fleet-server.mak`): + +- No K8s / Helm / docs / test-env updates → **PR-C omitted** +- Patch “docs” PR has no doc files → often skipped when version already matches + +## Local testing + +```bash +export PROJECT_OWNER="$USER" +export CURRENT_RELEASE="$(grep DefaultVersion version/version.go | sed -E 's/.*"([^"]+)".*/\1/')" +export DRY_RUN=true +export GITHUB_TOKEN="$(gh auth token)" + +mage release:runMajorMinor +``` diff --git a/dev-tools/mage/release/cmd/fleet-release/main.go b/dev-tools/mage/release/cmd/fleet-release/main.go new file mode 100644 index 0000000000..9f2a4b3928 --- /dev/null +++ b/dev-tools/mage/release/cmd/fleet-release/main.go @@ -0,0 +1,134 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. + +// Command fleet-release runs Fleet Server release automation from a nested Go +// module so tooling dependencies stay out of the root module. +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/elastic/fleet-server/dev-tools/mage/release" +) + +func main() { + if err := run(os.Args[1:]); err != nil { + fmt.Fprintf(os.Stderr, "fleet-release: %v\n", err) + os.Exit(1) + } +} + +func run(args []string) error { + if err := chdirFleetRoot(); err != nil { + return err + } + + if len(args) < 1 { + return fmt.Errorf("usage: fleet-release [args...]\n\n%s", usage()) + } + + cmd := args[0] + rest := args[1:] + + switch cmd { + case "update-version": + if len(rest) != 1 { + return fmt.Errorf("usage: fleet-release update-version ") + } + return release.UpdateVersion(rest[0]) + case "update-mergify": + if len(rest) != 1 { + return fmt.Errorf("usage: fleet-release update-mergify ") + } + return release.UpdateMergify(rest[0]) + case "run-major-minor": + if len(rest) != 0 { + return fmt.Errorf("usage: fleet-release run-major-minor") + } + cfg, err := release.LoadConfigFromEnv() + if err != nil { + return err + } + return release.RunMajorMinorRelease(cfg) + case "run-patch": + if len(rest) != 0 { + return fmt.Errorf("usage: fleet-release run-patch") + } + cfg, err := release.LoadConfigFromEnv() + if err != nil { + return err + } + return release.RunPatchRelease(cfg) + case "ensure-issue-tracker": + if len(rest) != 0 { + return fmt.Errorf("usage: fleet-release ensure-issue-tracker") + } + cfg, err := release.LoadConfigFromEnv() + if err != nil { + return err + } + return release.EnsureReleaseIssueTracker(cfg, nil) + case "help", "-h", "--help": + fmt.Print(usage()) + return nil + default: + return fmt.Errorf("unknown command %q\n\n%s", cmd, usage()) + } +} + +func usage() string { + return strings.TrimSpace(` +Commands: + update-version + update-mergify + run-major-minor + run-patch + ensure-issue-tracker + +Environment for run-major-minor / run-patch / ensure-issue-tracker: see RELEASE.md and +dev-tools/mage/release/README.md (CURRENT_RELEASE, DRY_RUN, GITHUB_TOKEN, …). +`) + "\n" +} + +// chdirFleetRoot finds the Fleet Server repository root and makes it the working +// directory. go run -C leaves cwd in the nested module, but release workflows +// expect to run from the repo root (OpenRepo("."), relative paths). +func chdirFleetRoot() error { + if root := os.Getenv("FLEET_SERVER_REPO_ROOT"); root != "" { + return os.Chdir(root) + } + + start, err := os.Getwd() + if err != nil { + return fmt.Errorf("get working directory: %w", err) + } + + dir := start + for { + modPath := filepath.Join(dir, "go.mod") + data, err := os.ReadFile(modPath) + if err == nil && isFleetRootModule(string(data)) { + return os.Chdir(dir) + } + + parent := filepath.Dir(dir) + if parent == dir { + return fmt.Errorf("fleet-server repository root not found from %s (set FLEET_SERVER_REPO_ROOT)", start) + } + dir = parent + } +} + +func isFleetRootModule(goMod string) bool { + for _, line := range strings.Split(goMod, "\n") { + line = strings.TrimSpace(line) + if line == "module github.com/elastic/fleet-server/v7" { + return true + } + } + return false +} diff --git a/dev-tools/mage/release/config.go b/dev-tools/mage/release/config.go new file mode 100644 index 0000000000..88e09d0612 --- /dev/null +++ b/dev-tools/mage/release/config.go @@ -0,0 +1,201 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. + +package release + +import ( + "fmt" + "os" + "strconv" + "strings" +) + +// ReleaseConfig holds the configuration for release operations. +type ReleaseConfig struct { + CurrentRelease string + LatestRelease string + NextRelease string + NextProjectMinorVersion string + NextProjectMinorBranch string + + BaseBranch string + ReleaseBranch string + + ProjectOwner string + ProjectRepo string + GitHubToken string + ProjectReviewers []string + + GitAuthorName string + GitAuthorEmail string + + DryRun bool +} + +// LoadConfigFromEnv loads release configuration from environment variables. +func LoadConfigFromEnv() (*ReleaseConfig, error) { + currentRelease := os.Getenv("CURRENT_RELEASE") + if currentRelease == "" { + return nil, fmt.Errorf("CURRENT_RELEASE environment variable is required") + } + + latestRelease, err := inferLatestRelease(currentRelease) + if err != nil { + return nil, fmt.Errorf("failed to infer LatestRelease: %w", err) + } + + nextRelease, err := inferNextRelease(currentRelease) + if err != nil { + return nil, fmt.Errorf("failed to infer NextRelease: %w", err) + } + + releaseBranch := inferReleaseBranch(currentRelease) + + nextProjectMinorVersion, err := inferNextProjectMinorVersion(currentRelease) + if err != nil { + return nil, fmt.Errorf("failed to infer NextProjectMinorVersion: %w", err) + } + nextProjectMinorBranch := inferNextProjectMinorBranch(currentRelease) + + cfg := &ReleaseConfig{ + CurrentRelease: currentRelease, + LatestRelease: latestRelease, + NextRelease: nextRelease, + NextProjectMinorVersion: nextProjectMinorVersion, + NextProjectMinorBranch: nextProjectMinorBranch, + BaseBranch: getEnvOrDefault("BASE_BRANCH", "main"), + ReleaseBranch: releaseBranch, + ProjectOwner: getEnvOrDefault("PROJECT_OWNER", "elastic"), + ProjectRepo: getEnvOrDefault("PROJECT_REPO", "fleet-server"), + GitHubToken: os.Getenv("GITHUB_TOKEN"), + GitAuthorName: getEnvOrDefault("GIT_AUTHOR_NAME", "elasticmachine"), + GitAuthorEmail: getEnvOrDefault("GIT_AUTHOR_EMAIL", "infra-root+elasticmachine@elastic.co"), + DryRun: getEnvOrDefault("DRY_RUN", "false") == "true", + } + + reviewers := getEnvOrDefault("PROJECT_REVIEWERS", "elastic/elastic-agent-control-plane") + cfg.ProjectReviewers = strings.Split(reviewers, ",") + + return cfg, nil +} + +// LoadReleaseConfigFromEnv is a deprecated alias for LoadConfigFromEnv. +func LoadReleaseConfigFromEnv() (*ReleaseConfig, error) { + return LoadConfigFromEnv() +} + +func getEnvOrDefault(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +// inferLatestRelease calculates the previous release version (patch - 1). +// For minor releases (patch == 0), returns empty string; callers may use EnsureLatestRelease. +func inferLatestRelease(currentRelease string) (string, error) { + parts := strings.Split(currentRelease, ".") + if len(parts) < 3 { + return "", fmt.Errorf("invalid version format: %s (expected major.minor.patch)", currentRelease) + } + + patch, err := strconv.Atoi(parts[2]) + if err != nil { + return "", fmt.Errorf("invalid patch version: %s", parts[2]) + } + + if patch == 0 { + return "", nil + } + + return fmt.Sprintf("%s.%s.%d", parts[0], parts[1], patch-1), nil +} + +func inferNextRelease(currentRelease string) (string, error) { + parts := strings.Split(currentRelease, ".") + if len(parts) < 3 { + return "", fmt.Errorf("invalid version format: %s (expected major.minor.patch)", currentRelease) + } + + patch, err := strconv.Atoi(parts[2]) + if err != nil { + return "", fmt.Errorf("invalid patch version: %s", parts[2]) + } + + return fmt.Sprintf("%s.%s.%d", parts[0], parts[1], patch+1), nil +} + +func inferReleaseBranch(currentRelease string) string { + parts := strings.Split(currentRelease, ".") + if len(parts) >= 2 { + return parts[0] + "." + parts[1] + } + return "" +} + +func inferNextProjectMinorVersion(currentRelease string) (string, error) { + parts := strings.Split(currentRelease, ".") + if len(parts) < 3 { + return "", fmt.Errorf("invalid version format: %s (expected major.minor.patch)", currentRelease) + } + + minor, err := strconv.Atoi(parts[1]) + if err != nil { + return "", fmt.Errorf("invalid minor version: %s", parts[1]) + } + + return fmt.Sprintf("%s.%d.0", parts[0], minor+1), nil +} + +func inferNextProjectMinorBranch(currentRelease string) string { + parts := strings.Split(currentRelease, ".") + if len(parts) < 2 { + return "" + } + + minor, err := strconv.Atoi(parts[1]) + if err != nil { + return "" + } + + return fmt.Sprintf("%s.%d", parts[0], minor+1) +} + +// fetchLatestReleaseBefore looks up the previous published release from GitHub. +// Tests may replace this to avoid network calls. +var fetchLatestReleaseBefore = func(token, owner, repo, current string) (string, error) { + return NewGitHubClient(token).LatestReleaseBefore(owner, repo, current) +} + +// EnsureLatestRelease sets LatestRelease when unset by querying elastic/fleet-server +// GitHub Releases, falling back to git tags when no releases are published. +func (c *ReleaseConfig) EnsureLatestRelease() error { + if c.LatestRelease != "" { + return nil + } + if c.CurrentRelease == "" { + return fmt.Errorf("CurrentRelease is required to resolve LatestRelease") + } + + latest, err := fetchLatestReleaseBefore(c.GitHubToken, releasesLookupOwner, releasesLookupRepo, c.CurrentRelease) + if err != nil { + return fmt.Errorf("failed to resolve LatestRelease from GitHub: %w", err) + } + c.LatestRelease = latest + fmt.Printf("Resolved LatestRelease from %s/%s: %s\n", releasesLookupOwner, releasesLookupRepo, latest) + return nil +} + +// Validate checks if the configuration is valid. +func (c *ReleaseConfig) Validate() error { + if c.CurrentRelease == "" { + return fmt.Errorf("CurrentRelease is required") + } + + if !c.DryRun && c.GitHubToken == "" { + return fmt.Errorf("GITHUB_TOKEN is required when not in dry-run mode") + } + + return nil +} diff --git a/dev-tools/mage/release/config_test.go b/dev-tools/mage/release/config_test.go new file mode 100644 index 0000000000..48f13f5c60 --- /dev/null +++ b/dev-tools/mage/release/config_test.go @@ -0,0 +1,148 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. + +package release + +import ( + "os" + "testing" +) + +func TestInferLatestRelease(t *testing.T) { + tests := []struct { + name string + current string + want string + wantErr bool + }{ + {name: "patch release", current: "9.6.2", want: "9.6.1"}, + {name: "x.y.0 returns empty", current: "9.6.0", want: ""}, + {name: "invalid version", current: "9.6", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := inferLatestRelease(tt.current) + if (err != nil) != tt.wantErr { + t.Fatalf("inferLatestRelease() error = %v, wantErr %v", err, tt.wantErr) + } + if got != tt.want { + t.Fatalf("inferLatestRelease() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestInferNextRelease(t *testing.T) { + got, err := inferNextRelease("9.6.0") + if err != nil { + t.Fatalf("inferNextRelease() failed: %v", err) + } + if got != "9.6.1" { + t.Fatalf("inferNextRelease() = %q, want 9.6.1", got) + } +} + +func TestInferNextProjectMinorVersion(t *testing.T) { + got, err := inferNextProjectMinorVersion("9.6.0") + if err != nil { + t.Fatalf("inferNextProjectMinorVersion() failed: %v", err) + } + if got != "9.7.0" { + t.Fatalf("inferNextProjectMinorVersion() = %q, want 9.7.0", got) + } +} + +func TestLoadConfigFromEnv(t *testing.T) { + tests := []struct { + name string + envVars map[string]string + wantErr bool + wantCheck func(*testing.T, *ReleaseConfig) + }{ + { + name: "defaults applied", + envVars: map[string]string{ + "CURRENT_RELEASE": "9.6.0", + }, + wantErr: false, + wantCheck: func(t *testing.T, cfg *ReleaseConfig) { + if cfg.ReleaseBranch != "9.6" { + t.Errorf("ReleaseBranch = %s, want 9.6", cfg.ReleaseBranch) + } + if cfg.NextRelease != "9.6.1" { + t.Errorf("NextRelease = %s, want 9.6.1", cfg.NextRelease) + } + if cfg.NextProjectMinorVersion != "9.7.0" { + t.Errorf("NextProjectMinorVersion = %s, want 9.7.0", cfg.NextProjectMinorVersion) + } + if cfg.ProjectOwner != "elastic" { + t.Errorf("ProjectOwner = %s, want elastic", cfg.ProjectOwner) + } + if cfg.ProjectRepo != "fleet-server" { + t.Errorf("ProjectRepo = %s, want fleet-server", cfg.ProjectRepo) + } + if cfg.DryRun { + t.Error("DryRun should be false by default") + } + }, + }, + { + name: "dry run only with literal true", + envVars: map[string]string{ + "CURRENT_RELEASE": "9.6.0", + "DRY_RUN": "true", + }, + wantCheck: func(t *testing.T, cfg *ReleaseConfig) { + if !cfg.DryRun { + t.Error("DryRun should be true") + } + }, + }, + { + name: "dry run rejects 1", + envVars: map[string]string{ + "CURRENT_RELEASE": "9.6.0", + "DRY_RUN": "1", + }, + wantCheck: func(t *testing.T, cfg *ReleaseConfig) { + if cfg.DryRun { + t.Error("DryRun should be false for DRY_RUN=1") + } + }, + }, + { + name: "missing CURRENT_RELEASE", + envVars: map[string]string{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + os.Clearenv() + for k, v := range tt.envVars { + t.Setenv(k, v) + } + + cfg, err := LoadConfigFromEnv() + if (err != nil) != tt.wantErr { + t.Fatalf("LoadConfigFromEnv() error = %v, wantErr %v", err, tt.wantErr) + } + if !tt.wantErr && tt.wantCheck != nil { + tt.wantCheck(t, cfg) + } + }) + } +} + +func TestEnsureLatestReleaseNoopWhenSet(t *testing.T) { + cfg := &ReleaseConfig{CurrentRelease: "9.6.1", LatestRelease: "9.6.0"} + if err := cfg.EnsureLatestRelease(); err != nil { + t.Fatalf("EnsureLatestRelease() unexpected error: %v", err) + } + if cfg.LatestRelease != "9.6.0" { + t.Fatalf("LatestRelease = %q, want 9.6.0", cfg.LatestRelease) + } +} diff --git a/dev-tools/mage/release/git.go b/dev-tools/mage/release/git.go new file mode 100644 index 0000000000..f68cf4e3ab --- /dev/null +++ b/dev-tools/mage/release/git.go @@ -0,0 +1,323 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. + +package release + +import ( + "errors" + "fmt" + "os" + "time" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/config" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/go-git/go-git/v5/plumbing/transport/http" +) + +// GitRepo wraps go-git Repository with helper methods. +type GitRepo struct { + repo *git.Repository + path string +} + +// OpenRepo opens a git repository at the specified path. +func OpenRepo(path string) (*GitRepo, error) { + repo, err := git.PlainOpen(path) + if err != nil { + return nil, fmt.Errorf("failed to open repository: %w", err) + } + + return &GitRepo{ + repo: repo, + path: path, + }, nil +} + +// BranchExists reports whether a local branch exists. +func (g *GitRepo) BranchExists(branchName string) (bool, error) { + _, err := g.repo.Reference(plumbing.NewBranchReferenceName(branchName), true) + if err == nil { + return true, nil + } + if errors.Is(err, plumbing.ErrReferenceNotFound) { + return false, nil + } + return false, fmt.Errorf("failed to check branch %s: %w", branchName, err) +} + +// CreateBranch creates a new branch from the current HEAD. +func (g *GitRepo) CreateBranch(branchName string) error { + exists, err := g.BranchExists(branchName) + if err != nil { + return err + } + if exists { + fmt.Printf("Branch already exists: %s\n", branchName) + return nil + } + + headRef, err := g.repo.Head() + if err != nil { + return fmt.Errorf("failed to get HEAD: %w", err) + } + + refName := plumbing.NewBranchReferenceName(branchName) + ref := plumbing.NewHashReference(refName, headRef.Hash()) + + err = g.repo.Storer.SetReference(ref) + if err != nil { + return fmt.Errorf("failed to create branch %s: %w", branchName, err) + } + + fmt.Printf("Created branch: %s\n", branchName) + return nil +} + +// EnsureBranchFrom checks out baseBranch and creates or checks out branchName from that point. +func (g *GitRepo) EnsureBranchFrom(baseBranch, branchName string) error { + if err := g.CheckoutBranch(baseBranch); err != nil { + return fmt.Errorf("failed to checkout base branch %s: %w", baseBranch, err) + } + + exists, err := g.BranchExists(branchName) + if err != nil { + return err + } + if exists { + return g.CheckoutBranch(branchName) + } + + if err := g.CreateBranch(branchName); err != nil { + return err + } + return g.CheckoutBranch(branchName) +} + +// EnsureBranch checks out an existing local or remote branch, or creates it from HEAD. +func (g *GitRepo) EnsureBranch(branchName string) error { + exists, err := g.BranchExists(branchName) + if err != nil { + return err + } + if exists { + return g.CheckoutBranch(branchName) + } + + remoteRef, err := g.repo.Reference(plumbing.NewRemoteReferenceName("origin", branchName), true) + if err == nil { + localRef := plumbing.NewHashReference(plumbing.NewBranchReferenceName(branchName), remoteRef.Hash()) + if err := g.repo.Storer.SetReference(localRef); err != nil { + return fmt.Errorf("failed to create local branch %s from origin: %w", branchName, err) + } + fmt.Printf("Created local branch from origin: %s\n", branchName) + return g.CheckoutBranch(branchName) + } + if !errors.Is(err, plumbing.ErrReferenceNotFound) { + return fmt.Errorf("failed to check remote branch %s: %w", branchName, err) + } + + if err := g.CreateBranch(branchName); err != nil { + return err + } + return g.CheckoutBranch(branchName) +} + +// CheckoutBranch checks out an existing branch. +func (g *GitRepo) CheckoutBranch(branchName string) error { + currentBranch, err := g.GetCurrentBranch() + if err == nil && currentBranch == branchName { + fmt.Printf("Already on branch: %s\n", branchName) + return nil + } + + w, err := g.repo.Worktree() + if err != nil { + return fmt.Errorf("failed to get worktree: %w", err) + } + + err = w.Checkout(&git.CheckoutOptions{ + Branch: plumbing.NewBranchReferenceName(branchName), + }) + if err != nil { + return fmt.Errorf("failed to checkout branch %s: %w", branchName, err) + } + + fmt.Printf("Checked out branch: %s\n", branchName) + return nil +} + +// CommitAll stages all changes and creates a commit. +func (g *GitRepo) CommitAll(message, authorName, authorEmail string) (bool, error) { + w, err := g.repo.Worktree() + if err != nil { + return false, fmt.Errorf("failed to get worktree: %w", err) + } + + status, err := w.Status() + if err != nil { + return false, fmt.Errorf("failed to get status: %w", err) + } + if status.IsClean() { + fmt.Println("No changes to commit") + return false, nil + } + + err = w.AddGlob(".") + if err != nil { + return false, fmt.Errorf("failed to stage changes: %w", err) + } + + commit, err := w.Commit(message, &git.CommitOptions{ + Author: &object.Signature{ + Name: authorName, + Email: authorEmail, + When: time.Now(), + }, + }) + if err != nil { + return false, fmt.Errorf("failed to commit: %w", err) + } + + fmt.Printf("Created commit: %s\n", commit.String()) + return true, nil +} + +// HasCommitsAheadOf reports whether HEAD has commits not reachable from baseBranch. +// Behind/equal: merge-base is HEAD → not ahead. Ahead/diverged: merge-base is not HEAD. +func (g *GitRepo) HasCommitsAheadOf(baseBranch string) (bool, error) { + headRef, err := g.repo.Head() + if err != nil { + return false, fmt.Errorf("failed to get HEAD: %w", err) + } + + baseRef, err := g.repo.Reference(plumbing.NewBranchReferenceName(baseBranch), true) + if err != nil { + if errors.Is(err, plumbing.ErrReferenceNotFound) { + return true, nil + } + return false, fmt.Errorf("failed to get base branch %s: %w", baseBranch, err) + } + + if headRef.Hash() == baseRef.Hash() { + return false, nil + } + + headCommit, err := g.repo.CommitObject(headRef.Hash()) + if err != nil { + return false, fmt.Errorf("failed to get HEAD commit: %w", err) + } + baseCommit, err := g.repo.CommitObject(baseRef.Hash()) + if err != nil { + return false, fmt.Errorf("failed to get base commit %s: %w", baseBranch, err) + } + + bases, err := headCommit.MergeBase(baseCommit) + if err != nil { + return false, fmt.Errorf("failed to compare commits with %s: %w", baseBranch, err) + } + if len(bases) == 0 { + return true, nil + } + for _, mb := range bases { + if mb.Hash == headRef.Hash() { + return false, nil + } + } + return true, nil +} + +// Push pushes the current branch to the remote. +func (g *GitRepo) Push(remoteName string) error { + token := os.Getenv("GITHUB_TOKEN") + if token == "" { + return fmt.Errorf("GITHUB_TOKEN environment variable is required for pushing") + } + + currentBranch, err := g.GetCurrentBranch() + if err != nil { + return err + } + + refSpec := config.RefSpec(fmt.Sprintf("refs/heads/%s:refs/heads/%s", currentBranch, currentBranch)) + + err = g.repo.Push(&git.PushOptions{ + RemoteName: remoteName, + RefSpecs: []config.RefSpec{refSpec}, + Auth: &http.BasicAuth{ + Username: "git", + Password: token, + }, + }) + if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) { + return fmt.Errorf("failed to push: %w", err) + } + + fmt.Printf("Pushed branch %s to remote: %s\n", currentBranch, remoteName) + return nil +} + +// GetCurrentBranch returns the name of the current branch. +func (g *GitRepo) GetCurrentBranch() (string, error) { + headRef, err := g.repo.Head() + if err != nil { + return "", fmt.Errorf("failed to get HEAD: %w", err) + } + + if !headRef.Name().IsBranch() { + return "", fmt.Errorf("HEAD is not a branch") + } + + return headRef.Name().Short(), nil +} + +// IsClean checks if the working directory is clean. +func (g *GitRepo) IsClean() (bool, error) { + w, err := g.repo.Worktree() + if err != nil { + return false, fmt.Errorf("failed to get worktree: %w", err) + } + + status, err := w.Status() + if err != nil { + return false, fmt.Errorf("failed to get status: %w", err) + } + + return status.IsClean(), nil +} + +// SetRemoteURL sets the URL for a remote. +func (g *GitRepo) SetRemoteURL(remoteName, url string) error { + _, err := g.repo.Remote(remoteName) + if errors.Is(err, git.ErrRemoteNotFound) { + _, err = g.repo.CreateRemote(&config.RemoteConfig{ + Name: remoteName, + URLs: []string{url}, + }) + if err != nil { + return fmt.Errorf("failed to create remote: %w", err) + } + fmt.Printf("Created remote %s: %s\n", remoteName, url) + return nil + } else if err != nil { + return fmt.Errorf("failed to get remote: %w", err) + } + + err = g.repo.DeleteRemote(remoteName) + if err != nil { + return fmt.Errorf("failed to delete remote: %w", err) + } + + _, err = g.repo.CreateRemote(&config.RemoteConfig{ + Name: remoteName, + URLs: []string{url}, + }) + if err != nil { + return fmt.Errorf("failed to recreate remote: %w", err) + } + + fmt.Printf("Updated remote %s: %s\n", remoteName, url) + return nil +} diff --git a/dev-tools/mage/release/git_test.go b/dev-tools/mage/release/git_test.go new file mode 100644 index 0000000000..a5f57bd0ec --- /dev/null +++ b/dev-tools/mage/release/git_test.go @@ -0,0 +1,149 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. + +package release + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/object" +) + +func createTestGitRepo(t *testing.T) (*GitRepo, string) { + t.Helper() + + tmpDir := t.TempDir() + + repo, err := git.PlainInit(tmpDir, false) + if err != nil { + t.Fatalf("failed to init repo: %v", err) + } + + w, err := repo.Worktree() + if err != nil { + t.Fatalf("failed to get worktree: %v", err) + } + + testFile := filepath.Join(tmpDir, "README.md") + err = os.WriteFile(testFile, []byte("# Test Repo"), 0o644) + if err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + _, err = w.Add("README.md") + if err != nil { + t.Fatalf("failed to add file: %v", err) + } + + _, err = w.Commit("Initial commit", &git.CommitOptions{ + Author: &object.Signature{ + Name: "Test User", + Email: "test@example.com", + }, + }) + if err != nil { + t.Fatalf("failed to create initial commit: %v", err) + } + + return &GitRepo{repo: repo, path: tmpDir}, tmpDir +} + +func TestEnsureBranchFrom(t *testing.T) { + gitRepo, tmpDir := createTestGitRepo(t) + + origDir, _ := os.Getwd() + defer func() { + _ = os.Chdir(origDir) + }() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to change to temp directory: %v", err) + } + + if err := gitRepo.EnsureBranchFrom("master", "9.6"); err != nil { + t.Fatalf("EnsureBranchFrom() failed: %v", err) + } + + branch, err := gitRepo.GetCurrentBranch() + if err != nil { + t.Fatalf("GetCurrentBranch() failed: %v", err) + } + if branch != "9.6" { + t.Fatalf("expected branch 9.6, got %s", branch) + } + + if err := gitRepo.EnsureBranchFrom("master", "9.6"); err != nil { + t.Fatalf("second EnsureBranchFrom() failed: %v", err) + } +} + +func TestCommitAllNoChanges(t *testing.T) { + gitRepo, _ := createTestGitRepo(t) + + committed, err := gitRepo.CommitAll("Empty commit", "Test Author", "test@example.com") + if err != nil { + t.Errorf("CommitAll() with no changes error = %v", err) + } + if committed { + t.Error("CommitAll() should not commit when there are no changes") + } +} + +func TestSetRemoteURLIdempotent(t *testing.T) { + gitRepo, _ := createTestGitRepo(t) + remoteURL := "https://github.com/test/repo.git" + + err := gitRepo.SetRemoteURL("origin", remoteURL) + if err != nil { + t.Fatalf("first SetRemoteURL() failed: %v", err) + } + + err = gitRepo.SetRemoteURL("origin", remoteURL) + if err != nil { + t.Fatalf("second SetRemoteURL() failed: %v", err) + } + + remote, err := gitRepo.repo.Remote("origin") + if err != nil { + t.Fatalf("failed to get remote: %v", err) + } + if len(remote.Config().URLs) == 0 || remote.Config().URLs[0] != remoteURL { + t.Errorf("SetRemoteURL() URL = %v, want %s", remote.Config().URLs, remoteURL) + } +} + +func TestWorkflowBranchNames(t *testing.T) { + names := []string{ + "ff-prep-main-9.6.0", + "ff-release-9.6.0", + "ff-prep-next-patch-9.6.1", + "patch-release-9.6.1", + } + seen := make(map[string]struct{}, len(names)) + for _, name := range names { + if _, ok := seen[name]; ok { + t.Fatalf("duplicate branch name: %s", name) + } + seen[name] = struct{}{} + if strings.TrimSpace(name) == "" { + t.Fatal("branch name empty") + } + } +} + +func TestIsReleaseWritablePath(t *testing.T) { + if !isReleaseWritablePath("version/version.go") { + t.Fatal("version/version.go should be writable") + } + if !isReleaseWritablePath("version\\version.go") && false { + // Windows path form is normalized via filepath.ToSlash in isReleaseWritablePath callers; + // direct backslash input is not required for allowlist. + } + if isReleaseWritablePath("main.go") { + t.Fatal("main.go should not be writable") + } +} diff --git a/dev-tools/mage/release/github.go b/dev-tools/mage/release/github.go new file mode 100644 index 0000000000..6633990b40 --- /dev/null +++ b/dev-tools/mage/release/github.go @@ -0,0 +1,509 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. + +package release + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "strings" + + "github.com/google/go-github/v68/github" + "golang.org/x/oauth2" +) + +// GitHubClient wraps the GitHub API client. +type GitHubClient struct { + client *github.Client + ctx context.Context +} + +// PROptions holds options for creating a pull request. +type PROptions struct { + Owner string + Repo string + Title string + Head string + Base string + Body string + Draft bool + Reviewers []string + Labels []string +} + +// NewGitHubClient creates a new GitHub API client. +// An empty token uses unauthenticated public API access (sufficient for reading public releases). +func NewGitHubClient(token string) *GitHubClient { + ctx := context.Background() + var httpClient *http.Client + if token != "" { + ts := oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: token}, + ) + httpClient = oauth2.NewClient(ctx, ts) + } + + return &GitHubClient{ + client: github.NewClient(httpClient), + ctx: ctx, + } +} + +// NewGitHubClientFromEnv creates a GitHub client using GITHUB_TOKEN env var. +func NewGitHubClientFromEnv() (*GitHubClient, error) { + token := os.Getenv("GITHUB_TOKEN") + if token == "" { + return nil, fmt.Errorf("GITHUB_TOKEN environment variable not set") + } + return NewGitHubClient(token), nil +} + +// CreatePR creates a pull request, or returns an existing open PR with the same head and base. +func (gh *GitHubClient) CreatePR(opts PROptions) (*github.PullRequest, error) { + existingPR, found, err := gh.FindOpenPR(opts.Owner, opts.Repo, opts.Head, opts.Base) + if err != nil { + return nil, err + } + if found { + fmt.Printf("Open PR already exists #%d: %s\n", existingPR.GetNumber(), existingPR.GetHTMLURL()) + gh.ensurePRLabels(opts.Owner, opts.Repo, existingPR.GetNumber(), opts.Labels) + return existingPR, nil + } + + newPR := &github.NewPullRequest{ + Title: github.Ptr(opts.Title), + Head: github.Ptr(opts.Head), + Base: github.Ptr(opts.Base), + Body: github.Ptr(opts.Body), + Draft: github.Ptr(opts.Draft), + } + + pr, _, err := gh.client.PullRequests.Create(gh.ctx, opts.Owner, opts.Repo, newPR) + if err != nil { + return nil, fmt.Errorf("failed to create PR: %w", err) + } + + if len(opts.Reviewers) > 0 { + reviewersReq := github.ReviewersRequest{ + Reviewers: opts.Reviewers, + } + _, _, err = gh.client.PullRequests.RequestReviewers(gh.ctx, opts.Owner, opts.Repo, pr.GetNumber(), reviewersReq) + if err != nil { + fmt.Printf("Warning: failed to add reviewers: %v\n", err) + } + } + + gh.ensurePRLabels(opts.Owner, opts.Repo, pr.GetNumber(), opts.Labels) + + fmt.Printf("Created PR #%d: %s\n", pr.GetNumber(), pr.GetHTMLURL()) + return pr, nil +} + +// FindOpenPR returns an open pull request for the given head and base branches, if one exists. +func (gh *GitHubClient) FindOpenPR(owner, repo, head, base string) (*github.PullRequest, bool, error) { + prs, err := gh.listPRsByHeadBase(owner, repo, head, base, "open") + if err != nil { + return nil, false, err + } + if len(prs) == 0 { + return nil, false, nil + } + return prs[0], true, nil +} + +// FindRelatedPR returns the best matching PR for head→base (open preferred, then +// merged, then closed). When none match by head/base, it falls back to an exact +// title match against the same base branch so already-merged release PRs still resolve. +func (gh *GitHubClient) FindRelatedPR(owner, repo, head, base, title string) (*github.PullRequest, bool, error) { + prs, err := gh.listPRsByHeadBase(owner, repo, head, base, "all") + if err != nil { + return nil, false, err + } + if pr := pickRelatedPR(prs); pr != nil { + return pr, true, nil + } + if title == "" { + return nil, false, nil + } + return gh.findPRByExactTitle(owner, repo, base, title) +} + +func (gh *GitHubClient) listPRsByHeadBase(owner, repo, head, base, state string) ([]*github.PullRequest, error) { + headQuery := head + if !strings.Contains(head, ":") { + headQuery = fmt.Sprintf("%s:%s", owner, head) + } + + prs, _, err := gh.client.PullRequests.List(gh.ctx, owner, repo, &github.PullRequestListOptions{ + State: state, + Head: headQuery, + Base: base, + ListOptions: github.ListOptions{ + PerPage: 30, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to list pull requests: %w", err) + } + return prs, nil +} + +func (gh *GitHubClient) findPRByExactTitle(owner, repo, base, title string) (*github.PullRequest, bool, error) { + query := fmt.Sprintf(`repo:%s/%s is:pr base:%s "%s" in:title`, owner, repo, base, title) + result, _, err := gh.client.Search.Issues(gh.ctx, query, &github.SearchOptions{ + Sort: "updated", + Order: "desc", + ListOptions: github.ListOptions{ + PerPage: 10, + }, + }) + if err != nil { + return nil, false, fmt.Errorf("failed to search pull requests by title: %w", err) + } + + var matches []*github.PullRequest + for _, issue := range result.Issues { + if !issue.IsPullRequest() { + continue + } + if issue.GetTitle() != title { + continue + } + pr, _, err := gh.client.PullRequests.Get(gh.ctx, owner, repo, issue.GetNumber()) + if err != nil { + return nil, false, fmt.Errorf("failed to get pull request #%d: %w", issue.GetNumber(), err) + } + matches = append(matches, pr) + } + if pr := pickRelatedPR(matches); pr != nil { + return pr, true, nil + } + return nil, false, nil +} + +// pickRelatedPR prefers open PRs, then merged, then other closed PRs. +func pickRelatedPR(prs []*github.PullRequest) *github.PullRequest { + var open, merged, closed *github.PullRequest + for _, pr := range prs { + if pr == nil { + continue + } + switch { + case pr.GetState() == "open": + if open == nil { + open = pr + } + case pr.GetMerged() || pr.MergedAt != nil: + if merged == nil { + merged = pr + } + default: + if closed == nil { + closed = pr + } + } + } + if open != nil { + return open + } + if merged != nil { + return merged + } + return closed +} + +// prDisplayState returns a short state label for workflow summaries. +func prDisplayState(pr *github.PullRequest) string { + if pr == nil { + return "unknown" + } + if pr.GetMerged() || pr.MergedAt != nil { + return "merged" + } + state := pr.GetState() + if state == "" { + return "unknown" + } + return state +} + +// mergeLabelDefs are auto-created when missing so merge-timing labels can be applied. +var mergeLabelDefs = map[string]struct { + Color string + Description string +}{ + mergeLabelFFDay: { + Color: "B60205", + Description: "Merge 1st: feature-freeze day (main)", + }, + mergeLabelAfterBranch: { + Color: "D93F0B", + Description: "Merge 2nd: ASAP after release branch exists", + }, + mergeLabelAfterImages: { + Color: "FBCA04", + Description: "Merge 3rd: after branch exists; may wait on Docker images", + }, + mergeLabelAfterRelease: { + Color: "0E8A16", + Description: "Merge 4th: after release day", + }, + mergeLabelBeforeBuild: { + Color: "B60205", + Description: "Merge before the final patch release build", + }, +} + +func (gh *GitHubClient) ensurePRLabels(owner, repo string, number int, labels []string) { + if len(labels) == 0 { + return + } + for _, label := range labels { + if def, ok := mergeLabelDefs[label]; ok { + if err := gh.EnsureLabel(owner, repo, label, def.Color, def.Description); err != nil { + fmt.Printf("Warning: failed to ensure label %q: %v\n", label, err) + } + } + } + if err := gh.AddLabels(owner, repo, number, labels); err != nil { + fmt.Printf("Warning: failed to add labels: %v\n", err) + } +} + +// EnsureLabel creates a repository label if it does not already exist. +func (gh *GitHubClient) EnsureLabel(owner, repo, name, color, description string) error { + _, resp, err := gh.client.Issues.GetLabel(gh.ctx, owner, repo, name) + if err == nil { + return nil + } + if resp == nil || resp.StatusCode != http.StatusNotFound { + return fmt.Errorf("failed to get label %q: %w", name, err) + } + + _, _, err = gh.client.Issues.CreateLabel(gh.ctx, owner, repo, &github.Label{ + Name: github.Ptr(name), + Color: github.Ptr(color), + Description: github.Ptr(description), + }) + if err != nil { + return fmt.Errorf("failed to create label %q: %w", name, err) + } + fmt.Printf("Created label %q\n", name) + return nil +} + +// AddLabels adds labels to a pull request or issue. +func (gh *GitHubClient) AddLabels(owner, repo string, number int, labels []string) error { + _, _, err := gh.client.Issues.AddLabelsToIssue(gh.ctx, owner, repo, number, labels) + if err != nil { + return fmt.Errorf("failed to add labels: %w", err) + } + + fmt.Printf("Added labels to #%d: %v\n", number, labels) + return nil +} + +// FindIssueByTitle finds an issue with an exact title match (open preferred over closed). +func (gh *GitHubClient) FindIssueByTitle(owner, repo, title string) (*github.Issue, bool, error) { + query := fmt.Sprintf(`repo:%s/%s is:issue in:title "%s"`, owner, repo, title) + result, _, err := gh.client.Search.Issues(gh.ctx, query, &github.SearchOptions{ + Sort: "updated", + Order: "desc", + ListOptions: github.ListOptions{ + PerPage: 20, + }, + }) + if err != nil { + return nil, false, fmt.Errorf("failed to search issues for title %q: %w", title, err) + } + + var closedMatch *github.Issue + for _, issue := range result.Issues { + if issue.IsPullRequest() { + continue + } + if issue.GetTitle() != title { + continue + } + if issue.GetState() == "open" { + return issue, true, nil + } + if closedMatch == nil { + closedMatch = issue + } + } + if closedMatch != nil { + return closedMatch, true, nil + } + return nil, false, nil +} + +// CreateIssue creates a repository issue with the given labels. +func (gh *GitHubClient) CreateIssue(owner, repo, title, body string, labels []string) (*github.Issue, error) { + req := &github.IssueRequest{ + Title: github.Ptr(title), + Body: github.Ptr(body), + } + if len(labels) > 0 { + req.Labels = &labels + } + issue, _, err := gh.client.Issues.Create(gh.ctx, owner, repo, req) + if err != nil { + return nil, fmt.Errorf("failed to create issue %q: %w", title, err) + } + return issue, nil +} + +// UpdateIssueBody replaces the body of an existing issue. +func (gh *GitHubClient) UpdateIssueBody(owner, repo string, number int, body string) error { + _, _, err := gh.client.Issues.Edit(gh.ctx, owner, repo, number, &github.IssueRequest{ + Body: github.Ptr(body), + }) + if err != nil { + return fmt.Errorf("failed to update issue #%d body: %w", number, err) + } + return nil +} + +// ListReleaseLabeledPRsForVersion returns Fleet Server PRs with label "release" whose title +// mentions the exact version. Open and recently updated closed/merged PRs are included. +func (gh *GitHubClient) ListReleaseLabeledPRsForVersion(owner, repo, version string) ([]*github.PullRequest, error) { + query := fmt.Sprintf(`repo:%s/%s is:pr label:release "%s"`, owner, repo, version) + opts := &github.SearchOptions{ + Sort: "updated", + Order: "desc", + ListOptions: github.ListOptions{ + PerPage: 100, + }, + } + + var prs []*github.PullRequest + seen := map[int]struct{}{} + for { + result, resp, err := gh.client.Search.Issues(gh.ctx, query, opts) + if err != nil { + return nil, fmt.Errorf("failed to search release PRs for %s: %w", version, err) + } + for _, issue := range result.Issues { + if !issue.IsPullRequest() { + continue + } + if !versionMentioned(issue.GetTitle(), version) { + continue + } + num := issue.GetNumber() + if _, ok := seen[num]; ok { + continue + } + seen[num] = struct{}{} + prs = append(prs, &github.PullRequest{ + Number: github.Ptr(num), + HTMLURL: github.Ptr(issue.GetHTMLURL()), + Title: github.Ptr(issue.GetTitle()), + State: github.Ptr(issue.GetState()), + }) + } + if resp == nil || resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + return prs, nil +} + +// GetDefaultBranch gets the default branch for a repository. +func (gh *GitHubClient) GetDefaultBranch(owner, repo string) (string, error) { + repository, _, err := gh.client.Repositories.Get(gh.ctx, owner, repo) + if err != nil { + return "", fmt.Errorf("failed to get repository: %w", err) + } + + return repository.GetDefaultBranch(), nil +} + +// BranchExists checks if a branch exists in the remote repository. +func (gh *GitHubClient) BranchExists(owner, repo, branch string) (bool, error) { + _, _, err := gh.client.Repositories.GetBranch(gh.ctx, owner, repo, branch, 0) + if err != nil { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response != nil && ghErr.Response.StatusCode == 404 { + return false, nil + } + return false, fmt.Errorf("failed to check branch: %w", err) + } + return true, nil +} + +// releasesLookupOwner and releasesLookupRepo are the canonical repo for published releases. +// Forks typically lack release history, so workflows always resolve LatestRelease from here. +const ( + releasesLookupOwner = "elastic" + releasesLookupRepo = "fleet-server" +) + +// LatestReleaseBefore returns the highest published release version with the same major +// that is strictly less than currentVersion (e.g. current 9.5.0 → 9.4.3). +// When the repo has no GitHub Releases, it falls back to git tags (fleet-server's usual case). +func (gh *GitHubClient) LatestReleaseBefore(owner, repo, currentVersion string) (string, error) { + versions, err := gh.listReleaseVersions(owner, repo) + if err != nil { + return "", err + } + if len(versions) == 0 { + versions, err = gh.listTagVersions(owner, repo) + if err != nil { + return "", err + } + } + return selectLatestReleaseBefore(versions, currentVersion) +} + +func (gh *GitHubClient) listReleaseVersions(owner, repo string) ([]string, error) { + var versions []string + opts := &github.ListOptions{PerPage: 100} + for { + releases, resp, err := gh.client.Repositories.ListReleases(gh.ctx, owner, repo, opts) + if err != nil { + return nil, fmt.Errorf("failed to list releases for %s/%s: %w", owner, repo, err) + } + for _, rel := range releases { + tag := rel.GetTagName() + if tag == "" { + continue + } + versions = append(versions, tag) + } + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + return versions, nil +} + +func (gh *GitHubClient) listTagVersions(owner, repo string) ([]string, error) { + var versions []string + opts := &github.ListOptions{PerPage: 100} + for { + tags, resp, err := gh.client.Repositories.ListTags(gh.ctx, owner, repo, opts) + if err != nil { + return nil, fmt.Errorf("failed to list tags for %s/%s: %w", owner, repo, err) + } + for _, tag := range tags { + name := tag.GetName() + if name == "" { + continue + } + versions = append(versions, name) + } + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + return versions, nil +} diff --git a/dev-tools/mage/release/github_test.go b/dev-tools/mage/release/github_test.go new file mode 100644 index 0000000000..934c894544 --- /dev/null +++ b/dev-tools/mage/release/github_test.go @@ -0,0 +1,216 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. + +package release + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/google/go-github/v68/github" +) + +func newTestGitHubClient(t *testing.T, handler http.Handler) *GitHubClient { + t.Helper() + + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + + client := github.NewClient(server.Client()) + baseURL, err := url.Parse(server.URL + "/") + if err != nil { + t.Fatalf("failed to parse test server URL: %v", err) + } + client.BaseURL = baseURL + client.UploadURL = baseURL + + return &GitHubClient{ + client: client, + ctx: context.Background(), + } +} + +func TestCreatePRIdempotent(t *testing.T) { + createCalls := 0 + existingPR := &github.PullRequest{ + Number: github.Ptr(42), + HTMLURL: github.Ptr("https://github.com/elastic/fleet-server/pull/42"), + Head: &github.PullRequestBranch{ + Ref: github.Ptr("9.5"), + }, + Base: &github.PullRequestBranch{ + Ref: github.Ptr("main"), + }, + State: github.Ptr("open"), + } + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/repos/elastic/fleet-server/pulls": + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode([]*github.PullRequest{existingPR}); err != nil { + t.Errorf("failed to encode PR list response: %v", err) + } + case r.Method == http.MethodPost && r.URL.Path == "/repos/elastic/fleet-server/pulls": + createCalls++ + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = fmt.Fprint(w, `{"message":"Validation Failed","errors":[{"message":"A pull request already exists for elastic:9.5."}]}`) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + }) + + ghClient := newTestGitHubClient(t, handler) + opts := PROptions{ + Owner: "elastic", + Repo: "fleet-server", + Title: "[Release 9.5.0] Prepare release branch", + Head: "9.5", + Base: "main", + Body: "Release PR body", + } + + pr, err := ghClient.CreatePR(opts) + if err != nil { + t.Fatalf("CreatePR() error = %v", err) + } + + if pr.GetNumber() != 42 { + t.Errorf("CreatePR() number = %d, want 42", pr.GetNumber()) + } + if createCalls != 0 { + t.Errorf("CreatePR() called create API %d times, want 0", createCalls) + } + + pr2, err := ghClient.CreatePR(opts) + if err != nil { + t.Fatalf("CreatePR() second call error = %v", err) + } + if pr2.GetNumber() != pr.GetNumber() { + t.Errorf("CreatePR() second call number = %d, want %d", pr2.GetNumber(), pr.GetNumber()) + } + if createCalls != 0 { + t.Errorf("CreatePR() called create API %d times after second call, want 0", createCalls) + } +} + +func TestLatestReleaseBeforeUsesReleasesWhenPresent(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/repos/elastic/fleet-server/releases": + w.Header().Set("Content-Type", "application/json") + releases := []*github.RepositoryRelease{ + {TagName: github.Ptr("v9.5.1")}, + {TagName: github.Ptr("v9.4.3")}, + {TagName: github.Ptr("v8.19.1")}, + } + if err := json.NewEncoder(w).Encode(releases); err != nil { + t.Errorf("failed to encode releases: %v", err) + } + case r.Method == http.MethodGet && r.URL.Path == "/repos/elastic/fleet-server/tags": + t.Errorf("ListTags should not be called when releases exist") + http.NotFound(w, r) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + }) + + ghClient := newTestGitHubClient(t, handler) + got, err := ghClient.LatestReleaseBefore("elastic", "fleet-server", "9.6.0") + if err != nil { + t.Fatalf("LatestReleaseBefore() error = %v", err) + } + if got != "9.5.1" { + t.Errorf("LatestReleaseBefore() = %q, want 9.5.1", got) + } +} + +func TestLatestReleaseBeforeFallsBackToTagsWhenReleasesEmpty(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/repos/elastic/fleet-server/releases": + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode([]*github.RepositoryRelease{}); err != nil { + t.Errorf("failed to encode empty releases: %v", err) + } + case r.Method == http.MethodGet && r.URL.Path == "/repos/elastic/fleet-server/tags": + w.Header().Set("Content-Type", "application/json") + tags := []*github.RepositoryTag{ + {Name: github.Ptr("v9.4.4")}, + {Name: github.Ptr("v9.4.3")}, + {Name: github.Ptr("v9.3.8")}, + {Name: github.Ptr("v8.19.1")}, + {Name: github.Ptr("not-a-version")}, + } + if err := json.NewEncoder(w).Encode(tags); err != nil { + t.Errorf("failed to encode tags: %v", err) + } + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + }) + + ghClient := newTestGitHubClient(t, handler) + got, err := ghClient.LatestReleaseBefore("elastic", "fleet-server", "9.6.0") + if err != nil { + t.Fatalf("LatestReleaseBefore() error = %v", err) + } + if got != "9.4.4" { + t.Errorf("LatestReleaseBefore() = %q, want 9.4.4", got) + } +} + +func TestCreatePRCreatesWhenNoneExists(t *testing.T) { + createCalls := 0 + newPR := &github.PullRequest{ + Number: github.Ptr(99), + HTMLURL: github.Ptr("https://github.com/elastic/fleet-server/pull/99"), + } + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/repos/elastic/fleet-server/pulls": + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode([]*github.PullRequest{}); err != nil { + t.Errorf("failed to encode empty PR list response: %v", err) + } + case r.Method == http.MethodPost && r.URL.Path == "/repos/elastic/fleet-server/pulls": + createCalls++ + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(newPR); err != nil { + t.Errorf("failed to encode create PR response: %v", err) + } + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + }) + + ghClient := newTestGitHubClient(t, handler) + pr, err := ghClient.CreatePR(PROptions{ + Owner: "elastic", + Repo: "fleet-server", + Title: "New release PR", + Head: "9.6", + Base: "main", + Body: "body", + }) + if err != nil { + t.Fatalf("CreatePR() error = %v", err) + } + if pr.GetNumber() != 99 { + t.Errorf("CreatePR() number = %d, want 99", pr.GetNumber()) + } + if createCalls != 1 { + t.Errorf("CreatePR() called create API %d times, want 1", createCalls) + } +} diff --git a/dev-tools/mage/release/go.mod b/dev-tools/mage/release/go.mod new file mode 100644 index 0000000000..d8f69da6b2 --- /dev/null +++ b/dev-tools/mage/release/go.mod @@ -0,0 +1,35 @@ +// Separate module so release tooling deps (go-git, go-github, …) do not +// enter the main fleet-server go.mod / NOTICE.txt. +module github.com/elastic/fleet-server/dev-tools/mage/release + +go 1.26.5 + +require ( + github.com/go-git/go-git/v5 v5.17.0 + github.com/google/go-github/v68 v68.0.0 + golang.org/x/oauth2 v0.36.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + dario.cat/mergo v1.0.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProtonMail/go-crypto v1.1.6 // indirect + github.com/cloudflare/circl v1.6.1 // indirect + github.com/cyphar/filepath-securejoin v0.4.1 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.8.0 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/pjbgf/sha1cd v0.3.2 // indirect + github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect + github.com/skeema/knownhosts v1.3.1 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect + golang.org/x/crypto v0.45.0 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sys v0.38.0 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect +) diff --git a/dev-tools/mage/release/go.sum b/dev-tools/mage/release/go.sum new file mode 100644 index 0000000000..7585b9ef60 --- /dev/null +++ b/dev-tools/mage/release/go.sum @@ -0,0 +1,110 @@ +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= +github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= +github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= +github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= +github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= +github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0= +github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= +github.com/go-git/go-git/v5 v5.17.0 h1:AbyI4xf+7DsjINHMu35quAh4wJygKBKBuXVjV/pxesM= +github.com/go-git/go-git/v5 v5.17.0/go.mod h1:f82C4YiLx+Lhi8eHxltLeGC5uBTXSFa6PC5WW9o4SjI= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-github/v68 v68.0.0 h1:ZW57zeNZiXTdQ16qrDiZ0k6XucrxZ2CGmoTvcCyQG6s= +github.com/google/go-github/v68 v68.0.0/go.mod h1:K9HAUBovM2sLwM408A18h+wd9vqdLOEqTUCbnRIcx68= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= +github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= +github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= +github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= +github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/dev-tools/mage/release/issue.go b/dev-tools/mage/release/issue.go new file mode 100644 index 0000000000..2e9a84642b --- /dev/null +++ b/dev-tools/mage/release/issue.go @@ -0,0 +1,297 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. + +package release + +import ( + "fmt" + "regexp" + "sort" + "strconv" + "strings" + + "github.com/google/go-github/v68/github" +) + +// globalReleaseTrackerURL is the ingest-wide release tracker linked from every Fleet Server release issue. +const globalReleaseTrackerURL = "https://github.com/elastic/ingest-dev/issues/8866" + +var ( + prCheckboxLineRE = regexp.MustCompile(`(?m)^- \[([ xX])\] (https://github\.com/[^/]+/[^/]+/pull/\d+)\s*$`) + prURLNumberRE = regexp.MustCompile(`https://github\.com/[^/]+/[^/]+/pull/(\d+)`) +) + +// warnEnsureReleaseIssueTracker runs EnsureReleaseIssueTracker but never fails the +// caller. Release workflows use this so a tracker outage cannot abort PR creation. +func warnEnsureReleaseIssueTracker(cfg *ReleaseConfig, workflowPRs []*github.PullRequest) { + if err := EnsureReleaseIssueTracker(cfg, workflowPRs); err != nil { + fmt.Printf("Warning: ensure release issue tracker failed (release workflow continues): %v\n", err) + fmt.Println("Re-run with: mage release:ensureIssueTracker") + } +} + +// EnsureReleaseIssueTracker creates or updates the Fleet Server release checklist issue for +// cfg.CurrentRelease. It links related Fleet Server PRs (workflow PRs plus open/merged PRs +// with label "release" that mention the same version). Updates are idempotent: missing +// PR links are appended; existing checklist and checkbox state are preserved. +// Standalone mage release:ensureIssueTracker surfaces errors; runMajorMinor/runPatch +// wrap this with warnEnsureReleaseIssueTracker so failures are non-blocking. +func EnsureReleaseIssueTracker(cfg *ReleaseConfig, workflowPRs []*github.PullRequest) error { + if cfg == nil { + return fmt.Errorf("release config is required") + } + if cfg.CurrentRelease == "" { + return fmt.Errorf("CURRENT_RELEASE is required") + } + + title := releaseIssueTitle(cfg.CurrentRelease) + seedURLs := prURLsFromPullRequests(workflowPRs) + + if cfg.DryRun { + fmt.Printf("\nDRY RUN: Would ensure issue tracker %q\n", title) + fmt.Printf("Seed PRs from workflow: %d\n", len(seedURLs)) + fmt.Printf("Global tracker: %s\n", globalReleaseTrackerURL) + return nil + } + + if cfg.GitHubToken == "" { + return fmt.Errorf("GITHUB_TOKEN is required to ensure the release issue tracker") + } + + gh := NewGitHubClient(cfg.GitHubToken) + owner := cfg.ProjectOwner + repo := cfg.ProjectRepo + + discovered, err := gh.ListReleaseLabeledPRsForVersion(owner, repo, cfg.CurrentRelease) + if err != nil { + return fmt.Errorf("discover release-labeled PRs for %s: %w", cfg.CurrentRelease, err) + } + prURLs := mergePRURLs(seedURLs, prURLsFromPullRequests(discovered)) + + existing, found, err := gh.FindIssueByTitle(owner, repo, title) + if err != nil { + return err + } + + if !found { + body := buildReleaseIssueBody(cfg.CurrentRelease, isMajorMinorRelease(cfg.CurrentRelease), prURLs, nil) + issue, err := gh.CreateIssue(owner, repo, title, body, []string{"release"}) + if err != nil { + return err + } + fmt.Printf("Created release issue tracker #%d: %s\n", issue.GetNumber(), issue.GetHTMLURL()) + return nil + } + + updatedBody, changed := mergeReleaseIssueBody(existing.GetBody(), cfg.CurrentRelease, prURLs) + if !changed { + fmt.Printf("Release issue tracker already up to date: #%d %s\n", existing.GetNumber(), existing.GetHTMLURL()) + return nil + } + + if err := gh.UpdateIssueBody(owner, repo, existing.GetNumber(), updatedBody); err != nil { + return err + } + if err := gh.AddLabels(owner, repo, existing.GetNumber(), []string{"release"}); err != nil { + fmt.Printf("Warning: failed to ensure release label on issue #%d: %v\n", existing.GetNumber(), err) + } + fmt.Printf("Updated release issue tracker #%d: %s\n", existing.GetNumber(), existing.GetHTMLURL()) + return nil +} + +func releaseIssueTitle(version string) string { + return fmt.Sprintf("[RELEASE %s] Instructions & Checklist", version) +} + +func isMajorMinorRelease(version string) bool { + parts := strings.Split(version, ".") + return len(parts) >= 3 && parts[2] == "0" +} + +func prURLsFromPullRequests(prs []*github.PullRequest) []string { + var urls []string + for _, pr := range prs { + if pr == nil { + continue + } + if u := pr.GetHTMLURL(); u != "" { + urls = append(urls, u) + } + } + return urls +} + +// mergePRURLs returns a de-duplicated, number-sorted union of PR URL lists. +func mergePRURLs(lists ...[]string) []string { + seen := map[string]struct{}{} + var out []string + for _, list := range lists { + for _, u := range list { + u = strings.TrimSpace(u) + if u == "" { + continue + } + if _, ok := seen[u]; ok { + continue + } + seen[u] = struct{}{} + out = append(out, u) + } + } + sort.Slice(out, func(i, j int) bool { + return prNumber(out[i]) < prNumber(out[j]) + }) + return out +} + +func prNumber(url string) int { + m := prURLNumberRE.FindStringSubmatch(url) + if len(m) < 2 { + return 0 + } + n, _ := strconv.Atoi(m[1]) + return n +} + +// extractPRCheckboxes returns PR URL -> checked from an issue body. +func extractPRCheckboxes(body string) map[string]bool { + out := map[string]bool{} + for _, m := range prCheckboxLineRE.FindAllStringSubmatch(body, -1) { + checked := m[1] == "x" || m[1] == "X" + out[m[2]] = checked + } + return out +} + +func buildReleaseIssueBody(version string, majorMinor bool, prURLs []string, checked map[string]bool) string { + var b strings.Builder + b.WriteString(fmt.Sprintf("Global tracker: %s\n\n", globalReleaseTrackerURL)) + b.WriteString("# Release Checklist\n\n") + b.WriteString("Checklist for the Fleet Server release process. The Fleet Server and Beats release documentation can be reviewed ") + b.WriteString("[here](https://github.com/elastic/ingest-dev/blob/main/fleet-platform/releases.md).\n\n") + b.WriteString("The release dates can be checked at https://ela.st/release-schedule.\n\n") + b.WriteString("Ensure you have joined the `#mission-control` Slack channel and the `@agent-team` slack group to receive release reminders and coordination messages.\n\n") + + b.WriteString("## On Feature Freeze\n\n") + b.WriteString("- [ ] Find the Fleet Server [release](https://github.com/elastic/fleet-server/labels/release) PRs for this version and link them from this issue.\n") + b.WriteString("- [ ] Check which of the PRs can be merged immediately or must wait until the release day (should be mentioned in each PR).\n") + + if majorMinor { + b.WriteString("\n### Only For Major/Minor Releases\n\n") + b.WriteString("- [ ] On `main`, find and merge the PR changing the version to the next minor as soon as it is green.\n") + b.WriteString("- [ ] Find the PR in observability-dev adding the backport labels for the project and merge them: https://github.com/elastic/observability-dev/pulls. The list of Github labels is maintained [here](https://github.com/elastic/observability-dev/tree/main/.github/labels).\n") + b.WriteString("- [ ] Ensure there is a [branch protection rule](https://github.com/elastic/fleet-server/settings/branches) for the new branch. `9.*`, `8.*` and `7.*` patterns may already exist.\n") + } + + b.WriteString("\n## On the Day before the Release\n\n") + b.WriteString("- [ ] Prepare the changelog. Follow the detailed instructions for [Fleet Server changelog preparation](https://github.com/elastic/ingest-dev/blob/main/fleet-platform/releases.md#fleet-server-changelog-preparation).\n") + + b.WriteString("\n## On the Release Date\n\n") + b.WriteString("- [ ] Wait for the ping from #mission-control to merge all version bump PRs and trigger the [Fleet Server packaging jobs](https://github.com/elastic/ingest-dev/blob/main/fleet-platform/releases.md#preparing-a-release) to stage artifacts for the DRA (daily releasable artifacts) process.\n") + b.WriteString("- [ ] Find all Fleet Server [release](https://github.com/elastic/fleet-server/labels/release) pending PRs for the current release and merge them.\n") + b.WriteString("- [ ] Merge the Changelog / release-notes PR.\n") + b.WriteString("- [ ] [Forward port](https://github.com/elastic/ingest-dev/blob/main/fleet-platform/releases.md#changelog-forward-ports) the changelog.\n") + + b.WriteString("\n## PRs\n\n") + b.WriteString(formatPRChecklist(prURLs, checked)) + b.WriteString("\n") + return b.String() +} + +func formatPRChecklist(prURLs []string, checked map[string]bool) string { + if len(prURLs) == 0 { + return "_No related Fleet Server release PRs discovered yet._\n" + } + var b strings.Builder + for _, u := range prURLs { + mark := " " + if checked != nil && checked[u] { + mark = "x" + } + b.WriteString(fmt.Sprintf("- [%s] %s\n", mark, u)) + } + return b.String() +} + +// mergeReleaseIssueBody updates an existing issue body with missing tracker link and PR URLs. +// Checklist item checkboxes outside the PRs section are left untouched. +func mergeReleaseIssueBody(existingBody, version string, prURLs []string) (string, bool) { + existingChecked := extractPRCheckboxes(existingBody) + allURLs := mergePRURLs(prURLs, keys(existingChecked)) + + if strings.TrimSpace(existingBody) == "" { + return buildReleaseIssueBody(version, isMajorMinorRelease(version), allURLs, existingChecked), true + } + + body := existingBody + changed := false + + if !strings.Contains(body, globalReleaseTrackerURL) { + body = fmt.Sprintf("Global tracker: %s\n\n%s", globalReleaseTrackerURL, body) + changed = true + } + + const prSection = "## PRs" + idx := strings.Index(body, prSection) + if idx < 0 { + updated := strings.TrimRight(body, "\n") + "\n\n" + prSection + "\n\n" + formatPRChecklist(allURLs, existingChecked) + return updated, true + } + + before := body[:idx] + oldPRBlock := strings.TrimPrefix(body[idx+len(prSection):], "\n") + newPRBlock := formatPRChecklist(allURLs, existingChecked) + + oldURLs := keys(extractPRCheckboxes(oldPRBlock)) + if !sameStringSet(oldURLs, allURLs) || normalizePRSection(oldPRBlock) != normalizePRSection(newPRBlock) { + changed = true + } + if !changed { + return existingBody, false + } + + updated := strings.TrimRight(before, "\n") + "\n\n" + prSection + "\n\n" + newPRBlock + return updated, true +} + +func keys(m map[string]bool) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} + +func sameStringSet(a, b []string) bool { + am := map[string]struct{}{} + for _, s := range a { + am[s] = struct{}{} + } + bm := map[string]struct{}{} + for _, s := range b { + bm[s] = struct{}{} + } + if len(am) != len(bm) { + return false + } + for s := range am { + if _, ok := bm[s]; !ok { + return false + } + } + return true +} + +func normalizePRSection(s string) string { + return strings.TrimSpace(strings.ReplaceAll(s, "\r\n", "\n")) +} + +// versionMentioned reports whether text refers to the exact version (avoids 9.4.1 matching 9.4.10). +func versionMentioned(text, version string) bool { + if version == "" || text == "" { + return false + } + re := regexp.MustCompile(`(?i)(?:^|[^0-9])` + regexp.QuoteMeta(version) + `(?:[^0-9]|$)`) + return re.MatchString(text) +} diff --git a/dev-tools/mage/release/issue_test.go b/dev-tools/mage/release/issue_test.go new file mode 100644 index 0000000000..65ea2a524d --- /dev/null +++ b/dev-tools/mage/release/issue_test.go @@ -0,0 +1,178 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. + +package release + +import ( + "strings" + "testing" + + "github.com/google/go-github/v68/github" +) + +func TestReleaseIssueTitle(t *testing.T) { + got := releaseIssueTitle("9.4.1") + want := "[RELEASE 9.4.1] Instructions & Checklist" + if got != want { + t.Fatalf("releaseIssueTitle() = %q, want %q", got, want) + } +} + +func TestIsMajorMinorRelease(t *testing.T) { + if !isMajorMinorRelease("9.5.0") { + t.Fatal("expected 9.5.0 to be treated as major/minor") + } + if isMajorMinorRelease("9.5.1") { + t.Fatal("expected 9.5.1 not to be treated as major/minor") + } +} + +func TestVersionMentioned(t *testing.T) { + if !versionMentioned("[Release] Update version to 9.4.1", "9.4.1") { + t.Fatal("exact version in title should match") + } + if versionMentioned("[Release] Update version to 9.4.10", "9.4.1") { + t.Fatal("9.4.1 must not match 9.4.10") + } + if !versionMentioned("Add Fleet Server 9.4.1 release notes", "9.4.1") { + t.Fatal("docs PR title should match") + } +} + +func TestBuildReleaseIssueBodyMajorMinorSection(t *testing.T) { + minorBody := buildReleaseIssueBody("9.5.0", true, nil, nil) + if !strings.Contains(minorBody, "### Only For Major/Minor Releases") { + t.Fatal("minor release body should include major/minor section") + } + if !strings.Contains(minorBody, globalReleaseTrackerURL) { + t.Fatal("body should link the global tracker") + } + if !strings.Contains(minorBody, "elastic/fleet-server/labels/release") { + t.Fatal("body should link fleet-server release label") + } + + patchBody := buildReleaseIssueBody("9.5.1", false, nil, nil) + if strings.Contains(patchBody, "### Only For Major/Minor Releases") { + t.Fatal("patch body should omit major/minor section") + } +} + +func TestMergePRURLsSortsAndDedups(t *testing.T) { + got := mergePRURLs( + []string{"https://github.com/elastic/fleet-server/pull/20", "https://github.com/elastic/fleet-server/pull/10"}, + []string{"https://github.com/elastic/fleet-server/pull/10", "https://github.com/elastic/fleet-server/pull/15"}, + ) + want := []string{ + "https://github.com/elastic/fleet-server/pull/10", + "https://github.com/elastic/fleet-server/pull/15", + "https://github.com/elastic/fleet-server/pull/20", + } + if len(got) != len(want) { + t.Fatalf("mergePRURLs() len = %d, want %d (%v)", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("mergePRURLs()[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestExtractPRCheckboxes(t *testing.T) { + body := ` +## PRs + +- [x] https://github.com/elastic/fleet-server/pull/10 +- [ ] https://github.com/elastic/fleet-server/pull/11 +` + got := extractPRCheckboxes(body) + if !got["https://github.com/elastic/fleet-server/pull/10"] { + t.Fatal("checked PR should be true") + } + if got["https://github.com/elastic/fleet-server/pull/11"] { + t.Fatal("unchecked PR should be false") + } +} + +func TestMergeReleaseIssueBodyAddsMissingPRsAndPreservesChecks(t *testing.T) { + existing := `Global tracker: https://github.com/elastic/ingest-dev/issues/8866 + +# Release Checklist + +- [x] Find the Fleet Server release PRs + +## PRs + +- [x] https://github.com/elastic/fleet-server/pull/10 +- [ ] https://github.com/elastic/fleet-server/pull/11 +` + updated, changed := mergeReleaseIssueBody(existing, "9.4.1", []string{ + "https://github.com/elastic/fleet-server/pull/11", + "https://github.com/elastic/fleet-server/pull/12", + }) + if !changed { + t.Fatal("adding a missing PR should mark the body changed") + } + if !strings.Contains(updated, "- [x] https://github.com/elastic/fleet-server/pull/10") { + t.Fatal("existing checked PR must stay checked") + } + if !strings.Contains(updated, "- [ ] https://github.com/elastic/fleet-server/pull/11") { + t.Fatal("existing unchecked PR stays unchecked") + } + if !strings.Contains(updated, "- [ ] https://github.com/elastic/fleet-server/pull/12") { + t.Fatal("new PR should be appended unchecked") + } + if !strings.Contains(updated, "- [x] Find the Fleet Server release PRs") { + t.Fatal("non-PR checklist state must be preserved") + } + + _, changedAgain := mergeReleaseIssueBody(updated, "9.4.1", []string{ + "https://github.com/elastic/fleet-server/pull/10", + "https://github.com/elastic/fleet-server/pull/11", + "https://github.com/elastic/fleet-server/pull/12", + }) + if changedAgain { + t.Fatal("second merge with same PRs should be a no-op") + } +} + +func TestMergeReleaseIssueBodyAddsTrackerWhenMissing(t *testing.T) { + existing := `# Release Checklist + +## PRs + +- [ ] https://github.com/elastic/fleet-server/pull/10 +` + updated, changed := mergeReleaseIssueBody(existing, "9.4.1", []string{"https://github.com/elastic/fleet-server/pull/10"}) + if !changed { + t.Fatal("missing global tracker should trigger an update") + } + if !strings.HasPrefix(strings.TrimSpace(updated), "Global tracker: "+globalReleaseTrackerURL) { + t.Fatalf("tracker link should be prepended, got prefix %q", updated[:min(80, len(updated))]) + } +} + +func TestEnsureReleaseIssueTrackerDryRun(t *testing.T) { + cfg := &ReleaseConfig{ + CurrentRelease: "9.4.1", + DryRun: true, + } + pr := &github.PullRequest{HTMLURL: github.Ptr("https://github.com/elastic/fleet-server/pull/99")} + if err := EnsureReleaseIssueTracker(cfg, []*github.PullRequest{pr}); err != nil { + t.Fatalf("dry-run ensureIssueTracker should not call GitHub: %v", err) + } +} + +func TestWarnEnsureReleaseIssueTrackerDoesNotPanicOnError(t *testing.T) { + warnEnsureReleaseIssueTracker(&ReleaseConfig{}, nil) +} + +func TestPrURLsFromPullRequestsSkipsNil(t *testing.T) { + got := prURLsFromPullRequests([]*github.PullRequest{ + nil, + {HTMLURL: github.Ptr("https://github.com/elastic/fleet-server/pull/1")}, + }) + if len(got) != 1 || got[0] != "https://github.com/elastic/fleet-server/pull/1" { + t.Fatalf("unexpected URLs: %v", got) + } +} diff --git a/dev-tools/mage/release/mergify.go b/dev-tools/mage/release/mergify.go new file mode 100644 index 0000000000..8328344112 --- /dev/null +++ b/dev-tools/mage/release/mergify.go @@ -0,0 +1,81 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. + +package release + +import ( + "fmt" + "os" + "strings" + + "gopkg.in/yaml.v3" +) + +// UpdateMergify adds a new backport rule to .mergify.yml. +func UpdateMergify(version string) error { + mergifyFile := ".mergify.yml" + + content, err := os.ReadFile(mergifyFile) + if err != nil { + return fmt.Errorf("failed to read %s: %w", mergifyFile, err) + } + + var config map[string]interface{} + if err := yaml.Unmarshal(content, &config); err != nil { + return fmt.Errorf("failed to parse %s: %w", mergifyFile, err) + } + + parts := strings.Split(version, ".") + if len(parts) < 2 { + return fmt.Errorf("invalid version format: %s (expected X.Y.Z)", version) + } + branchVersion := fmt.Sprintf("%s.%s", parts[0], parts[1]) + + rules, ok := config["pull_request_rules"].([]interface{}) + if !ok { + return fmt.Errorf("pull_request_rules not found or invalid format") + } + + label := fmt.Sprintf("backport-%s", branchVersion) + for _, rule := range rules { + ruleMap, ok := rule.(map[string]interface{}) + if !ok { + continue + } + name, ok := ruleMap["name"].(string) + if ok && strings.Contains(name, branchVersion) { + fmt.Printf("Backport rule for %s already exists\n", branchVersion) + return nil + } + } + + newRule := map[string]interface{}{ + "name": fmt.Sprintf("backport patches to %s branch", branchVersion), + "conditions": []interface{}{ + "merged", + fmt.Sprintf("label=%s", label), + }, + "actions": map[string]interface{}{ + "backport": map[string]interface{}{ + "branches": []interface{}{branchVersion}, + }, + }, + } + + rules = append(rules, newRule) + config["pull_request_rules"] = rules + + output, err := yaml.Marshal(config) + if err != nil { + return fmt.Errorf("failed to marshal YAML: %w", err) + } + + err = writeRepoFile(mergifyFile, output) + if err != nil { + return fmt.Errorf("failed to write %s: %w", mergifyFile, err) + } + + fmt.Printf("Added backport rule for %s to %s\n", branchVersion, mergifyFile) + return nil +} diff --git a/dev-tools/mage/release/release.go b/dev-tools/mage/release/release.go new file mode 100644 index 0000000000..5d262df8d3 --- /dev/null +++ b/dev-tools/mage/release/release.go @@ -0,0 +1,101 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. + +package release + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +const versionGoPath = "version/version.go" + +var defaultVersionPattern = regexp.MustCompile(`const DefaultVersion = "([^"]+)"`) + +// ReadFleetVersion returns DefaultVersion from version/version.go. +func ReadFleetVersion() (string, error) { + content, err := os.ReadFile(versionGoPath) + if err != nil { + return "", fmt.Errorf("failed to read %s: %w", versionGoPath, err) + } + match := defaultVersionPattern.FindSubmatch(content) + if match == nil { + return "", fmt.Errorf("version pattern not found in %s", versionGoPath) + } + return string(match[1]), nil +} + +func validateRepoRelativePath(path string) (string, error) { + if path == "" { + return "", fmt.Errorf("path must not be empty") + } + if filepath.IsAbs(path) { + return "", fmt.Errorf("absolute path not allowed: %s", path) + } + + cleaned := filepath.Clean(path) + if cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(os.PathSeparator)) { + return "", fmt.Errorf("path escapes repository root: %s", path) + } + + return cleaned, nil +} + +func isReleaseWritablePath(path string) bool { + switch filepath.ToSlash(path) { + case versionGoPath, ".mergify.yml": + return true + default: + return false + } +} + +func writeRepoFile(relPath string, content []byte) error { + safePath, err := validateRepoRelativePath(relPath) + if err != nil { + return err + } + + if !isReleaseWritablePath(safePath) { + return fmt.Errorf("unsupported file path: %s", relPath) + } + + return os.WriteFile(safePath, content, 0o644) //nolint:gosec // safePath is validated and allowlisted for release automation files +} + +// UpdateVersion updates the version in version/version.go. +func UpdateVersion(newVersion string) error { + versionFile, err := validateRepoRelativePath(versionGoPath) + if err != nil { + return err + } + + content, err := os.ReadFile(versionFile) + if err != nil { + return fmt.Errorf("failed to read %s: %w", versionFile, err) + } + + re := regexp.MustCompile(`(const\s+DefaultVersion\s*=\s*)"[^"]+"`) + contentStr := string(content) + alreadyAtVersion := regexp.MustCompile(`const\s+DefaultVersion\s*=\s*"` + regexp.QuoteMeta(newVersion) + `"`) + if alreadyAtVersion.MatchString(contentStr) { + fmt.Printf("Version already set to %s in %s\n", newVersion, versionFile) + return nil + } + + newContent := re.ReplaceAllString(contentStr, `${1}"`+newVersion+`"`) + if newContent == contentStr { + return fmt.Errorf("version pattern not found in %s", versionFile) + } + + if err := writeRepoFile(versionFile, []byte(newContent)); err != nil { + return fmt.Errorf("failed to write %s: %w", versionFile, err) + } + + fmt.Printf("Updated version to %s in %s\n", newVersion, versionFile) + return nil +} diff --git a/dev-tools/mage/release/release_test.go b/dev-tools/mage/release/release_test.go new file mode 100644 index 0000000000..3f7b9cd5d8 --- /dev/null +++ b/dev-tools/mage/release/release_test.go @@ -0,0 +1,229 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. + +package release + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestUpdateVersion(t *testing.T) { + tests := []struct { + name string + initial string + newVersion string + wantErr bool + wantContent string + }{ + { + name: "update version successfully", + initial: `const DefaultVersion = "9.4.0"`, + newVersion: "9.5.0", + wantErr: false, + wantContent: `const DefaultVersion = "9.5.0"`, + }, + { + name: "update with different spacing", + initial: `const DefaultVersion = "9.4.0"`, + newVersion: "9.5.0", + wantErr: false, + wantContent: `const DefaultVersion = "9.5.0"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + versionDir := filepath.Join(tmpDir, "version") + err := os.Mkdir(versionDir, 0o755) + if err != nil { + t.Fatalf("failed to create version dir: %v", err) + } + + versionFile := filepath.Join(versionDir, "version.go") + initialContent := `// Copyright header + +package version + +` + tt.initial + ` +` + err = os.WriteFile(versionFile, []byte(initialContent), 0o644) + if err != nil { + t.Fatalf("failed to write initial file: %v", err) + } + + origDir, _ := os.Getwd() + defer func() { + _ = os.Chdir(origDir) + }() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to change to temp directory: %v", err) + } + + err = UpdateVersion(tt.newVersion) + + if (err != nil) != tt.wantErr { + t.Errorf("UpdateVersion() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if !tt.wantErr { + content, err := os.ReadFile(versionFile) + if err != nil { + t.Fatalf("failed to read updated file: %v", err) + } + + if !strings.Contains(string(content), tt.wantContent) { + t.Errorf("UpdateVersion() content = %s, want to contain %s", string(content), tt.wantContent) + } + } + }) + } +} + +func TestUpdateVersionIdempotent(t *testing.T) { + tmpDir := t.TempDir() + versionDir := filepath.Join(tmpDir, "version") + err := os.Mkdir(versionDir, 0o755) + if err != nil { + t.Fatalf("failed to create version dir: %v", err) + } + + versionFile := filepath.Join(versionDir, "version.go") + initialContent := `package version + +const DefaultVersion = "9.4.0" +` + err = os.WriteFile(versionFile, []byte(initialContent), 0o644) + if err != nil { + t.Fatalf("failed to write initial file: %v", err) + } + + origDir, _ := os.Getwd() + defer func() { + _ = os.Chdir(origDir) + }() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to change to temp directory: %v", err) + } + + err = UpdateVersion("9.5.0") + if err != nil { + t.Fatalf("first UpdateVersion() failed: %v", err) + } + + content1, err := os.ReadFile(versionFile) + if err != nil { + t.Fatalf("failed to read file after first update: %v", err) + } + + err = UpdateVersion("9.5.0") + if err != nil { + t.Fatalf("second UpdateVersion() failed: %v", err) + } + + content2, err := os.ReadFile(versionFile) + if err != nil { + t.Fatalf("failed to read file after second update: %v", err) + } + + if string(content1) != string(content2) { + t.Error("UpdateVersion() is not idempotent - content changed on second run") + } +} + +func TestUpdateMergify(t *testing.T) { + tmpDir := t.TempDir() + mergifyFile := filepath.Join(tmpDir, ".mergify.yml") + + initialContent := `pull_request_rules: + - name: backport patches to 9.3 branch + conditions: + - merged + - label=backport-9.3 + actions: + backport: + branches: + - "9.3" +` + err := os.WriteFile(mergifyFile, []byte(initialContent), 0o644) + if err != nil { + t.Fatalf("failed to write initial file: %v", err) + } + + origDir, _ := os.Getwd() + defer func() { + _ = os.Chdir(origDir) + }() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to change to temp directory: %v", err) + } + + err = UpdateMergify("9.5.0") + if err != nil { + t.Fatalf("UpdateMergify() failed: %v", err) + } + + content, err := os.ReadFile(mergifyFile) + if err != nil { + t.Fatalf("failed to read updated file: %v", err) + } + + contentStr := string(content) + if !strings.Contains(contentStr, "backport patches to 9.5 branch") { + t.Errorf("UpdateMergify() missing rule name for 9.5") + } + if !strings.Contains(contentStr, "9.3") { + t.Error("UpdateMergify() removed existing rules") + } +} + +func TestUpdateMergifyIdempotent(t *testing.T) { + tmpDir := t.TempDir() + mergifyFile := filepath.Join(tmpDir, ".mergify.yml") + + initialContent := `pull_request_rules: + - name: backport patches to 9.5 branch + conditions: + - merged + - label=backport-9.5 + actions: + backport: + branches: + - "9.5" +` + err := os.WriteFile(mergifyFile, []byte(initialContent), 0o644) + if err != nil { + t.Fatalf("failed to write initial file: %v", err) + } + + origDir, _ := os.Getwd() + defer func() { + _ = os.Chdir(origDir) + }() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to change to temp directory: %v", err) + } + + err = UpdateMergify("9.5.0") + if err != nil { + t.Fatalf("first UpdateMergify() failed: %v", err) + } + + content1, _ := os.ReadFile(mergifyFile) + + err = UpdateMergify("9.5.0") + if err != nil { + t.Fatalf("second UpdateMergify() failed: %v", err) + } + + content2, _ := os.ReadFile(mergifyFile) + + if string(content1) != string(content2) { + t.Error("UpdateMergify() is not idempotent - content changed on second run") + } +} diff --git a/dev-tools/mage/release/version.go b/dev-tools/mage/release/version.go new file mode 100644 index 0000000000..9a94b97d18 --- /dev/null +++ b/dev-tools/mage/release/version.go @@ -0,0 +1,80 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. + +package release + +import ( + "fmt" + "strconv" + "strings" +) + +type semver struct { + major, minor, patch int +} + +func parseSemver(version string) (semver, error) { + version = strings.TrimPrefix(strings.TrimSpace(version), "v") + parts := strings.Split(version, ".") + if len(parts) != 3 { + return semver{}, fmt.Errorf("invalid version format: %s (expected major.minor.patch)", version) + } + major, err := strconv.Atoi(parts[0]) + if err != nil { + return semver{}, fmt.Errorf("invalid major version: %s", parts[0]) + } + minor, err := strconv.Atoi(parts[1]) + if err != nil { + return semver{}, fmt.Errorf("invalid minor version: %s", parts[1]) + } + patch, err := strconv.Atoi(parts[2]) + if err != nil { + return semver{}, fmt.Errorf("invalid patch version: %s", parts[2]) + } + return semver{major: major, minor: minor, patch: patch}, nil +} + +func (v semver) String() string { + return fmt.Sprintf("%d.%d.%d", v.major, v.minor, v.patch) +} + +func (v semver) less(other semver) bool { + if v.major != other.major { + return v.major < other.major + } + if v.minor != other.minor { + return v.minor < other.minor + } + return v.patch < other.patch +} + +// selectLatestReleaseBefore picks the highest same-major version strictly less than current. +func selectLatestReleaseBefore(versions []string, currentVersion string) (string, error) { + current, err := parseSemver(currentVersion) + if err != nil { + return "", err + } + + var best *semver + for _, raw := range versions { + candidate, err := parseSemver(raw) + if err != nil { + continue + } + if candidate.major != current.major { + continue + } + if !candidate.less(current) { + continue + } + if best == nil || best.less(candidate) { + c := candidate + best = &c + } + } + if best == nil { + return "", fmt.Errorf("no published release found before %s (same major)", currentVersion) + } + return best.String(), nil +} diff --git a/dev-tools/mage/release/version_test.go b/dev-tools/mage/release/version_test.go new file mode 100644 index 0000000000..e1e9545321 --- /dev/null +++ b/dev-tools/mage/release/version_test.go @@ -0,0 +1,18 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. + +package release + +import "testing" + +func TestSelectLatestReleaseBefore(t *testing.T) { + versions := []string{"9.4.0", "9.4.3", "9.5.0", "v9.3.2", "8.19.1", "not-a-version"} + got, err := selectLatestReleaseBefore(versions, "9.5.0") + if err != nil { + t.Fatalf("selectLatestReleaseBefore() error = %v", err) + } + if got != "9.4.3" { + t.Errorf("selectLatestReleaseBefore() = %s, want 9.4.3", got) + } +} diff --git a/dev-tools/mage/release/workflows.go b/dev-tools/mage/release/workflows.go new file mode 100644 index 0000000000..07b629e728 --- /dev/null +++ b/dev-tools/mage/release/workflows.go @@ -0,0 +1,541 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. + +package release + +import ( + "fmt" + "strings" + + "github.com/google/go-github/v68/github" +) + +// PR label sets match vault-bot / beats / elastic-agent release PRs. +var ( + releasePRLabels = []string{"release", "Team:Automation", "skip-changelog"} + ffReleasePRLabels = []string{"release", "docs", "in progress", "Team:Automation", "skip-changelog"} + patchDocsPRLabels = []string{"docs", "in progress", "release", "Team:Automation", "skip-changelog"} +) + +// Feature-freeze merge-timing labels (number = RM merge order). +const ( + mergeLabelFFDay = "merge:1-ff-day" + mergeLabelAfterBranch = "merge:2-after-branch" + mergeLabelAfterImages = "merge:3-after-images" // defined for shared label defs; PR-C omitted so unused in workflows + mergeLabelAfterRelease = "merge:4-after-release" +) + +// Patch-release merge-timing labels. +const ( + mergeLabelBeforeBuild = "merge:1-before-build" +) + +func backportLabel(releaseBranch string) string { + return fmt.Sprintf("backport-%s", releaseBranch) +} + +func prAMainLabels(releaseBranch string) []string { + return []string{"release", "impact:critical", backportLabel(releaseBranch), "skip-changelog", "Team:Automation", mergeLabelFFDay} +} + +func prBReleaseLabels() []string { + return append(append([]string{}, ffReleasePRLabels...), mergeLabelAfterBranch) +} + +func prDNextPatchLabels() []string { + return append(append([]string{}, releasePRLabels...), mergeLabelAfterRelease) +} + +func patchBeforeBuildPRLabels() []string { + return append(append([]string{}, patchDocsPRLabels...), mergeLabelBeforeBuild) +} + +func checkRequirements(cfg *ReleaseConfig) error { + parts := strings.Split(cfg.CurrentRelease, ".") + if len(parts) < 2 { + return fmt.Errorf("invalid version format: %s", cfg.CurrentRelease) + } + + major := parts[0] + patch := "" + if len(parts) >= 3 { + patch = parts[2] + } + + if (major == "6" || major == "7" || major == "8") && patch == "0" { + return fmt.Errorf("minor releases for version %s.x are deprecated and blocked", major) + } + + repo, err := OpenRepo(".") + if err != nil { + return err + } + + clean, err := repo.IsClean() + if err != nil { + return err + } + if !clean { + return fmt.Errorf("working directory is not clean. Please commit or stash changes first") + } + + return nil +} + +type workflowPR struct { + branch string + base string + opts PROptions +} + +type workflowPRResult struct { + item workflowPR + pr *github.PullRequest +} + +// RunMajorMinorRelease executes the feature-freeze workflow: +// 1. Creates the release branch from BASE_BRANCH +// 2. Opens PR-A on main (backport rule + next minor version) +// 3. Opens PR-B on release branch (ensure CURRENT version) +// 4. Opens PR-D on release branch (next patch prep) +// +// PR-C (main docs / test-env) is omitted: fleet-server has no docs or test-env +// version updates in the former fleet-server.mak process. +func RunMajorMinorRelease(cfg *ReleaseConfig) error { + fmt.Println("=== Starting Major/Minor Release Workflow ===") + + if err := cfg.EnsureLatestRelease(); err != nil { + return err + } + + if err := cfg.Validate(); err != nil { + return err + } + + if err := checkRequirements(cfg); err != nil { + return err + } + + repo, err := OpenRepo(".") + if err != nil { + return err + } + + if err := ensureMajorMinorCurrentReleaseMatchesBase(repo, cfg); err != nil { + return err + } + + releaseBranch := cfg.ReleaseBranch + + fmt.Printf("Creating release branch: %s\n", releaseBranch) + if err := repo.EnsureBranchFrom(cfg.BaseBranch, releaseBranch); err != nil { + return err + } + + prA, err := prepMainBackportAndVersion(repo, cfg) + if err != nil { + return err + } + prB, err := prepFFRelease(repo, cfg) + if err != nil { + return err + } + prD, err := prepNextPatchOnReleaseBranch(repo, cfg) + if err != nil { + return err + } + + branchesToFinalize := []workflowPR{prA, prB, prD} + + if cfg.DryRun { + fmt.Println("\nDRY RUN: Skipping push and PR creation") + fmt.Printf("Release branch prepared: %s\n", releaseBranch) + for _, item := range branchesToFinalize { + fmt.Printf("Branch prepared: %s\n", item.branch) + } + fmt.Println("Note: PR-C (main docs) is omitted for fleet-server (no docs/test-env updates)") + warnEnsureReleaseIssueTracker(cfg, nil) + return nil + } + + if err := repo.CheckoutBranch(releaseBranch); err != nil { + return err + } + if err := repo.Push("origin"); err != nil { + return err + } + + gh := NewGitHubClient(cfg.GitHubToken) + results, err := finalizeWorkflowPRs(repo, gh, branchesToFinalize) + if err != nil { + return err + } + + fmt.Printf("\n=== Major/Minor Release Workflow Complete ===\n") + fmt.Printf("Release branch created: %s\n", releaseBranch) + printWorkflowPRResults(results) + fmt.Println("\nNote: PR-C (main docs) is omitted for fleet-server (no docs/test-env updates)") + + warnEnsureReleaseIssueTracker(cfg, prsFromWorkflowResults(results)) + return nil +} + +func prepMainBackportAndVersion(repo *GitRepo, cfg *ReleaseConfig) (workflowPR, error) { + branch := fmt.Sprintf("ff-prep-main-%s", cfg.CurrentRelease) + fmt.Printf("\n--- Preparing PR-A: backport rule + version %s on %s ---\n", cfg.NextProjectMinorVersion, cfg.BaseBranch) + + if err := repo.EnsureBranchFrom(cfg.BaseBranch, branch); err != nil { + return workflowPR{}, err + } + if err := UpdateMergify(cfg.ReleaseBranch); err != nil { + return workflowPR{}, err + } + if err := UpdateVersion(cfg.NextProjectMinorVersion); err != nil { + return workflowPR{}, err + } + commitMsg := fmt.Sprintf("[Release %s] Prepare main for %s and mergify backport-%s", cfg.CurrentRelease, cfg.NextProjectMinorVersion, cfg.ReleaseBranch) + if _, err := repo.CommitAll(commitMsg, cfg.GitAuthorName, cfg.GitAuthorEmail); err != nil { + return workflowPR{}, err + } + + return workflowPR{ + branch: branch, + base: cfg.BaseBranch, + opts: PROptions{ + Owner: cfg.ProjectOwner, + Repo: cfg.ProjectRepo, + Title: fmt.Sprintf("[Release %s] Prepare main for %s and mergify backport-%s", cfg.CurrentRelease, cfg.NextProjectMinorVersion, cfg.ReleaseBranch), + Head: branch, + Base: cfg.BaseBranch, + Body: prAMainBody(cfg), + Reviewers: cfg.ProjectReviewers, + Labels: prAMainLabels(cfg.ReleaseBranch), + }, + }, nil +} + +func prepFFRelease(repo *GitRepo, cfg *ReleaseConfig) (workflowPR, error) { + branch := fmt.Sprintf("ff-release-%s", cfg.CurrentRelease) + fmt.Printf("\n--- Preparing PR-B: ff-release %s on %s ---\n", cfg.CurrentRelease, cfg.ReleaseBranch) + + if err := repo.EnsureBranchFrom(cfg.ReleaseBranch, branch); err != nil { + return workflowPR{}, err + } + if err := UpdateVersion(cfg.CurrentRelease); err != nil { + return workflowPR{}, err + } + commitMsg := fmt.Sprintf("[Release %s] ff-release: update versions %s", cfg.CurrentRelease, cfg.CurrentRelease) + if _, err := repo.CommitAll(commitMsg, cfg.GitAuthorName, cfg.GitAuthorEmail); err != nil { + return workflowPR{}, err + } + + return workflowPR{ + branch: branch, + base: cfg.ReleaseBranch, + opts: PROptions{ + Owner: cfg.ProjectOwner, + Repo: cfg.ProjectRepo, + Title: fmt.Sprintf("[Release %s] ff-release: update versions %s", cfg.CurrentRelease, cfg.CurrentRelease), + Head: branch, + Base: cfg.ReleaseBranch, + Body: prBReleaseBody(cfg), + Reviewers: cfg.ProjectReviewers, + Labels: prBReleaseLabels(), + }, + }, nil +} + +func prepNextPatchOnReleaseBranch(repo *GitRepo, cfg *ReleaseConfig) (workflowPR, error) { + branch := fmt.Sprintf("ff-prep-next-patch-%s", cfg.NextRelease) + fmt.Printf("\n--- Preparing PR-D: next patch %s on %s ---\n", cfg.NextRelease, cfg.ReleaseBranch) + + if err := repo.EnsureBranchFrom(cfg.ReleaseBranch, branch); err != nil { + return workflowPR{}, err + } + if err := UpdateVersion(cfg.NextRelease); err != nil { + return workflowPR{}, err + } + commitMsg := fmt.Sprintf("[Release %s] Update version to %s", cfg.CurrentRelease, cfg.NextRelease) + if _, err := repo.CommitAll(commitMsg, cfg.GitAuthorName, cfg.GitAuthorEmail); err != nil { + return workflowPR{}, err + } + + return workflowPR{ + branch: branch, + base: cfg.ReleaseBranch, + opts: PROptions{ + Owner: cfg.ProjectOwner, + Repo: cfg.ProjectRepo, + Title: fmt.Sprintf("[Release %s] Update version to %s", cfg.CurrentRelease, cfg.NextRelease), + Head: branch, + Base: cfg.ReleaseBranch, + Body: prDNextPatchBody(cfg), + Reviewers: cfg.ProjectReviewers, + Labels: prDNextPatchLabels(), + }, + }, nil +} + +func prAMainBody(cfg *ReleaseConfig) string { + return fmt.Sprintf(`## [Release %s] + +Prepares %s for the %s feature freeze. + +- Adds Mergify backport rule for branch %s (label %s) +- Bumps version/version.go to %s (next minor) + +**Merge:** before release branch work is finalized. +`, cfg.CurrentRelease, cfg.BaseBranch, cfg.CurrentRelease, cfg.ReleaseBranch, backportLabel(cfg.ReleaseBranch), cfg.NextProjectMinorVersion) +} + +func prBReleaseBody(cfg *ReleaseConfig) string { + return fmt.Sprintf(`## [Release %s] + +Feature-freeze release branch updates for %s (version/version.go). + +Fleet Server has no docs/test-env updates in this step. + +**Merge:** as soon as the %s branch exists. +`, cfg.CurrentRelease, cfg.CurrentRelease, cfg.ReleaseBranch) +} + +func prDNextPatchBody(cfg *ReleaseConfig) string { + return fmt.Sprintf(`## [Release %s] + +Prepares the %s branch after release of %s. + +- Bumps version/version.go to %s + +**Merge:** after the release of %s. +`, cfg.CurrentRelease, cfg.ReleaseBranch, cfg.CurrentRelease, cfg.NextRelease, cfg.CurrentRelease) +} + +func ensureMajorMinorCurrentReleaseMatchesBase(repo *GitRepo, cfg *ReleaseConfig) error { + base := cfg.BaseBranch + if base == "" { + base = "main" + } + if err := repo.CheckoutBranch(base); err != nil { + return err + } + branchVersion, err := ReadFleetVersion() + if err != nil { + return err + } + if branchVersion != cfg.CurrentRelease { + return fmt.Errorf( + "CURRENT_RELEASE=%s does not match version on %s (%s in version/version.go); "+ + "set CURRENT_RELEASE to the version already on %s (the minor being feature-frozen)", + cfg.CurrentRelease, base, branchVersion, base, + ) + } + fmt.Printf("Verified CURRENT_RELEASE=%s matches %s on branch %s\n", cfg.CurrentRelease, branchVersion, base) + return nil +} + +// RunPatchRelease executes the patch release workflow on an existing release branch: +// 1. Opens PR-A (ensure CURRENT version — historically the "docs" PR; often a no-op) +// 2. Opens PR-B (next patch version — after release) +func RunPatchRelease(cfg *ReleaseConfig) error { + fmt.Println("=== Starting Patch Release Workflow ===") + + if err := cfg.EnsureLatestRelease(); err != nil { + return err + } + + if err := cfg.Validate(); err != nil { + return err + } + + if err := checkRequirements(cfg); err != nil { + return err + } + + repo, err := OpenRepo(".") + if err != nil { + return err + } + + if cfg.ReleaseBranch == "" { + cfg.ReleaseBranch = inferReleaseBranch(cfg.CurrentRelease) + } + + if err := ensurePatchCurrentReleaseMatchesBranch(repo, cfg); err != nil { + return err + } + + prA, err := prepPatchBeforeBuild(repo, cfg) + if err != nil { + return err + } + prB, err := prepNextPatchOnReleaseBranch(repo, cfg) + if err != nil { + return err + } + + branchesToFinalize := []workflowPR{prA, prB} + + if cfg.DryRun { + fmt.Println("\nDRY RUN: Skipping push and PR creation") + for _, item := range branchesToFinalize { + fmt.Printf("Branch prepared: %s\n", item.branch) + } + warnEnsureReleaseIssueTracker(cfg, nil) + return nil + } + + gh := NewGitHubClient(cfg.GitHubToken) + results, err := finalizeWorkflowPRs(repo, gh, branchesToFinalize) + if err != nil { + return err + } + + fmt.Printf("\n=== Patch Release Workflow Complete ===\n") + printWorkflowPRResults(results) + + warnEnsureReleaseIssueTracker(cfg, prsFromWorkflowResults(results)) + return nil +} + +func ensurePatchCurrentReleaseMatchesBranch(repo *GitRepo, cfg *ReleaseConfig) error { + if err := repo.CheckoutBranch(cfg.ReleaseBranch); err != nil { + return err + } + branchVersion, err := ReadFleetVersion() + if err != nil { + return err + } + if branchVersion != cfg.CurrentRelease { + return fmt.Errorf( + "CURRENT_RELEASE=%s does not match version on branch %s (%s in version/version.go); "+ + "set CURRENT_RELEASE to the version already on the release branch (the patch being released)", + cfg.CurrentRelease, cfg.ReleaseBranch, branchVersion, + ) + } + fmt.Printf("Verified CURRENT_RELEASE=%s matches %s on branch %s\n", cfg.CurrentRelease, branchVersion, cfg.ReleaseBranch) + return nil +} + +func prepPatchBeforeBuild(repo *GitRepo, cfg *ReleaseConfig) (workflowPR, error) { + branch := fmt.Sprintf("patch-release-%s", cfg.CurrentRelease) + fmt.Printf("\n--- Preparing PR-A: ensure version %s on %s ---\n", cfg.CurrentRelease, cfg.ReleaseBranch) + + if err := repo.EnsureBranchFrom(cfg.ReleaseBranch, branch); err != nil { + return workflowPR{}, err + } + // Fleet Server has no docs/test-env files. Former prepare-patch-release only + // updated version.go; with the shared CURRENT_RELEASE guard that is usually a no-op. + if err := UpdateVersion(cfg.CurrentRelease); err != nil { + return workflowPR{}, err + } + commitMsg := fmt.Sprintf("[Release %s] Update docs versions %s", cfg.CurrentRelease, cfg.CurrentRelease) + if _, err := repo.CommitAll(commitMsg, cfg.GitAuthorName, cfg.GitAuthorEmail); err != nil { + return workflowPR{}, err + } + + return workflowPR{ + branch: branch, + base: cfg.ReleaseBranch, + opts: PROptions{ + Owner: cfg.ProjectOwner, + Repo: cfg.ProjectRepo, + Title: fmt.Sprintf("[Release %s] Update docs versions %s", cfg.CurrentRelease, cfg.CurrentRelease), + Head: branch, + Base: cfg.ReleaseBranch, + Body: patchBeforeBuildPRBody(cfg.CurrentRelease), + Reviewers: cfg.ProjectReviewers, + Labels: patchBeforeBuildPRLabels(), + }, + }, nil +} + +func patchBeforeBuildPRBody(currentRelease string) string { + return fmt.Sprintf(`## [Release %s] + +Former prepare-patch-release PR for Fleet Server. + +Fleet Server has no docs/K8s updates; version/version.go is already %s on the release branch, so this PR is often empty and skipped. + +**Merge:** before the final Release build (when changes exist). +`, currentRelease, currentRelease) +} + +func finalizeWorkflowPRs(repo *GitRepo, gh *GitHubClient, items []workflowPR) ([]workflowPRResult, error) { + results := make([]workflowPRResult, 0, len(items)) + for i, item := range items { + pr, err := finalizePR(repo, gh, item.branch, item.base, item.opts) + if err != nil { + return results, fmt.Errorf("failed to finalize PR %d/%d: %w", i+1, len(items), err) + } + results = append(results, workflowPRResult{item: item, pr: pr}) + } + return results, nil +} + +func printWorkflowPRResults(results []workflowPRResult) { + for i, result := range results { + fmt.Println(formatWorkflowPRLine(i+1, result)) + } +} + +func prsFromWorkflowResults(results []workflowPRResult) []*github.PullRequest { + var prs []*github.PullRequest + for _, result := range results { + if result.pr != nil { + prs = append(prs, result.pr) + } + } + return prs +} + +func formatWorkflowPRLine(index int, result workflowPRResult) string { + if result.pr != nil { + return fmt.Sprintf("PR %d: %s (%s)", index, result.pr.GetHTMLURL(), prDisplayState(result.pr)) + } + return fmt.Sprintf( + "PR %d: skipped (no related open/merged PR for %s → %s)", + index, result.item.branch, result.item.base, + ) +} + +// finalizePR pushes a branch when it has new commits and creates or reuses an open PR. +// When the branch has nothing new to push, it still resolves a related open or merged PR +// so workflow summaries always list every expected slot. +func finalizePR(repo *GitRepo, gh *GitHubClient, branchName, baseBranch string, opts PROptions) (*github.PullRequest, error) { + if err := repo.CheckoutBranch(branchName); err != nil { + return nil, err + } + + existingPR, found, err := gh.FindOpenPR(opts.Owner, opts.Repo, opts.Head, opts.Base) + if err != nil { + return nil, err + } + if found { + gh.ensurePRLabels(opts.Owner, opts.Repo, existingPR.GetNumber(), opts.Labels) + return existingPR, nil + } + + ahead, err := repo.HasCommitsAheadOf(baseBranch) + if err != nil { + return nil, err + } + if !ahead { + fmt.Printf("No new commits on %s compared to %s; skipping push and PR creation\n", branchName, baseBranch) + related, found, err := gh.FindRelatedPR(opts.Owner, opts.Repo, opts.Head, opts.Base, opts.Title) + if err != nil { + return nil, err + } + if found { + fmt.Printf("Related PR #%d (%s): %s\n", related.GetNumber(), prDisplayState(related), related.GetHTMLURL()) + return related, nil + } + return nil, nil + } + + if err := repo.Push("origin"); err != nil { + return nil, err + } + + return gh.CreatePR(opts) +} diff --git a/dev-tools/mage/release/workflows_test.go b/dev-tools/mage/release/workflows_test.go new file mode 100644 index 0000000000..f63cb82e1f --- /dev/null +++ b/dev-tools/mage/release/workflows_test.go @@ -0,0 +1,66 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. + +package release + +import ( + "strings" + "testing" +) + +func TestLabelHelpers(t *testing.T) { + if got := backportLabel("9.6"); got != "backport-9.6" { + t.Fatalf("backportLabel = %q", got) + } + + labels := prAMainLabels("9.6") + if !contains(labels, mergeLabelFFDay) || !contains(labels, "backport-9.6") { + t.Fatalf("prAMainLabels = %v", labels) + } + if contains(labels, mergeLabelAfterImages) { + t.Fatal("PR-A must not include merge:3-after-images (PR-C omitted)") + } + + if !contains(prBReleaseLabels(), mergeLabelAfterBranch) { + t.Fatal("PR-B missing merge:2-after-branch") + } + if !contains(prDNextPatchLabels(), mergeLabelAfterRelease) { + t.Fatal("PR-D missing merge:4-after-release") + } + if !contains(patchBeforeBuildPRLabels(), mergeLabelBeforeBuild) { + t.Fatal("patch PR-A missing merge:1-before-build") + } +} + +func TestPRBodiesMentionFleetScope(t *testing.T) { + cfg := &ReleaseConfig{ + CurrentRelease: "9.6.0", + NextRelease: "9.6.1", + NextProjectMinorVersion: "9.7.0", + BaseBranch: "main", + ReleaseBranch: "9.6", + } + + if !strings.Contains(prAMainBody(cfg), "version/version.go") { + t.Fatal("PR-A body missing version.go mention") + } + if !strings.Contains(prBReleaseBody(cfg), "no docs/test-env") { + t.Fatal("PR-B body should note missing docs/test-env") + } + if !strings.Contains(prDNextPatchBody(cfg), "9.6.1") { + t.Fatal("PR-D body missing next patch") + } + if !strings.Contains(patchBeforeBuildPRBody("9.6.1"), "often empty") { + t.Fatal("patch PR-A body should note often-empty PR") + } +} + +func contains(ss []string, want string) bool { + for _, s := range ss { + if s == want { + return true + } + } + return false +} diff --git a/go.mod b/go.mod index b920004ffc..0ac423dbb3 100644 --- a/go.mod +++ b/go.mod @@ -100,3 +100,5 @@ require ( gopkg.in/yaml.v2 v2.4.0 // indirect howett.net/plist v1.0.1 // indirect ) + +replace github.com/elastic/fleet-server/dev-tools => ./dev-tools diff --git a/magefile.go b/magefile.go index 02c5f7a2fc..0d743886fa 100644 --- a/magefile.go +++ b/magefile.go @@ -356,6 +356,9 @@ type Test mg.Namespace // Docker is the namespace for docker related tasks. type Docker mg.Namespace +// Release is the namespace for release automation. +type Release mg.Namespace + // envToBool reads the env var string s and parses it as a bool. func envToBool(s string) bool { v, ok := os.LookupEnv(s) @@ -2294,3 +2297,44 @@ func (Test) CloudE2ERun() error { err = errors.Join(err, os.WriteFile(filepath.Join("build", "test-cloude2e.out"), b.Bytes(), 0o644)) return err } + +const releaseToolDir = "dev-tools/mage/release" + +// runReleaseTool invokes the nested-module CLI with the Fleet Server repo as cwd. +func runReleaseTool(args ...string) error { + root, err := os.Getwd() + if err != nil { + return err + } + env := map[string]string{"FLEET_SERVER_REPO_ROOT": root} + cmdArgs := append([]string{"run", "-C", releaseToolDir, "./cmd/fleet-release"}, args...) + return sh.RunWithV(env, "go", cmdArgs...) +} + +// UpdateVersion updates the version in version/version.go. +func (Release) UpdateVersion(version string) error { + return runReleaseTool("update-version", version) +} + +// UpdateMergify adds a new backport rule to .mergify.yml. +func (Release) UpdateMergify(version string) error { + return runReleaseTool("update-mergify", version) +} + +// RunMajorMinor orchestrates the major/minor release workflow after feature freeze. +// Set DRY_RUN=true to preview changes without pushing. +func (Release) RunMajorMinor() error { + return runReleaseTool("run-major-minor") +} + +// RunPatch orchestrates the complete patch release workflow. +// Set DRY_RUN=true to preview changes without pushing. +func (Release) RunPatch() error { + return runReleaseTool("run-patch") +} + +// EnsureIssueTracker creates or updates the Fleet Server release checklist issue for +// CURRENT_RELEASE, linking related PRs labeled "release". +func (Release) EnsureIssueTracker() error { + return runReleaseTool("ensure-issue-tracker") +}