diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 000000000..86a3915e6 --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,44 @@ +name: Nightly Release + +on: + schedule: + - cron: "0 6 * * *" # 06:00 UTC daily + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: nightly-release-${{ github.ref }} + cancel-in-progress: true +jobs: + create-nightly-tag: + runs-on: ubuntu-latest + steps: + - name: Generate token + id: app-token + uses: actions/create-github-app-token@v3.0.0 + with: + app-id: ${{ secrets.HOMEBREW_TAP_APP_ID }} + private-key: ${{ secrets.HOMEBREW_TAP_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: cli + + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} + + - name: Create nightly tag + run: | + TAG=$(scripts/create-nightly-tag.sh) + EXIT_CODE=$? + if [ "$EXIT_CODE" -eq 2 ]; then + echo "Skipping — no new nightly needed." + exit 0 + elif [ "$EXIT_CODE" -ne 0 ] || [ -z "$TAG" ]; then + echo "::error::Failed to generate nightly tag" + exit 1 + fi + git tag "$TAG" + git push origin "$TAG" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 99b19b3f4..d66351eca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,7 +30,18 @@ jobs: homebrew-tap scoop-bucket + - name: Detect release type + id: release-type + run: | + VERSION="${GITHUB_REF_NAME#v}" + if [[ "$VERSION" == *-* ]]; then + echo "prerelease=true" >> "$GITHUB_OUTPUT" + else + echo "prerelease=false" >> "$GITHUB_OUTPUT" + fi + - name: Extract release notes from CHANGELOG.md + if: steps.release-type.outputs.prerelease == 'false' run: | VERSION="${GITHUB_REF_NAME#v}" awk -v ver="$VERSION" 'BEGIN{header="^## \\[" ver "\\]"} $0 ~ header{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md > "$RUNNER_TEMP/release_notes.md" @@ -39,6 +50,23 @@ jobs: exit 1 fi + - name: Generate nightly release notes + if: steps.release-type.outputs.prerelease == 'true' + run: | + LAST_NIGHTLY=$(git tag -l 'v*-nightly.*' --sort=-creatordate | grep -v "^${GITHUB_REF_NAME}$" | head -1) + if [ -n "$LAST_NIGHTLY" ]; then + BASE_TAG="$LAST_NIGHTLY" + else + BASE_TAG=$(git describe --tags --abbrev=0 --match 'v[0-9]*' --exclude '*-*' HEAD 2>/dev/null || echo "") + fi + echo "## Nightly Build (${GITHUB_REF_NAME})" > "$RUNNER_TEMP/release_notes.md" + echo "" >> "$RUNNER_TEMP/release_notes.md" + if [ -n "$BASE_TAG" ]; then + echo "Changes since ${BASE_TAG}:" >> "$RUNNER_TEMP/release_notes.md" + echo "" >> "$RUNNER_TEMP/release_notes.md" + git log --oneline "${BASE_TAG}..HEAD" --no-merges >> "$RUNNER_TEMP/release_notes.md" + fi + - name: Run GoReleaser uses: goreleaser/goreleaser-action@v7 with: diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 0191ad1b0..2d88597e5 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -66,6 +66,8 @@ archives: checksum: name_template: "checksums.txt" +release: + prerelease: auto scoops: - repository: @@ -75,6 +77,7 @@ scoops: homepage: "https://github.com/entireio/cli" description: "CLI for Entire" license: MIT + skip_upload: auto homebrew_casks: - name: entire @@ -91,9 +94,30 @@ homebrew_casks: bash: "completions/entire.bash" zsh: "completions/entire.zsh" fish: "completions/entire.fish" + conflicts: + - cask: entire@nightly + skip_upload: auto + + - name: entire@nightly + repository: + owner: entireio + name: homebrew-tap + token: "{{ .Env.TAP_GITHUB_TOKEN }}" + directory: Casks + homepage: "https://github.com/entireio/cli" + description: "CLI for Entire (nightly build)" + binaries: + - entire + completions: + bash: "completions/entire.bash" + zsh: "completions/entire.zsh" + fish: "completions/entire.fish" + conflicts: + - cask: entire + skip_upload: "{{ if not .Prerelease }}true{{ end }}" announce: discord: - enabled: '{{ and (isEnvSet "DISCORD_WEBHOOK_ID") (isEnvSet "DISCORD_WEBHOOK_TOKEN") }}' + enabled: '{{ and (not .Prerelease) (isEnvSet "DISCORD_WEBHOOK_ID") (isEnvSet "DISCORD_WEBHOOK_TOKEN") }}' message_template: "Beep, boop. **Entire CLI {{ .Tag }}** is out!\n\n{{ .ReleaseURL }}\n\nSee https://docs.entire.io/cli/installation for help installing and updating." author: "Entire" diff --git a/cmd/entire/cli/versioncheck/types.go b/cmd/entire/cli/versioncheck/types.go index d4773416d..6b02e90e2 100644 --- a/cmd/entire/cli/versioncheck/types.go +++ b/cmd/entire/cli/versioncheck/types.go @@ -13,10 +13,13 @@ type GitHubRelease struct { Prerelease bool `json:"prerelease"` } -// githubAPIURL is the GitHub API endpoint for fetching the latest release. +// githubAPIURL is the GitHub API endpoint for fetching the latest stable release. // This is a var (not const) to allow overriding in tests. var githubAPIURL = "https://api.github.com/repos/entireio/cli/releases/latest" +// githubReleasesURL is the GitHub API endpoint for listing releases (used for nightly checks). +var githubReleasesURL = "https://api.github.com/repos/entireio/cli/releases" + const ( // checkInterval is the duration between version checks. checkInterval = 24 * time.Hour diff --git a/cmd/entire/cli/versioncheck/versioncheck.go b/cmd/entire/cli/versioncheck/versioncheck.go index df989b3cf..e7568cfb0 100644 --- a/cmd/entire/cli/versioncheck/versioncheck.go +++ b/cmd/entire/cli/versioncheck/versioncheck.go @@ -13,6 +13,7 @@ import ( "time" "github.com/entireio/cli/cmd/entire/cli/logging" + "github.com/entireio/cli/cmd/entire/cli/versioninfo" "golang.org/x/mod/semver" ) @@ -42,8 +43,13 @@ func CheckAndNotify(ctx context.Context, w io.Writer, currentVersion string) { return } - // Fetch the latest version from GitHub API - latestVersion, err := fetchLatestVersion(ctx) + // Fetch the latest version from the appropriate channel + var latestVersion string + if isNightly(currentVersion) { + latestVersion, err = fetchLatestNightlyVersion(ctx) + } else { + latestVersion, err = fetchLatestVersion(ctx) + } // Always update cache to avoid retrying on every CLI invocation cache.LastCheckTime = time.Now() @@ -200,6 +206,57 @@ func fetchLatestVersion(ctx context.Context) (string, error) { return version, nil } +// isNightly returns true if the version string is a nightly build. +func isNightly(version string) bool { + if !strings.HasPrefix(version, "v") { + version = "v" + version + } + return strings.Contains(semver.Prerelease(version), "nightly") +} + +// fetchLatestNightlyVersion fetches the latest nightly version from the GitHub releases list. +func fetchLatestNightlyVersion(ctx context.Context) (string, error) { + ctx, cancel := context.WithTimeout(ctx, httpTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, githubReleasesURL+"?per_page=20", nil) + if err != nil { + return "", fmt.Errorf("creating request: %w", err) + } + + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "entire-cli/"+versioninfo.Version) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("fetching releases: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return "", fmt.Errorf("reading response: %w", err) + } + + var releases []GitHubRelease + if err := json.Unmarshal(body, &releases); err != nil { + return "", fmt.Errorf("parsing JSON: %w", err) + } + + for _, r := range releases { + if r.Prerelease && strings.Contains(r.TagName, "-nightly.") { + return r.TagName, nil + } + } + + return "", errors.New("no nightly release found") +} + // parseGitHubRelease parses the GitHub API response and extracts the latest stable version. // Filters out prerelease versions. func parseGitHubRelease(body []byte) (string, error) { diff --git a/cmd/entire/cli/versioncheck/versioncheck_test.go b/cmd/entire/cli/versioncheck/versioncheck_test.go index 42dea19c9..26fabdf0b 100644 --- a/cmd/entire/cli/versioncheck/versioncheck_test.go +++ b/cmd/entire/cli/versioncheck/versioncheck_test.go @@ -40,6 +40,11 @@ func TestIsOutdated(t *testing.T) { {"1.0.0-rc1", "1.0.0", true, "prerelease in current"}, {"1.0.0", "1.0.1-rc1", true, "prerelease in latest is still newer"}, {"1.0.0-dev-xxx", "1.0.1", false, "dev build skips version check"}, + + // Nightly-vs-nightly comparisons (same channel) + {"0.5.3-nightly.202604051159.abc1234", "0.5.3-nightly.202604061200.def5678", true, "older nightly is outdated by newer nightly"}, + {"0.5.3-nightly.202604061200.def5678", "0.5.3-nightly.202604051159.abc1234", false, "newer nightly is not outdated by older nightly"}, + {"0.5.3-nightly.202604061200.abc1234", "0.5.3-nightly.202604061200.abc1234", false, "same nightly is not outdated"}, } for _, tt := range tests { @@ -52,6 +57,77 @@ func TestIsOutdated(t *testing.T) { } } +func TestIsNightly(t *testing.T) { + t.Parallel() + tests := []struct { + version string + want bool + }{ + {"0.5.3-nightly.202604061200.abc1234", true}, + {"v0.5.3-nightly.202604061200.abc1234", true}, + {"1.0.0", false}, + {"v1.0.0", false}, + {"1.0.0-rc1", false}, + {"1.0.0-dev-xxx", false}, + {"dev", false}, + } + for _, tt := range tests { + t.Run(tt.version, func(t *testing.T) { + t.Parallel() + if got := isNightly(tt.version); got != tt.want { + t.Errorf("isNightly(%q) = %v, want %v", tt.version, got, tt.want) + } + }) + } +} + +func TestFetchLatestNightlyVersion(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + releases := []GitHubRelease{ + {TagName: "v0.5.4", Prerelease: false}, + {TagName: "v0.5.4-nightly.202604061200.abc1234", Prerelease: true}, + {TagName: "v0.5.4-nightly.202604051159.def5678", Prerelease: true}, + } + w.Header().Set("Content-Type", "application/json") + //nolint:errcheck // test helper + json.NewEncoder(w).Encode(releases) + })) + defer server.Close() + + original := githubReleasesURL + githubReleasesURL = server.URL + t.Cleanup(func() { githubReleasesURL = original }) + + version, err := fetchLatestNightlyVersion(context.Background()) + if err != nil { + t.Fatalf("fetchLatestNightlyVersion() error = %v", err) + } + if version != "v0.5.4-nightly.202604061200.abc1234" { + t.Errorf("fetchLatestNightlyVersion() = %q, want v0.5.4-nightly.202604061200.abc1234", version) + } +} + +func TestFetchLatestNightlyVersion_NoNightlies(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + releases := []GitHubRelease{ + {TagName: "v0.5.4", Prerelease: false}, + } + w.Header().Set("Content-Type", "application/json") + //nolint:errcheck // test helper + json.NewEncoder(w).Encode(releases) + })) + defer server.Close() + + original := githubReleasesURL + githubReleasesURL = server.URL + t.Cleanup(func() { githubReleasesURL = original }) + + _, err := fetchLatestNightlyVersion(context.Background()) + if err == nil { + t.Fatal("fetchLatestNightlyVersion() expected error when no nightlies, got nil") + } +} + func TestCacheReadWrite(t *testing.T) { // Create a temporary directory for the test tmpDir := t.TempDir() diff --git a/mise-tasks/release b/mise-tasks/release new file mode 100755 index 000000000..57b054746 --- /dev/null +++ b/mise-tasks/release @@ -0,0 +1,45 @@ +#!/bin/sh +#MISE description="Create stable + nightly release tags and push them (usage: mise run release vX.X.X)" +set -eu + +VERSION="${1:-}" +if [ -z "$VERSION" ]; then + echo "Usage: mise run release vX.X.X" + exit 1 +fi + +# Ensure version starts with v +case "$VERSION" in + v*) ;; + *) VERSION="v${VERSION}" ;; +esac + +# Validate semver format (vMAJOR.MINOR.PATCH) +if ! echo "$VERSION" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "Error: invalid version format '${VERSION}', expected vMAJOR.MINOR.PATCH" + exit 1 +fi + +# Ensure working tree is clean +if [ -n "$(git status --porcelain)" ]; then + echo "Error: working tree is not clean, commit or stash changes first" + exit 1 +fi + +# Create stable tag first so the nightly script can find it +git tag "$VERSION" +echo "Created tag ${VERSION}" + +# Generate and create nightly tag if possible +NIGHTLY_TAG=$(scripts/create-nightly-tag.sh) || true + +git push origin "$VERSION" +echo "Pushed tag ${VERSION}" + +if [ -n "$NIGHTLY_TAG" ]; then + git tag "$NIGHTLY_TAG" + echo "Created tag ${NIGHTLY_TAG}" + git push origin "$NIGHTLY_TAG" +fi +echo "" +echo "Done. Release workflows will start automatically." diff --git a/scripts/create-nightly-tag.sh b/scripts/create-nightly-tag.sh new file mode 100755 index 000000000..8ab46b781 --- /dev/null +++ b/scripts/create-nightly-tag.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Calculate the next nightly release tag based on the latest stable tag and HEAD. +# Outputs the tag name to stdout, or exits 0 silently if a nightly already exists for HEAD. +# +# Tag format: v..-nightly.. +# Usage: scripts/create-nightly-tag.sh + +SHORT_COMMIT=$(git rev-parse --short HEAD) + +# Skip if a nightly tag already exists for this commit +if git tag -l "v*-nightly.*.${SHORT_COMMIT}" | grep -q .; then + echo "Nightly tag already exists for commit ${SHORT_COMMIT}, skipping." >&2 + exit 2 +fi + +# Find the latest stable version tag (exclude prereleases) +LATEST_STABLE=$(git describe --tags --abbrev=0 --match 'v[0-9]*' --exclude '*-*' 2>/dev/null) +if [ -z "$LATEST_STABLE" ]; then + echo "::error::No stable version tag found" >&2 + exit 1 +fi + +# Bump patch version: v0.5.4 → v0.5.5 +MAJOR=$(echo "$LATEST_STABLE" | sed 's/^v//' | cut -d. -f1) +MINOR=$(echo "$LATEST_STABLE" | sed 's/^v//' | cut -d. -f2) +PATCH=$(echo "$LATEST_STABLE" | sed 's/^v//' | cut -d. -f3) +NEXT_PATCH=$((PATCH + 1)) +NIGHTLY_VERSION="v${MAJOR}.${MINOR}.${NEXT_PATCH}" + +DATE=$(TZ=UTC0 date +%Y%m%d%H%M) +TAG="${NIGHTLY_VERSION}-nightly.${DATE}.${SHORT_COMMIT}" + +# Skip if this exact tag already exists +if git rev-parse "${TAG}" >/dev/null 2>&1; then + echo "Tag ${TAG} already exists, skipping." >&2 + exit 2 +fi + +echo "${TAG}"