From f7ee21372fd61d95625ee291f4548ece2b57a962 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 19 Apr 2026 17:18:24 +0800 Subject: [PATCH] feat(workflows): enhance notarization and release processes for macOS and Linux - Updated the notarization workflow to automatically resolve version and run ID from the latest release or inputs. - Added a finalize job to verify assets on R2 and trigger CDN updates after notarization. - Improved the release workflow to upload binaries to R2 with enhanced error handling and asset verification. - Refactored upgrade command to support CDN fallback for version resolution, improving update reliability. - Enhanced localization in the command output for better user experience. --- .github/workflows/notarize-macos.yml | 133 ++++++++++++- .github/workflows/release-linux.yml | 20 -- .github/workflows/release.yml | 40 +++- .github/workflows/update-cdn-latest.yml | 120 +++++++++++ cmd/root.go | 43 ++-- cmd/upgrade.go | 254 ++++++++++++++++++++---- 6 files changed, 527 insertions(+), 83 deletions(-) create mode 100644 .github/workflows/update-cdn-latest.yml diff --git a/.github/workflows/notarize-macos.yml b/.github/workflows/notarize-macos.yml index 20b9dd13..4d883d61 100644 --- a/.github/workflows/notarize-macos.yml +++ b/.github/workflows/notarize-macos.yml @@ -1,23 +1,79 @@ name: Notarize macOS on: + workflow_run: + workflows: ["Release"] + types: [completed] workflow_dispatch: inputs: - run_id: - description: "Release macOS workflow run ID (to download artifacts from)" - required: true version: - description: "Version used in the release build (e.g. 1.0.0 or 1.0.0-alpha)" - required: true + description: "Version (auto-detected from latest release if empty)" + required: false + run_id: + description: "Release macOS workflow run ID (auto-detected if empty)" + required: false permissions: contents: write + actions: write + +concurrency: + group: notarize-${{ github.event.workflow_run.head_branch || github.run_id }} + cancel-in-progress: true jobs: + # =================================================================== + # Resolve version + macOS build run_id automatically + # =================================================================== + resolve: + runs-on: ubuntu-latest + if: > + github.event_name == 'workflow_dispatch' || + (github.event.workflow_run.conclusion == 'success' && + startsWith(github.event.workflow_run.head_branch, 'v')) + outputs: + version: ${{ steps.resolve.outputs.version }} + run_id: ${{ steps.resolve.outputs.run_id }} + steps: + - name: Resolve version and run_id + id: resolve + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + VERSION="${{ github.event.inputs.version }}" + RUN_ID="${{ github.event.inputs.run_id }}" + if [ -z "$VERSION" ]; then + TAG=$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName -q '.tagName') + VERSION="${TAG#v}" + fi + if [ -z "$RUN_ID" ]; then + RUN_ID=$(gh run list --repo "$GITHUB_REPOSITORY" \ + --workflow="Release macOS" --branch="v${VERSION}" --limit=1 \ + --json databaseId --jq '.[0].databaseId') + fi + else + TAG="${{ github.event.workflow_run.head_branch }}" + VERSION="${TAG#v}" + RUN_ID=$(gh run list --repo "$GITHUB_REPOSITORY" \ + --workflow="Release macOS" --branch="${TAG}" --limit=1 \ + --json databaseId --jq '.[0].databaseId') + fi + + if [ -z "$VERSION" ] || [ -z "$RUN_ID" ]; then + echo "::error::Failed to resolve version='${VERSION}' run_id='${RUN_ID}'" + exit 1 + fi + + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "run_id=${RUN_ID}" >> $GITHUB_OUTPUT + echo "Resolved: version=${VERSION} run_id=${RUN_ID}" + # =================================================================== # Notarize Yao binaries (arm64 + amd64) # =================================================================== notarize: + needs: resolve runs-on: macos-latest strategy: matrix: @@ -28,7 +84,7 @@ jobs: with: name: yao-darwin-${{ matrix.arch }} path: bin - run-id: ${{ github.event.inputs.run_id }} + run-id: ${{ needs.resolve.outputs.run_id }} github-token: ${{ secrets.GITHUB_TOKEN }} - name: Install Certificates @@ -86,3 +142,68 @@ jobs: exit 1 fi echo "Yao ${{ matrix.arch }} notarization accepted." + + # =================================================================== + # After both architectures finish: verify R2 assets + trigger CDN + # =================================================================== + finalize: + needs: [resolve, notarize] + runs-on: ubuntu-latest + if: success() + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + R2_ENDPOINTS: ${{ secrets.R2_ENDPOINTS }} + R2_BUCKET: ${{ secrets.R2_BUCKET || 'get-yaoapps' }} + steps: + - name: Checkout (for gh CLI context) + uses: actions/checkout@v4 + with: + sparse-checkout: .github + + - name: Configure AWS CLI + run: | + aws configure set default.region us-east-1 + aws configure set default.s3.signature_version s3v4 + + - name: Verify all platform assets on R2 + run: | + VERSION="${{ needs.resolve.outputs.version }}" + PREFIX="releases/yao/${VERSION}" + + PLATFORMS=( + "darwin-arm64" + "darwin-amd64" + "linux-amd64" + "linux-arm64" + ) + + MISSING=0 + for P in "${PLATFORMS[@]}"; do + KEY="${PREFIX}/yao-${VERSION}-${P}" + if ! aws s3 ls "s3://${R2_BUCKET}/${KEY}" --endpoint-url "$R2_ENDPOINTS" >/dev/null 2>&1; then + echo "::warning::Missing asset: ${KEY}" + MISSING=$((MISSING+1)) + fi + if ! aws s3 ls "s3://${R2_BUCKET}/${KEY}.sha256" --endpoint-url "$R2_ENDPOINTS" >/dev/null 2>&1; then + echo "::warning::Missing sha256: ${KEY}.sha256" + MISSING=$((MISSING+1)) + fi + done + + if [ "$MISSING" -gt 0 ]; then + echo "::error::$MISSING required asset(s) missing on R2." + echo "Ensure release.yml has completed successfully before running notarize." + exit 1 + fi + echo "All 4 platform assets verified on R2." + + - name: Trigger CDN latest.json update + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION="${{ needs.resolve.outputs.version }}" + gh workflow run update-cdn-latest.yml \ + -f version="${VERSION}" \ + -f mark_latest="true" + echo "Triggered update-cdn-latest.yml for ${VERSION}" diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml index db057837..009aa303 100644 --- a/.github/workflows/release-linux.yml +++ b/.github/workflows/release-linux.yml @@ -20,20 +20,7 @@ jobs: runs-on: ubuntu-latest container: image: yaoapp/yao-build:1.0.0 - env: - CF_ACCESS_KEY_ID: ${{ secrets.CF_ACCESS_KEY_ID }} - CF_SECRET_ACCESS_KEY: ${{ secrets.CF_SECRET_ACCESS_KEY }} - R2_BUCKET: ${{ secrets.R2_BUCKET }} - R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} steps: - - name: Configure R2 For Cloudflare - run: | - aws configure set aws_access_key_id $CF_ACCESS_KEY_ID - aws configure set aws_secret_access_key $CF_SECRET_ACCESS_KEY - aws configure set default.region us-east-1 - aws configure set default.s3.signature_version s3v4 - aws configure set default.s3.endpoint_url https://$R2_ACCOUNT_ID.r2.cloudflarestorage.com - - name: Build run: | export PATH=$PATH:/github/home/go/bin @@ -72,13 +59,6 @@ jobs: mv /app/yao/dist/release/* /data/ ls -l /data - - name: Push To R2 - run: | - for file in /data/*; do - aws s3 cp "$file" s3://$R2_BUCKET/archives/ \ - --endpoint-url https://$R2_ACCOUNT_ID.r2.cloudflarestorage.com - done - - name: Upload Artifact uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 030a7600..c4c90572 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -87,8 +87,13 @@ jobs: VERSION="${{ steps.version.outputs.version }}" mkdir -p release - # Linux artifacts (already named correctly from build.sh) - cp dist/linux/* release/ 2>/dev/null || true + # Linux prod binaries (canonical name = prod/stripped) + cp "dist/linux/yao-${VERSION}-linux-amd64-prod" "release/yao-${VERSION}-linux-amd64" + cp "dist/linux/yao-${VERSION}-linux-arm64-prod" "release/yao-${VERSION}-linux-arm64" + + # Linux dev binaries + cp "dist/linux/yao-${VERSION}-linux-amd64" "release/yao-${VERSION}-linux-amd64-dev" + cp "dist/linux/yao-${VERSION}-linux-arm64" "release/yao-${VERSION}-linux-arm64-dev" # macOS prod binaries cp dist/macos/arm64-prod/yao "release/yao-${VERSION}-darwin-arm64" @@ -112,3 +117,34 @@ jobs: name: Yao v${{ steps.version.outputs.version }} files: release/* generate_release_notes: true + + - name: Upload all binaries to R2 + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + R2_ENDPOINTS: ${{ secrets.R2_ENDPOINTS }} + R2_BUCKET: ${{ secrets.R2_BUCKET || 'get-yaoapps' }} + run: | + aws configure set default.region us-east-1 + aws configure set default.s3.signature_version s3v4 + + VERSION="${{ steps.version.outputs.version }}" + PREFIX="releases/yao/${VERSION}" + + echo "Uploading release binaries to R2: ${PREFIX}" + + for file in release/yao-${VERSION}-*; do + name=$(basename "$file") + case "$name" in *-dev|*-prod|*.sha256) continue ;; esac + + sha256sum "$file" | awk '{print $1}' > "/tmp/${name}.sha256" + + aws s3 cp "$file" "s3://${R2_BUCKET}/${PREFIX}/${name}" \ + --endpoint-url "$R2_ENDPOINTS" \ + --content-type "application/octet-stream" + aws s3 cp "/tmp/${name}.sha256" "s3://${R2_BUCKET}/${PREFIX}/${name}.sha256" \ + --endpoint-url "$R2_ENDPOINTS" \ + --content-type "text/plain" + + echo "Uploaded: ${name} + ${name}.sha256" + done diff --git a/.github/workflows/update-cdn-latest.yml b/.github/workflows/update-cdn-latest.yml new file mode 100644 index 00000000..4bc198c3 --- /dev/null +++ b/.github/workflows/update-cdn-latest.yml @@ -0,0 +1,120 @@ +name: Update CDN latest.json + +# Assembles releases/yao/latest.json after all platform binaries are on R2. +# +# Normally triggered automatically by notarize-macos.yml's finalize job after +# notarization completes. Can also be triggered manually as a fallback. +# +# Prerequisites: release.yml must have uploaded all 4 platform binaries to R2. + +on: + workflow_dispatch: + inputs: + version: + description: "Engine version to publish (e.g. 1.0.0 or 1.0.0-alpha)" + required: true + mark_latest: + description: "Also update releases/yao/latest.json (set false for pre-releases you want on CDN but not as latest)" + required: false + default: "true" + +permissions: + contents: read + +jobs: + publish-latest: + runs-on: ubuntu-latest + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + R2_ENDPOINTS: ${{ secrets.R2_ENDPOINTS }} + R2_BUCKET: ${{ secrets.R2_BUCKET || 'get-yaoapps' }} + CDN_BASE: https://get.yaoapps.com + steps: + - name: Configure AWS CLI + run: | + aws configure set default.region us-east-1 + aws configure set default.s3.signature_version s3v4 + + - name: Verify platform assets exist + run: | + VERSION="${{ github.event.inputs.version }}" + PREFIX="releases/yao/${VERSION}" + + PLATFORMS=( + "darwin-arm64" + "darwin-amd64" + "linux-amd64" + "linux-arm64" + ) + + MISSING=0 + for P in "${PLATFORMS[@]}"; do + KEY="${PREFIX}/yao-${VERSION}-${P}" + echo "Checking s3://${R2_BUCKET}/${KEY}" + if ! aws s3 ls "s3://${R2_BUCKET}/${KEY}" --endpoint-url "$R2_ENDPOINTS" >/dev/null 2>&1; then + echo "::warning::Missing asset: ${KEY}" + MISSING=$((MISSING+1)) + fi + if ! aws s3 ls "s3://${R2_BUCKET}/${KEY}.sha256" --endpoint-url "$R2_ENDPOINTS" >/dev/null 2>&1; then + echo "::warning::Missing sha256: ${KEY}.sha256" + MISSING=$((MISSING+1)) + fi + done + + if [ "$MISSING" -gt 0 ]; then + echo "::error::$MISSING required asset(s) are missing on R2. Run platform CI workflows first." + exit 1 + fi + echo "All platform assets verified." + + - name: Build latest.json + run: | + VERSION="${{ github.event.inputs.version }}" + RELEASED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + + python3 < /tmp/latest.json + import json + version = "${VERSION}" + base = "${CDN_BASE}/releases/yao/${VERSION}" + assets = { + "darwin-arm64": f"{base}/yao-{version}-darwin-arm64", + "darwin-amd64": f"{base}/yao-{version}-darwin-amd64", + "linux-amd64": f"{base}/yao-{version}-linux-amd64", + "linux-arm64": f"{base}/yao-{version}-linux-arm64", + } + sha256 = { + "darwin-arm64": f"{base}/yao-{version}-darwin-arm64.sha256", + "darwin-amd64": f"{base}/yao-{version}-darwin-amd64.sha256", + "linux-amd64": f"{base}/yao-{version}-linux-amd64.sha256", + "linux-arm64": f"{base}/yao-{version}-linux-arm64.sha256", + } + data = { + "version": version, + "released_at": "${RELEASED_AT}", + "assets": assets, + "sha256": sha256, + } + print(json.dumps(data, indent=2, ensure_ascii=False)) + PY + + cat /tmp/latest.json + + - name: Upload versioned latest.json + run: | + VERSION="${{ github.event.inputs.version }}" + aws s3 cp /tmp/latest.json \ + "s3://${R2_BUCKET}/releases/yao/${VERSION}/latest.json" \ + --endpoint-url "$R2_ENDPOINTS" \ + --content-type "application/json" \ + --cache-control "public, max-age=60" + + - name: Promote to releases/yao/latest.json + if: ${{ github.event.inputs.mark_latest != 'false' }} + run: | + aws s3 cp /tmp/latest.json \ + "s3://${R2_BUCKET}/releases/yao/latest.json" \ + --endpoint-url "$R2_ENDPOINTS" \ + --content-type "application/json" \ + --cache-control "public, max-age=60" + echo "Promoted to releases/yao/latest.json" diff --git a/cmd/root.go b/cmd/root.go index 0f619878..5bd8ed96 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -56,26 +56,29 @@ var langs = map[string]string{ "✨STOPPED✨": "✨服务已停止✨", "SessionPort": "会话服务端口", "Force migrate": "强制更新数据表结构", - "Migrate is not allowed on production mode.": "Migrate 不能再生产环境下使用", - "Upgrade yao to latest version": "升级 yao 到最新版本", - "Current version:": "当前版本:", - "Latest version: ": "最新版本: ", - "Checking latest version...": "正在检查最新版本...", - "🎉Current version is the latest🎉": "🎉当前版本是最新的🎉", - "Do you want to update to %s ? (y/n): ": "是否更新到 %s ? (y/n): ", - "Invalid input": "输入错误", - "Canceled upgrade": "已取消更新", - "Downloading...": "正在下载...", - "Progress:": "进度:", - "Available assets:": "可用的制品:", - "Error occurred while updating binary: %s": "更新二进制文件时出错: %s", - "🎉Successfully updated to version: %s🎉": "🎉成功更新到版本: %s🎉", - "Print all version information": "显示详细版本信息", - "SUI Template Engine": "SUI 模板引擎命令", - "MCP commands": "MCP 包管理命令", - "MCP package management commands": "MCP 包管理命令", - "Robot commands": "Robot 包管理命令", - "Robot package management commands": "Robot 包管理命令", + "Migrate is not allowed on production mode.": "Migrate 不能再生产环境下使用", + "Upgrade yao to latest version": "升级 yao 到最新版本", + "Current version:": "当前版本:", + "Latest version: ": "最新版本: ", + "Checking latest version...": "正在检查最新版本...", + "🎉Current version is the latest🎉": "🎉当前版本是最新的🎉", + "Do you want to update to %s ? (y/n): ": "是否更新到 %s ? (y/n): ", + "Invalid input": "输入错误", + "Canceled upgrade": "已取消更新", + "Downloading...": "正在下载...", + "Progress:": "进度:", + "Available assets:": "可用的制品:", + "Error occurred while updating binary: %s": "更新二进制文件时出错: %s", + "🎉Successfully updated to version: %s🎉": "🎉成功更新到版本: %s🎉", + "Skip interactive confirmation": "跳过交互式确认", + "Only check for updates and print JSON result": "仅检查更新并输出 JSON 结果", + "Custom download source URL (e.g. https://get.yaoapps.com/releases/yao)": "自定义下载源 URL (例如 https://get.yaoapps.com/releases/yao)", + "Print all version information": "显示详细版本信息", + "SUI Template Engine": "SUI 模板引擎命令", + "MCP commands": "MCP 包管理命令", + "MCP package management commands": "MCP 包管理命令", + "Robot commands": "Robot 包管理命令", + "Robot package management commands": "Robot 包管理命令", } // L Language switch diff --git a/cmd/upgrade.go b/cmd/upgrade.go index 986bb323..cc5e935f 100644 --- a/cmd/upgrade.go +++ b/cmd/upgrade.go @@ -18,6 +18,14 @@ import ( ) const githubReleasesAPI = "https://api.github.com/repos/YaoApp/yao/releases/latest" +const cdnFallbackBase = "https://get.yaoapps.com/releases/yao" + +// Upgrade command flags +var ( + upgradeYes bool + upgradeCheck bool + upgradeSource string +) // githubRelease represents a GitHub release response type githubRelease struct { @@ -36,55 +44,87 @@ type githubAsset struct { Size int64 `json:"size"` } +// cdnLatest represents CDN latest.json format +// +// { +// "version": "1.0.0", +// "released_at": "...", +// "assets": { "darwin-arm64": "https://...", ... } +// } +type cdnLatest struct { + Version string `json:"version"` + ReleasedAt string `json:"released_at"` + Assets map[string]string `json:"assets"` +} + +// checkResult is the JSON payload emitted by `yao upgrade --check` +type checkResult struct { + Current string `json:"current"` + Latest string `json:"latest"` + UpdateAvailable bool `json:"update_available"` + DownloadURL string `json:"download_url,omitempty"` + Source string `json:"source"` +} + var upgradeCmd = &cobra.Command{ Use: "upgrade", Short: L("Upgrade yao to latest version"), Long: L("Upgrade yao to latest version"), Run: func(cmd *cobra.Command, args []string) { - fmt.Printf("%s %s\n", color.WhiteString(L("Current version:")), color.CyanString(share.VERSION)) - fmt.Println(color.WhiteString(L("Checking latest version..."))) - - release, err := fetchLatestRelease() + latestVersion, downloadURL, err := resolveLatest() if err != nil { + if upgradeCheck { + emitCheckJSON(checkResult{ + Current: share.VERSION, + Source: upgradeSource, + }, err) + os.Exit(1) + } fmt.Println(color.RedString(L("Fatal: %s"), err.Error())) os.Exit(1) } - latestVersion := strings.TrimPrefix(release.TagName, "v") + updateAvailable := compareVersions(latestVersion, share.VERSION) > 0 + + if upgradeCheck { + emitCheckJSON(checkResult{ + Current: share.VERSION, + Latest: latestVersion, + UpdateAvailable: updateAvailable, + DownloadURL: downloadURL, + Source: upgradeSource, + }, nil) + return + } + + fmt.Printf("%s %s\n", color.WhiteString(L("Current version:")), color.CyanString(share.VERSION)) fmt.Printf("%s %s\n", color.WhiteString(L("Latest version: ")), color.GreenString(latestVersion)) - if latestVersion == share.VERSION { + if !updateAvailable { fmt.Println(color.GreenString(L("🎉Current version is the latest🎉"))) - os.Exit(0) - } - - assetName := buildAssetName(latestVersion) - asset := findAsset(release.Assets, assetName) - if asset == nil { - fmt.Println(color.RedString(L("Fatal: %s"), fmt.Sprintf("asset not found: %s", assetName))) - fmt.Printf("%s %s\n", color.WhiteString(L("Available assets:")), "") - for _, a := range release.Assets { - if !strings.HasSuffix(a.Name, ".sha256") && !strings.HasSuffix(a.Name, ".zip") && !strings.HasSuffix(a.Name, ".tar.gz") { - fmt.Printf(" - %s\n", color.YellowString(a.Name)) - } - } - os.Exit(1) - } - - fmt.Printf("%s %s\n", color.WhiteString(L("Do you want to update to %s ? (y/n): "), latestVersion), "") - fmt.Print("> ") - input, err := bufio.NewReader(os.Stdin).ReadString('\n') - if err != nil { - fmt.Println(color.RedString(L("Fatal: %s"), err.Error())) - os.Exit(1) - } - - input = strings.TrimSpace(input) - if input != "y" && input != "Y" { - fmt.Println(color.YellowString(L("Canceled upgrade"))) return } + if downloadURL == "" { + fmt.Println(color.RedString(L("Fatal: %s"), fmt.Sprintf("asset not found for %s-%s", runtime.GOOS, runtime.GOARCH))) + os.Exit(1) + } + + if !upgradeYes { + fmt.Printf("%s %s\n", color.WhiteString(L("Do you want to update to %s ? (y/n): "), latestVersion), "") + fmt.Print("> ") + input, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil { + fmt.Println(color.RedString(L("Fatal: %s"), err.Error())) + os.Exit(1) + } + input = strings.TrimSpace(input) + if input != "y" && input != "Y" { + fmt.Println(color.YellowString(L("Canceled upgrade"))) + return + } + } + exe, err := os.Executable() if err != nil { fmt.Println(color.RedString(L("Fatal: %s"), err.Error())) @@ -96,8 +136,8 @@ var upgradeCmd = &cobra.Command{ os.Exit(1) } - fmt.Printf("%s %s\n", color.WhiteString(L("Downloading...")), color.CyanString(asset.BrowserDownloadURL)) - if err := downloadAndReplace(asset.BrowserDownloadURL, exe); err != nil { + fmt.Printf("%s %s\n", color.WhiteString(L("Downloading...")), color.CyanString(downloadURL)) + if err := downloadAndReplace(downloadURL, exe); err != nil { fmt.Println(color.RedString(L("Error occurred while updating binary: %s"), err.Error())) os.Exit(1) } @@ -106,6 +146,144 @@ var upgradeCmd = &cobra.Command{ }, } +// resolveLatest fetches latest version info. Priority: +// 1. --source flag (explicit CDN URL) +// 2. GitHub Releases API (default) +// 3. Fallback to get.yaoapps.com CDN if GitHub fails (for users in China) +func resolveLatest() (string, string, error) { + if upgradeSource != "" { + return resolveFromCDN(upgradeSource) + } + ver, url, err := resolveFromGitHub() + if err == nil { + return ver, url, nil + } + fmt.Println(color.YellowString(L("GitHub unavailable (%s), trying CDN fallback..."), err.Error())) + return resolveFromCDN(cdnFallbackBase) +} + +// resolveFromCDN fetches latest.json from the given CDN base URL. +// The URL should point to the directory containing latest.json, +// e.g. "https://get.yaoapps.com/releases/yao". +func resolveFromCDN(base string) (string, string, error) { + url := strings.TrimRight(base, "/") + "/latest.json" + client := &http.Client{Timeout: 30 * time.Second} + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return "", "", err + } + req.Header.Set("User-Agent", fmt.Sprintf("yao/%s", share.VERSION)) + resp, err := client.Do(req) + if err != nil { + return "", "", fmt.Errorf("fetch CDN latest.json failed: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", "", fmt.Errorf("CDN returned status %d for %s", resp.StatusCode, url) + } + var data cdnLatest + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return "", "", fmt.Errorf("parse latest.json failed: %w", err) + } + key := fmt.Sprintf("%s-%s", runtime.GOOS, runtime.GOARCH) + dl := data.Assets[key] + return strings.TrimPrefix(data.Version, "v"), dl, nil +} + +// resolveFromGitHub keeps the original GitHub Releases API behavior. +func resolveFromGitHub() (string, string, error) { + release, err := fetchLatestRelease() + if err != nil { + return "", "", err + } + latestVersion := strings.TrimPrefix(release.TagName, "v") + assetName := buildAssetName(latestVersion) + asset := findAsset(release.Assets, assetName) + if asset == nil { + return latestVersion, "", nil + } + return latestVersion, asset.BrowserDownloadURL, nil +} + +// compareVersions returns >0 if a > b, 0 if equal, <0 if a < b. +// Uses dotted numeric comparison; falls back to string comparison for non-numeric parts. +func compareVersions(a, b string) int { + a = strings.TrimPrefix(a, "v") + b = strings.TrimPrefix(b, "v") + if a == b { + return 0 + } + // Separate pre-release suffix (after '-') + baseA, preA := splitVersion(a) + baseB, preB := splitVersion(b) + + partsA := strings.Split(baseA, ".") + partsB := strings.Split(baseB, ".") + n := len(partsA) + if len(partsB) > n { + n = len(partsB) + } + for i := 0; i < n; i++ { + var pa, pb string + if i < len(partsA) { + pa = partsA[i] + } + if i < len(partsB) { + pb = partsB[i] + } + if c := compareNumeric(pa, pb); c != 0 { + return c + } + } + // Base parts equal: release version > pre-release version + if preA == "" && preB != "" { + return 1 + } + if preA != "" && preB == "" { + return -1 + } + return strings.Compare(preA, preB) +} + +func splitVersion(v string) (string, string) { + if idx := strings.Index(v, "-"); idx >= 0 { + return v[:idx], v[idx+1:] + } + return v, "" +} + +func compareNumeric(a, b string) int { + var na, nb int + _, errA := fmt.Sscanf(a, "%d", &na) + _, errB := fmt.Sscanf(b, "%d", &nb) + if errA == nil && errB == nil { + if na < nb { + return -1 + } + if na > nb { + return 1 + } + return 0 + } + return strings.Compare(a, b) +} + +// emitCheckJSON writes the check result as a single-line JSON to stdout. +func emitCheckJSON(r checkResult, err error) { + if err != nil { + payload := map[string]interface{}{ + "current": r.Current, + "source": r.Source, + "error": err.Error(), + } + b, _ := json.Marshal(payload) + fmt.Println(string(b)) + return + } + b, _ := json.Marshal(r) + fmt.Println(string(b)) +} + // fetchLatestRelease fetches the latest release from GitHub API func fetchLatestRelease() (*githubRelease, error) { client := &http.Client{Timeout: 30 * time.Second} @@ -256,3 +434,9 @@ func copyFile(src, dst string) error { _, err = io.Copy(out, in) return err } + +func init() { + upgradeCmd.Flags().BoolVarP(&upgradeYes, "yes", "y", false, L("Skip interactive confirmation")) + upgradeCmd.Flags().BoolVar(&upgradeCheck, "check", false, L("Only check for updates and print JSON result")) + upgradeCmd.Flags().StringVar(&upgradeSource, "source", "", L("Custom download source URL (e.g. https://get.yaoapps.com/releases/yao)")) +}