diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index 3293fb8..d2ba851 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -51,6 +51,10 @@ jobs: run: | set -euo pipefail TAG="${RELEASE_TAG}" + BUILD_VERSION="${TAG}" + if [ -z "${BUILD_VERSION}" ] && [ -f VERSION ]; then + BUILD_VERSION="$(cat VERSION | tr -d '[:space:]')" + fi mkdir -p dist targets=( @@ -73,7 +77,7 @@ jobs: mkdir -p "${STAGE}/static" CGO_ENABLED=0 GOOS="${GOOS}" GOARCH="${GOARCH}" \ - go build -trimpath -ldflags="-s -w" -o "${STAGE}/${BIN}" ./cmd/ds2api + go build -trimpath -ldflags="-s -w -X ds2api/internal/version.BuildVersion=${BUILD_VERSION}" -o "${STAGE}/${BIN}" ./cmd/ds2api cp config.example.json .env.example sha3_wasm_bg.7b9ca65ddd.wasm LICENSE README.MD README.en.md "${STAGE}/" cp -R static/admin "${STAGE}/static/admin" diff --git a/.github/workflows/release-dockerhub.yml b/.github/workflows/release-dockerhub.yml index 536c3dc..815ab8d 100644 --- a/.github/workflows/release-dockerhub.yml +++ b/.github/workflows/release-dockerhub.yml @@ -123,5 +123,7 @@ jobs: labels: | org.opencontainers.image.version=${{ steps.next_version.outputs.new_version }} org.opencontainers.image.revision=${{ github.sha }} + build-args: | + BUILD_VERSION=${{ steps.next_version.outputs.new_tag }} cache-from: type=gha cache-to: type=gha,mode=max diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index da3264f..ebd0cef 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,128 +1,130 @@ -name: Release to Aliyun CR - -on: - workflow_dispatch: - inputs: - version_type: - description: '版本类型' - required: true - default: 'patch' - type: choice - options: - - patch - - minor - - major - -permissions: - contents: write - -jobs: - release: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v5 - with: - fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Get current version - id: get_version - run: | - LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") - TAG_VERSION=${LATEST_TAG#v} - - if [ -f VERSION ]; then - FILE_VERSION=$(cat VERSION | tr -d '[:space:]') - else - FILE_VERSION="0.0.0" - fi - - function version_gt() { test "$(printf '%s\n' "$@" | sort -V | head -n 1)" != "$1"; } - - if version_gt "$FILE_VERSION" "$TAG_VERSION"; then - VERSION="$FILE_VERSION" - else - VERSION="$TAG_VERSION" - fi - - echo "Current version: $VERSION" - echo "current_version=$VERSION" >> $GITHUB_OUTPUT - - - name: Calculate next version - id: next_version - env: - VERSION_TYPE: ${{ github.event.inputs.version_type }} - run: | - VERSION="${{ steps.get_version.outputs.current_version }}" - BASE_VERSION=$(echo "$VERSION" | sed 's/-.*$//') - - IFS='.' read -r -a version_parts <<< "$BASE_VERSION" - MAJOR="${version_parts[0]:-0}" - MINOR="${version_parts[1]:-0}" - PATCH="${version_parts[2]:-0}" - - case "$VERSION_TYPE" in - major) - NEW_VERSION="$((MAJOR + 1)).0.0" - ;; - minor) - NEW_VERSION="${MAJOR}.$((MINOR + 1)).0" - ;; - *) - NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))" - ;; - esac - - echo "New version: $NEW_VERSION" - echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT - echo "new_tag=v$NEW_VERSION" >> $GITHUB_OUTPUT - - - name: Update VERSION file - run: | - echo "${{ steps.next_version.outputs.new_version }}" > VERSION - - - name: Commit VERSION and create tag - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - git add VERSION - if ! git diff --cached --quiet; then - git commit -m "chore: bump version to ${{ steps.next_version.outputs.new_tag }} [skip ci]" - fi - - NEW_TAG="${{ steps.next_version.outputs.new_tag }}" - git tag -a "$NEW_TAG" -m "Release $NEW_TAG" - git push origin HEAD:main "$NEW_TAG" - - # Docker 构建并推送到阿里云 - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to Aliyun Container Registry - uses: docker/login-action@v3 - with: - registry: ${{ secrets.ALIYUN_REGISTRY }} - username: ${{ secrets.ALIYUN_REGISTRY_USER }} - password: ${{ secrets.ALIYUN_REGISTRY_PASSWORD }} - - - name: Build and push Docker image - uses: docker/build-push-action@v6 - with: - context: . - file: ./Dockerfile - platforms: linux/amd64,linux/arm64 - push: true - tags: | - ${{ secrets.ALIYUN_REGISTRY }}/${{ secrets.ALIYUN_REGISTRY_NAMESPACE }}/ds2api:${{ steps.next_version.outputs.new_tag }} - ${{ secrets.ALIYUN_REGISTRY }}/${{ secrets.ALIYUN_REGISTRY_NAMESPACE }}/ds2api:${{ steps.next_version.outputs.new_version }} - ${{ secrets.ALIYUN_REGISTRY }}/${{ secrets.ALIYUN_REGISTRY_NAMESPACE }}/ds2api:latest - labels: | - org.opencontainers.image.version=${{ steps.next_version.outputs.new_version }} - org.opencontainers.image.revision=${{ github.sha }} - cache-from: type=gha - cache-to: type=gha,mode=max +name: Release to Aliyun CR + +on: + workflow_dispatch: + inputs: + version_type: + description: '版本类型' + required: true + default: 'patch' + type: choice + options: + - patch + - minor + - major + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Get current version + id: get_version + run: | + LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") + TAG_VERSION=${LATEST_TAG#v} + + if [ -f VERSION ]; then + FILE_VERSION=$(cat VERSION | tr -d '[:space:]') + else + FILE_VERSION="0.0.0" + fi + + function version_gt() { test "$(printf '%s\n' "$@" | sort -V | head -n 1)" != "$1"; } + + if version_gt "$FILE_VERSION" "$TAG_VERSION"; then + VERSION="$FILE_VERSION" + else + VERSION="$TAG_VERSION" + fi + + echo "Current version: $VERSION" + echo "current_version=$VERSION" >> $GITHUB_OUTPUT + + - name: Calculate next version + id: next_version + env: + VERSION_TYPE: ${{ github.event.inputs.version_type }} + run: | + VERSION="${{ steps.get_version.outputs.current_version }}" + BASE_VERSION=$(echo "$VERSION" | sed 's/-.*$//') + + IFS='.' read -r -a version_parts <<< "$BASE_VERSION" + MAJOR="${version_parts[0]:-0}" + MINOR="${version_parts[1]:-0}" + PATCH="${version_parts[2]:-0}" + + case "$VERSION_TYPE" in + major) + NEW_VERSION="$((MAJOR + 1)).0.0" + ;; + minor) + NEW_VERSION="${MAJOR}.$((MINOR + 1)).0" + ;; + *) + NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))" + ;; + esac + + echo "New version: $NEW_VERSION" + echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT + echo "new_tag=v$NEW_VERSION" >> $GITHUB_OUTPUT + + - name: Update VERSION file + run: | + echo "${{ steps.next_version.outputs.new_version }}" > VERSION + + - name: Commit VERSION and create tag + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + git add VERSION + if ! git diff --cached --quiet; then + git commit -m "chore: bump version to ${{ steps.next_version.outputs.new_tag }} [skip ci]" + fi + + NEW_TAG="${{ steps.next_version.outputs.new_tag }}" + git tag -a "$NEW_TAG" -m "Release $NEW_TAG" + git push origin HEAD:main "$NEW_TAG" + + # Docker 构建并推送到阿里云 + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Aliyun Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ secrets.ALIYUN_REGISTRY }} + username: ${{ secrets.ALIYUN_REGISTRY_USER }} + password: ${{ secrets.ALIYUN_REGISTRY_PASSWORD }} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ${{ secrets.ALIYUN_REGISTRY }}/${{ secrets.ALIYUN_REGISTRY_NAMESPACE }}/ds2api:${{ steps.next_version.outputs.new_tag }} + ${{ secrets.ALIYUN_REGISTRY }}/${{ secrets.ALIYUN_REGISTRY_NAMESPACE }}/ds2api:${{ steps.next_version.outputs.new_version }} + ${{ secrets.ALIYUN_REGISTRY }}/${{ secrets.ALIYUN_REGISTRY_NAMESPACE }}/ds2api:latest + labels: | + org.opencontainers.image.version=${{ steps.next_version.outputs.new_version }} + org.opencontainers.image.revision=${{ github.sha }} + build-args: | + BUILD_VERSION=${{ steps.next_version.outputs.new_tag }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile index 6996f43..b40570c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,13 +10,16 @@ FROM golang:1.24 AS go-builder WORKDIR /app ARG TARGETOS ARG TARGETARCH +ARG BUILD_VERSION COPY go.mod go.sum* ./ RUN go mod download COPY . . RUN set -eux; \ GOOS="${TARGETOS:-$(go env GOOS)}"; \ GOARCH="${TARGETARCH:-$(go env GOARCH)}"; \ - CGO_ENABLED=0 GOOS="${GOOS}" GOARCH="${GOARCH}" go build -o /out/ds2api ./cmd/ds2api + BUILD_VERSION_RESOLVED="${BUILD_VERSION}"; \ + if [ -z "${BUILD_VERSION_RESOLVED}" ] && [ -f VERSION ]; then BUILD_VERSION_RESOLVED="$(cat VERSION | tr -d "[:space:]")"; fi; \ + CGO_ENABLED=0 GOOS="${GOOS}" GOARCH="${GOARCH}" go build -ldflags="-s -w -X ds2api/internal/version.BuildVersion=${BUILD_VERSION_RESOLVED}" -o /out/ds2api ./cmd/ds2api FROM busybox:1.36.1-musl AS busybox-tools diff --git a/VERSION b/VERSION index 7ecf123..e75da3e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0 +2.3.6 diff --git a/internal/admin/handler.go b/internal/admin/handler.go index 96111e2..fc20533 100644 --- a/internal/admin/handler.go +++ b/internal/admin/handler.go @@ -39,5 +39,6 @@ func RegisterRoutes(r chi.Router, h *Handler) { pr.Get("/export", h.exportConfig) pr.Get("/dev/captures", h.getDevCaptures) pr.Delete("/dev/captures", h.clearDevCaptures) + pr.Get("/version", h.getVersion) }) } diff --git a/internal/admin/handler_version.go b/internal/admin/handler_version.go new file mode 100644 index 0000000..1c38dfe --- /dev/null +++ b/internal/admin/handler_version.go @@ -0,0 +1,75 @@ +package admin + +import ( + "encoding/json" + "net/http" + "strings" + "time" + + "ds2api/internal/version" +) + +const latestReleaseAPI = "https://api.github.com/repos/CJackHwang/ds2api/releases/latest" + +type latestReleasePayload struct { + TagName string `json:"tag_name"` + HTMLURL string `json:"html_url"` + PublishedAt string `json:"published_at"` +} + +func (h *Handler) getVersion(w http.ResponseWriter, _ *http.Request) { + current, source := version.Current() + resp := map[string]any{ + "success": true, + "current_version": current, + "current_tag": version.Tag(current), + "source": source, + "checked_at": time.Now().UTC().Format(time.RFC3339), + } + + req, err := http.NewRequest(http.MethodGet, latestReleaseAPI, nil) + if err != nil { + resp["check_error"] = err.Error() + writeJSON(w, http.StatusOK, resp) + return + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "ds2api-version-check") + + client := &http.Client{Timeout: 4 * time.Second} + r, err := client.Do(req) + if err != nil { + resp["check_error"] = err.Error() + writeJSON(w, http.StatusOK, resp) + return + } + defer r.Body.Close() + if r.StatusCode < 200 || r.StatusCode >= 300 { + resp["check_error"] = "github api status: " + r.Status + writeJSON(w, http.StatusOK, resp) + return + } + + var data latestReleasePayload + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { + resp["check_error"] = err.Error() + writeJSON(w, http.StatusOK, resp) + return + } + + latest := strings.TrimSpace(data.TagName) + if latest == "" { + resp["check_error"] = "missing latest tag" + writeJSON(w, http.StatusOK, resp) + return + } + latestVersion := strings.TrimPrefix(latest, "v") + + resp["latest_tag"] = latest + resp["latest_version"] = latestVersion + resp["release_url"] = data.HTMLURL + resp["published_at"] = data.PublishedAt + resp["has_update"] = version.Compare(current, latestVersion) < 0 + + writeJSON(w, http.StatusOK, resp) +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..03542b9 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,185 @@ +package version + +import ( + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "sync" +) + +// BuildVersion can be injected at build time via -ldflags. +// In release builds it should come from Git tag (e.g. v2.3.5). +var BuildVersion = "" + +var ( + currentOnce sync.Once + currentVal string + sourceVal string +) + +func Current() (value string, source string) { + currentOnce.Do(func() { + if build := strings.TrimSpace(BuildVersion); build != "" { + currentVal = normalize(build) + sourceVal = "build-ldflags" + return + } + if fv := readVersionFile(); fv != "" { + currentVal = normalize(fv) + sourceVal = "file:VERSION" + return + } + + if vv := versionFromVercelEnv(); vv != "" { + currentVal = vv + sourceVal = "env:vercel" + return + } + currentVal = "dev" + sourceVal = "default" + }) + return currentVal, sourceVal +} + +func readVersionFile() string { + candidates := []string{"VERSION"} + if wd, err := os.Getwd(); err == nil { + candidates = append(candidates, filepath.Join(wd, "VERSION")) + } + if _, file, _, ok := runtime.Caller(0); ok { + repoRoot := filepath.Clean(filepath.Join(filepath.Dir(file), "../..")) + candidates = append(candidates, filepath.Join(repoRoot, "VERSION")) + } + seen := map[string]struct{}{} + for _, c := range candidates { + c = filepath.Clean(strings.TrimSpace(c)) + if c == "" { + continue + } + if _, ok := seen[c]; ok { + continue + } + seen[c] = struct{}{} + b, err := os.ReadFile(c) + if err != nil { + continue + } + if v := strings.TrimSpace(string(b)); v != "" { + return v + } + } + return "" +} + +func normalize(v string) string { + v = strings.TrimSpace(v) + if v == "" { + return "" + } + return strings.TrimPrefix(v, "v") +} + +func Tag(v string) string { + v = normalize(v) + if v == "" || v == "dev" { + return v + } + if v[0] < '0' || v[0] > '9' { + return v + } + return "v" + v +} + +func versionFromVercelEnv() string { + if tag := normalize(strings.TrimSpace(os.Getenv("VERCEL_GIT_COMMIT_TAG"))); tag != "" { + return tag + } + ref := strings.TrimSpace(os.Getenv("VERCEL_GIT_COMMIT_REF")) + sha := strings.TrimSpace(os.Getenv("VERCEL_GIT_COMMIT_SHA")) + if len(sha) > 7 { + sha = sha[:7] + } + ref = sanitizeVersionLabel(ref) + sha = sanitizeVersionLabel(sha) + if ref == "" && sha == "" { + return "" + } + if ref != "" && sha != "" { + return "preview-" + ref + "." + sha + } + if ref != "" { + return "preview-" + ref + } + return "preview-" + sha +} + +func sanitizeVersionLabel(in string) string { + in = strings.TrimSpace(strings.ToLower(in)) + if in == "" { + return "" + } + var b strings.Builder + b.Grow(len(in)) + prevDash := false + for i := 0; i < len(in); i++ { + c := in[i] + if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') { + b.WriteByte(c) + prevDash = false + continue + } + if !prevDash { + b.WriteByte('-') + prevDash = true + } + } + out := strings.Trim(b.String(), "-") + return out +} + +func Compare(a, b string) int { + pa := parse(normalize(a)) + pb := parse(normalize(b)) + for i := 0; i < 3; i++ { + if pa[i] < pb[i] { + return -1 + } + if pa[i] > pb[i] { + return 1 + } + } + return 0 +} + +func parse(v string) [3]int { + var out [3]int + parts := strings.SplitN(v, ".", 4) + for i := 0; i < 3 && i < len(parts); i++ { + n := readLeadingInt(parts[i]) + out[i] = n + } + return out +} + +func readLeadingInt(s string) int { + s = strings.TrimSpace(s) + if s == "" { + return 0 + } + i := 0 + for ; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + break + } + } + if i == 0 { + return 0 + } + n, err := strconv.Atoi(s[:i]) + if err != nil { + return 0 + } + return n +} diff --git a/internal/version/version_test.go b/internal/version/version_test.go new file mode 100644 index 0000000..03f7e95 --- /dev/null +++ b/internal/version/version_test.go @@ -0,0 +1,39 @@ +package version + +import "testing" + +func TestNormalizeAndTag(t *testing.T) { + if got := normalize("v2.3.5"); got != "2.3.5" { + t.Fatalf("normalize failed: %q", got) + } + if got := Tag("2.3.5"); got != "v2.3.5" { + t.Fatalf("tag failed: %q", got) + } +} + +func TestCompare(t *testing.T) { + if Compare("2.3.5", "2.3.5") != 0 { + t.Fatal("expected equal") + } + if Compare("2.3.5", "2.3.6") >= 0 { + t.Fatal("expected less") + } + if Compare("v2.10.0", "2.3.9") <= 0 { + t.Fatal("expected greater") + } +} + +func TestTagKeepsPreviewStyle(t *testing.T) { + if got := Tag("preview-dev.abcd123"); got != "preview-dev.abcd123" { + t.Fatalf("expected preview tag unchanged, got %q", got) + } +} + +func TestVersionFromVercelEnv(t *testing.T) { + t.Setenv("VERCEL_GIT_COMMIT_TAG", "") + t.Setenv("VERCEL_GIT_COMMIT_REF", "dev") + t.Setenv("VERCEL_GIT_COMMIT_SHA", "abcdef123456") + if got := versionFromVercelEnv(); got != "preview-dev.abcdef1" { + t.Fatalf("unexpected vercel preview version: %q", got) + } +} diff --git a/vercel.json b/vercel.json index bad49e0..ee5aaff 100644 --- a/vercel.json +++ b/vercel.json @@ -78,6 +78,10 @@ "source": "/admin/export", "destination": "/api/index" }, + { + "source": "/admin/version", + "destination": "/api/index" + }, { "source": "/admin", "destination": "/admin/index.html" diff --git a/webui/src/layout/DashboardShell.jsx b/webui/src/layout/DashboardShell.jsx index 399833b..5e91f8a 100644 --- a/webui/src/layout/DashboardShell.jsx +++ b/webui/src/layout/DashboardShell.jsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { LayoutDashboard, Upload, @@ -47,6 +47,29 @@ export default function DashboardShell({ token, onLogout, config, fetchConfig, s return res }, [onLogout, t, token]) + + const [versionInfo, setVersionInfo] = useState(null) + + useEffect(() => { + let disposed = false + async function loadVersion() { + try { + const res = await authFetch('/admin/version') + const data = await res.json() + if (!disposed) { + setVersionInfo(data) + } + } catch (_err) { + if (!disposed) { + setVersionInfo(null) + } + } + } + loadVersion() + return () => { + disposed = true + } + }, [authFetch]) const renderTab = () => { switch (activeTab) { case 'accounts': @@ -135,6 +158,20 @@ export default function DashboardShell({ token, onLogout, config, fetchConfig, s
{config.keys?.length || 0}
+
+
{t('sidebar.version')}
+
{versionInfo?.current_tag || '-'}
+ {versionInfo?.has_update && ( + + {t('sidebar.updateAvailable', { latest: versionInfo.latest_tag || '' })} + + )} +