diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 84fc8e464..47a45408d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1345,3 +1345,135 @@ jobs: test "$draft" = "false" test "$tag" = "$RELEASE_TAG" test "$target" = "$SOURCE_COMMIT" + + mirror-r2: + name: Mirror latest Release to R2 + needs: [source, prepare-release, publish-release] + runs-on: ubuntu-latest + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: auto + AWS_EC2_METADATA_DISABLED: "true" + R2_ACCOUNT_ID: ${{ vars.R2_ACCOUNT_ID }} + R2_BUCKET: ${{ vars.R2_BUCKET }} + R2_PUBLIC_BASE_URL: ${{ vars.R2_PUBLIC_BASE_URL }} + RELEASE_TAG: ${{ needs.source.outputs.tag }} + steps: + - name: Checkout validated source + uses: actions/checkout@v5 + with: + ref: ${{ needs.source.outputs.commit }} + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: Download validated release candidate + uses: actions/download-artifact@v4 + with: + name: agentdock-release-dist + path: dist + + - name: Validate R2 mirror configuration + shell: bash + run: | + set -euo pipefail + for name in R2_ACCOUNT_ID R2_BUCKET R2_PUBLIC_BASE_URL AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY; do + if [[ -z "${!name:-}" ]]; then + echo "$name is required for the R2 release mirror." >&2 + exit 1 + fi + done + [[ "$R2_PUBLIC_BASE_URL" == https://* ]] + command -v aws >/dev/null + aws --version + + - name: Prepare R2 bootstrap assets + shell: bash + run: | + set -euo pipefail + release_base="${R2_PUBLIC_BASE_URL%/}/releases/$RELEASE_TAG" + go run ./tools/release mirror-prepare "$release_base" dist + (cd dist && sha256sum -c install.sh.sha256) + + - name: Generate latest mirror manifest + shell: bash + run: | + set -euo pipefail + release_base="${R2_PUBLIC_BASE_URL%/}/releases/$RELEASE_TAG" + go run ./tools/release mirror-manifest "$RELEASE_TAG" "$release_base" dist > dist/latest.json + jq -e --arg tag "$RELEASE_TAG" '.tag_name == $tag and (.assets | length > 0)' dist/latest.json >/dev/null + + - name: Upload and verify versioned R2 assets + shell: bash + run: | + set -euo pipefail + endpoint="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" + release_prefix="releases/$RELEASE_TAG" + + while IFS= read -r asset; do + key="$release_prefix/$asset" + aws s3 cp "dist/$asset" "s3://$R2_BUCKET/$key" \ + --endpoint-url "$endpoint" \ + --cache-control "public, max-age=31536000, immutable" \ + --only-show-errors + aws s3api head-object \ + --bucket "$R2_BUCKET" \ + --key "$key" \ + --endpoint-url "$endpoint" >/dev/null + done < <(jq -r '.assets[].name' dist/latest.json) + + - name: Verify public versioned assets + shell: bash + run: | + set -euo pipefail + while IFS= read -r url; do + curl --fail --silent --show-error --location \ + --retry 5 --retry-all-errors \ + --head "$url" >/dev/null + done < <(jq -r '.assets[].browser_download_url' dist/latest.json) + + - name: Publish latest.json + shell: bash + run: | + set -euo pipefail + endpoint="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" + aws s3 cp dist/latest.json "s3://$R2_BUCKET/latest.json" \ + --endpoint-url "$endpoint" \ + --content-type "application/json; charset=utf-8" \ + --cache-control "no-cache, max-age=0, must-revalidate" \ + --only-show-errors + + manifest_url="${R2_PUBLIC_BASE_URL%/}/latest.json?run=${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + curl --fail --silent --show-error --location \ + --retry 5 --retry-all-errors \ + -H "Cache-Control: no-cache" \ + "$manifest_url" \ + -o "$RUNNER_TEMP/published-latest.json" + jq -e --arg tag "$RELEASE_TAG" '.tag_name == $tag and (.assets | length > 0)' \ + "$RUNNER_TEMP/published-latest.json" >/dev/null + + - name: Remove stale R2 release objects + shell: bash + run: | + set -euo pipefail + endpoint="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" + current_prefix="releases/$RELEASE_TAG/" + aws s3api list-objects-v2 \ + --bucket "$R2_BUCKET" \ + --prefix "releases/" \ + --endpoint-url "$endpoint" \ + --output json \ + | jq -r '.Contents[]?.Key' \ + > "$RUNNER_TEMP/r2-release-keys" + + while IFS= read -r key; do + [[ -z "$key" || "$key" == "$current_prefix"* ]] && continue + aws s3api delete-object \ + --bucket "$R2_BUCKET" \ + --key "$key" \ + --endpoint-url "$endpoint" >/dev/null + done < "$RUNNER_TEMP/r2-release-keys" diff --git a/docs/release-distribution.md b/docs/release-distribution.md new file mode 100644 index 000000000..a58e2dc0a --- /dev/null +++ b/docs/release-distribution.md @@ -0,0 +1,60 @@ +# Release 下载分发 + +AgentDock 的正式版本仍以 GitHub Release 为唯一历史档案。`Release` workflow 在正式 GitHub Release 发布成功后,把当前最新版镜像到 Cloudflare R2,供 NexusDock 官网和后续客户端更新使用。 + +## R2 对象布局 + +R2 只保留当前稳定版: + +```text +latest.json +releases/ +└── vX.Y.Z/ + ├── AgentDockSetup-amd64.exe + ├── AgentDockSetup-amd64.exe.sha256 + ├── AgentDockSetup-arm64.exe + ├── AgentDockSetup-arm64.exe.sha256 + ├── AgentDock-macos-universal.dmg + ├── AgentDock-macos-universal.dmg.sha256 + ├── AgentDock-macos-universal.zip + ├── AgentDock-macos-universal.zip.sha256 + ├── agentdock_{linux,darwin,windows}_{amd64,arm64}.* + ├── install.sh + ├── install.sh.sha256 + ├── install.ps1 + └── install.ps1.sha256 +``` + +`latest.json` 使用 GitHub Release API 的最小兼容结构:`tag_name` 和 `assets[].name/browser_download_url`。这样客户端以后可以把最新版元数据源切到 NexusDock 下载域名,而不需要维护第二套 Release 数据模型。 + +发布顺序是: + +1. GitHub Release 经过现有跨平台验证并正式公开。 +2. 下载已验证的 `agentdock-release-dist`。 +3. 上传 `releases//` 下的不可变对象并通过 R2 API 校验。 +4. 从公开下载域名逐个验证版本化 URL。 +5. 最后更新根目录 `latest.json`,避免元数据提前指向未完成上传的版本。 +6. `latest.json` 验证成功后,删除 `releases/` 下其他版本对象。 + +R2 中的 `install.sh` 会在镜像阶段把默认 Release 基础地址改写为同版本的 +`https://download.nexusdock.co/releases/`,并重新生成 `install.sh.sha256`。 +GitHub Release 中的原始 `install.sh` 不变,因此历史版本仍保持 GitHub 自身的可复现下载链路。 + +任何步骤在更新 `latest.json` 前失败时,旧版下载入口保持不变;更新之后的清理失败只会留下旧对象,不会破坏当前最新版。 + +## GitHub Actions 配置 + +Repository Variables: + +- `R2_ACCOUNT_ID`:Cloudflare Account ID。 +- `R2_BUCKET`:用于最新版安装包镜像的 R2 bucket。 +- `R2_PUBLIC_BASE_URL`:R2 自定义公开域名,例如 `https://download.nexusdock.co`,不要带末尾 `/`。 + +Repository Secrets: + +- `R2_ACCESS_KEY_ID` +- `R2_SECRET_ACCESS_KEY` + +R2 凭据只需要目标 bucket 的对象读写权限,不需要 Cloudflare 账号级管理员权限。 + +历史版本、Release Notes 和旧安装包继续从 GitHub Releases 获取。R2 不承担历史归档职责。 diff --git a/tools/release/catalog.go b/tools/release/catalog.go index ac5b3ab20..2f26b72ee 100644 --- a/tools/release/catalog.go +++ b/tools/release/catalog.go @@ -60,6 +60,7 @@ func ReleaseCatalog() []Artifact { {Name: "agentdock_windows_amd64.zip", Kind: "binary-archive", Platform: "windows", Arch: "amd64", Required: true}, {Name: "agentdock_windows_arm64.zip", Kind: "binary-archive", Platform: "windows", Arch: "arm64", Required: true}, {Name: "AgentDock-macos-universal.dmg", Kind: "disk-image", Platform: "darwin", Arch: "universal", Required: true}, + {Name: "AgentDock-macos-universal.zip", Kind: "desktop-archive", Platform: "darwin", Arch: "universal", Required: true}, {Name: "AgentDockSetup-amd64.exe", Kind: "setup", Platform: "windows", Arch: "amd64", Required: true, PublicContract: true}, {Name: "AgentDockSetup-arm64.exe", Kind: "setup", Platform: "windows", Arch: "arm64", Required: true, PublicContract: true}, } @@ -79,5 +80,13 @@ func ReleaseCatalog() []Artifact { }) } catalog = append(catalog, scripts...) + for _, artifact := range scripts { + catalog = append(catalog, Artifact{ + Name: artifact.Name + ".sha256", + Kind: "checksum", + Platform: artifact.Platform, + Required: artifact.Required, + }) + } return catalog } diff --git a/tools/release/main.go b/tools/release/main.go index 2d78c96d2..1fb176f4d 100644 --- a/tools/release/main.go +++ b/tools/release/main.go @@ -23,7 +23,7 @@ func main() { func run(args []string, stdout io.Writer) error { if len(args) == 0 { - return errors.New("用法:release [参数]") + return errors.New("用法:release [参数]") } switch args[0] { case "version": @@ -55,6 +55,16 @@ func run(args []string, stdout io.Writer) error { } fmt.Fprintf(stdout, "%s %s\n", sum, filepath.Base(args[1])) return nil + case "mirror-manifest": + if len(args) != 4 { + return errors.New("用法:release mirror-manifest ") + } + return writeMirrorManifest(args[1], args[2], args[3], stdout) + case "mirror-prepare": + if len(args) != 3 { + return errors.New("用法:release mirror-prepare ") + } + return prepareMirrorBootstrap(args[1], args[2]) default: return fmt.Errorf("未知命令:%s", args[0]) } diff --git a/tools/release/mirror.go b/tools/release/mirror.go new file mode 100644 index 000000000..37df0f580 --- /dev/null +++ b/tools/release/mirror.go @@ -0,0 +1,126 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "strings" +) + +type mirrorRelease struct { + TagName string `json:"tag_name"` + Assets []mirrorReleaseAsset `json:"assets"` +} + +type mirrorReleaseAsset struct { + Name string `json:"name"` + URL string `json:"browser_download_url"` +} + +func prepareMirrorBootstrap(publicBaseURL, distDir string) error { + baseURL, err := normalizeMirrorBaseURL(publicBaseURL) + if err != nil { + return err + } + if strings.TrimSpace(distDir) == "" { + return errors.New("mirror dist 目录不能为空") + } + + installPath := filepath.Join(distDir, "install.sh") + info, err := os.Stat(installPath) + if err != nil { + return fmt.Errorf("检查 mirror install.sh 失败: %w", err) + } + if !info.Mode().IsRegular() { + return errors.New("mirror install.sh 不是普通文件") + } + + content, err := os.ReadFile(installPath) + if err != nil { + return fmt.Errorf("读取 mirror install.sh 失败: %w", err) + } + lines := strings.Split(string(content), "\n") + const prefix = `DEFAULT_BASE_URL="` + replaced := false + for i, line := range lines { + if !strings.HasPrefix(line, prefix) { + continue + } + if replaced { + return errors.New("mirror install.sh 包含多个 DEFAULT_BASE_URL") + } + lines[i] = fmt.Sprintf(`DEFAULT_BASE_URL="%s"`, baseURL) + replaced = true + } + if !replaced { + return errors.New("mirror install.sh 缺少 DEFAULT_BASE_URL") + } + + if err := os.WriteFile(installPath, []byte(strings.Join(lines, "\n")), info.Mode().Perm()); err != nil { + return fmt.Errorf("写入 mirror install.sh 失败: %w", err) + } + sum, err := fileSHA256(installPath) + if err != nil { + return fmt.Errorf("计算 mirror install.sh SHA-256 失败: %w", err) + } + checksum := fmt.Sprintf("%s install.sh\n", sum) + if err := os.WriteFile(filepath.Join(distDir, "install.sh.sha256"), []byte(checksum), 0o644); err != nil { + return fmt.Errorf("写入 mirror install.sh.sha256 失败: %w", err) + } + return nil +} + +func writeMirrorManifest(tag, publicBaseURL, distDir string, stdout io.Writer) error { + tag = strings.TrimSpace(tag) + if tag == "" || strings.ContainsAny(tag, `/\`) { + return fmt.Errorf("无效 Release tag:%q", tag) + } + baseURL, err := normalizeMirrorBaseURL(publicBaseURL) + if err != nil { + return err + } + if strings.TrimSpace(distDir) == "" { + return errors.New("mirror dist 目录不能为空") + } + + manifest := mirrorRelease{TagName: tag} + for _, artifact := range ReleaseCatalog() { + path := filepath.Join(distDir, artifact.Name) + info, err := os.Stat(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("mirror dist 缺少 Release 资产:%s", artifact.Name) + } + return fmt.Errorf("检查 mirror Release 资产 %s 失败: %w", artifact.Name, err) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("mirror Release 资产不是普通文件:%s", artifact.Name) + } + manifest.Assets = append(manifest.Assets, mirrorReleaseAsset{ + Name: artifact.Name, + URL: baseURL + "/" + artifact.Name, + }) + } + encoder := json.NewEncoder(stdout) + encoder.SetIndent("", " ") + return encoder.Encode(manifest) +} + +func normalizeMirrorBaseURL(raw string) (string, error) { + raw = strings.TrimSpace(raw) + parsed, err := url.Parse(raw) + if err != nil { + return "", fmt.Errorf("解析 mirror public base URL 失败: %w", err) + } + if parsed.Scheme != "https" || parsed.Host == "" { + return "", errors.New("mirror public base URL 必须是完整 HTTPS 地址") + } + if parsed.RawQuery != "" || parsed.Fragment != "" { + return "", errors.New("mirror public base URL 不能包含 query 或 fragment") + } + return strings.TrimRight(parsed.String(), "/"), nil +} diff --git a/tools/release/mirror_test.go b/tools/release/mirror_test.go new file mode 100644 index 000000000..536abc8eb --- /dev/null +++ b/tools/release/mirror_test.go @@ -0,0 +1,119 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestMirrorManifestUsesReleaseCatalogAndVersionedBaseURL(t *testing.T) { + dir := t.TempDir() + for _, artifact := range ReleaseCatalog() { + if err := os.WriteFile(filepath.Join(dir, artifact.Name), []byte(artifact.Name), 0o644); err != nil { + t.Fatal(err) + } + } + + var output bytes.Buffer + if err := writeMirrorManifest("v1.2.3", "https://download.nexusdock.co/releases/v1.2.3/", dir, &output); err != nil { + t.Fatal(err) + } + + var manifest mirrorRelease + if err := json.Unmarshal(output.Bytes(), &manifest); err != nil { + t.Fatal(err) + } + if manifest.TagName != "v1.2.3" { + t.Fatalf("tag_name = %q, want v1.2.3", manifest.TagName) + } + if len(manifest.Assets) != len(ReleaseCatalog()) { + t.Fatalf("asset count = %d, want %d", len(manifest.Assets), len(ReleaseCatalog())) + } + for i, artifact := range ReleaseCatalog() { + asset := manifest.Assets[i] + if asset.Name != artifact.Name { + t.Fatalf("asset[%d].name = %q, want %q", i, asset.Name, artifact.Name) + } + wantURL := "https://download.nexusdock.co/releases/v1.2.3/" + artifact.Name + if asset.URL != wantURL { + t.Fatalf("asset[%d].url = %q, want %q", i, asset.URL, wantURL) + } + } +} + +func TestMirrorManifestRejectsMissingAsset(t *testing.T) { + dir := t.TempDir() + for _, artifact := range ReleaseCatalog() { + if artifact.Name == "agentdock_linux_amd64.tar.gz" { + continue + } + if err := os.WriteFile(filepath.Join(dir, artifact.Name), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + err := writeMirrorManifest("v1.2.3", "https://download.nexusdock.co/releases/v1.2.3", dir, &bytes.Buffer{}) + if err == nil || !strings.Contains(err.Error(), "agentdock_linux_amd64.tar.gz") { + t.Fatalf("missing asset error = %v", err) + } +} + +func TestMirrorManifestRejectsUnsafeInputs(t *testing.T) { + dir := t.TempDir() + if err := writeMirrorManifest("../v1.2.3", "https://download.nexusdock.co/releases/v1.2.3", dir, &bytes.Buffer{}); err == nil { + t.Fatal("tag with path separator must fail") + } + if err := writeMirrorManifest("v1.2.3", "http://download.nexusdock.co/releases/v1.2.3", dir, &bytes.Buffer{}); err == nil { + t.Fatal("non-HTTPS public base URL must fail") + } +} + +func TestPrepareMirrorBootstrapUsesR2ReleaseBaseAndRefreshesChecksum(t *testing.T) { + dir := t.TempDir() + installPath := filepath.Join(dir, "install.sh") + content := "#!/bin/sh\nDEFAULT_BASE_URL=\"https://github.com/uvwt/agentdock/releases/download/v1.2.3\"\necho ok\n" + if err := os.WriteFile(installPath, []byte(content), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "install.sh.sha256"), []byte("stale\n"), 0o644); err != nil { + t.Fatal(err) + } + + const baseURL = "https://download.nexusdock.co/releases/v1.2.3" + if err := prepareMirrorBootstrap(baseURL, dir); err != nil { + t.Fatal(err) + } + + updated, err := os.ReadFile(installPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(updated), `DEFAULT_BASE_URL="`+baseURL+`"`) { + t.Fatalf("install.sh was not rewritten to %s", baseURL) + } + + sum, err := fileSHA256(installPath) + if err != nil { + t.Fatal(err) + } + checksum, err := os.ReadFile(filepath.Join(dir, "install.sh.sha256")) + if err != nil { + t.Fatal(err) + } + if string(checksum) != sum+" install.sh\n" { + t.Fatalf("install.sh.sha256 = %q, want refreshed checksum", string(checksum)) + } +} + +func TestPrepareMirrorBootstrapRequiresDefaultBase(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "install.sh"), []byte("#!/bin/sh\necho ok\n"), 0o755); err != nil { + t.Fatal(err) + } + err := prepareMirrorBootstrap("https://download.nexusdock.co/releases/v1.2.3", dir) + if err == nil || !strings.Contains(err.Error(), "DEFAULT_BASE_URL") { + t.Fatalf("missing DEFAULT_BASE_URL error = %v", err) + } +}