Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
@@ -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"
28 changes: 28 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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:
Expand Down
26 changes: 25 additions & 1 deletion .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ archives:
checksum:
name_template: "checksums.txt"

release:
prerelease: auto

scoops:
- repository:
Expand All @@ -75,6 +77,7 @@ scoops:
homepage: "https://github.com/entireio/cli"
description: "CLI for Entire"
license: MIT
skip_upload: auto

homebrew_casks:
- name: entire
Expand All @@ -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"
5 changes: 4 additions & 1 deletion cmd/entire/cli/versioncheck/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 59 additions & 2 deletions cmd/entire/cli/versioncheck/versioncheck.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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) {
Expand Down
76 changes: 76 additions & 0 deletions cmd/entire/cli/versioncheck/versioncheck_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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()
Expand Down
45 changes: 45 additions & 0 deletions mise-tasks/release
Original file line number Diff line number Diff line change
@@ -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."
Loading
Loading