diff --git a/.github/scripts/client-versions.mjs b/.github/scripts/client-versions.mjs new file mode 100644 index 0000000..e8bdae2 --- /dev/null +++ b/.github/scripts/client-versions.mjs @@ -0,0 +1,83 @@ +const products = [ + { + target: "client", + key: "CLIENT", + assets: (version) => [`JumpServer-${version}-Setup.exe`, `JumpServer-${version}-arm64.dmg`], + }, + { + target: "weblite", + key: "WEBLITE", + assets: (version) => [`JumpServer-WebLite-${version}-x64.msi`], + }, +]; + +function parseVersion(version) { + const match = version.match( + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/, + ); + if (!match) return null; + const prerelease = match[4]?.split(".") || []; + if (prerelease.some((part) => /^0\d+$/.test(part))) return null; + return { release: match.slice(1, 4).map(BigInt), prerelease }; +} + +function compareVersions(a, b) { + for (let i = 0; i < 3; i++) { + if (a.release[i] !== b.release[i]) return a.release[i] > b.release[i] ? 1 : -1; + } + if (!a.prerelease.length || !b.prerelease.length) { + return Number(!a.prerelease.length) - Number(!b.prerelease.length); + } + for (let i = 0; i < Math.max(a.prerelease.length, b.prerelease.length); i++) { + const x = a.prerelease[i]; + const y = b.prerelease[i]; + if (x === y) continue; + if (x === undefined || y === undefined) return x === undefined ? -1 : 1; + const xNumeric = /^\d+$/.test(x); + const yNumeric = /^\d+$/.test(y); + if (xNumeric && yNumeric) return BigInt(x) > BigInt(y) ? 1 : -1; + if (xNumeric !== yNumeric) return xNumeric ? -1 : 1; + return x > y ? 1 : -1; + } + return 0; +} + +function readSetting(content, key) { + const matches = [...content.matchAll(new RegExp(`^${key}=([^\\r\\n]*)\\r?$`, "gm"))]; + if (matches.length !== 1) throw new Error(`Expected one ${key} in client-version.txt`); + return matches[0][1]; +} + +export function updateClientVersions(content, releases) { + for (const { target, key, assets } of products) { + const currentVersion = readSetting(content, `${key}_VERSION`); + const current = parseVersion(currentVersion); + if (!current) { + throw new Error(`Invalid ${key} version in client-version.txt`); + } + let selected; + for (const release of releases) { + if (release.draft || release.prerelease || !release.tag_name?.startsWith(`${target}-v`)) + continue; + const version = release.tag_name.slice(`${target}-v`.length); + const parsed = parseVersion(version); + if (!parsed || compareVersions(parsed, current) < 0) continue; + if ( + !assets(version).every((name) => + release.assets?.some((asset) => asset.name === name && asset.size > 0), + ) + ) + continue; + if (!selected || compareVersions(parsed, selected.parsed) > 0) { + selected = { version, parsed }; + } + } + if (selected) { + content = content.replace( + new RegExp(`^${key}_VERSION=[^\\r\\n]*`, "m"), + `${key}_VERSION=${selected.version}`, + ); + } + } + return content; +} diff --git a/.github/scripts/client-versions.test.mjs b/.github/scripts/client-versions.test.mjs new file mode 100644 index 0000000..ee9d42e --- /dev/null +++ b/.github/scripts/client-versions.test.mjs @@ -0,0 +1,161 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { + copyFileSync, + mkdirSync, + mkdtempSync, + readFileSync, + readlinkSync, + rmSync, + writeFileSync, +} from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { updateClientVersions } from "./client-versions.mjs"; + +const repo = fileURLToPath(new URL("../..", import.meta.url)); +const pinned = + "CLIENT_VERSION=5.1.0-rc3\nCLIENT_NAME=JumpServer\nWEBLITE_VERSION=5.1.0-rc3\n"; + +function release(target, version, overrides = {}) { + const names = + target === "client" + ? [`JumpServer-${version}-Setup.exe`, `JumpServer-${version}-arm64.dmg`] + : [`JumpServer-WebLite-${version}-x64.msi`]; + return { + tag_name: `${target}-v${version}`, + draft: false, + prerelease: false, + assets: names.map((name) => ({ name, size: 1024 })), + ...overrides, + }; +} + +test("selects complete published releases independently by version, preserving installer names", () => { + const releases = [ + release("client", "5.1.9"), + release("weblite", "5.2.0"), + release("client", "5.1.10"), + release("client", "6.0.0", { draft: true }), + release("client", "7.0.0", { prerelease: true }), + release("client", "8.0.0", { assets: [] }), + release("client", "9.0.0", { tag_name: "v9.0.0" }), + ]; + const content = updateClientVersions(pinned, releases); + assert.match(content, /^CLIENT_VERSION=5\.1\.10$/m); + assert.match(content, /^WEBLITE_VERSION=5\.2\.0$/m); + assert.equal(updateClientVersions(content, releases), content); + assert.equal( + releases.reduce((pins, item) => updateClientVersions(pins, [item]), pinned), + content, + ); +}); + +test("client-only updates retain the pinned WebLite version", () => { + const content = updateClientVersions(pinned, [release("client", "5.1.0")]); + assert.match(content, /^CLIENT_VERSION=5\.1\.0$/m); + for (const line of pinned.split("\n").filter((line) => line.startsWith("WEBLITE_"))) { + assert.ok(content.split("\n").includes(line)); + } +}); + +test("late, malformed and incomplete releases cannot downgrade either product", () => { + assert.equal( + updateClientVersions(pinned, [ + release("client", "5.0.0"), + release("weblite", "5.0.0"), + release("client", "5.01.0"), + release("weblite", "5.1.0-01"), + release("client", "6.0.0", { assets: [{ name: "JumpServer-6.0.0-Setup.exe", size: 1024 }] }), + release("weblite", "6.0.0", { + assets: [{ name: "JumpServer-WebLite-6.0.0-x64.msi", size: 0 }], + }), + ]), + pinned, + ); + assert.throws(() => + updateClientVersions(pinned.replace("CLIENT_VERSION=", "INVALID_VERSION="), []), + ); + assert.throws(() => + updateClientVersions( + pinned.replace("CLIENT_VERSION=5.1.0-rc3", "CLIENT_VERSION=invalid"), + [], + ), + ); +}); + +test("orders promoted release candidates numerically", () => { + const baseline = pinned.replaceAll("5.1.0-rc3", "5.1.0-rc.3"); + assert.match( + updateClientVersions(baseline, [ + release("weblite", "5.1.0-rc.10"), + release("weblite", "5.1.0-rc.9"), + ]), + /^WEBLITE_VERSION=5\.1\.0-rc\.10$/m, + ); +}); + +test("the checked-in pins have valid product versions", () => { + const content = readFileSync(path.join(repo, "client-version.txt"), "utf8"); + assert.equal(updateClientVersions(content, []), content); +}); + +function download(context, settings) { + const root = mkdtempSync(path.join(os.tmpdir(), "docker-web-downloads-")); + context.after(() => rmSync(root, { recursive: true, force: true })); + copyFileSync(path.join(repo, "client.sh"), path.join(root, "client.sh")); + writeFileSync(path.join(root, "client-version.txt"), settings); + mkdirSync(path.join(root, "bin")); + writeFileSync( + path.join(root, "bin/wget"), + `#!/bin/sh +set -eu +printf '%s\\n' "$3" >> "$DOWNLOAD_LOG" +printf '%s\\n' "$3" > "\${2#--output-document=}" +`, + { mode: 0o755 }, + ); + const log = path.join(root, "downloads"); + const result = spawnSync("sh", [path.join(root, "client.sh")], { + env: { + ...process.env, + PATH: `${path.join(root, "bin")}:${process.env.PATH}`, + DOWNLOAD_LOG: log, + CLIENT_DOWNLOAD_DIR: path.join(root, "public"), + WEBLITE_DOWNLOAD_DIR: path.join(root, "applets"), + CLIENT_RELEASE_URL: "", + WEBLITE_RELEASE_URL: "", + }, + encoding: "utf8", + timeout: 10_000, + }); + assert.equal(result.status, 0, result.stderr); + return { root, urls: readFileSync(log, "utf8").trim().split("\n") }; +} + +test("downloader uses separate release tags while retaining public filenames and aliases", (context) => { + const { root, urls } = download( + context, + "CLIENT_VERSION=5.1.1\nCLIENT_NAME=JumpServer\nWEBLITE_VERSION=5.2.0\n", + ); + assert.deepEqual(urls, [ + "https://github.com/jumpserver/luna/releases/download/client-v5.1.1/JumpServer-5.1.1-Setup.exe", + "https://github.com/jumpserver/luna/releases/download/client-v5.1.1/JumpServer-5.1.1-arm64.dmg", + "https://github.com/jumpserver/luna/releases/download/weblite-v5.2.0/JumpServer-WebLite-5.2.0-x64.msi", + ]); + assert.equal( + readlinkSync(path.join(root, "public/Client_5.1.1_x64-setup.exe")), + "JumpServerClient_5.1.1_x64-setup.exe", + ); + assert.equal( + readlinkSync(path.join(root, "public/Client_5.1.1_aarch64.dmg")), + "JumpServerClient_5.1.1_aarch64.dmg", + ); + assert.ok( + readFileSync(path.join(root, "applets/JumpServer-WebLite-5.2.0-x64.msi"), "utf8").includes( + "weblite-v5.2.0", + ), + ); +}); diff --git a/.github/workflows/check-deps-versions.yml b/.github/workflows/check-deps-versions.yml index 763a07f..5ac42a4 100644 --- a/.github/workflows/check-deps-versions.yml +++ b/.github/workflows/check-deps-versions.yml @@ -1,6 +1,6 @@ on: repository_dispatch: - types: [client-released] + types: [client-released, weblite-released] schedule: - cron: "0 1 * * *" push: @@ -9,9 +9,14 @@ on: name: Check Dependencies Versions +concurrency: + group: dependency-versions-dev + cancel-in-progress: false + jobs: build: runs-on: ubuntu-latest + timeout-minutes: 10 env: GITHUB_TOKEN: ${{ secrets.PRIVATE_TOKEN }} strategy: @@ -22,6 +27,10 @@ jobs: uses: actions/checkout@v4 with: ref: ${{ matrix.branch }} + token: ${{ secrets.PRIVATE_TOKEN }} + + - name: Validate independent product versions + run: node --test --test-concurrency=1 .github/scripts/client-versions.test.mjs - name: Set up Git run: | @@ -51,42 +60,31 @@ jobs: git checkout -b pr@${{ matrix.branch }}@upgrade_client fi - - name: Check Luna release version - env: - RELEASED_CLIENT_VERSION: ${{ github.event.client_payload.version }} - run: | - if [ -n "$RELEASED_CLIENT_VERSION" ]; then - version="$RELEASED_CLIENT_VERSION" - else - version=$(curl --fail --silent --show-error 'https://api.github.com/repos/jumpserver/luna/releases?per_page=100' | jq -r ' - first( - .[] - | . as $release - | (.tag_name | ltrimstr("v")) as $version - | select( - any( - .assets[]?; - .name == "JumpServer-\($version)-Setup.exe" - ) - and any( - .assets[]?; - .name == "JumpServer-\($version)-arm64.dmg" - ) - and any( - .assets[]?; - .name == "JumpServer-WebLite-\($version)-x64.msi" - ) - ) - | .tag_name - ) // empty - ') - fi - version="${version#v}" - if [ -n "$version" ]; then - sed -i "s/CLIENT_VERSION=.*/CLIENT_VERSION=${version}/g" client-version.txt - else - echo "No published Luna release with desktop client and WebLite assets found" - fi + - name: Check Client and WebLite releases + uses: actions/github-script@v8 + with: + github-token: ${{ secrets.PRIVATE_TOKEN }} + script: | + const { readFile, writeFile } = require('node:fs/promises'); + const { pathToFileURL } = require('node:url'); + const { updateClientVersions } = await import(pathToFileURL( + `${process.env.GITHUB_WORKSPACE}/.github/scripts/client-versions.mjs` + )); + // Recheck both products on every event; delayed notifications must not + // roll back a pin or erase the other product's pending update. + const path = 'client-version.txt'; + const previous = await readFile(path, 'utf8'); + let content = previous; + // ponytail: inspect at most 1,000 recent releases, one page at a time. + // Raise this limit if a product's releases fall outside that window. + for (let page = 1; page <= 10; page++) { + const { data } = await github.rest.repos.listReleases({ + owner: 'jumpserver', repo: 'luna', per_page: 100, page, + }); + content = updateClientVersions(content, data); + if (data.length < 100) break; + } + if (content !== previous) await writeFile(path, content); - name: Check tinker version run: | @@ -104,7 +102,7 @@ jobs: - name: Update Client Version if: env.need_update == '1' run: | - git add . + git add client-version.txt versions.txt git commit -m "Update pkg versions" if [ "$branch_exists" == "true" ]; then git push --force-with-lease origin pr@${{ matrix.branch }}@upgrade_client diff --git a/.github/workflows/sync-weblite-version.yml b/.github/workflows/sync-weblite-version.yml index 1222fcd..efcaa46 100644 --- a/.github/workflows/sync-weblite-version.yml +++ b/.github/workflows/sync-weblite-version.yml @@ -35,10 +35,10 @@ jobs: return Buffer.from(data.content, 'base64').toString('utf8'); } - function clientVersion(content) { - const matches = [...content.matchAll(/^CLIENT_VERSION=([^\r\n]*)\r?$/gm)]; + function webliteVersion(content) { + const matches = [...content.matchAll(/^WEBLITE_VERSION=([^\r\n]*)\r?$/gm)]; if (matches.length !== 1 || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(matches[0][1])) { - throw new Error('Expected one valid CLIENT_VERSION in client-version.txt'); + throw new Error('Expected one valid WEBLITE_VERSION in client-version.txt'); } return matches[0][1]; } @@ -49,7 +49,7 @@ jobs: }); const sha = data.object.sha; const content = await readFile(context.repo, 'client-version.txt', sha); - return { sha, version: clientVersion(content) }; + return { sha, version: webliteVersion(content) }; } const source = await sourceVersion(); @@ -84,7 +84,7 @@ jobs: // An older queued run must not publish a superseded version. if ((await sourceVersion()).version !== source.version) { - core.info('The client version changed during this run; the queued run will synchronize it'); + core.info('The WebLite version changed during this run; the queued run will synchronize it'); return; } @@ -140,7 +140,7 @@ jobs: const details = { ...target, title: `chore: sync WebLite to ${source.version}`, - body: `Update the WebLite installer, manifest version and uninstall package to ${source.version}, matching [docker-web/client-version.txt](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${source.sha}/client-version.txt).`, + body: `Update the WebLite installer, manifest version and uninstall package to ${source.version}, matching WEBLITE_VERSION in [docker-web/client-version.txt](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${source.sha}/client-version.txt).`, }; const { data: updatedPull } = pull ? await github.rest.pulls.update({ ...details, pull_number: pull.number }) diff --git a/README.md b/README.md index 339f30a..26fe0d7 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,21 @@ JumpServer 的 LB Nginx Build 项目,其中包含 Lina, Luna 和一些静态 `Dockerfile-ee` 构建时通过 `client.sh` 下载客户端到 `/opt/download/public/`,并将 WebLite MSI 下载到 `/opt/download/applets/`。 现有 `/download/public/Client__...` 下载地址保持不变;CE 镜像中本地文件不存在时, -Nginx 会回源到公共静态文件服务。客户端和 WebLite 共用 `client-version.txt` 中的 Luna 版本, -更新该文件不会触发 `web-static` 重建。 +Nginx 会回源到公共静态文件服务。 + +`client-version.txt` 分别用 `CLIENT_VERSION`、`WEBLITE_VERSION` 固定两个产品的版本, +下载时分别使用 `client-v`、`weblite-v` Release tag,安装包文件名保持不变。 +可分别使用 `CLIENT_RELEASE_URL`、`WEBLITE_RELEASE_URL` 覆盖下载源。 + +Luna 将正式发布的文件同步到 OSS 后,分别发送 `client-released`、`weblite-released` 通知。 +版本检查每次分页查询最近最多 1,000 个 Release,分别选择两个产品的完整正式版本, +避免迟到通知降级版本或覆盖另一产品的更新;没有候选版本时保留原固定值。 +定时检查使用同一规则,候选 Release 必须包含所需安装包且文件非空。 +更新该文件不会触发 `web-static` 重建;Web / Core 镜像仍按服务端版本发布。 + +WebLite 清单同步只读取 `WEBLITE_VERSION`,自动向 Core 的 `dev` 创建或更新 PR, +同步 `manifest.yml`、`setup.yml`、`uninstall.yml`。Client 更新不会推进 WebLite 清单。 +合并后需配套发布 Web / Core 镜像,确保离线环境的清单版本与内置 MSI 一致。 ## Docker 构建 diff --git a/client-version.txt b/client-version.txt index ee2c847..8c779c7 100644 --- a/client-version.txt +++ b/client-version.txt @@ -1,3 +1,4 @@ # Client and WebLite installers are downloaded only while building Dockerfile-ee. CLIENT_VERSION=5.1.0-rc3 CLIENT_NAME=JumpServer +WEBLITE_VERSION=5.1.0-rc3 diff --git a/client.sh b/client.sh index 20b598d..8c9bf3b 100755 --- a/client.sh +++ b/client.sh @@ -7,8 +7,10 @@ PROJECT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) : "${CLIENT_VERSION:?CLIENT_VERSION is required}" : "${CLIENT_NAME:?CLIENT_NAME is required}" +: "${WEBLITE_VERSION:?WEBLITE_VERSION is required}" -RELEASE_URL=${CLIENT_RELEASE_URL:-https://github.com/jumpserver/luna/releases/download/v${CLIENT_VERSION}} +RELEASE_URL=${CLIENT_RELEASE_URL:-https://github.com/jumpserver/luna/releases/download/client-v${CLIENT_VERSION}} +WEBLITE_RELEASE_URL=${WEBLITE_RELEASE_URL:-https://github.com/jumpserver/luna/releases/download/weblite-v${WEBLITE_VERSION}} DOWNLOAD_DIR=${CLIENT_DOWNLOAD_DIR:-/opt/download/public} WEBLITE_DOWNLOAD_DIR=${WEBLITE_DOWNLOAD_DIR:-/opt/download/applets} @@ -17,7 +19,7 @@ cd "${DOWNLOAD_DIR}" windows_file="${CLIENT_NAME}Client_${CLIENT_VERSION}_x64-setup.exe" macos_arm64_file="${CLIENT_NAME}Client_${CLIENT_VERSION}_aarch64.dmg" -weblite_file="JumpServer-WebLite-${CLIENT_VERSION}-x64.msi" +weblite_file="JumpServer-WebLite-${WEBLITE_VERSION}-x64.msi" wget --https-only \ --output-document="${windows_file}" \ @@ -27,7 +29,7 @@ wget --https-only \ "${RELEASE_URL}/${CLIENT_NAME}-${CLIENT_VERSION}-arm64.dmg" wget --https-only \ --output-document="${WEBLITE_DOWNLOAD_DIR}/${weblite_file}" \ - "${RELEASE_URL}/${weblite_file}" + "${WEBLITE_RELEASE_URL}/${weblite_file}" ln -s "${windows_file}" "Client_${CLIENT_VERSION}_x64-setup.exe" ln -s "${macos_arm64_file}" "Client_${CLIENT_VERSION}_aarch64.dmg"