Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions .github/workflows/release-publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
name: release-publish

on:
workflow_dispatch:
inputs:
tag:
description: "Release tag to publish (v2.1.4 or browser-extension-v0.11.0); published tags can retry README sync"
required: true

permissions:
contents: write
pull-requests: write

env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "24"
RELEASE_TAG: ${{ inputs.tag }}

concurrency:
group: publish-release
cancel-in-progress: false

jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Publish draft with explicit Latest policy
id: release
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
shell: bash
run: |
set -euo pipefail
if [[ "$RELEASE_TAG" =~ ^browser-extension-v[0-9]+(\.[0-9]+){0,3}$ ]]; then
latest=false
elif [[ "$RELEASE_TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
latest=true
else
echo "Unsupported release tag: $RELEASE_TAG" >&2
exit 1
fi

release_json="$RUNNER_TEMP/javboss-release.json"
gh release view "$RELEASE_TAG" --repo "$GH_REPO" --json isDraft,isPrerelease,assets > "$release_json"
jq -e '.isPrerelease == false' "$release_json" > /dev/null
if [[ "$latest" == true ]]; then
for platform in windows-x86_64 linux-x86_64 macos-x86_64 macos-arm64; do
jq -e --arg name "javboss-${RELEASE_TAG}-${platform}.zip" 'any(.assets[]; .name == $name)' "$release_json" > /dev/null
done
else
jq -e --arg name "javboss-${RELEASE_TAG}.zip" 'any(.assets[]; .name == $name)' "$release_json" > /dev/null
fi
jq -e '[.assets[] | select(.name | test("^javboss-browser-extension-v[0-9]+(\\.[0-9]+){0,3}\\.zip$"))] | length <= 1' "$release_json" > /dev/null
if jq -e '.isDraft == true' "$release_json" > /dev/null; then
gh release edit "$RELEASE_TAG" --repo "$GH_REPO" --draft=false --latest="$latest"
else
echo "Release is already published; checking whether README sync is needed."
fi

# Retrying an older release must not replace the current Latest links.
update_readme=true
if [[ "$latest" == true ]]; then
latest_tag="$(gh api "repos/$GH_REPO/releases/latest" --jq .tag_name)"
if [[ "$latest_tag" == "$RELEASE_TAG" ]]; then
echo "Updating README for the latest application release."
else
update_readme=false
echo "Latest is $latest_tag; leaving README unchanged."
fi
fi
echo "update_readme=$update_readme" >> "$GITHUB_OUTPUT"

- name: Checkout publishing scripts
if: steps.release.outputs.update_readme == 'true'
uses: actions/checkout@v5
with:
path: release-tools
persist-credentials: false

- name: Checkout main for README update
if: steps.release.outputs.update_readme == 'true'
uses: actions/checkout@v5
with:
ref: main
path: main
# Optional: use a PAT if the README PR needs to trigger other workflows.
token: ${{ secrets.RELEASE_PUBLISH_TOKEN || github.token }}

- name: Setup Node
if: steps.release.outputs.update_readme == 'true'
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}

- name: Create or update README pull request
if: steps.release.outputs.update_readme == 'true'
working-directory: main
env:
GH_TOKEN: ${{ secrets.RELEASE_PUBLISH_TOKEN || github.token }}
GH_REPO: ${{ github.repository }}
shell: bash
run: |
set -euo pipefail
node "$GITHUB_WORKSPACE/release-tools/scripts/update-release-version.mjs" "$RELEASE_TAG" "$RUNNER_TEMP/javboss-release.json" README.md
if git diff --quiet -- README.md; then
echo "README download links already point to $RELEASE_TAG."
exit 0
fi

branch="release-readme/${RELEASE_TAG}"
remote_sha="$(git ls-remote --heads origin "refs/heads/$branch" | cut -f1)"
git checkout -b "$branch"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add -- README.md
git commit -m "docs: update download links to $RELEASE_TAG"
# Only replace this workflow's branch, and reject concurrent changes.
git push --force-with-lease="refs/heads/$branch:$remote_sha" origin "HEAD:refs/heads/$branch"

title="docs: update download links for $RELEASE_TAG"
body_file="$RUNNER_TEMP/readme-pr-body.md"
printf 'Updates the applicable application and browser extension download links in README.md for [%s](https://github.com/%s/releases/tag/%s).\n\nGenerated after publishing the release. Merge this PR to update the links on main.\n' "$RELEASE_TAG" "$GH_REPO" "$RELEASE_TAG" > "$body_file"
pr_number="$(gh pr list --repo "$GH_REPO" --head "$branch" --base main --state open --json number --jq '.[0].number // empty')"
if [[ -n "$pr_number" ]]; then
gh pr edit "$pr_number" --repo "$GH_REPO" --title "$title" --body-file "$body_file"
else
gh pr create --repo "$GH_REPO" --head "$branch" --base main --title "$title" --body-file "$body_file"
fi
71 changes: 0 additions & 71 deletions .github/workflows/update-release-version.yml

This file was deleted.

1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
.gocache/
.updates/
data/
/cache/
docker-data/
Expand Down
26 changes: 26 additions & 0 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import (
"javboss/internal/jav/translation"
"javboss/internal/models"
"javboss/internal/mpv"
"javboss/internal/update"
"javboss/internal/util"

"javboss/internal/download"
Expand All @@ -54,6 +55,13 @@ const (
)

func main() {
if len(os.Args) == 3 && os.Args[1] == "--apply-update" {
if err := update.RunHelper(os.Args[2]); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
return
}
os.Exit(run())
}

Expand Down Expand Up @@ -94,6 +102,18 @@ func run() int {
if background {
return 0
}
if buildMode == "release" && !common.ContainerMode() {
lock, err := update.AcquireRuntimeLock(baseDir)
if err != nil {
if errors.Is(err, util.ErrLockHeld) {
notifyAlreadyRunning("JavBoss 已在运行或更新中。")
return 0
}
logger.Printf("acquire update runtime lock: %v", err)
return 1
}
defer lock.Release()
}

bootstrapCfg, err := clientpkg.LoadBootstrapConfig(baseDir)
if err != nil {
Expand Down Expand Up @@ -185,6 +205,8 @@ func run() int {
logger.Printf("password reset applied; all existing sessions were revoked")
}
runtimeCfg := applyRuntimeConfig(ctx)
update.Init(ctx, update.Options{BaseDir: baseDir, DatabasePath: cfg.DatabasePath,
Version: common.Version, Release: buildMode == "release", Container: common.ContainerMode(), Stop: stop})
allowLANAccess := configBool(runtimeCfg["allow_lan_access"]) && !common.ContainerMode()

var activeDirs []models.Directory
Expand Down Expand Up @@ -268,6 +290,7 @@ func run() int {
actualPort := listener.Addr().(*net.TCPAddr).Port
displayURL := fmt.Sprintf("http://localhost:%d", actualPort)
logger.Printf("server listening on %s", listener.Addr().String())
update.AcknowledgeStartup(baseDir, common.Version)
if err := serveWithReleaseControls(ctx, stop, displayURL, "", logger, func() error {
return api.ServeHTTP(ctx, srv, listener, allowLANAccess, !common.ContainerMode())
}); err != nil {
Expand All @@ -289,6 +312,8 @@ func resolveClientServerURL(flagValue, configuredValue string) string {
}

func runClientMode(ctx context.Context, stop context.CancelFunc, baseDir, serverURL string, configuredPort int, logger *log.Logger) error {
update.Init(ctx, update.Options{BaseDir: baseDir, Version: common.Version,
Release: buildMode == "release", Container: common.ContainerMode(), Stop: stop})
port := configuredPort
if port == 0 {
if buildMode == "release" && !common.ContainerMode() {
Expand Down Expand Up @@ -330,6 +355,7 @@ func runClientMode(ctx context.Context, stop context.CancelFunc, baseDir, server
defer listener.Close()
displayURL := fmt.Sprintf("http://localhost:%d", port)
logger.Printf("client mode listening on %s, remote server %s", listenAddr, strings.TrimSpace(serverURL))
update.AcknowledgeStartup(baseDir, common.Version)
if err := serveWithReleaseControls(ctx, stop, displayURL, serverURL, logger, func() error {
return api.ServeHTTP(ctx, srv, listener, false, false)
}); err != nil {
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ require (
github.com/shirou/gopsutil/v4 v4.25.9
github.com/urfave/cli/v3 v3.13.0
golang.org/x/crypto v0.40.0
golang.org/x/mod v0.25.0
golang.org/x/net v0.42.0
golang.org/x/sys v0.35.0
golang.org/x/term v0.33.0
Expand Down Expand Up @@ -88,7 +89,6 @@ require (
go.uber.org/mock v0.5.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/mod v0.25.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/text v0.27.0 // indirect
golang.org/x/tools v0.34.0 // indirect
Expand Down
11 changes: 11 additions & 0 deletions internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,22 @@ package api
import (
"net/http"

"javboss/internal/update"

"github.com/gin-gonic/gin"
)

// RegisterRoutes wires handlers onto the provided router.
func RegisterRoutes(router gin.IRoutes) {
// Update status/check/install have no query parameters. Installation uses the
// official release selected by the server and requires normal session auth.
for _, route := range []struct{ method, path string }{
{http.MethodGet, "/system/update"},
{http.MethodPost, "/system/update/check"},
{http.MethodPost, "/system/update/install"},
} {
router.Handle(route.method, route.path, func(c *gin.Context) { update.ServeHTTP(c.Writer, c.Request, "/system/update") })
}
router.GET("/auth/extension-tokens", listExtensionTokens)
router.POST("/auth/extension-tokens", createExtensionToken)
router.POST("/auth/extension-tokens/:id/rotate", rotateExtensionToken)
Expand Down
33 changes: 33 additions & 0 deletions internal/api/update_api_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package api

import (
"net/http"
"testing"

"github.com/gin-gonic/gin"
)

func TestUpdateEndpointsRequireSessionAndSameOrigin(t *testing.T) {
deps := newTestDependencies()
gin.SetMode(gin.TestMode)
router := NewRouter("", testAuthService(deps, t))
for _, tc := range []struct{ method, path string }{
{http.MethodGet, "/system/update"},
{http.MethodPost, "/system/update/check"},
{http.MethodPost, "/system/update/install"},
} {
response := performRequest(router, tc.method, tc.path, nil, "", nil)
if response.Code != http.StatusUnauthorized {
t.Fatalf("%s %s status=%d", tc.method, tc.path, response.Code)
}
}
login := performRequest(router, http.MethodPost, "/auth/login", []byte(`{"password":"admin"}`), "http://example.com", nil)
if login.Code != http.StatusOK {
t.Fatalf("login=%d %s", login.Code, login.Body.String())
}
cookie := login.Result().Cookies()[0]
response := performRequest(router, http.MethodPost, "/system/update/install", nil, "http://attacker.example", cookie)
if response.Code != http.StatusForbidden {
t.Fatalf("cross-origin update status=%d", response.Code)
}
}
3 changes: 3 additions & 0 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (

"javboss/internal/common/logging"
"javboss/internal/mpv"
"javboss/internal/update"
)

const maxClientRequestBody = 1 << 20
Expand Down Expand Up @@ -137,6 +138,8 @@ func (c *Client) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
switch {
case r.URL.Path == "/__client/update" || strings.HasPrefix(r.URL.Path, "/__client/update/"):
update.ServeHTTP(w, r, "/__client/update")
case r.URL.Path == "/healthz":
c.handleHealth(w)
case strings.HasPrefix(r.URL.Path, "/__client/media/"):
Expand Down
Loading