From 6fe9ad4af6e540f9608301182fa76f04026f4357 Mon Sep 17 00:00:00 2001 From: ryan Date: Tue, 10 Mar 2026 23:02:51 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=A0=E9=99=A4=E6=97=A7=E7=9A=84?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E5=B7=A5=E4=BD=9C=E6=B5=81=EF=BC=8C=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E6=96=B0=E7=9A=84=E5=8F=91=E5=B8=83=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=EF=BC=8C=E9=87=8D=E6=9E=84=E6=9B=B4=E6=96=B0=E9=80=BB?= =?UTF-8?q?=E8=BE=91=E4=BB=A5=E6=94=AF=E6=8C=81=E8=B7=A8=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E9=87=8D=E5=90=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/agent-release.yml | 66 ---------- .../{linux-release.yml => release.yml} | 115 ++++++++++++++---- atsf_agent/internal/updater/restart_unix.go | 27 ++++ .../internal/updater/restart_windows.go | 53 ++++++++ atsf_agent/internal/updater/updater.go | 42 +++---- 5 files changed, 189 insertions(+), 114 deletions(-) delete mode 100644 .github/workflows/agent-release.yml rename .github/workflows/{linux-release.yml => release.yml} (51%) create mode 100644 atsf_agent/internal/updater/restart_unix.go create mode 100644 atsf_agent/internal/updater/restart_windows.go diff --git a/.github/workflows/agent-release.yml b/.github/workflows/agent-release.yml deleted file mode 100644 index 99b2ba99..00000000 --- a/.github/workflows/agent-release.yml +++ /dev/null @@ -1,66 +0,0 @@ -name: Agent Release -permissions: - contents: write - -on: - push: - tags: - - "*" - workflow_dispatch: - inputs: - reason: - description: "Reason for manual trigger" - required: false - -jobs: - release: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - - name: Set up Go - uses: actions/setup-go@v3 - with: - go-version: ">=1.18.0" - - - name: Build Agent (linux/amd64) - working-directory: atsf_agent - run: | - CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-s -w" -o ../atsflare-agent-linux-amd64 ./cmd/agent - - - name: Build Agent (linux/arm64) - working-directory: atsf_agent - run: | - CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w" -o ../atsflare-agent-linux-arm64 ./cmd/agent - - - name: Build Agent (darwin/arm64) - working-directory: atsf_agent - run: | - CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "-s -w" -o ../atsflare-agent-darwin-arm64 ./cmd/agent - - - name: Determine prerelease - id: prerelease - run: | - TAG="${GITHUB_REF#refs/tags/}" - if [[ "$TAG" == *alpha* ]] || [[ "$TAG" == *beta* ]] || [[ "$TAG" == *rc* ]]; then - echo "is_prerelease=true" >> "$GITHUB_OUTPUT" - else - echo "is_prerelease=false" >> "$GITHUB_OUTPUT" - fi - - - name: Release - uses: softprops/action-gh-release@v1 - if: startsWith(github.ref, 'refs/tags/') - with: - files: | - atsflare-agent-linux-amd64 - atsflare-agent-linux-arm64 - atsflare-agent-darwin-arm64 - draft: false - prerelease: ${{ steps.prerelease.outputs.is_prerelease }} - generate_release_notes: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/linux-release.yml b/.github/workflows/release.yml similarity index 51% rename from .github/workflows/linux-release.yml rename to .github/workflows/release.yml index 25be4155..10a0f0c0 100644 --- a/.github/workflows/linux-release.yml +++ b/.github/workflows/release.yml @@ -1,17 +1,38 @@ -name: Server Release +name: Release permissions: contents: write on: + workflow_dispatch: push: - tags: - - "*" - -env: - VERSION: ${{ github.ref_name }} + branches: ["main"] + tags: ["v*"] jobs: + prepare: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + is_prerelease: ${{ steps.version.outputs.is_prerelease }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Resolve version metadata + id: version + run: | + VERSION="$(git describe --tags)" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + if [[ "$VERSION" =~ ^v[0-9]+(\.[0-9]+)*$ ]]; then + echo "is_prerelease=false" >> "$GITHUB_OUTPUT" + else + echo "is_prerelease=true" >> "$GITHUB_OUTPUT" + fi + build-frontend: + needs: prepare runs-on: ubuntu-latest steps: - name: Checkout @@ -27,6 +48,7 @@ jobs: - name: Build Frontend env: CI: "" + VERSION: ${{ needs.prepare.outputs.version }} run: | cd atsf_server/web npm install @@ -40,7 +62,9 @@ jobs: retention-days: 1 build-binaries: - needs: build-frontend + needs: + - prepare + - build-frontend runs-on: ubuntu-latest strategy: fail-fast: false @@ -86,6 +110,7 @@ jobs: GOOS: ${{ matrix.goos }} GOARCH: ${{ matrix.goarch }} ASSET_NAME: ${{ matrix.asset_name }} + VERSION: ${{ needs.prepare.outputs.version }} run: | go mod download mkdir -p ../dist @@ -98,34 +123,82 @@ jobs: path: dist/${{ matrix.asset_name }} retention-days: 1 + build-agent-binaries: + needs: prepare + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - goos: linux + goarch: amd64 + asset_name: atsflare-agent-linux-amd64 + - goos: linux + goarch: arm64 + asset_name: atsflare-agent-linux-arm64 + - goos: darwin + goarch: amd64 + asset_name: atsflare-agent-darwin-amd64 + - goos: darwin + goarch: arm64 + asset_name: atsflare-agent-darwin-arm64 + - goos: windows + goarch: amd64 + asset_name: atsflare-agent-windows-amd64.exe + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: atsf_agent/go.mod + + - name: Build Agent + working-directory: atsf_agent + env: + CGO_ENABLED: 0 + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + ASSET_NAME: ${{ matrix.asset_name }} + run: | + go mod download + mkdir -p ../dist + go build -trimpath -ldflags "-s -w" -o "../dist/$ASSET_NAME" ./cmd/agent + + - name: Upload Agent Artifact + uses: actions/upload-artifact@v4 + with: + name: agent-${{ matrix.goos }}-${{ matrix.goarch }} + path: dist/${{ matrix.asset_name }} + retention-days: 1 + release: - needs: build-binaries + needs: + - prepare + - build-binaries + - build-agent-binaries runs-on: ubuntu-latest steps: - name: Download Binary Artifacts uses: actions/download-artifact@v4 with: - pattern: server-* + pattern: "*-*" path: dist merge-multiple: true - - name: Determine prerelease - id: prerelease - run: | - TAG="$VERSION" - if [[ "$TAG" == *alpha* ]] || [[ "$TAG" == *beta* ]] || [[ "$TAG" == *rc* ]]; then - echo "is_prerelease=true" >> "$GITHUB_OUTPUT" - else - echo "is_prerelease=false" >> "$GITHUB_OUTPUT" - fi - - name: Release uses: softprops/action-gh-release@v1 - if: startsWith(github.ref, 'refs/tags/') with: + tag_name: ${{ needs.prepare.outputs.version }} + name: ${{ needs.prepare.outputs.version }} + target_commitish: ${{ github.sha }} files: dist/* draft: false - prerelease: ${{ steps.prerelease.outputs.is_prerelease }} + prerelease: ${{ needs.prepare.outputs.is_prerelease == 'true' }} generate_release_notes: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/atsf_agent/internal/updater/restart_unix.go b/atsf_agent/internal/updater/restart_unix.go new file mode 100644 index 00000000..e349dddd --- /dev/null +++ b/atsf_agent/internal/updater/restart_unix.go @@ -0,0 +1,27 @@ +//go:build !windows + +package updater + +import ( + "fmt" + "os" + "syscall" +) + +func replaceAndRestart(execPath string, tmpPath string) error { + backupPath := execPath + ".bak" + os.Remove(backupPath) + if err := os.Rename(execPath, backupPath); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("backup current binary: %w", err) + } + if err := os.Rename(tmpPath, execPath); err != nil { + os.Rename(backupPath, execPath) + return fmt.Errorf("replace binary: %w", err) + } + os.Remove(backupPath) + if err := syscall.Exec(execPath, os.Args, os.Environ()); err != nil { + return fmt.Errorf("exec restart: %w", err) + } + return fmt.Errorf("unreachable after exec") +} \ No newline at end of file diff --git a/atsf_agent/internal/updater/restart_windows.go b/atsf_agent/internal/updater/restart_windows.go new file mode 100644 index 00000000..3ecc4e6f --- /dev/null +++ b/atsf_agent/internal/updater/restart_windows.go @@ -0,0 +1,53 @@ +//go:build windows + +package updater + +import ( + "fmt" + "os" + "os/exec" + "strings" +) + +func replaceAndRestart(execPath string, tmpPath string) error { + backupPath := execPath + ".bak" + scriptPath := execPath + ".update.cmd" + script := fmt.Sprintf(`@echo off +setlocal +:waitloop +move /Y "%s" "%s" >nul 2>nul +if errorlevel 1 ( + ping 127.0.0.1 -n 2 >nul + goto waitloop +) +move /Y "%s" "%s" >nul 2>nul +if errorlevel 1 exit /b 1 +start "" %s +del /Q "%s" >nul 2>nul +del /Q "%%~f0" >nul 2>nul +`, execPath, backupPath, tmpPath, execPath, buildWindowsCommandLine(execPath, os.Args[1:]), backupPath) + if err := os.WriteFile(scriptPath, []byte(script), 0o700); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("write restart script: %w", err) + } + cmd := exec.Command("cmd", "/C", "start", "", scriptPath) + if err := cmd.Start(); err != nil { + os.Remove(scriptPath) + os.Remove(tmpPath) + return fmt.Errorf("schedule restart: %w", err) + } + os.Exit(0) + return nil +} + +func buildWindowsCommandLine(execPath string, args []string) string { + parts := []string{quoteWindowsArg(execPath)} + for _, arg := range args { + parts = append(parts, quoteWindowsArg(arg)) + } + return strings.Join(parts, " ") +} + +func quoteWindowsArg(value string) string { + return `"` + strings.ReplaceAll(value, `"`, `""`) + `"` +} \ No newline at end of file diff --git a/atsf_agent/internal/updater/updater.go b/atsf_agent/internal/updater/updater.go index fa81efdc..2f325dea 100644 --- a/atsf_agent/internal/updater/updater.go +++ b/atsf_agent/internal/updater/updater.go @@ -3,7 +3,6 @@ package updater import ( "context" "encoding/json" - "errors" "fmt" "io" "log" @@ -11,7 +10,6 @@ import ( "os" "runtime" "strings" - "syscall" "time" "atsflare-agent/internal/config" @@ -59,7 +57,7 @@ func (s *Service) CheckAndUpdate(ctx context.Context, repo string) error { } log.Printf("agent update available: %s -> %s", localVersion, remoteVersion) - assetName := fmt.Sprintf("atsflare-agent-%s-%s", runtime.GOOS, runtime.GOARCH) + assetName := assetNameForGOOSGOARCH(runtime.GOOS, runtime.GOARCH) var downloadURL string for _, asset := range release.Assets { @@ -77,12 +75,10 @@ func (s *Service) CheckAndUpdate(ctx context.Context, repo string) error { if err != nil { return fmt.Errorf("get executable path: %w", err) } - if err = s.downloadAndReplace(ctx, downloadURL, execPath); err != nil { - return fmt.Errorf("download and replace: %w", err) + if err = s.downloadAndRestart(ctx, downloadURL, execPath); err != nil { + return fmt.Errorf("download and restart: %w", err) } - - log.Printf("agent binary updated, restarting...") - return s.restart(execPath) + return nil } func (s *Service) getLatestRelease(ctx context.Context, repo string) (*githubRelease, error) { @@ -113,7 +109,7 @@ func (s *Service) getLatestRelease(ctx context.Context, repo string) (*githubRel return &release, nil } -func (s *Service) downloadAndReplace(ctx context.Context, url string, targetPath string) error { +func (s *Service) downloadAndRestart(ctx context.Context, url string, targetPath string) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return err @@ -129,6 +125,9 @@ func (s *Service) downloadAndReplace(ctx context.Context, url string, targetPath } tmpPath := targetPath + ".update" + if runtime.GOOS == "windows" && !strings.HasSuffix(strings.ToLower(tmpPath), ".exe") { + tmpPath += ".exe" + } tmpFile, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) if err != nil { return err @@ -140,27 +139,16 @@ func (s *Service) downloadAndReplace(ctx context.Context, url string, targetPath } tmpFile.Close() - backupPath := targetPath + ".bak" - os.Remove(backupPath) - if err = os.Rename(targetPath, backupPath); err != nil { - os.Remove(tmpPath) - return fmt.Errorf("backup current binary: %w", err) - } - if err = os.Rename(tmpPath, targetPath); err != nil { - // Attempt to restore backup - os.Rename(backupPath, targetPath) - return fmt.Errorf("replace binary: %w", err) - } - os.Remove(backupPath) - return nil + log.Printf("agent binary updated, restarting...") + return replaceAndRestart(targetPath, tmpPath) } -func (s *Service) restart(execPath string) error { - argv := os.Args - if err := syscall.Exec(execPath, argv, os.Environ()); err != nil { - return fmt.Errorf("exec restart: %w", err) +func assetNameForGOOSGOARCH(goos string, goarch string) string { + name := fmt.Sprintf("atsflare-agent-%s-%s", goos, goarch) + if goos == "windows" { + return name + ".exe" } - return errors.New("unreachable after exec") + return name } func normalizeVersion(v string) string {