diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 000000000..c499382be --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,48 @@ +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + publish: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install Dagger + run: | + curl -fsSL https://dl.dagger.io/dagger/install.sh | BIN_DIR=/usr/local/bin sudo -E sh + - name: Extract version + id: version + run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + - name: Publish release + run: | + dagger call publish \ + --version "${{ steps.version.outputs.version }}" \ + --one-password-service-account-production env:OP_SERVICE_ACCOUNT_PRODUCTION \ + --github-token env:GITHUB_TOKEN \ + --clean \ + --progress plain + env: + OP_SERVICE_ACCOUNT_PRODUCTION: ${{ secrets.OP_SERVICE_ACCOUNT_PRODUCTION }} + GITHUB_TOKEN: ${{ secrets.GH_PAT }} + + docs: + runs-on: ubuntu-24.04 + needs: publish + steps: + - uses: actions/checkout@v4 + - name: Install Dagger + run: | + curl -fsSL https://dl.dagger.io/dagger/install.sh | BIN_DIR=/usr/local/bin sudo -E sh + - name: Generate docs PR + run: | + dagger call generate-docs \ + --github-token env:GITHUB_TOKEN \ + --skip-validation \ + --progress plain + env: + GITHUB_TOKEN: ${{ secrets.GH_PAT }} diff --git a/Makefile b/Makefile index c77db3ddc..3ef920ea1 100644 --- a/Makefile +++ b/Makefile @@ -93,18 +93,4 @@ build: .PHONY: release release: - dagger call release \ - --one-password-service-account-production env:OP_SERVICE_ACCOUNT_PRODUCTION \ - --version $(version) \ - --github-token env:GITHUB_TOKEN \ - --progress plain - @echo "" - @echo "✓ Release completed successfully" - @echo "Generating documentation PR..." - @$(MAKE) docs - -.PHONY: docs -docs: - dagger --progress plain call generate-docs \ - --github-token env:GITHUB_TOKEN \ - --progress plain + ./scripts/release-tag.sh $(version) diff --git a/dagger/docs.go b/dagger/docs.go index 35d248606..3ee090d34 100644 --- a/dagger/docs.go +++ b/dagger/docs.go @@ -23,10 +23,16 @@ func (r *Replicated) GenerateDocs( source *dagger.Directory, githubToken *dagger.Secret, + + // Skip git tree validation (useful in CI where the checkout is a tag, not main) + // +default=false + skipValidation bool, ) error { - err := checkGitTree(ctx, source, githubToken) - if err != nil { - return errors.Wrap(err, "failed to check git tree") + if !skipValidation { + err := checkGitTree(ctx, source, githubToken) + if err != nil { + return errors.Wrap(err, "failed to check git tree") + } } latestVersion, err := getLatestVersion(ctx, githubToken) diff --git a/dagger/release.go b/dagger/release.go index f0df52780..8adafe8bb 100644 --- a/dagger/release.go +++ b/dagger/release.go @@ -14,12 +14,16 @@ import ( var goreleaserVersion = "v2.10.2" -func (r *Replicated) Release( +// Publish builds and publishes the release artifacts (Docker images, GoReleaser) +// for a version that has already been tagged and pushed. This is intended to be +// called from CI after the tag is created. +func (r *Replicated) Publish( ctx context.Context, // +defaultPath="./" source *dagger.Directory, + // The version tag to publish (e.g. "v1.2.3") version string, // +default=false @@ -32,79 +36,23 @@ func (r *Replicated) Release( githubToken *dagger.Secret, ) error { - err := checkGitTree(ctx, source, githubToken) - if err != nil { - return errors.Wrap(err, "failed to check git tree") - } - - previousVersionTag, err := getLatestVersion(ctx, githubToken) - if err != nil { - return errors.Wrap(err, "failed to get latest version") - } - - previousReleaseBranchName, err := getReleaseBranchName(ctx, previousVersionTag) - if err != nil { - return errors.Wrap(err, "failed to get release branch name") - } - - major, minor, patch, err := getNextVersion(ctx, previousVersionTag, version) - if err != nil { - return errors.Wrap(err, "failed to get next version") - } - - fmt.Printf("Releasing as version %d.%d.%d\n", major, minor, patch) - - // replace the version in the Makefile - buildFileContent, err := source.File("./pkg/version/build.go").Contents(ctx) - if err != nil { - return errors.Wrap(err, "failed to get build file contents") - } - buildFileContent = strings.ReplaceAll(buildFileContent, "const version = \"unknown\"", fmt.Sprintf("const version = \"%d.%d.%d\"", major, minor, patch)) - updatedSource := source.WithNewFile("./pkg/version/build.go", buildFileContent) - - releaseBranchName := fmt.Sprintf("release-%d.%d.%d", major, minor, patch) - githubTokenPlaintext, err := githubToken.Plaintext(ctx) - if err != nil { - return errors.Wrap(err, "failed to get github token plaintext") - } - - // mount that and commit the updated build.go to git (don't push) - // so that goreleaser won't have a dirty git tree error - gitCommitContainer := dag.Container(). - From("alpine/git:latest"). - WithMountedDirectory("/go/src/github.com/replicatedhq/replicated", updatedSource). - WithWorkdir("/go/src/github.com/replicatedhq/replicated"). - WithExec([]string{"git", "config", "user.email", "release@replicated.com"}). - WithExec([]string{"git", "config", "user.name", "Replicated Release Pipeline"}). - WithExec([]string{"git", "remote", "add", "dagger", fmt.Sprintf("https://%s@github.com/replicatedhq/replicated.git", githubTokenPlaintext)}). - WithExec([]string{"git", "checkout", "-b", releaseBranchName}). - WithExec([]string{"git", "add", "pkg/version/build.go"}). - WithExec([]string{"git", "commit", "-m", fmt.Sprintf("Set version to %d.%d.%d", major, minor, patch)}). - WithExec([]string{"git", "push", "dagger", releaseBranchName}) - _, err = gitCommitContainer.Stdout(ctx) + parsedVersion, err := semver.NewVersion(version) if err != nil { - return errors.Wrap(err, "failed to get git commit stdout") + return errors.Wrapf(err, "failed to parse version %q", version) } - updatedSource = gitCommitContainer.Directory("/go/src/github.com/replicatedhq/replicated") + major := parsedVersion.Major() + minor := parsedVersion.Minor() + patch := parsedVersion.Patch() nextVersionTag := fmt.Sprintf("v%d.%d.%d", major, minor, patch) - tagContainer := dag.Container(). - From("alpine/git:latest"). - WithMountedDirectory("/go/src/github.com/replicatedhq/replicated", updatedSource). - WithWorkdir("/go/src/github.com/replicatedhq/replicated"). - With(CacheBustingExec([]string{"git", "tag", nextVersionTag})). - With(CacheBustingExec([]string{"git", "push", "dagger", nextVersionTag})). - With(CacheBustingExec([]string{"git", "fetch", "dagger", previousReleaseBranchName})). - With(CacheBustingExec([]string{"git", "fetch", "dagger", "--tags"})) - _, err = tagContainer.Stdout(ctx) + fmt.Printf("Publishing version %d.%d.%d\n", major, minor, patch) + + previousVersionTag, err := getLatestVersion(ctx, githubToken) if err != nil { - return errors.Wrap(err, "failed to get tag stdout") + return errors.Wrap(err, "failed to get latest version") } - // copy the source that has the tag included in it - updatedSource = tagContainer.Directory("/go/src/github.com/replicatedhq/replicated") - goModCache := dag.CacheVolume("replicated-go-mod-122") goBuildCache := dag.CacheVolume("replicated-go-build-121") @@ -112,7 +60,7 @@ func (r *Replicated) Release( Platform: "linux/amd64", }). From("golang:1.24"). - WithMountedDirectory("/go/src/github.com/replicatedhq/replicated", updatedSource). + WithMountedDirectory("/go/src/github.com/replicatedhq/replicated", source). WithoutFile("/go/src/github.com/replicatedhq/replicated/bin/replicated"). WithWorkdir("/go/src/github.com/replicatedhq/replicated"). WithMountedCache("/go/pkg/mod", goModCache). @@ -182,7 +130,7 @@ func (r *Replicated) Release( Version: goreleaserVersion, Ctr: goreleaserContainer, }). - WithSource(updatedSource). + WithSource(source). Snapshot(ctx, dagger.GoreleaserSnapshotOpts{ Clean: clean, }) @@ -195,7 +143,7 @@ func (r *Replicated) Release( Version: goreleaserVersion, Ctr: goreleaserContainer, }). - WithSource(updatedSource). + WithSource(source). Release(ctx, dagger.GoreleaserReleaseOpts{ Clean: clean, }) @@ -207,6 +155,102 @@ func (r *Replicated) Release( return nil } +// Release handles git operations (branch, tag, push) and then delegates to +// Publish for building and publishing artifacts. Prefer using `make release` + +// CI for new releases, which splits these two phases between local and CI. +func (r *Replicated) Release( + ctx context.Context, + + // +defaultPath="./" + source *dagger.Directory, + + version string, + + // +default=false + snapshot bool, + + // +default=false + clean bool, + + onePasswordServiceAccountProduction *dagger.Secret, + + githubToken *dagger.Secret, +) error { + err := checkGitTree(ctx, source, githubToken) + if err != nil { + return errors.Wrap(err, "failed to check git tree") + } + + previousVersionTag, err := getLatestVersion(ctx, githubToken) + if err != nil { + return errors.Wrap(err, "failed to get latest version") + } + + previousReleaseBranchName, err := getReleaseBranchName(ctx, previousVersionTag) + if err != nil { + return errors.Wrap(err, "failed to get release branch name") + } + + major, minor, patch, err := getNextVersion(ctx, previousVersionTag, version) + if err != nil { + return errors.Wrap(err, "failed to get next version") + } + + fmt.Printf("Releasing as version %d.%d.%d\n", major, minor, patch) + + // replace the version in build.go + buildFileContent, err := source.File("./pkg/version/build.go").Contents(ctx) + if err != nil { + return errors.Wrap(err, "failed to get build file contents") + } + buildFileContent = strings.ReplaceAll(buildFileContent, "const version = \"unknown\"", fmt.Sprintf("const version = \"%d.%d.%d\"", major, minor, patch)) + updatedSource := source.WithNewFile("./pkg/version/build.go", buildFileContent) + + releaseBranchName := fmt.Sprintf("release-%d.%d.%d", major, minor, patch) + githubTokenPlaintext, err := githubToken.Plaintext(ctx) + if err != nil { + return errors.Wrap(err, "failed to get github token plaintext") + } + + // commit the updated build.go to a release branch so goreleaser has a clean tree + gitCommitContainer := dag.Container(). + From("alpine/git:latest"). + WithMountedDirectory("/go/src/github.com/replicatedhq/replicated", updatedSource). + WithWorkdir("/go/src/github.com/replicatedhq/replicated"). + WithExec([]string{"git", "config", "user.email", "release@replicated.com"}). + WithExec([]string{"git", "config", "user.name", "Replicated Release Pipeline"}). + WithExec([]string{"git", "remote", "add", "dagger", fmt.Sprintf("https://%s@github.com/replicatedhq/replicated.git", githubTokenPlaintext)}). + WithExec([]string{"git", "checkout", "-b", releaseBranchName}). + WithExec([]string{"git", "add", "pkg/version/build.go"}). + WithExec([]string{"git", "commit", "-m", fmt.Sprintf("Set version to %d.%d.%d", major, minor, patch)}). + WithExec([]string{"git", "push", "dagger", releaseBranchName}) + _, err = gitCommitContainer.Stdout(ctx) + if err != nil { + return errors.Wrap(err, "failed to get git commit stdout") + } + updatedSource = gitCommitContainer.Directory("/go/src/github.com/replicatedhq/replicated") + + nextVersionTag := fmt.Sprintf("v%d.%d.%d", major, minor, patch) + + tagContainer := dag.Container(). + From("alpine/git:latest"). + WithMountedDirectory("/go/src/github.com/replicatedhq/replicated", updatedSource). + WithWorkdir("/go/src/github.com/replicatedhq/replicated"). + With(CacheBustingExec([]string{"git", "tag", nextVersionTag})). + With(CacheBustingExec([]string{"git", "push", "dagger", nextVersionTag})). + With(CacheBustingExec([]string{"git", "fetch", "dagger", previousReleaseBranchName})). + With(CacheBustingExec([]string{"git", "fetch", "dagger", "--tags"})) + _, err = tagContainer.Stdout(ctx) + if err != nil { + return errors.Wrap(err, "failed to get tag stdout") + } + + updatedSource = tagContainer.Directory("/go/src/github.com/replicatedhq/replicated") + + // Delegate to Publish for building and publishing artifacts + return r.Publish(ctx, updatedSource, nextVersionTag, snapshot, clean, onePasswordServiceAccountProduction, githubToken) +} + func getNextVersion(ctx context.Context, latestVersion string, version string) (int64, int64, int64, error) { parsedLatestVersion, err := semver.NewVersion(latestVersion) if err != nil { diff --git a/scripts/release-tag.sh b/scripts/release-tag.sh new file mode 100755 index 000000000..60d33089c --- /dev/null +++ b/scripts/release-tag.sh @@ -0,0 +1,95 @@ +#!/bin/bash +set -euo pipefail + +VERSION="${1:?Usage: $0 }" + +# Validate clean git tree on main +if [[ -n $(git status --porcelain) ]]; then + echo "Error: git tree is not clean" >&2 + exit 1 +fi + +BRANCH=$(git branch --show-current) +if [[ "$BRANCH" != "main" ]]; then + echo "Error: must be on main branch (currently on $BRANCH)" >&2 + exit 1 +fi + +# Verify HEAD is pushed to remote +HEAD=$(git rev-parse HEAD) +git fetch origin main --quiet +REMOTE_HEAD=$(git rev-parse origin/main) +if [[ "$HEAD" != "$REMOTE_HEAD" ]]; then + echo "Error: local HEAD doesn't match origin/main. Push your changes first." >&2 + exit 1 +fi + +# Get latest version tag +LATEST_TAG=$(git describe --tags --abbrev=0 --match 'v*' 2>/dev/null || echo "v0.0.0") + +# Parse semver from latest tag +if [[ "$LATEST_TAG" =~ ^v?([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + CUR_MAJOR="${BASH_REMATCH[1]}" + CUR_MINOR="${BASH_REMATCH[2]}" + CUR_PATCH="${BASH_REMATCH[3]}" +else + echo "Error: could not parse latest tag '$LATEST_TAG'" >&2 + exit 1 +fi + +# Calculate next version +case "$VERSION" in + major) + NEW_MAJOR=$((CUR_MAJOR + 1)) + NEW_MINOR=0 + NEW_PATCH=0 + ;; + minor) + NEW_MAJOR=$CUR_MAJOR + NEW_MINOR=$((CUR_MINOR + 1)) + NEW_PATCH=0 + ;; + patch) + NEW_MAJOR=$CUR_MAJOR + NEW_MINOR=$CUR_MINOR + NEW_PATCH=$((CUR_PATCH + 1)) + ;; + *) + if [[ "$VERSION" =~ ^v?([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + NEW_MAJOR="${BASH_REMATCH[1]}" + NEW_MINOR="${BASH_REMATCH[2]}" + NEW_PATCH="${BASH_REMATCH[3]}" + else + echo "Error: version must be 'major', 'minor', 'patch', or a semver string (e.g. 1.2.3)" >&2 + exit 1 + fi + ;; +esac + +NEW_VERSION="${NEW_MAJOR}.${NEW_MINOR}.${NEW_PATCH}" +TAG="v${NEW_VERSION}" +RELEASE_BRANCH="release-${NEW_VERSION}" + +echo "Releasing as version ${NEW_VERSION} (previous: ${LATEST_TAG})" + +# Update build.go with the version +sed -i.bak "s/const version = \"unknown\"/const version = \"${NEW_VERSION}\"/" pkg/version/build.go +rm -f pkg/version/build.go.bak + +# Create release branch with version commit +git checkout -b "$RELEASE_BRANCH" +git add pkg/version/build.go +git commit -m "Set version to ${NEW_VERSION}" +git push origin "$RELEASE_BRANCH" + +# Create and push tag on the release branch +git tag "$TAG" +git push origin "$TAG" + +# Return to main (build.go on main stays as "unknown") +git checkout main + +echo "" +echo "✓ Tag ${TAG} created and pushed" +echo "✓ Release branch ${RELEASE_BRANCH} pushed" +echo "GitHub Actions will handle the release build"