Merge pull request #1519 from trheyi/main

feat(workflows): enhance notarization and release processes for macOS…
This commit is contained in:
Max 2026-04-19 17:18:49 +08:00 committed by GitHub
commit ccedc28828
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 527 additions and 83 deletions

View file

@ -1,23 +1,79 @@
name: Notarize macOS name: Notarize macOS
on: on:
workflow_run:
workflows: ["Release"]
types: [completed]
workflow_dispatch: workflow_dispatch:
inputs: inputs:
run_id:
description: "Release macOS workflow run ID (to download artifacts from)"
required: true
version: version:
description: "Version used in the release build (e.g. 1.0.0 or 1.0.0-alpha)" description: "Version (auto-detected from latest release if empty)"
required: true required: false
run_id:
description: "Release macOS workflow run ID (auto-detected if empty)"
required: false
permissions: permissions:
contents: write contents: write
actions: write
concurrency:
group: notarize-${{ github.event.workflow_run.head_branch || github.run_id }}
cancel-in-progress: true
jobs: 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 Yao binaries (arm64 + amd64)
# =================================================================== # ===================================================================
notarize: notarize:
needs: resolve
runs-on: macos-latest runs-on: macos-latest
strategy: strategy:
matrix: matrix:
@ -28,7 +84,7 @@ jobs:
with: with:
name: yao-darwin-${{ matrix.arch }} name: yao-darwin-${{ matrix.arch }}
path: bin path: bin
run-id: ${{ github.event.inputs.run_id }} run-id: ${{ needs.resolve.outputs.run_id }}
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Install Certificates - name: Install Certificates
@ -86,3 +142,68 @@ jobs:
exit 1 exit 1
fi fi
echo "Yao ${{ matrix.arch }} notarization accepted." 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}"

View file

@ -20,20 +20,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: yaoapp/yao-build:1.0.0 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: 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 - name: Build
run: | run: |
export PATH=$PATH:/github/home/go/bin export PATH=$PATH:/github/home/go/bin
@ -72,13 +59,6 @@ jobs:
mv /app/yao/dist/release/* /data/ mv /app/yao/dist/release/* /data/
ls -l /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 - name: Upload Artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:

View file

@ -87,8 +87,13 @@ jobs:
VERSION="${{ steps.version.outputs.version }}" VERSION="${{ steps.version.outputs.version }}"
mkdir -p release mkdir -p release
# Linux artifacts (already named correctly from build.sh) # Linux prod binaries (canonical name = prod/stripped)
cp dist/linux/* release/ 2>/dev/null || true 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 # macOS prod binaries
cp dist/macos/arm64-prod/yao "release/yao-${VERSION}-darwin-arm64" cp dist/macos/arm64-prod/yao "release/yao-${VERSION}-darwin-arm64"
@ -112,3 +117,34 @@ jobs:
name: Yao v${{ steps.version.outputs.version }} name: Yao v${{ steps.version.outputs.version }}
files: release/* files: release/*
generate_release_notes: true 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

120
.github/workflows/update-cdn-latest.yml vendored Normal file
View file

@ -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 <<PY > /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"

View file

@ -56,26 +56,29 @@ var langs = map[string]string{
"✨STOPPED✨": "✨服务已停止✨", "✨STOPPED✨": "✨服务已停止✨",
"SessionPort": "会话服务端口", "SessionPort": "会话服务端口",
"Force migrate": "强制更新数据表结构", "Force migrate": "强制更新数据表结构",
"Migrate is not allowed on production mode.": "Migrate 不能再生产环境下使用", "Migrate is not allowed on production mode.": "Migrate 不能再生产环境下使用",
"Upgrade yao to latest version": "升级 yao 到最新版本", "Upgrade yao to latest version": "升级 yao 到最新版本",
"Current version:": "当前版本:", "Current version:": "当前版本:",
"Latest version: ": "最新版本: ", "Latest version: ": "最新版本: ",
"Checking latest version...": "正在检查最新版本...", "Checking latest version...": "正在检查最新版本...",
"🎉Current version is the latest🎉": "🎉当前版本是最新的🎉", "🎉Current version is the latest🎉": "🎉当前版本是最新的🎉",
"Do you want to update to %s ? (y/n): ": "是否更新到 %s ? (y/n): ", "Do you want to update to %s ? (y/n): ": "是否更新到 %s ? (y/n): ",
"Invalid input": "输入错误", "Invalid input": "输入错误",
"Canceled upgrade": "已取消更新", "Canceled upgrade": "已取消更新",
"Downloading...": "正在下载...", "Downloading...": "正在下载...",
"Progress:": "进度:", "Progress:": "进度:",
"Available assets:": "可用的制品:", "Available assets:": "可用的制品:",
"Error occurred while updating binary: %s": "更新二进制文件时出错: %s", "Error occurred while updating binary: %s": "更新二进制文件时出错: %s",
"🎉Successfully updated to version: %s🎉": "🎉成功更新到版本: %s🎉", "🎉Successfully updated to version: %s🎉": "🎉成功更新到版本: %s🎉",
"Print all version information": "显示详细版本信息", "Skip interactive confirmation": "跳过交互式确认",
"SUI Template Engine": "SUI 模板引擎命令", "Only check for updates and print JSON result": "仅检查更新并输出 JSON 结果",
"MCP commands": "MCP 包管理命令", "Custom download source URL (e.g. https://get.yaoapps.com/releases/yao)": "自定义下载源 URL (例如 https://get.yaoapps.com/releases/yao)",
"MCP package management commands": "MCP 包管理命令", "Print all version information": "显示详细版本信息",
"Robot commands": "Robot 包管理命令", "SUI Template Engine": "SUI 模板引擎命令",
"Robot package management commands": "Robot 包管理命令", "MCP commands": "MCP 包管理命令",
"MCP package management commands": "MCP 包管理命令",
"Robot commands": "Robot 包管理命令",
"Robot package management commands": "Robot 包管理命令",
} }
// L Language switch // L Language switch

View file

@ -18,6 +18,14 @@ import (
) )
const githubReleasesAPI = "https://api.github.com/repos/YaoApp/yao/releases/latest" 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 // githubRelease represents a GitHub release response
type githubRelease struct { type githubRelease struct {
@ -36,55 +44,87 @@ type githubAsset struct {
Size int64 `json:"size"` 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{ var upgradeCmd = &cobra.Command{
Use: "upgrade", Use: "upgrade",
Short: L("Upgrade yao to latest version"), Short: L("Upgrade yao to latest version"),
Long: L("Upgrade yao to latest version"), Long: L("Upgrade yao to latest version"),
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("%s %s\n", color.WhiteString(L("Current version:")), color.CyanString(share.VERSION)) latestVersion, downloadURL, err := resolveLatest()
fmt.Println(color.WhiteString(L("Checking latest version...")))
release, err := fetchLatestRelease()
if err != nil { 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())) fmt.Println(color.RedString(L("Fatal: %s"), err.Error()))
os.Exit(1) 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)) 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🎉"))) 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 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() exe, err := os.Executable()
if err != nil { if err != nil {
fmt.Println(color.RedString(L("Fatal: %s"), err.Error())) fmt.Println(color.RedString(L("Fatal: %s"), err.Error()))
@ -96,8 +136,8 @@ var upgradeCmd = &cobra.Command{
os.Exit(1) os.Exit(1)
} }
fmt.Printf("%s %s\n", color.WhiteString(L("Downloading...")), color.CyanString(asset.BrowserDownloadURL)) fmt.Printf("%s %s\n", color.WhiteString(L("Downloading...")), color.CyanString(downloadURL))
if err := downloadAndReplace(asset.BrowserDownloadURL, exe); err != nil { if err := downloadAndReplace(downloadURL, exe); err != nil {
fmt.Println(color.RedString(L("Error occurred while updating binary: %s"), err.Error())) fmt.Println(color.RedString(L("Error occurred while updating binary: %s"), err.Error()))
os.Exit(1) 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 // fetchLatestRelease fetches the latest release from GitHub API
func fetchLatestRelease() (*githubRelease, error) { func fetchLatestRelease() (*githubRelease, error) {
client := &http.Client{Timeout: 30 * time.Second} client := &http.Client{Timeout: 30 * time.Second}
@ -256,3 +434,9 @@ func copyFile(src, dst string) error {
_, err = io.Copy(out, in) _, err = io.Copy(out, in)
return err 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)"))
}