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
83 changes: 83 additions & 0 deletions .github/scripts/client-versions.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
161 changes: 161 additions & 0 deletions .github/scripts/client-versions.test.mjs
Original file line number Diff line number Diff line change
@@ -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",
),
);
});
74 changes: 36 additions & 38 deletions .github/workflows/check-deps-versions.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
on:
repository_dispatch:
types: [client-released]
types: [client-released, weblite-released]
schedule:
- cron: "0 1 * * *"
push:
Expand All @@ -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:
Expand All @@ -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: |
Expand Down Expand Up @@ -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: |
Expand All @@ -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
Expand Down
12 changes: 6 additions & 6 deletions .github/workflows/sync-weblite-version.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}
Expand All @@ -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();
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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 })
Expand Down
Loading
Loading